Mem-search enhancements: table output, simplified API, Sonnet default, and removed fake URIs (#317)

* feat: Add batch fetching for observations and update documentation

- Implemented a new endpoint for fetching multiple observations by IDs in a single request.
- Updated the DataRoutes to include a POST /api/observations/batch endpoint.
- Enhanced SKILL.md documentation to reflect changes in the search process and batch fetching capabilities.
- Increased the default limit for search results from 5 to 40 for better usability.

* feat!: Fix timeline parameter passing with SearchManager alignment

BREAKING CHANGE: Timeline MCP tools now use standardized parameter names
- anchor_id → anchor
- before → depth_before
- after → depth_after
- obs_type → type (timeline tool only)

Fixes timeline endpoint failures caused by parameter name mismatch between
MCP layer and SearchManager. Adds new SessionStore methods for fetching
prompts and session summaries by ID.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* docs: reframe timeline parameter fix as bug fix, not breaking change

The timeline tools were completely broken due to parameter name mismatch.
There's nothing to migrate from since the old parameters never worked.

Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>

* Refactor mem-search documentation and optimize API tool definitions

- Updated SKILL.md to emphasize batch fetching for observations, clarifying usage and efficiency.
- Removed deprecated tools from mcp-server.ts and streamlined tool definitions for clarity.
- Enhanced formatting in FormattingService.ts for better output readability.
- Adjusted SearchManager.ts to improve result headers and removed unnecessary search tips from combined text.

* Refactor FormattingService and SearchManager for table-based output

- Updated FormattingService to format search results as tables, including methods for formatting observations, sessions, and user prompts.
- Removed JSON format handling from SearchManager and streamlined result formatting to consistently use table format.
- Enhanced readability and consistency in search tips and formatting logic.
- Introduced token estimation for observations and improved time formatting.

* refactor: update documentation and API references for version bump and search functionalities

* Refactor code structure for improved readability and maintainability

* chore: change default model from haiku to sonnet

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: unify timeline formatting across search and context services

Extract shared timeline formatting utilities into reusable module to align
MCP search output format with context-generator's date/file-grouped format.

Changes:
- Create src/shared/timeline-formatting.ts with reusable utilities
  (parseJsonArray, formatDateTime, formatTime, formatDate, toRelativePath,
  extractFirstFile, groupByDate)
- Refactor context-generator.ts to use shared utilities
- Update SearchManager.search() to use date/file grouping
- Add search-specific row formatters to FormattingService
- Fix timeline methods to extract actual file paths from metadata
  instead of hardcoding 'General'
- Remove Work column from search output (kept in context output)

Result: Consistent date/file-grouped markdown formatting across both
systems while maintaining their different column requirements.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor: remove redundant legend from search output

Remove legend from search/timeline results since it's already shown
in SessionStart context. Saves ~30 tokens per search result.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Refactor session summary rendering to remove links

- Removed link generation for session summaries in context generation and search manager.
- Updated output formatting to exclude links while maintaining the session summary structure.
- Adjusted related components in TimelineService to ensure consistency across the application.

* fix: move skillPath declaration outside try block to fix scoping bug

The skillPath variable was declared inside the try block but referenced
in the catch block for error logging. Since const is block-scoped, this
would cause a ReferenceError when the error handler executes.

Moved skillPath declaration before the try block so it's accessible in
both try and catch scopes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: address PR #317 code review feedback

**Critical Fixes:**
- Replace happy_path_error__with_fallback debug calls with proper logger methods in mcp-server.ts
- All HTTP API calls now use logger.debug/error for consistent logging

**Code Quality Improvements:**
- Extract 90-day recency window magic numbers to named constants
- Added RECENCY_WINDOW_DAYS and RECENCY_WINDOW_MS constants in SearchManager

**Documentation:**
- Document model cost implications of Haiku → Sonnet upgrade in CHANGELOG
- Provide clear migration path for users who want to revert to Haiku

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor: simplify CHANGELOG - remove cost documentation

Removed model cost comparison documentation per user feedback.
Kept only the technical code quality improvements.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
This commit is contained in:
Alex Newman
2025-12-14 21:58:11 -05:00
committed by GitHub
parent 7fdf5e75ab
commit 61488042d8
41 changed files with 1606 additions and 1064 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ This directory contains skills **for developing and maintaining the claude-mem p
## Skills in This Directory
### version-bump
Manages semantic versioning for the claude-mem project itself. Handles updating all four version files (package.json, marketplace.json, plugin.json, CLAUDE.md), creating git tags, and GitHub releases.
Manages semantic versioning for the claude-mem project itself. Handles updating all three version files (package.json, marketplace.json, plugin.json), creating git tags, and GitHub releases.
**Usage**: Only for claude-mem maintainers releasing new versions.
+7 -10
View File
@@ -1,6 +1,6 @@
---
name: version-bump
description: Manage semantic version updates for claude-mem project. Handles patch, minor, and major version increments following semantic versioning. Updates package.json, marketplace.json, plugin.json, and CLAUDE.md version number (NOT version history). Creates git tags and GitHub releases. Auto-generates CHANGELOG.md from releases.
description: Manage semantic version updates for claude-mem project. Handles patch, minor, and major version increments following semantic versioning. Updates package.json, marketplace.json, and plugin.json. Creates git tags and GitHub releases. Auto-generates CHANGELOG.md from releases.
---
# Version Bump Skill
@@ -9,11 +9,10 @@ Manage semantic versioning across the claude-mem project with consistent updates
## Quick Reference
**Files requiring updates (ALL FOUR):**
**Files requiring updates (ALL THREE):**
1. `package.json` (line 3)
2. `.claude-plugin/marketplace.json` (line 13)
3. `plugin/.claude-plugin/plugin.json` (line 3)
4. `CLAUDE.md` (line 9 ONLY - version number, NOT version history)
**Semantic versioning:**
- **PATCH** (x.y.Z): Bugfixes only
@@ -37,7 +36,7 @@ See [operations/workflow.md](operations/workflow.md) for detailed step-by-step p
1. Determine version type (PATCH/MINOR/MAJOR)
2. Calculate new version from current
3. Preview changes to user
4. Update ALL FOUR files
4. Update ALL THREE files
5. Verify consistency
6. Build and test
7. Commit and create git tag
@@ -54,29 +53,27 @@ See [operations/scenarios.md](operations/scenarios.md) for examples:
## Critical Rules
**ALWAYS:**
- Update ALL FOUR files with matching version numbers
- Update ALL THREE files with matching version numbers
- Create git tag with format `vX.Y.Z`
- Create GitHub release from the tag
- Generate CHANGELOG.md from releases after creating release
- Ask user if version type is unclear
**NEVER:**
- Update only one, two, or three files
- Update only one or two files
- Skip the verification step
- Forget to create git tag or GitHub release
- Add version history entries to CLAUDE.md (that's managed separately)
## Verification Checklist
Before considering the task complete:
- [ ] All FOUR files have matching version numbers
- [ ] All THREE files have matching version numbers
- [ ] `npm run build` succeeds
- [ ] Git commit created with all version files
- [ ] Git tag created (format: vX.Y.Z)
- [ ] Commit and tags pushed to remote
- [ ] GitHub release created from the tag
- [ ] CHANGELOG.md generated and committed
- [ ] CLAUDE.md: ONLY line 9 updated (version number), NOT version history
## Reference Commands
@@ -92,7 +89,7 @@ git tag -l -n1
# Check what will be committed
git status
git diff package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json CLAUDE.md
git diff package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json
```
For more commands, see [operations/reference.md](operations/reference.md).
@@ -4,7 +4,7 @@ Quick reference for version bump commands and file locations.
## File Locations
### Version-Tracked Files (ALL FOUR)
### Version-Tracked Files (ALL THREE)
1. **package.json**
- Path: `package.json`
@@ -21,11 +21,6 @@ Quick reference for version bump commands and file locations.
- Line: 3
- Format: `"version": "X.Y.Z",`
4. **CLAUDE.md**
- Path: `CLAUDE.md`
- Line: 9
- Format: `**Current Version**: X.Y.Z`
## Essential Commands
### View Current Version
@@ -39,7 +34,6 @@ grep '"version"' package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
# From all version files
grep '"version"' package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json
grep "Current Version" CLAUDE.md
```
### Verify Version Consistency
@@ -52,10 +46,6 @@ grep '"version"' package.json .claude-plugin/marketplace.json plugin/.claude-plu
# package.json:3: "version": "5.3.0",
# .claude-plugin/marketplace.json:13: "version": "5.3.0",
# plugin/.claude-plugin/plugin.json:3: "version": "5.3.0",
# Check CLAUDE.md
grep "Current Version" CLAUDE.md
# Should output: **Current Version**: 5.3.0
```
### Git Commands
@@ -96,7 +86,7 @@ npm test
```bash
# Stage version files
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json CLAUDE.md plugin/scripts/
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin/scripts/
# Commit
git commit -m "Release vX.Y.Z: [Description]"
@@ -163,11 +153,11 @@ MAJOR: 5.3.2 → 6.0.0 (resets minor and patch)
```bash
# Example: 5.3.0 → 5.3.1
# 1. Update all four files to 5.3.1
# 1. Update all three files to 5.3.1
# 2. Build and test
npm run build
# 3. Commit and tag
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json CLAUDE.md plugin/scripts/
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin/scripts/
git commit -m "Release v5.3.1: Fixed observer crash"
git tag v5.3.1 -m "Release v5.3.1: Fixed observer crash"
git push && git push --tags
@@ -179,11 +169,11 @@ gh release create v5.3.1 --title "v5.3.1" --notes "Fixed observer crash on empty
```bash
# Example: 5.3.0 → 5.4.0
# 1. Update all four files to 5.4.0
# 1. Update all three files to 5.4.0
# 2. Build and test
npm run build
# 3. Commit and tag
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json CLAUDE.md plugin/scripts/
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin/scripts/
git commit -m "Release v5.4.0: Added dark mode support"
git tag v5.4.0 -m "Release v5.4.0: Added dark mode support"
git push && git push --tags
@@ -195,11 +185,11 @@ gh release create v5.4.0 --title "v5.4.0" --generate-notes
```bash
# Example: 5.3.0 → 6.0.0
# 1. Update all four files to 6.0.0
# 1. Update all three files to 6.0.0
# 2. Build and test
npm run build
# 3. Commit and tag
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json CLAUDE.md plugin/scripts/
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin/scripts/
git commit -m "Release v6.0.0: Storage layer redesign"
git tag v6.0.0 -m "Release v6.0.0: Storage layer redesign"
git push && git push --tags
@@ -19,7 +19,7 @@ Current: 4.2.8
New: 4.2.9 (PATCH)
Steps:
1. Update all four files to 4.2.9
1. Update all three files to 4.2.9
2. npm run build
3. git commit -m "Release v4.2.9: Fixed memory leak in search"
4. git tag v4.2.9 -m "Release v4.2.9: Fixed memory leak in search"
@@ -44,7 +44,7 @@ Current: 4.2.8
New: 4.3.0 (MINOR - reset patch to 0)
Steps:
1. Update all four files to 4.3.0
1. Update all three files to 4.3.0
2. npm run build
3. git commit -m "Release v4.3.0: Added web search MCP integration"
4. git tag v4.3.0 -m "Release v4.3.0: Added web search MCP integration"
@@ -69,7 +69,7 @@ Current: 4.2.8
New: 5.0.0 (MAJOR - reset minor and patch to 0)
Steps:
1. Update all four files to 5.0.0
1. Update all three files to 5.0.0
2. npm run build
3. git commit -m "Release v5.0.0: Storage layer redesign with migration required"
4. git tag v5.0.0 -m "Release v5.0.0: Storage layer redesign"
@@ -94,7 +94,7 @@ Current: 4.2.8
New: 4.2.9 (PATCH)
Steps:
1. Update all four files to 4.2.9
1. Update all three files to 4.2.9
2. npm run build
3. git commit -m "Release v4.2.9: Multiple bug fixes
@@ -122,7 +122,7 @@ Current: 5.1.0
New: 5.2.0 (MINOR)
Steps:
1. Update all four files to 5.2.0
1. Update all three files to 5.2.0
2. npm run build
3. git commit -m "Release v5.2.0: Dark mode support + bug fixes
@@ -64,7 +64,6 @@ Files to update:
- package.json: "version": "4.2.9"
- marketplace.json: "version": "4.2.9"
- plugin.json: "version": "4.2.9"
- CLAUDE.md line 9: "**Current Version**: 4.2.9" (version number ONLY)
- Git tag: v4.2.9
Proceed? (yes/no)
@@ -116,18 +115,6 @@ File: `plugin/.claude-plugin/plugin.json`
Update line 3 with new version.
### Update CLAUDE.md
File: `CLAUDE.md`
**ONLY update line 9 with the version number:**
```markdown
**Current Version**: 4.2.9
```
**CRITICAL:** DO NOT add version history entries to CLAUDE.md. Version history is managed separately outside this skill.
## Step 6: Verify Consistency
```bash
@@ -155,7 +142,7 @@ Build must succeed before proceeding.
```bash
# Stage all version files
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json CLAUDE.md plugin/scripts/
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin/scripts/
# Commit with descriptive message
git commit -m "Release vX.Y.Z: [Brief description]
+32 -6
View File
@@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [8.0.0] - 2025-12-14
### Fixed
**Timeline MCP Tools Parameter Bug**
Fixed critical bug where timeline tools were completely non-functional due to parameter name mismatch between MCP layer and SearchManager. The tools now use correct parameter names:
- `anchor` (was incorrectly `anchor_id`)
- `depth_before` (was incorrectly `before`)
- `depth_after` (was incorrectly `after`)
- `type` (was incorrectly `obs_type` in timeline tool only)
**Affected Tools:** `timeline`, `get_context_timeline`, `get_timeline_by_query`
**Impact:** These tools were previously broken and would fail with "Cannot read properties of undefined (reading 'length')" errors. They now work correctly with the proper parameter names that match the underlying SearchManager implementation.
### Added
- New `get_batch_observations` MCP tool for efficiently fetching multiple observations in a single request
- Enhanced SessionStore methods for fetching prompts and session summaries by ID
### Changed
- Extracted magic numbers to constants (`RECENCY_WINDOW_DAYS`, `RECENCY_WINDOW_MS`)
- Replaced debug logging calls with proper logger methods
---
## [7.2.1] - 2025-12-14
## Translation Script Enhancements
@@ -2392,12 +2418,12 @@ None (patch version)
## [4.3.0] - 2025-10-25
## What's Changed
* feat: Enhanced context hook with session observations and cross-platform improvements by @thedotmack in https://github.com/thedotmack/claude-mem/pull/25
## New Contributors
* @thedotmack made their first contribution in https://github.com/thedotmack/claude-mem/pull/25
## What's Changed
* feat: Enhanced context hook with session observations and cross-platform improvements by @thedotmack in https://github.com/thedotmack/claude-mem/pull/25
## New Contributors
* @thedotmack made their first contribution in https://github.com/thedotmack/claude-mem/pull/25
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v4.2.11...v4.3.0
## [4.2.10] - 2025-10-25
+13 -37
View File
@@ -6,8 +6,6 @@
Claude-mem is a Claude Code plugin providing persistent memory across sessions. It captures tool usage, compresses observations using the Claude Agent SDK, and injects relevant context into future sessions.
**Current Version**: 7.2.1
## Architecture
**5 Lifecycle Hooks**: SessionStart → UserPromptSubmit → PostToolUse → Summary → SessionEnd
@@ -34,37 +32,30 @@ Claude-mem is a Claude Code plugin providing persistent memory across sessions.
## Build Commands
**Hooks only**: `npm run build && npm run sync-marketplace`
```bash
npm run build-and-sync # Build, sync to marketplace, restart worker (most common)
npm run build # Compile TypeScript only
npm run sync-marketplace # Copy to ~/.claude/plugins only
npm run worker:restart # Restart worker service only
npm run worker:status # Check worker status
npm run worker:logs # View worker logs
```
**Worker changes**: `npm run build && npm run sync-marketplace && npm run worker:restart`
**Skills only**: `npm run sync-marketplace`
**Viewer UI**: `npm run build && npm run sync-marketplace && npm run worker:restart`
**Viewer UI**: http://localhost:37777
## Configuration
Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created with defaults on first run.
**Core Settings:**
- `CLAUDE_MEM_MODEL` - Model for observations/summaries (default: claude-haiku-4-5)
- `CLAUDE_MEM_CONTEXT_OBSERVATIONS` - Observations injected at SessionStart (default: 50)
- `CLAUDE_MEM_MODEL` - Model for observations/summaries (default: claude-sonnet-4-5)
- `CLAUDE_MEM_CONTEXT_OBSERVATIONS` - Observations injected at SessionStart
- `CLAUDE_MEM_WORKER_PORT` - Worker service port (default: 37777)
- `CLAUDE_MEM_WORKER_HOST` - Worker bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)
**System Configuration:**
- `CLAUDE_MEM_DATA_DIR` - Data directory location (default: ~/.claude-mem)
- `CLAUDE_MEM_LOG_LEVEL` - Log verbosity: DEBUG, INFO, WARN, ERROR, SILENT (default: INFO)
- `CLAUDE_MEM_PYTHON_VERSION` - Python version for uvx/chroma-mcp (default: 3.13, avoids onnxruntime compatibility issues with Python 3.14+)
- `CLAUDE_CODE_PATH` - Path to Claude executable (default: auto-detect via 'which claude')
**Settings File Format:**
```json
{
"CLAUDE_MEM_MODEL": "claude-haiku-4-5",
"CLAUDE_MEM_WORKER_PORT": "37777"
}
```
## File Locations
@@ -73,30 +64,15 @@ Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created
- **Installed Plugin**: `~/.claude/plugins/marketplaces/thedotmack/`
- **Database**: `~/.claude-mem/claude-mem.db`
- **Chroma**: `~/.claude-mem/chroma/`
- **Usage Logs**: `~/.claude-mem/usage-logs/usage-YYYY-MM-DD.jsonl`
## Requirements
- **Bun** >= 1.0 (all platforms - auto-installed if missing)
- **Bun** (all platforms - auto-installed if missing)
- **uv** (all platforms - auto-installed if missing, provides Python for Chroma)
- Node.js >= 18 (build tools only)
## Quick Reference
```bash
npm run build # Compile TypeScript
npm run sync-marketplace # Copy to ~/.claude/plugins
npm run worker:restart # Restart worker service
npm run worker:status # Check worker status
npm run worker:logs # View worker logs
```
**Viewer UI**: http://localhost:37777
**Worker Logs**: `~/.claude-mem/logs/worker-YYYY-MM-DD.log`
- Node.js (build tools only)
## Documentation
**Public Docs**: https://docs.claude-mem.ai (Mintlify)
**Source**: `docs/public/` - MDX files, edit `docs.json` for navigation
**Deploy**: Auto-deploys from GitHub on push to main
**Local Dev**: `cd docs/public && npx mintlify dev`
+3 -3
View File
@@ -85,7 +85,7 @@ Restart Claude Code. Context from previous sessions will automatically appear in
- 🔒 **Privacy Control** - Use `<private>` tags to exclude sensitive content from storage
- ⚙️ **Context Configuration** - Fine-grained control over what context gets injected
- 🤖 **Automatic Operation** - No manual intervention required
- 🔗 **Citations** - Reference past decisions with `claude-mem://` URIs
- 🔗 **Citations** - Reference past observations with IDs (access via http://localhost:37777/api/observation/{id} or view all in the web viewer at http://localhost:37777)
- 🧪 **Beta Channel** - Try experimental features like Endless Mode via version switching
---
@@ -324,7 +324,7 @@ Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created
| Setting | Default | Description |
|---------|---------|-------------|
| `CLAUDE_MEM_MODEL` | `claude-haiku-4-5` | AI model for observations |
| `CLAUDE_MEM_MODEL` | `claude-sonnet-4-5` | AI model for observations |
| `CLAUDE_MEM_WORKER_PORT` | `37777` | Worker service port |
| `CLAUDE_MEM_WORKER_HOST` | `127.0.0.1` | Worker bind address (use `0.0.0.0` for remote access) |
| `CLAUDE_MEM_DATA_DIR` | `~/.claude-mem` | Data directory location |
@@ -350,7 +350,7 @@ curl http://localhost:37777/api/settings
```json
{
"CLAUDE_MEM_MODEL": "claude-haiku-4-5",
"CLAUDE_MEM_MODEL": "claude-sonnet-4-5",
"CLAUDE_MEM_WORKER_PORT": "37777",
"CLAUDE_MEM_CONTEXT_OBSERVATIONS": "50"
}
+390
View File
@@ -0,0 +1,390 @@
# TypeScript SDK V2 interface (preview)
Preview of the simplified V2 TypeScript Agent SDK, with session-based send/receive patterns for multi-turn conversations.
---
<Warning>
The V2 interface is an **unstable preview**. APIs may change based on feedback before becoming stable. Some features like session forking are only available in the [V1 SDK](/docs/en/agent-sdk/typescript).
</Warning>
The V2 Claude Agent TypeScript SDK removes the need for async generators and yield coordination. This makes multi-turn conversations simpler—instead of managing generator state across turns, each turn is a separate `send()`/`receive()` cycle. The API surface reduces to three concepts:
- `createSession()` / `resumeSession()`: Start or continue a conversation
- `session.send()`: Send a message
- `session.receive()`: Get the response
## Installation
The V2 interface is included in the existing SDK package:
```bash
npm install @anthropic-ai/claude-agent-sdk
```
## Quick start
### One-shot prompt
For simple single-turn queries where you don't need to maintain a session, use `unstable_v2_prompt()`. This example sends a math question and logs the answer:
```typescript
import { unstable_v2_prompt } from '@anthropic-ai/claude-agent-sdk'
const result = await unstable_v2_prompt('What is 2 + 2?', {
model: 'claude-sonnet-4-5-20250929'
})
console.log(result.result)
```
<details>
<summary>See the same operation in V1</summary>
```typescript
import { query } from '@anthropic-ai/claude-agent-sdk'
const q = query({
prompt: 'What is 2 + 2?',
options: { model: 'claude-sonnet-4-5-20250929' }
})
for await (const msg of q) {
if (msg.type === 'result') {
console.log(msg.result)
}
}
```
</details>
### Basic session
For interactions beyond a single prompt, create a session. V2 separates sending and receiving into distinct steps:
- `send()` dispatches your message
- `receive()` streams back the response
This explicit separation makes it easier to add logic between turns (like processing responses before sending follow-ups).
The example below creates a session, sends "Hello!" to Claude, and prints the text response. It uses [`await using`](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management) (TypeScript 5.2+) to automatically close the session when the block exits. You can also call `session.close()` manually.
```typescript
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
await using session = unstable_v2_createSession({
model: 'claude-sonnet-4-5-20250929'
})
await session.send('Hello!')
for await (const msg of session.receive()) {
// Filter for assistant messages to get human-readable output
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log(text)
}
}
```
<details>
<summary>See the same operation in V1</summary>
In V1, both input and output flow through a single async generator. For a basic prompt this looks similar, but adding multi-turn logic requires restructuring to use an input generator.
```typescript
import { query } from '@anthropic-ai/claude-agent-sdk'
const q = query({
prompt: 'Hello!',
options: { model: 'claude-sonnet-4-5-20250929' }
})
for await (const msg of q) {
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log(text)
}
}
```
</details>
### Multi-turn conversation
Sessions persist context across multiple exchanges. To continue a conversation, call `send()` again on the same session. Claude remembers the previous turns.
This example asks a math question, then asks a follow-up that references the previous answer:
```typescript
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
await using session = unstable_v2_createSession({
model: 'claude-sonnet-4-5-20250929'
})
// Turn 1
await session.send('What is 5 + 3?')
for await (const msg of session.receive()) {
// Filter for assistant messages to get human-readable output
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log(text)
}
}
// Turn 2
await session.send('Multiply that by 2')
for await (const msg of session.receive()) {
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log(text)
}
}
```
<details>
<summary>See the same operation in V1</summary>
```typescript
import { query } from '@anthropic-ai/claude-agent-sdk'
// Must create an async iterable to feed messages
async function* createInputStream() {
yield {
type: 'user',
session_id: '',
message: { role: 'user', content: [{ type: 'text', text: 'What is 5 + 3?' }] },
parent_tool_use_id: null
}
// Must coordinate when to yield next message
yield {
type: 'user',
session_id: '',
message: { role: 'user', content: [{ type: 'text', text: 'Multiply by 2' }] },
parent_tool_use_id: null
}
}
const q = query({
prompt: createInputStream(),
options: { model: 'claude-sonnet-4-5-20250929' }
})
for await (const msg of q) {
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log(text)
}
}
```
</details>
### Session resume
If you have a session ID from a previous interaction, you can resume it later. This is useful for long-running workflows or when you need to persist conversations across application restarts.
This example creates a session, stores its ID, closes it, then resumes the conversation:
```typescript
import {
unstable_v2_createSession,
unstable_v2_resumeSession,
type SDKMessage
} from '@anthropic-ai/claude-agent-sdk'
// Helper to extract text from assistant messages
function getAssistantText(msg: SDKMessage): string | null {
if (msg.type !== 'assistant') return null
return msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
// Create initial session and have a conversation
const session = unstable_v2_createSession({
model: 'claude-sonnet-4-5-20250929'
})
await session.send('Remember this number: 42')
// Get the session ID from any received message
let sessionId: string | undefined
for await (const msg of session.receive()) {
sessionId = msg.session_id
const text = getAssistantText(msg)
if (text) console.log('Initial response:', text)
}
console.log('Session ID:', sessionId)
session.close()
// Later: resume the session using the stored ID
await using resumedSession = unstable_v2_resumeSession(sessionId!, {
model: 'claude-sonnet-4-5-20250929'
})
await resumedSession.send('What number did I ask you to remember?')
for await (const msg of resumedSession.receive()) {
const text = getAssistantText(msg)
if (text) console.log('Resumed response:', text)
}
```
<details>
<summary>See the same operation in V1</summary>
```typescript
import { query } from '@anthropic-ai/claude-agent-sdk'
// Create initial session
const initialQuery = query({
prompt: 'Remember this number: 42',
options: { model: 'claude-sonnet-4-5-20250929' }
})
// Get session ID from any message
let sessionId: string | undefined
for await (const msg of initialQuery) {
sessionId = msg.session_id
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log('Initial response:', text)
}
}
console.log('Session ID:', sessionId)
// Later: resume the session
const resumedQuery = query({
prompt: 'What number did I ask you to remember?',
options: {
model: 'claude-sonnet-4-5-20250929',
resume: sessionId
}
})
for await (const msg of resumedQuery) {
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log('Resumed response:', text)
}
}
```
</details>
### Cleanup
Sessions can be closed manually or automatically using [`await using`](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management), a TypeScript 5.2+ feature for automatic resource cleanup. If you're using an older TypeScript version or encounter compatibility issues, use manual cleanup instead.
**Automatic cleanup (TypeScript 5.2+):**
```typescript
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
await using session = unstable_v2_createSession({
model: 'claude-sonnet-4-5-20250929'
})
// Session closes automatically when the block exits
```
**Manual cleanup:**
```typescript
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
const session = unstable_v2_createSession({
model: 'claude-sonnet-4-5-20250929'
})
// ... use the session ...
session.close()
```
## API reference
### `unstable_v2_createSession()`
Creates a new session for multi-turn conversations.
```typescript
function unstable_v2_createSession(options: {
model: string;
// Additional options supported
}): Session
```
### `unstable_v2_resumeSession()`
Resumes an existing session by ID.
```typescript
function unstable_v2_resumeSession(
sessionId: string,
options: {
model: string;
// Additional options supported
}
): Session
```
### `unstable_v2_prompt()`
One-shot convenience function for single-turn queries.
```typescript
function unstable_v2_prompt(
prompt: string,
options: {
model: string;
// Additional options supported
}
): Promise<Result>
```
### Session interface
```typescript
interface Session {
send(message: string): Promise<void>;
receive(): AsyncGenerator<SDKMessage>;
close(): void;
}
```
## Feature availability
Not all V1 features are available in V2 yet. The following require using the [V1 SDK](/docs/en/agent-sdk/typescript):
- Session forking (`forkSession` option)
- Some advanced streaming input patterns
## Feedback
Share your feedback on the V2 interface before it becomes stable. Report issues and suggestions through [GitHub Issues](https://github.com/anthropics/claude-code/issues).
## See also
- [TypeScript SDK reference (V1)](/docs/en/agent-sdk/typescript) - Full V1 SDK documentation
- [SDK overview](/docs/en/agent-sdk/overview) - General SDK concepts
- [V2 examples on GitHub](https://github.com/anthropics/claude-agent-sdk-demos/tree/main/hello-world-v2) - Working code examples
+1 -1
View File
@@ -57,7 +57,7 @@ GET /api/context/recent?project=my-project&limit=3
### Environment Variables
```bash
CLAUDE_MEM_MODEL=claude-haiku-4-5 # Model for observations/summaries
CLAUDE_MEM_MODEL=claude-sonnet-4-5 # Model for observations/summaries
CLAUDE_MEM_CONTEXT_OBSERVATIONS=50 # Observations injected at SessionStart
CLAUDE_MEM_WORKER_PORT=37777 # Worker service port
CLAUDE_MEM_PYTHON_VERSION=3.13 # Python version for chroma-mcp
+1 -1
View File
@@ -864,7 +864,7 @@ async startSession(session: ActiveSession, worker?: any) {
const queryResult = query({
prompt: messageGenerator,
options: {
model: 'claude-haiku-4-5',
model: 'claude-sonnet-4-5',
disallowedTools: ['Bash', 'Read', 'Write', ...], // Observer-only
abortController: session.abortController
}
+6 -6
View File
@@ -13,7 +13,7 @@ Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created
| Setting | Default | Description |
|-------------------------------|---------------------------------|---------------------------------------|
| `CLAUDE_MEM_MODEL` | `haiku` | AI model for processing observations |
| `CLAUDE_MEM_MODEL` | `sonnet` | AI model for processing observations |
| `CLAUDE_MEM_CONTEXT_OBSERVATIONS` | `50` | Number of observations to inject |
| `CLAUDE_MEM_WORKER_PORT` | `37777` | Worker service port |
| `CLAUDE_MEM_SKIP_TOOLS` | `ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion` | Comma-separated tools to exclude from observations |
@@ -35,8 +35,8 @@ Configure which AI model processes your observations.
Shorthand model names automatically forward to the latest version:
- `haiku` - Fast, cost-efficient (default)
- `sonnet` - Balanced
- `haiku` - Fast, cost-efficient
- `sonnet` - Balanced (default)
- `opus` - Most capable
### Using the Interactive Script
@@ -53,7 +53,7 @@ Edit `~/.claude-mem/settings.json`:
```json
{
"CLAUDE_MEM_MODEL": "haiku"
"CLAUDE_MEM_MODEL": "sonnet"
}
```
@@ -262,7 +262,7 @@ Token economics help you understand the value of cached observations vs. re-read
| Setting | Default | Description |
|---------|---------|-------------|
| **Model** | haiku | AI model for generating observations |
| **Model** | sonnet | AI model for generating observations |
| **Worker Port** | 37777 | Port for background worker service |
| **MCP search server** | true | Enable Model Context Protocol search tools |
| **Include last summary** | false | Add previous session's summary to context |
@@ -420,7 +420,7 @@ npm run worker:logs
### Invalid Model Name
If you specify an invalid model name, the worker will fall back to `haiku` and log a warning.
If you specify an invalid model name, the worker will fall back to `sonnet` and log a warning.
Valid shorthand models (forward to latest version):
- haiku
+1 -1
View File
@@ -29,7 +29,7 @@ Restart Claude Code. Context from previous sessions will automatically appear in
- ⚙️ **Context Configuration** - Fine-grained control over what context gets injected
- 🤖 **Automatic Operation** - No manual intervention required
- 📊 **FTS5 Search** - Fast full-text search across observations
- 🔗 **Citations** - Reference past decisions with `claude-mem://` URIs
- 🔗 **Citations** - Reference past observations with IDs (access via http://localhost:37777/api/observation/{id} or view all in the web viewer at http://localhost:37777)
## How It Works
+1 -1
View File
@@ -46,7 +46,7 @@ GET /api/context/recent?project=my-project&limit=3
### Environment Variables
```bash
CLAUDE_MEM_MODEL=claude-haiku-4-5 # Model for observations/summaries
CLAUDE_MEM_MODEL=claude-sonnet-4-5 # Model for observations/summaries
CLAUDE_MEM_CONTEXT_OBSERVATIONS=50 # Observations injected at SessionStart
CLAUDE_MEM_WORKER_PORT=37777 # Worker service port
CLAUDE_MEM_PYTHON_VERSION=3.13 # Python version for chroma-mcp
+3 -4
View File
@@ -246,11 +246,10 @@ authentication for better scalability and stateless design...
## Citations
All search results include citations using the `claude-mem://` URI scheme:
All search results include observation IDs that can be accessed via the HTTP API:
- `claude-mem://observation/123` - Specific observation
- `claude-mem://session/abc-456` - Specific session
- `claude-mem://user-prompt/789` - Specific user prompt
- `http://localhost:37777/api/observation/{id}` - Get specific observation by ID
- View all observations in the web viewer at `http://localhost:37777`
These citations enable referencing specific historical context in your work.
+1
View File
@@ -32,6 +32,7 @@
},
"scripts": {
"build": "node scripts/build-hooks.js",
"build-and-sync": "npm run build && npm run sync-marketplace && sleep 1 && cd ~/.claude/plugins/marketplaces/thedotmack && npm run worker:restart",
"test": "vitest",
"test:parser": "npx tsx src/sdk/parser.test.ts",
"test:context": "echo '{\"session_id\":\"test-'$(date +%s)'\",\"cwd\":\"'$(pwd)'\",\"source\":\"startup\"}' | node plugin/scripts/context-hook.js 2>/dev/null",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+121 -50
View File
@@ -10,6 +10,7 @@ Search past work across all sessions. Simple workflow: search → get IDs → fe
## When to Use
Use when users ask about PREVIOUS sessions (not current conversation):
- "Did we already fix this?"
- "How did we solve X last time?"
- "What happened last week?"
@@ -19,47 +20,57 @@ Use when users ask about PREVIOUS sessions (not current conversation):
**ALWAYS follow this exact flow:**
1. **Search** - Get an index of results with IDs
2. **Timeline** (optional) - Get context around top results to understand what was happening
2. **Timeline** - Get context around top results to understand what was happening
3. **Review** - Look at titles/dates/context, pick relevant IDs
4. **Fetch** - Get full details ONLY for those IDs
### Step 1: Search Everything
```bash
curl "http://localhost:37777/api/search?query=authentication&format=index&limit=5"
```
Use the `search` MCP tool:
**Required parameters:**
- `query` - Search term
- `format=index` - ALWAYS start with index (lightweight)
- `limit=5` - Start small (3-5 results)
- `limit: 20` - You can request large indexes as necessary
- `project` - Project name (required)
**Example:**
```
search(query="authentication", limit=20, project="my-project")
```
**Returns:**
```
1. [feature] Added JWT authentication
Date: 11/17/2025, 3:48:45 PM
ID: 11131
2. [bugfix] Fixed auth token expiration
Date: 11/16/2025, 2:15:22 PM
ID: 10942
```
| ID | Time | T | Title | Read | Work |
|----|------|---|-------|------|------|
| #11131 | 3:48 PM | 🟣 | Added JWT authentication | ~75 | 🛠️ 450 |
| #10942 | 2:15 PM | 🔴 | Fixed auth token expiration | ~50 | 🛠️ 200 |
```
### Step 2: Get Timeline Context (Optional)
### Step 2: Get Timeline Context
When you need to understand "what was happening" around a result:
You MUST understand "what was happening" around a result.
```bash
# Get timeline around an observation ID
curl "http://localhost:37777/api/timeline?anchor=11131&depth_before=3&depth_after=3"
Use the `timeline` MCP tool:
# Or use query to find + get timeline in one step
curl "http://localhost:37777/api/timeline?query=authentication&depth_before=3&depth_after=3"
**Example with observation ID:**
```
timeline(anchor=11131, depth_before=3, depth_after=3, project="my-project")
```
**Example with query (finds anchor automatically):**
```
timeline(query="authentication", depth_before=3, depth_after=3, project="my-project")
```
**Returns exactly `depth_before + 1 + depth_after` items** - observations, sessions, and prompts interleaved chronologically around the anchor.
**When to use:**
- User asks "what was happening when..."
- Need to understand sequence of events
- Want broader context around a specific observation
@@ -70,34 +81,68 @@ Review the index results (and timeline if used). Identify which IDs are actually
### Step 4: Fetch by ID
For each relevant ID, fetch full details:
For each relevant ID, fetch full details using MCP tools:
```bash
# Fetch observation
curl "http://localhost:37777/api/observation/11131"
**Fetch multiple observations (ALWAYS use for 2+ IDs):**
# Fetch session
curl "http://localhost:37777/api/session/2005"
```
get_batch_observations(ids=[11131, 10942, 10855])
```
# Fetch prompt
curl "http://localhost:37777/api/prompt/5421"
**With ordering and limit:**
```
get_batch_observations(
ids=[11131, 10942, 10855],
orderBy="date_desc",
limit=10,
project="my-project"
)
```
**Fetch single observation (only when fetching exactly 1):**
```
get_observation(id=11131)
```
**Fetch session:**
```
get_session(id=2005) # Just the number from S2005
```
**Fetch prompt:**
```
get_prompt(id=5421)
```
**ID formats:**
- Observations: Just the number (11131)
- Sessions: Just the number (2005) from "S2005"
- Prompts: Just the number (5421)
**Batch optimization:**
- **ALWAYS use `get_batch_observations` for 2+ observations**
- 10-100x more efficient than individual fetches
- Single HTTP request vs N requests
- Returns all results in one response
- Supports ordering and filtering
## Search Parameters
**Basic:**
- `query` - What to search for (required)
- `format` - "index" or "full" (always use "index" first)
- `limit` - How many results (default 5, max 100)
- `limit` - How many results (default 20)
- `project` - Filter by project name (required)
**Filters (optional):**
- `type` - Filter to "observations", "sessions", or "prompts"
- `project` - Filter by project name
- `dateStart` - Start date (YYYY-MM-DD or epoch timestamp)
- `dateEnd` - End date (YYYY-MM-DD or epoch timestamp)
- `obs_type` - Filter observations by type (comma-separated): bugfix, feature, decision, discovery, change
@@ -105,39 +150,65 @@ curl "http://localhost:37777/api/prompt/5421"
## Examples
**Find recent bug fixes:**
```bash
curl "http://localhost:37777/api/search?query=bug&type=observations&obs_type=bugfix&format=index&limit=5"
Use the `search` MCP tool with filters:
```
search(query="bug", type="observations", obs_type="bugfix", limit=20, project="my-project")
```
**Find what happened last week:**
```bash
curl "http://localhost:37777/api/search?query=&type=observations&dateStart=2025-11-11&format=index&limit=10"
Use date filters:
```
search(type="observations", dateStart="2025-11-11", limit=20, project="my-project")
```
**Search everything:**
```bash
curl "http://localhost:37777/api/search?query=database+migration&format=index&limit=5"
Simple query search:
```
search(query="database migration", limit=20, project="my-project")
```
**Get detailed instructions:**
Use the `progressive_description` tool to load full instructions on-demand:
```
progressive_description(topic="workflow") # Get 4-step workflow
progressive_description(topic="search_params") # Get parameters reference
progressive_description(topic="examples") # Get usage examples
progressive_description(topic="all") # Get complete guide
```
## Why This Workflow?
**Token efficiency:**
- Index format: ~50-100 tokens per result
- Full format: ~500-1000 tokens per result
- **10x difference** - only fetch full when you know it's relevant
- **Search results:** ~50-100 tokens per result (table index)
- **Full observation:** ~500-1000 tokens each
- **10x savings** - only fetch full when you know it's relevant
**Batch fetching:**
- **Individual fetches:** 10 HTTP requests, ~5-10s latency
- **Batch fetch:** 1 HTTP request, ~0.5-1s latency
- **10-100x faster** for multi-observation queries
**Clarity:**
- See everything first
- Pick what matters
- Get details only for what you need
## Error Handling
If search fails, tell the user the worker isn't available and suggest:
```bash
pm2 list # Check if worker is running
```
- See everything first (table index)
- Get timeline context around interesting results
- Pick what matters based on context
- Fetch details only for what you need (batch when possible)
---
**Remember:** ALWAYS search with format=index first. ALWAYS fetch by ID for details. The IDs are there for a reason - USE THEM.
**Remember:**
- ALWAYS get timeline context to understand what was happening
- ALWAYS use `get_batch_observations` when fetching 2+ observations
- The workflow is optimized: search → timeline → batch fetch = 10-100x faster
@@ -95,7 +95,7 @@ echo '{"env":{"CLAUDE_MEM_WORKER_PORT":"37778"}}' > ~/.claude-mem/settings.json
# Change AI model
{
"env": {
"CLAUDE_MEM_MODEL": "claude-haiku-4-5"
"CLAUDE_MEM_MODEL": "claude-sonnet-4-5"
}
}
```
File diff suppressed because one or more lines are too long
+174 -226
View File
@@ -30,18 +30,9 @@ const WORKER_BASE_URL = `http://${WORKER_HOST}:${WORKER_PORT}`;
const TOOL_ENDPOINT_MAP: Record<string, string> = {
'search': '/api/search',
'timeline': '/api/timeline',
'decisions': '/api/decisions',
'changes': '/api/changes',
'how_it_works': '/api/how-it-works',
'search_observations': '/api/search/observations',
'search_sessions': '/api/search/sessions',
'search_user_prompts': '/api/search/prompts',
'find_by_concept': '/api/search/by-concept',
'find_by_file': '/api/search/by-file',
'find_by_type': '/api/search/by-type',
'get_recent_context': '/api/context/recent',
'get_context_timeline': '/api/context/timeline',
'get_timeline_by_query': '/api/timeline/by-query'
'progressive_description': '/api/instructions'
};
/**
@@ -89,6 +80,94 @@ async function callWorkerAPI(
}
}
/**
* Call Worker HTTP API with path parameter (GET)
*/
async function callWorkerAPIWithPath(
endpoint: string,
id: number
): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> {
logger.debug('HTTP', 'Worker API request (path)', undefined, { endpoint, id });
try {
const url = `${WORKER_BASE_URL}${endpoint}/${id}`;
const response = await fetch(url);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Worker API error (${response.status}): ${errorText}`);
}
const data = await response.json();
logger.debug('HTTP', 'Worker API success (path)', undefined, { endpoint, id });
// Wrap raw data in MCP format
return {
content: [{
type: 'text' as const,
text: JSON.stringify(data, null, 2)
}]
};
} catch (error: any) {
logger.error('HTTP', 'Worker API error (path)', undefined, { endpoint, id, error: error.message });
return {
content: [{
type: 'text' as const,
text: `Error calling Worker API: ${error.message}`
}],
isError: true
};
}
}
/**
* Call Worker HTTP API with POST body
*/
async function callWorkerAPIPost(
endpoint: string,
body: Record<string, any>
): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> {
logger.debug('HTTP', 'Worker API request (POST)', undefined, { endpoint });
try {
const url = `${WORKER_BASE_URL}${endpoint}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Worker API error (${response.status}): ${errorText}`);
}
const data = await response.json();
logger.debug('HTTP', 'Worker API success (POST)', undefined, { endpoint });
// Wrap raw data in MCP format
return {
content: [{
type: 'text' as const,
text: JSON.stringify(data, null, 2)
}]
};
} catch (error: any) {
logger.error('HTTP', 'Worker API error (POST)', undefined, { endpoint, error: error.message });
return {
content: [{
type: 'text' as const,
text: `Error calling Worker API: ${error.message}`
}],
isError: true
};
}
}
/**
* Verify Worker is accessible
*/
@@ -103,24 +182,24 @@ async function verifyWorkerConnection(): Promise<boolean> {
/**
* Tool definitions with HTTP-based handlers
* Descriptions removed - use progressive_description tool for parameter documentation
*/
const tools = [
{
name: 'search',
description: 'Unified search across all memory types (observations, sessions, and user prompts) using vector-first semantic search (ChromaDB). Returns combined results from all document types. IMPORTANT: Always use index format first (default) to get an overview with minimal token usage, then use format: "full" only for specific items of interest.',
description: 'Search memory',
inputSchema: z.object({
query: z.string().optional().describe('Natural language search query for semantic ranking via ChromaDB vector search. Optional - omit for date-filtered queries only (Chroma cannot filter by date, requires direct SQLite).'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED for initial search), "full" for complete details (use only after reviewing index results)'),
type: z.enum(['observations', 'sessions', 'prompts']).optional().describe('Filter by document type (observations, sessions, or prompts). Omit to search all types.'),
obs_type: z.string().optional().describe('Filter observations by type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change). Only applies when type="observations"'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list). Only applies when type="observations"'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match). Only applies when type="observations"'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('date_desc').describe('Sort order')
query: z.string().optional(),
type: z.enum(['observations', 'sessions', 'prompts']).optional(),
obs_type: z.string().optional(),
concepts: z.string().optional(),
files: z.string().optional(),
project: z.string().optional(),
dateStart: z.union([z.string(), z.number()]).optional(),
dateEnd: z.union([z.string(), z.number()]).optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('date_desc')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['search'];
@@ -129,197 +208,33 @@ const tools = [
},
{
name: 'timeline',
description: 'Fetch timeline of observations around a specific point in time. Supports two modes: anchor-based (fetch observations before/after a specific observation ID) and query-based (semantic search for anchor point). IMPORTANT: Use anchor_id when you know the specific observation, or query to find an anchor point first.',
description: 'Timeline context',
inputSchema: z.object({
query: z.string().optional().describe('Natural language query to find anchor observation (query-based mode). Mutually exclusive with anchor_id.'),
anchor_id: z.number().optional().describe('Observation ID to use as anchor (anchor-based mode). Mutually exclusive with query.'),
before: z.number().min(0).max(100).default(10).describe('Number of observations to fetch before anchor'),
after: z.number().min(0).max(100).default(10).describe('Number of observations to fetch after anchor'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
obs_type: z.string().optional().describe('Filter observations by type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name')
query: z.string().optional(),
anchor: z.number().optional(),
depth_before: z.number().min(0).max(100).default(10),
depth_after: z.number().min(0).max(100).default(10),
type: z.string().optional(),
concepts: z.string().optional(),
files: z.string().optional(),
project: z.string().optional()
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['timeline'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'decisions',
description: 'Semantic shortcut for finding architectural, design, and implementation decisions. Optimized for decision-type observations with relevant keyword boosting.',
inputSchema: z.object({
query: z.string().describe('Natural language query for finding decisions'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['decisions'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'changes',
description: 'Semantic shortcut for finding code changes, refactorings, and modifications. Optimized for change-type observations with relevant keyword boosting.',
inputSchema: z.object({
query: z.string().describe('Natural language query for finding changes'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['changes'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'how_it_works',
description: 'Semantic shortcut for understanding system architecture, design patterns, and implementation details. Optimized for discovery-type observations with architecture/design keyword boosting.',
inputSchema: z.object({
query: z.string().describe('Natural language query for understanding how something works'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['how_it_works'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'search_observations',
description: '[DEPRECATED - Use "search" with type="observations" instead] Search observations (facts/narratives) using FTS5 full-text search. Supports filtering by type, concepts, files, and date range.',
inputSchema: z.object({
query: z.string().optional().describe('Full-text search query (FTS5)'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('relevance').describe('Sort order (relevance only when query provided)')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['search_observations'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'search_sessions',
description: '[DEPRECATED - Use "search" with type="sessions" instead] Search session summaries using FTS5 full-text search. Returns both request_summary and learned_summary fields.',
inputSchema: z.object({
query: z.string().optional().describe('Full-text search query (FTS5)'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('relevance').describe('Sort order (relevance only when query provided)')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['search_sessions'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'search_user_prompts',
description: '[DEPRECATED - Use "search" with type="prompts" instead] Search user prompts using FTS5 full-text search. Searches prompt text only.',
inputSchema: z.object({
query: z.string().optional().describe('Full-text search query (FTS5)'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('relevance').describe('Sort order (relevance only when query provided)')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['search_user_prompts'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'find_by_concept',
description: 'Find observations tagged with specific concepts. Returns observations that match any of the provided concept tags.',
inputSchema: z.object({
concepts: z.string().describe('Concept tag(s) to filter by (single value or comma-separated list)'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['date_desc', 'date_asc']).default('date_desc').describe('Sort order')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['find_by_concept'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'find_by_file',
description: 'Find observations related to specific file paths. Uses partial matching - searches for file paths containing the provided string.',
inputSchema: z.object({
files: z.string().describe('File path(s) to filter by (single value or comma-separated list for partial match)'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['date_desc', 'date_asc']).default('date_desc').describe('Sort order')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['find_by_file'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'find_by_type',
description: 'Find observations of specific types. Returns observations matching any of the provided observation types.',
inputSchema: z.object({
type: z.string().describe('Observation type(s) to filter by (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['date_desc', 'date_asc']).default('date_desc').describe('Sort order')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['find_by_type'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'get_recent_context',
description: 'Get recent session context for timeline display. Returns recent observations, sessions, and user prompts with metadata for building timeline UI.',
description: 'Recent context',
inputSchema: z.object({
limit: z.number().min(1).max(100).default(30).describe('Maximum number of timeline items to return'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
limit: z.number().min(1).max(100).default(30),
type: z.string().optional(),
concepts: z.string().optional(),
files: z.string().optional(),
project: z.string().optional(),
dateStart: z.union([z.string(), z.number()]).optional(),
dateEnd: z.union([z.string(), z.number()]).optional()
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['get_recent_context'];
@@ -328,16 +243,15 @@ const tools = [
},
{
name: 'get_context_timeline',
description: 'Get timeline of observations around a specific observation ID. Returns observations before and after the anchor point with metadata for timeline display.',
description: 'Timeline around ID',
inputSchema: z.object({
anchor_id: z.number().describe('Observation ID to use as anchor point'),
before: z.number().min(0).max(100).default(10).describe('Number of observations to fetch before anchor'),
after: z.number().min(0).max(100).default(10).describe('Number of observations to fetch after anchor'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name')
anchor: z.number(),
depth_before: z.number().min(0).max(100).default(10),
depth_after: z.number().min(0).max(100).default(10),
type: z.string().optional(),
concepts: z.string().optional(),
files: z.string().optional(),
project: z.string().optional()
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['get_context_timeline'];
@@ -345,24 +259,58 @@ const tools = [
}
},
{
name: 'get_timeline_by_query',
description: 'Combined search + timeline tool. First searches for observations matching the query, then returns timeline around the best match. Useful for finding specific observations and viewing their context.',
name: 'progressive_description',
description: 'Usage help',
inputSchema: z.object({
query: z.string().describe('Natural language query to find anchor observation'),
before: z.number().min(0).max(100).default(10).describe('Number of observations to fetch before anchor'),
after: z.number().min(0).max(100).default(10).describe('Number of observations to fetch after anchor'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
topic: z.enum(['workflow', 'search_params', 'examples', 'all']).default('all')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['get_timeline_by_query'];
const endpoint = TOOL_ENDPOINT_MAP['progressive_description'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'get_observation',
description: 'Fetch by ID',
inputSchema: z.object({
id: z.number()
}),
handler: async (args: any) => {
return await callWorkerAPIWithPath('/api/observation', args.id);
}
},
{
name: 'get_batch_observations',
description: 'Batch fetch',
inputSchema: z.object({
ids: z.array(z.number()),
orderBy: z.enum(['date_desc', 'date_asc']).optional(),
limit: z.number().optional(),
project: z.string().optional()
}),
handler: async (args: any) => {
return await callWorkerAPIPost('/api/observations/batch', args);
}
},
{
name: 'get_session',
description: 'Session by ID',
inputSchema: z.object({
id: z.number()
}),
handler: async (args: any) => {
return await callWorkerAPIWithPath('/api/session', args.id);
}
},
{
name: 'get_prompt',
description: 'Prompt by ID',
inputSchema: z.object({
id: z.number()
}),
handler: async (args: any) => {
return await callWorkerAPIWithPath('/api/prompt', args.id);
}
}
];
+11 -58
View File
@@ -17,6 +17,14 @@ import {
} from '../constants/observation-metadata.js';
import { logger } from '../utils/logger.js';
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
import {
parseJsonArray,
formatDateTime,
formatTime,
formatDate,
toRelativePath,
extractFirstFile
} from '../shared/timeline-formatting.js';
// Version marker path - use homedir-based path that works in both CJS and ESM contexts
const VERSION_MARKER_PATH = path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'plugin', '.install-version');
@@ -145,57 +153,6 @@ interface SessionSummary {
created_at_epoch: number;
}
// Helper: Parse JSON array safely
function parseJsonArray(json: string | null): string[] {
if (!json) return [];
try {
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? parsed : [];
} catch (err) {
return [];
}
}
// Helper: Format date with time
function formatDateTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
// Helper: Format just time (no date)
function formatTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
// Helper: Format just date
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
// Helper: Convert absolute paths to relative paths
function toRelativePath(filePath: string, cwd: string): string {
if (path.isAbsolute(filePath)) {
return path.relative(cwd, filePath);
}
return filePath;
}
// Helper: Render a summary field
function renderSummaryField(label: string, value: string | null, color: string, useColors: boolean): string[] {
if (!value) return [];
@@ -544,20 +501,16 @@ export async function generateContext(input?: ContextInput, useColors: boolean =
const summary = item.data;
const summaryTitle = `${summary.request || 'Session started'} (${formatDateTime(summary.displayTime)})`;
const link = summary.shouldShowLink ? `claude-mem://session-summary/${summary.id}` : '';
if (useColors) {
const linkPart = link ? `${colors.dim}[${link}]${colors.reset}` : '';
output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle} ${linkPart}`);
output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle}`);
} else {
const linkPart = link ? ` [→](${link})` : '';
output.push(`**🎯 #S${summary.id}** ${summaryTitle}${linkPart}`);
output.push(`**🎯 #S${summary.id}** ${summaryTitle}`);
}
output.push('');
} else {
const obs = item.data;
const files = parseJsonArray(obs.files_modified);
const file = (files.length > 0 && files[0]) ? toRelativePath(files[0], cwd) : 'General';
const file = extractFirstFile(obs.files_modified, cwd);
if (file !== currentFile) {
if (tableOpen) {
+108 -1
View File
@@ -1614,8 +1614,9 @@ export class SessionStore {
prompts: prompts.map(p => ({
id: p.id,
claude_session_id: p.claude_session_id,
prompt_number: p.prompt_number,
prompt_text: p.prompt_text,
project: p.project,
prompt: p.prompt_text,
created_at: p.created_at,
created_at_epoch: p.created_at_epoch
}))
@@ -1626,6 +1627,112 @@ export class SessionStore {
}
}
/**
* Get a single user prompt by ID
*/
getPromptById(id: number): {
id: number;
claude_session_id: string;
prompt_number: number;
prompt_text: string;
project: string;
created_at: string;
created_at_epoch: number;
} | null {
const stmt = this.db.prepare(`
SELECT
p.id,
p.claude_session_id,
p.prompt_number,
p.prompt_text,
s.project,
p.created_at,
p.created_at_epoch
FROM user_prompts p
LEFT JOIN sdk_sessions s ON p.claude_session_id = s.claude_session_id
WHERE p.id = ?
LIMIT 1
`);
return stmt.get(id) || null;
}
/**
* Get multiple user prompts by IDs
*/
getPromptsByIds(ids: number[]): Array<{
id: number;
claude_session_id: string;
prompt_number: number;
prompt_text: string;
project: string;
created_at: string;
created_at_epoch: number;
}> {
if (ids.length === 0) return [];
const placeholders = ids.map(() => '?').join(',');
const stmt = this.db.prepare(`
SELECT
p.id,
p.claude_session_id,
p.prompt_number,
p.prompt_text,
s.project,
p.created_at,
p.created_at_epoch
FROM user_prompts p
LEFT JOIN sdk_sessions s ON p.claude_session_id = s.claude_session_id
WHERE p.id IN (${placeholders})
ORDER BY p.created_at_epoch DESC
`);
return stmt.all(...ids) as Array<{
id: number;
claude_session_id: string;
prompt_number: number;
prompt_text: string;
project: string;
created_at: string;
created_at_epoch: number;
}>;
}
/**
* Get full session summary by ID (includes request_summary and learned_summary)
*/
getSessionSummaryById(id: number): {
id: number;
sdk_session_id: string | null;
claude_session_id: string;
project: string;
user_prompt: string;
request_summary: string | null;
learned_summary: string | null;
status: string;
created_at: string;
created_at_epoch: number;
} | null {
const stmt = this.db.prepare(`
SELECT
id,
sdk_session_id,
claude_session_id,
project,
user_prompt,
request_summary,
learned_summary,
status,
created_at,
created_at_epoch
FROM sdk_sessions
WHERE id = ?
LIMIT 1
`);
return stmt.get(id) || null;
}
/**
* Close the database connection
*/
+63
View File
@@ -9,6 +9,7 @@
import express from 'express';
import http from 'http';
import path from 'path';
import * as fs from 'fs';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
@@ -142,6 +143,39 @@ export class WorkerService {
}
});
// Instructions endpoint - loads SKILL.md sections on-demand for progressive instruction loading
this.app.get('/api/instructions', async (req, res) => {
const topic = (req.query.topic as string) || 'all';
// Read SKILL.md from plugin directory
// Path resolution: __dirname is build output directory (plugin/scripts/)
// SKILL.md is at plugin/skills/mem-search/SKILL.md
const skillPath = path.join(__dirname, '../skills/mem-search/SKILL.md');
try {
const fullContent = await fs.promises.readFile(skillPath, 'utf-8');
// Extract section based on topic
const section = this.extractInstructionSection(fullContent, topic);
// Return in MCP format
res.json({
content: [{
type: 'text',
text: section
}]
});
} catch (error) {
logger.error('WORKER', 'Failed to load instructions', { topic, skillPath }, error as Error);
res.status(500).json({
content: [{
type: 'text',
text: `Error loading instructions: ${error instanceof Error ? error.message : 'Unknown error'}`
}],
isError: true
});
}
});
// Admin endpoints for process management
this.app.post('/api/admin/restart', async (_req, res) => {
res.json({ status: 'restarting' });
@@ -334,6 +368,35 @@ export class WorkerService {
}
}
/**
* Extract a specific section from instruction content
* Used by /api/instructions endpoint for progressive instruction loading
*/
private extractInstructionSection(content: string, topic: string): string {
const sections: Record<string, string> = {
'workflow': this.extractBetween(content, '## The Workflow', '## Search Parameters'),
'search_params': this.extractBetween(content, '## Search Parameters', '## Examples'),
'examples': this.extractBetween(content, '## Examples', '## Why This Workflow'),
'all': content
};
return sections[topic] || sections['all'];
}
/**
* Extract text between two markers
* Helper for extractInstructionSection
*/
private extractBetween(content: string, startMarker: string, endMarker: string): string {
const startIdx = content.indexOf(startMarker);
const endIdx = content.indexOf(endMarker);
if (startIdx === -1) return content;
if (endIdx === -1) return content.substring(startIdx);
return content.substring(startIdx, endIdx).trim();
}
/**
* Shutdown the worker service
*/
+128 -204
View File
@@ -1,12 +1,13 @@
/**
* FormattingService - Handles all formatting logic for search results
* Extracted from mcp-server.ts to follow worker service organization pattern
* Uses table format matching context-generator style for visual consistency
*/
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
import { logger } from '../../utils/logger.js';
import { TYPE_ICON_MAP, TYPE_WORK_EMOJI_MAP } from '../../constants/observation-metadata.js';
export type FormatType = 'index' | 'full';
// Token estimation constant (matches context-generator)
const CHARS_PER_TOKEN_ESTIMATE = 4;
export class FormattingService {
/**
@@ -15,232 +16,155 @@ export class FormattingService {
formatSearchTips(): string {
return `\n---
💡 Search Strategy:
ALWAYS search with index format FIRST to get an overview and identify relevant results.
This is critical for token efficiency - index format uses ~10x fewer tokens than full format.
1. Search with index to see titles, dates, IDs
2. Use timeline to get context around interesting results
3. Batch fetch full details: get_batch_observations(ids=[...])
Search workflow:
1. Initial search: Use default (index) format to see titles, dates, and sources
2. Review results: Identify which items are most relevant to your needs
3. Deep dive: Only then use format: "full" on specific items of interest
4. Narrow down: Use filters (type, dateStart/dateEnd, concepts, files) to refine results
Other tips:
To search by concept: Use find_by_concept tool
To browse by type: Use find_by_type with ["decision", "feature", etc.]
To sort by date: Use orderBy: "date_desc" or "date_asc"`;
Tips:
Filter by type: obs_type="bugfix,feature"
Filter by date: dateStart="2025-01-01"
Sort: orderBy="date_desc" or "date_asc"`;
}
/**
* Format observation as index entry (title, date, ID only)
* Format time from epoch (matches context-generator formatTime)
*/
formatObservationIndex(obs: ObservationSearchResult, index: number): string {
const title = obs.title || `Observation #${obs.id}`;
const date = new Date(obs.created_at_epoch).toLocaleString();
const type = obs.type ? `[${obs.type}]` : '';
return `${index + 1}. ${type} ${title}
Date: ${date}
Source: claude-mem://observation/${obs.id}`;
private formatTime(epoch: number): string {
return new Date(epoch).toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
/**
* Format session summary as index entry (title, date, ID only)
* Estimate read tokens for an observation
*/
formatSessionIndex(session: SessionSummarySearchResult, index: number): string {
const title = session.request || `Session ${session.sdk_session_id?.substring(0, 8) || 'unknown'}`;
const date = new Date(session.created_at_epoch).toLocaleString();
return `${index + 1}. ${title}
Date: ${date}
Source: claude-mem://session/${session.sdk_session_id}`;
private estimateReadTokens(obs: ObservationSearchResult): number {
const size = (obs.title?.length || 0) +
(obs.subtitle?.length || 0) +
(obs.narrative?.length || 0) +
(obs.facts?.length || 0);
return Math.ceil(size / CHARS_PER_TOKEN_ESTIMATE);
}
/**
* Format user prompt as index entry (full text - don't truncate context!)
* Format observation as table row
* | ID | Time | T | Title | Read | Work |
*/
formatUserPromptIndex(prompt: UserPromptSearchResult, index: number): string {
const date = new Date(prompt.created_at_epoch).toLocaleString();
formatObservationIndex(obs: ObservationSearchResult, _index: number): string {
const id = `#${obs.id}`;
const time = this.formatTime(obs.created_at_epoch);
const icon = TYPE_ICON_MAP[obs.type as keyof typeof TYPE_ICON_MAP] || '•';
const title = obs.title || 'Untitled';
const readTokens = this.estimateReadTokens(obs);
const workEmoji = TYPE_WORK_EMOJI_MAP[obs.type as keyof typeof TYPE_WORK_EMOJI_MAP] || '🔍';
const workTokens = obs.discovery_tokens || 0;
const workDisplay = workTokens > 0 ? `${workEmoji} ${workTokens}` : '-';
return `${index + 1}. "${prompt.prompt_text}"
Date: ${date} | Prompt #${prompt.prompt_number}
Source: claude-mem://user-prompt/${prompt.id}`;
return `| ${id} | ${time} | ${icon} | ${title} | ~${readTokens} | ${workDisplay} |`;
}
/**
* Format observation as text content with metadata
* Format session summary as table row
* | ID | Time | T | Title | - | - |
*/
formatObservationResult(obs: ObservationSearchResult): string {
const title = obs.title || `Observation #${obs.id}`;
// Build content from available fields
const contentParts: string[] = [];
contentParts.push(`## ${title}`);
contentParts.push(`*Source: claude-mem://observation/${obs.id}*`);
contentParts.push('');
if (obs.subtitle) {
contentParts.push(`**${obs.subtitle}**`);
contentParts.push('');
}
if (obs.narrative) {
contentParts.push(obs.narrative);
contentParts.push('');
}
if (obs.text) {
contentParts.push(obs.text);
contentParts.push('');
}
// Add metadata
const metadata: string[] = [];
metadata.push(`Type: ${obs.type}`);
if (obs.facts) {
try {
const facts = JSON.parse(obs.facts);
if (facts.length > 0) {
metadata.push(`Facts: ${facts.join('; ')}`);
}
} catch (e) {
logger.warn('FORMAT', 'Invalid JSON in facts field', { obsId: obs.id });
}
}
if (obs.concepts) {
try {
const concepts = JSON.parse(obs.concepts);
if (concepts.length > 0) {
metadata.push(`Concepts: ${concepts.join(', ')}`);
}
} catch (e) {
logger.warn('FORMAT', 'Invalid JSON in concepts field', { obsId: obs.id });
}
}
if (obs.files_read || obs.files_modified) {
const files: string[] = [];
if (obs.files_read) {
try {
files.push(...JSON.parse(obs.files_read));
} catch (e) {
logger.warn('FORMAT', 'Invalid JSON in files_read field', { obsId: obs.id });
}
}
if (obs.files_modified) {
try {
files.push(...JSON.parse(obs.files_modified));
} catch (e) {
logger.warn('FORMAT', 'Invalid JSON in files_modified field', { obsId: obs.id });
}
}
if (files.length > 0) {
metadata.push(`Files: ${[...new Set(files)].join(', ')}`);
}
}
if (metadata.length > 0) {
contentParts.push('---');
contentParts.push(metadata.join(' | '));
}
// Add date
const date = new Date(obs.created_at_epoch).toLocaleString();
contentParts.push('');
contentParts.push(`---`);
contentParts.push(`Date: ${date}`);
return contentParts.join('\n');
}
/**
* Format session summary as text content with metadata
*/
formatSessionResult(session: SessionSummarySearchResult): string {
formatSessionIndex(session: SessionSummarySearchResult, _index: number): string {
const id = `#S${session.id}`;
const time = this.formatTime(session.created_at_epoch);
const icon = '🎯';
const title = session.request || `Session ${session.sdk_session_id?.substring(0, 8) || 'unknown'}`;
// Build content from available fields
const contentParts: string[] = [];
contentParts.push(`## ${title}`);
contentParts.push(`*Source: claude-mem://session/${session.sdk_session_id}*`);
contentParts.push('');
if (session.completed) {
contentParts.push(`**Completed:** ${session.completed}`);
contentParts.push('');
}
if (session.learned) {
contentParts.push(`**Learned:** ${session.learned}`);
contentParts.push('');
}
if (session.investigated) {
contentParts.push(`**Investigated:** ${session.investigated}`);
contentParts.push('');
}
if (session.next_steps) {
contentParts.push(`**Next Steps:** ${session.next_steps}`);
contentParts.push('');
}
if (session.notes) {
contentParts.push(`**Notes:** ${session.notes}`);
contentParts.push('');
}
// Add metadata
const metadata: string[] = [];
if (session.files_read || session.files_edited) {
const files: string[] = [];
if (session.files_read) {
try {
files.push(...JSON.parse(session.files_read));
} catch (e) {
logger.warn('FORMAT', 'Invalid JSON in session files_read field', { sessionId: session.sdk_session_id });
}
}
if (session.files_edited) {
try {
files.push(...JSON.parse(session.files_edited));
} catch (e) {
logger.warn('FORMAT', 'Invalid JSON in session files_edited field', { sessionId: session.sdk_session_id });
}
}
if (files.length > 0) {
metadata.push(`Files: ${[...new Set(files)].join(', ')}`);
}
}
const date = new Date(session.created_at_epoch).toLocaleDateString();
metadata.push(`Date: ${date}`);
if (metadata.length > 0) {
contentParts.push('---');
contentParts.push(metadata.join(' | '));
}
return contentParts.join('\n');
return `| ${id} | ${time} | ${icon} | ${title} | - | - |`;
}
/**
* Format user prompt as text content with metadata
* Format user prompt as table row
* | ID | Time | T | Title | - | - |
*/
formatUserPromptResult(prompt: UserPromptSearchResult): string {
const contentParts: string[] = [];
contentParts.push(`## User Prompt #${prompt.prompt_number}`);
contentParts.push(`*Source: claude-mem://user-prompt/${prompt.id}*`);
contentParts.push('');
contentParts.push(prompt.prompt_text);
contentParts.push('');
contentParts.push('---');
formatUserPromptIndex(prompt: UserPromptSearchResult, _index: number): string {
const id = `#P${prompt.id}`;
const time = this.formatTime(prompt.created_at_epoch);
const icon = '💬';
// Truncate long prompts for table display
const title = prompt.prompt_text.length > 60
? prompt.prompt_text.substring(0, 57) + '...'
: prompt.prompt_text;
const date = new Date(prompt.created_at_epoch).toLocaleString();
contentParts.push(`Date: ${date}`);
return `| ${id} | ${time} | ${icon} | ${title} | - | - |`;
}
return contentParts.join('\n');
/**
* Generate table header for observations
*/
formatTableHeader(): string {
return `| ID | Time | T | Title | Read | Work |
|-----|------|---|-------|------|------|`;
}
/**
* Generate table header for search results (no Work column)
*/
formatSearchTableHeader(): string {
return `| ID | Time | T | Title | Read |
|----|------|---|-------|------|`;
}
/**
* Format observation as table row for search results (no Work column)
*/
formatObservationSearchRow(obs: ObservationSearchResult, lastTime: string): { row: string; time: string } {
const id = `#${obs.id}`;
const time = this.formatTime(obs.created_at_epoch);
const icon = TYPE_ICON_MAP[obs.type as keyof typeof TYPE_ICON_MAP] || '•';
const title = obs.title || 'Untitled';
const readTokens = this.estimateReadTokens(obs);
// Use ditto mark if same time as previous row
const timeDisplay = time === lastTime ? '″' : time;
return {
row: `| ${id} | ${timeDisplay} | ${icon} | ${title} | ~${readTokens} |`,
time
};
}
/**
* Format session summary as table row for search results (no Work column)
*/
formatSessionSearchRow(session: SessionSummarySearchResult, lastTime: string): { row: string; time: string } {
const id = `#S${session.id}`;
const time = this.formatTime(session.created_at_epoch);
const icon = '🎯';
const title = session.request || `Session ${session.sdk_session_id?.substring(0, 8) || 'unknown'}`;
// Use ditto mark if same time as previous row
const timeDisplay = time === lastTime ? '″' : time;
return {
row: `| ${id} | ${timeDisplay} | ${icon} | ${title} | - |`,
time
};
}
/**
* Format user prompt as table row for search results (no Work column)
*/
formatUserPromptSearchRow(prompt: UserPromptSearchResult, lastTime: string): { row: string; time: string } {
const id = `#P${prompt.id}`;
const time = this.formatTime(prompt.created_at_epoch);
const icon = '💬';
// Truncate long prompts for table display
const title = prompt.prompt_text.length > 60
? prompt.prompt_text.substring(0, 57) + '...'
: prompt.prompt_text;
// Use ditto mark if same time as previous row
const timeDisplay = time === lastTime ? '″' : time;
return {
row: `| ${id} | ${timeDisplay} | ${icon} | ${title} | - |`,
time
};
}
}
+140 -193
View File
@@ -14,8 +14,11 @@ import { FormattingService } from './FormattingService.js';
import { TimelineService, TimelineItem } from './TimelineService.js';
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
import { logger } from '../../utils/logger.js';
import { formatDate, formatTime, extractFirstFile, groupByDate } from '../../shared/timeline-formatting.js';
const COLLECTION_NAME = 'cm__claude-mem';
const RECENCY_WINDOW_DAYS = 90;
const RECENCY_WINDOW_MS = RECENCY_WINDOW_DAYS * 24 * 60 * 60 * 1000;
export class SearchManager {
constructor(
@@ -84,7 +87,7 @@ export class SearchManager {
try {
// Normalize URL-friendly params to internal format
const normalized = this.normalizeParams(args);
const { query, format = 'index', type, obs_type, concepts, files, ...options } = normalized;
const { query, type, obs_type, concepts, files, ...options } = normalized;
let observations: ObservationSearchResult[] = [];
let sessions: SessionSummarySearchResult[] = [];
let prompts: UserPromptSearchResult[] = [];
@@ -132,7 +135,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
const recentMetadata = chromaResults.metadatas.map((meta, idx) => ({
id: chromaResults.ids[idx],
meta,
@@ -198,13 +201,6 @@ export class SearchManager {
const totalResults = observations.length + sessions.length + prompts.length;
if (totalResults === 0) {
if (format === 'json') {
return {
observations: [],
sessions: [],
prompts: []
};
}
return {
content: [{
type: 'text' as const,
@@ -218,15 +214,31 @@ export class SearchManager {
type: 'observation' | 'session' | 'prompt';
data: any;
epoch: number;
created_at: string;
}
const allResults: CombinedResult[] = [
...observations.map(obs => ({ type: 'observation' as const, data: obs, epoch: obs.created_at_epoch })),
...sessions.map(sess => ({ type: 'session' as const, data: sess, epoch: sess.created_at_epoch })),
...prompts.map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch }))
...observations.map(obs => ({
type: 'observation' as const,
data: obs,
epoch: obs.created_at_epoch,
created_at: obs.created_at
})),
...sessions.map(sess => ({
type: 'session' as const,
data: sess,
epoch: sess.created_at_epoch,
created_at: sess.created_at
})),
...prompts.map(prompt => ({
type: 'prompt' as const,
data: prompt,
epoch: prompt.created_at_epoch,
created_at: prompt.created_at
}))
];
// Sort by date (most recent first)
// Sort by date
if (options.orderBy === 'date_desc') {
allResults.sort((a, b) => b.epoch - a.epoch);
} else if (options.orderBy === 'date_asc') {
@@ -236,46 +248,62 @@ export class SearchManager {
// Apply limit across all types
const limitedResults = allResults.slice(0, options.limit || 20);
// Format based on requested format
if (format === 'json') {
// Raw JSON format for exports
return {
observations,
sessions,
prompts
};
}
// Group by date, then by file within each day
const cwd = process.cwd();
const resultsByDate = groupByDate(limitedResults, item => item.created_at);
let combinedText: string;
if (format === 'index') {
const header = `Found ${totalResults} result(s) matching "${query}" (${observations.length} obs, ${sessions.length} sessions, ${prompts.length} prompts):\n\n`;
const formattedResults = limitedResults.map((item, i) => {
if (item.type === 'observation') {
return this.formatter.formatObservationIndex(item.data, i);
} else if (item.type === 'session') {
return this.formatter.formatSessionIndex(item.data, i);
} else {
return this.formatter.formatUserPromptIndex(item.data, i);
// Build output with date/file grouping
const lines: string[] = [];
lines.push(`Found ${totalResults} result(s) matching "${query}" (${observations.length} obs, ${sessions.length} sessions, ${prompts.length} prompts)`);
lines.push('');
for (const [day, dayResults] of resultsByDate) {
lines.push(`### ${day}`);
lines.push('');
// Group by file within this day
const resultsByFile = new Map<string, CombinedResult[]>();
for (const result of dayResults) {
let file = 'General';
if (result.type === 'observation') {
file = extractFirstFile(result.data.files_modified, cwd);
}
});
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults = limitedResults.map(item => {
if (item.type === 'observation') {
return this.formatter.formatObservationResult(item.data);
} else if (item.type === 'session') {
return this.formatter.formatSessionResult(item.data);
} else {
return this.formatter.formatUserPromptResult(item.data);
if (!resultsByFile.has(file)) {
resultsByFile.set(file, []);
}
});
combinedText = formattedResults.join('\n\n---\n\n');
resultsByFile.get(file)!.push(result);
}
// Render each file section
for (const [file, fileResults] of resultsByFile) {
lines.push(`**${file}**`);
lines.push(this.formatter.formatSearchTableHeader());
let lastTime = '';
for (const result of fileResults) {
if (result.type === 'observation') {
const formatted = this.formatter.formatObservationSearchRow(result.data as ObservationSearchResult, lastTime);
lines.push(formatted.row);
lastTime = formatted.time;
} else if (result.type === 'session') {
const formatted = this.formatter.formatSessionSearchRow(result.data as SessionSummarySearchResult, lastTime);
lines.push(formatted.row);
lastTime = formatted.time;
} else {
const formatted = this.formatter.formatUserPromptSearchRow(result.data as UserPromptSearchResult, lastTime);
lines.push(formatted.row);
lastTime = formatted.time;
}
}
lines.push('');
}
}
return {
content: [{
type: 'text' as const,
text: combinedText
text: lines.join('\n')
}]
};
} catch (error: any) {
@@ -295,6 +323,7 @@ export class SearchManager {
async timeline(args: any): Promise<any> {
try {
const { anchor, query, depth_before = 10, depth_after = 10, project } = args;
const cwd = process.cwd();
// Validate: must provide either anchor or query, not both
if (!anchor && !query) {
@@ -333,7 +362,7 @@ export class SearchManager {
logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults?.ids?.length ?? 0 });
if (chromaResults?.ids && chromaResults.ids.length > 0) {
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -495,9 +524,6 @@ export class SearchManager {
lines.push(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`);
lines.push('');
// Legend
lines.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | 🧠 decision`);
lines.push('');
// Group by day
const dayMap = new Map<string, TimelineItem[]>();
@@ -541,10 +567,9 @@ export class SearchManager {
const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary';
const link = `claude-mem://session-summary/${sess.id}`;
const marker = isAnchor ? ' ← **ANCHOR**' : '';
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)}) [→](${link})${marker}`);
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)})${marker}`);
lines.push('');
} else if (item.type === 'prompt') {
if (tableOpen) {
@@ -562,7 +587,7 @@ export class SearchManager {
lines.push('');
} else if (item.type === 'observation') {
const obs = item.data as ObservationSearchResult;
const file = 'General';
const file = extractFirstFile(obs.files_modified, cwd);
if (file !== currentFile) {
if (tableOpen) {
@@ -629,7 +654,7 @@ export class SearchManager {
async decisions(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { query, format = 'index', ...filters } = normalized;
const { query, ...filters } = normalized;
let results: ObservationSearchResult[] = [];
// Search for decision-type observations
@@ -686,20 +711,14 @@ export class SearchManager {
};
}
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} decision(s):\n\n`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
combinedText = header + formattedResults.join('\n\n');
} else {
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} decision(s)\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -719,7 +738,7 @@ export class SearchManager {
async changes(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { format = 'index', ...filters } = normalized;
const { ...filters } = normalized;
let results: ObservationSearchResult[] = [];
// Search for change-type observations and change-related concepts
@@ -784,20 +803,14 @@ export class SearchManager {
};
}
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} change-related observation(s):\n\n`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
combinedText = header + formattedResults.join('\n\n');
} else {
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} change-related observation(s)\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -817,7 +830,7 @@ export class SearchManager {
async howItWorks(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { format = 'index', ...filters } = normalized;
const { ...filters } = normalized;
let results: ObservationSearchResult[] = [];
// Search for how-it-works concept observations
@@ -860,20 +873,14 @@ export class SearchManager {
};
}
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} "how it works" observation(s):\n\n`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
combinedText = header + formattedResults.join('\n\n');
} else {
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} "how it works" observation(s)\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -893,7 +900,7 @@ export class SearchManager {
async searchObservations(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { query, format = 'index', ...options } = normalized;
const { query, ...options } = normalized;
let results: ObservationSearchResult[] = [];
// Vector-first search via ChromaDB
@@ -907,7 +914,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -936,21 +943,14 @@ export class SearchManager {
};
}
// Format based on requested format
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} observation(s) matching "${query}":\n\n`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} observation(s) matching "${query}"\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -970,7 +970,7 @@ export class SearchManager {
async searchSessions(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { query, format = 'index', ...options } = normalized;
const { query, ...options } = normalized;
let results: SessionSummarySearchResult[] = [];
// Vector-first search via ChromaDB
@@ -984,7 +984,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -1013,21 +1013,14 @@ export class SearchManager {
};
}
// Format based on requested format
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} session(s) matching "${query}":\n\n`;
const formattedResults = results.map((session, i) => this.formatter.formatSessionIndex(session, i));
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults = results.map((session) => this.formatter.formatSessionResult(session));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} session(s) matching "${query}"\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((session, i) => this.formatter.formatSessionIndex(session, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -1047,7 +1040,7 @@ export class SearchManager {
async searchUserPrompts(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { query, format = 'index', ...options } = normalized;
const { query, ...options } = normalized;
let results: UserPromptSearchResult[] = [];
// Vector-first search via ChromaDB
@@ -1061,7 +1054,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -1090,21 +1083,14 @@ export class SearchManager {
};
}
// Format based on requested format
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} user prompt(s) matching "${query}":\n\n`;
const formattedResults = results.map((prompt, i) => this.formatter.formatUserPromptIndex(prompt, i));
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults = results.map((prompt) => this.formatter.formatUserPromptResult(prompt));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} user prompt(s) matching "${query}"\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((prompt, i) => this.formatter.formatUserPromptIndex(prompt, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -1124,7 +1110,7 @@ export class SearchManager {
async findByConcept(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { concepts: concept, format = 'index', ...filters } = normalized;
const { concepts: concept, ...filters } = normalized;
let results: ObservationSearchResult[] = [];
// Metadata-first, semantic-enhanced search
@@ -1179,21 +1165,14 @@ export class SearchManager {
};
}
// Format based on requested format
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} observation(s) with concept "${concept}":\n\n`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} observation(s) with concept "${concept}"\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -1213,7 +1192,7 @@ export class SearchManager {
async findByFile(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { files: filePath, format = 'index', ...filters } = normalized;
const { files: filePath, ...filters } = normalized;
let observations: ObservationSearchResult[] = [];
let sessions: SessionSummarySearchResult[] = [];
@@ -1277,42 +1256,24 @@ export class SearchManager {
};
}
let combinedText: string;
if (format === 'index') {
const header = `Found ${totalResults} result(s) for file "${filePath}":\n\n`;
const formattedResults: string[] = [];
// Format as table
const header = `Found ${totalResults} result(s) for file "${filePath}"\n\n${this.formatter.formatTableHeader()}`;
const formattedResults: string[] = [];
// Add observations
observations.forEach((obs, i) => {
formattedResults.push(this.formatter.formatObservationIndex(obs, i));
});
// Add observations
observations.forEach((obs, i) => {
formattedResults.push(this.formatter.formatObservationIndex(obs, i));
});
// Add sessions
sessions.forEach((session, i) => {
formattedResults.push(this.formatter.formatSessionIndex(session, i + observations.length));
});
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults: string[] = [];
// Add observations
observations.forEach((obs) => {
formattedResults.push(this.formatter.formatObservationResult(obs));
});
// Add sessions
sessions.forEach((session) => {
formattedResults.push(this.formatter.formatSessionResult(session));
});
combinedText = formattedResults.join('\n\n---\n\n');
}
// Add sessions
sessions.forEach((session, i) => {
formattedResults.push(this.formatter.formatSessionIndex(session, i + observations.length));
});
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -1332,7 +1293,7 @@ export class SearchManager {
async findByType(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { type, format = 'index', ...filters } = normalized;
const { type, ...filters } = normalized;
const typeStr = Array.isArray(type) ? type.join(', ') : type;
let results: ObservationSearchResult[] = [];
@@ -1388,21 +1349,14 @@ export class SearchManager {
};
}
// Format based on requested format
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} observation(s) with type "${typeStr}":\n\n`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} observation(s) with type "${typeStr}"\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -1556,6 +1510,7 @@ export class SearchManager {
async getContextTimeline(args: any): Promise<any> {
try {
const { anchor, depth_before = 10, depth_after = 10, project } = args;
const cwd = process.cwd();
let anchorEpoch: number;
let anchorId: string | number = anchor;
@@ -1680,9 +1635,6 @@ export class SearchManager {
lines.push(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`);
lines.push('');
// Legend
lines.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | 🧠 decision`);
lines.push('');
// Group by day
const dayMap = new Map<string, TimelineItem[]>();
@@ -1728,10 +1680,9 @@ export class SearchManager {
// Render session
const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary';
const link = `claude-mem://session-summary/${sess.id}`;
const marker = isAnchor ? ' ← **ANCHOR**' : '';
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)}) [→](${link})${marker}`);
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)})${marker}`);
lines.push('');
} else if (item.type === 'prompt') {
// Close any open table
@@ -1752,7 +1703,7 @@ export class SearchManager {
} else if (item.type === 'observation') {
// Render observation in table
const obs = item.data as ObservationSearchResult;
const file = 'General'; // Simplified for timeline view
const file = extractFirstFile(obs.files_modified, cwd);
// Check if we need a new file section
if (file !== currentFile) {
@@ -1824,6 +1775,7 @@ export class SearchManager {
async getTimelineByQuery(args: any): Promise<any> {
try {
const { query, mode = 'auto', depth_before = 10, depth_after = 10, limit = 5, project } = args;
const cwd = process.cwd();
// Step 1: Search for observations
let results: ObservationSearchResult[] = [];
@@ -1837,7 +1789,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) {
// Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -1889,7 +1841,6 @@ export class SearchManager {
if (obs.subtitle) {
lines.push(` - ${obs.subtitle}`);
}
lines.push(` - Source: claude-mem://observation/${obs.id}`);
lines.push('');
}
@@ -1975,9 +1926,6 @@ export class SearchManager {
lines.push(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`);
lines.push('');
// Legend
lines.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | 🧠 decision`);
lines.push('');
// Group by day
const dayMap = new Map<string, TimelineItem[]>();
@@ -2020,9 +1968,8 @@ export class SearchManager {
// Render session
const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary';
const link = `claude-mem://session-summary/${sess.id}`;
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)}) [→](${link})`);
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)})`);
lines.push('');
} else if (item.type === 'prompt') {
// Close any open table
@@ -2043,7 +1990,7 @@ export class SearchManager {
} else if (item.type === 'observation') {
// Render observation in table
const obs = item.data as ObservationSearchResult;
const file = 'General'; // Simplified for timeline view
const file = extractFirstFile(obs.files_modified, cwd);
// Check if we need a new file section
if (file !== currentFile) {
+1 -2
View File
@@ -148,10 +148,9 @@ export class TimelineService {
const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary';
const link = `claude-mem://session-summary/${sess.id}`;
const marker = isAnchor ? ' ← **ANCHOR**' : '';
lines.push(`**🎯 #S${sess.id}** ${title} (${this.formatDateTime(item.epoch)}) [→](${link})${marker}`);
lines.push(`**🎯 #S${sess.id}** ${title} (${this.formatDateTime(item.epoch)})${marker}`);
lines.push('');
} else if (item.type === 'prompt') {
if (tableOpen) {
@@ -38,6 +38,7 @@ export class DataRoutes extends BaseRouteHandler {
// Fetch by ID endpoints
app.get('/api/observation/:id', this.handleGetObservationById.bind(this));
app.post('/api/observations/batch', this.handleGetObservationsByIds.bind(this));
app.get('/api/session/:id', this.handleGetSessionById.bind(this));
app.get('/api/prompt/:id', this.handleGetPromptById.bind(this));
@@ -96,6 +97,36 @@ export class DataRoutes extends BaseRouteHandler {
res.json(observation);
});
/**
* Get observations by array of IDs
* POST /api/observations/batch
* Body: { ids: number[], orderBy?: 'date_desc' | 'date_asc', limit?: number, project?: string }
*/
private handleGetObservationsByIds = this.wrapHandler((req: Request, res: Response): void => {
const { ids, orderBy, limit, project } = req.body;
if (!ids || !Array.isArray(ids)) {
this.badRequest(res, 'ids must be an array of numbers');
return;
}
if (ids.length === 0) {
res.json([]);
return;
}
// Validate all IDs are numbers
if (!ids.every(id => typeof id === 'number' && Number.isInteger(id))) {
this.badRequest(res, 'All ids must be integers');
return;
}
const store = this.dbManager.getSessionStore();
const observations = store.getObservationsByIds(ids, { orderBy, limit, project });
res.json(observations);
});
/**
* Get session by ID
* GET /api/session/:id
+11 -17
View File
@@ -45,7 +45,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Unified search (observations + sessions + prompts)
* GET /api/search?query=...&type=observations&format=index&limit=20
* GET /api/search?query=...&type=observations&limit=20
*/
private handleUnifiedSearch = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.search(req.query);
@@ -63,7 +63,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Semantic shortcut for finding decision observations
* GET /api/decisions?format=index&limit=20
* GET /api/decisions?limit=20
*/
private handleDecisions = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.decisions(req.query);
@@ -72,7 +72,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Semantic shortcut for finding change-related observations
* GET /api/changes?format=index&limit=20
* GET /api/changes?limit=20
*/
private handleChanges = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.changes(req.query);
@@ -81,7 +81,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Semantic shortcut for finding "how it works" explanations
* GET /api/how-it-works?format=index&limit=20
* GET /api/how-it-works?limit=20
*/
private handleHowItWorks = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.howItWorks(req.query);
@@ -90,7 +90,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Search observations (use /api/search?type=observations instead)
* GET /api/search/observations?query=...&format=index&limit=20&project=...
* GET /api/search/observations?query=...&limit=20&project=...
*/
private handleSearchObservations = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.searchObservations(req.query);
@@ -99,7 +99,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Search session summaries
* GET /api/search/sessions?query=...&format=index&limit=20
* GET /api/search/sessions?query=...&limit=20
*/
private handleSearchSessions = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.searchSessions(req.query);
@@ -108,7 +108,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Search user prompts
* GET /api/search/prompts?query=...&format=index&limit=20
* GET /api/search/prompts?query=...&limit=20
*/
private handleSearchPrompts = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.searchUserPrompts(req.query);
@@ -117,7 +117,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Search observations by concept
* GET /api/search/by-concept?concept=discovery&format=index&limit=5
* GET /api/search/by-concept?concept=discovery&limit=5
*/
private handleSearchByConcept = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.findByConcept(req.query);
@@ -126,7 +126,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Search by file path
* GET /api/search/by-file?filePath=...&format=index&limit=10
* GET /api/search/by-file?filePath=...&limit=10
*/
private handleSearchByFile = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.findByFile(req.query);
@@ -135,7 +135,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Search observations by type
* GET /api/search/by-type?type=bugfix&format=index&limit=10
* GET /api/search/by-type?type=bugfix&limit=10
*/
private handleSearchByType = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.findByType(req.query);
@@ -252,7 +252,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Search observations using full-text search',
parameters: {
query: 'Search query (required)',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 20)',
project: 'Filter by project name (optional)'
}
@@ -263,7 +262,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Search session summaries using full-text search',
parameters: {
query: 'Search query (required)',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 20)'
}
},
@@ -273,7 +271,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Search user prompts using full-text search',
parameters: {
query: 'Search query (required)',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 20)',
project: 'Filter by project name (optional)'
}
@@ -284,7 +281,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Find observations by concept tag',
parameters: {
concept: 'Concept tag (required): discovery, decision, bugfix, feature, refactor',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 10)',
project: 'Filter by project name (optional)'
}
@@ -295,7 +291,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Find observations and sessions by file path',
parameters: {
filePath: 'File path or partial path (required)',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results per type (default: 10)',
project: 'Filter by project name (optional)'
}
@@ -306,7 +301,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Find observations by type',
parameters: {
type: 'Observation type (required): discovery, decision, bugfix, feature, refactor',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 10)',
project: 'Filter by project name (optional)'
}
@@ -350,7 +344,7 @@ export class SearchRoutes extends BaseRouteHandler {
}
],
examples: [
'curl "http://localhost:37777/api/search/observations?query=authentication&format=index&limit=5"',
'curl "http://localhost:37777/api/search/observations?query=authentication&limit=5"',
'curl "http://localhost:37777/api/search/by-type?type=bugfix&limit=10"',
'curl "http://localhost:37777/api/context/recent?project=claude-mem&limit=3"',
'curl "http://localhost:37777/api/context/timeline?anchor=123&depth_before=5&depth_after=5"'
+1 -1
View File
@@ -44,7 +44,7 @@ export class SettingsDefaultsManager {
* Default values for all settings
*/
private static readonly DEFAULTS: SettingsDefaults = {
CLAUDE_MEM_MODEL: 'claude-haiku-4-5',
CLAUDE_MEM_MODEL: 'claude-sonnet-4-5',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: '37777',
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
+112
View File
@@ -0,0 +1,112 @@
/**
* Shared timeline formatting utilities
*
* Pure formatting and grouping functions extracted from context-generator.ts
* to be reused by SearchManager and other services.
*/
import path from 'path';
/**
* Parse JSON array string, returning empty array on failure
*/
export function parseJsonArray(json: string | null): string[] {
if (!json) return [];
try {
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? parsed : [];
} catch (err) {
return [];
}
}
/**
* Format date with time (e.g., "Dec 14, 7:30 PM")
*/
export function formatDateTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
/**
* Format just time, no date (e.g., "7:30 PM")
*/
export function formatTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
/**
* Format just date (e.g., "Dec 14, 2025")
*/
export function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
/**
* Convert absolute paths to relative paths
*/
export function toRelativePath(filePath: string, cwd: string): string {
if (path.isAbsolute(filePath)) {
return path.relative(cwd, filePath);
}
return filePath;
}
/**
* Extract first file from files_modified JSON array, or return 'General'
*/
export function extractFirstFile(filesModified: string | null, cwd: string): string {
const files = parseJsonArray(filesModified);
return files.length > 0 ? toRelativePath(files[0], cwd) : 'General';
}
/**
* Group items by date
*
* Generic function that works with any item type that has a date field.
* Returns a Map of date string -> items array, sorted chronologically.
*
* @param items - Array of items to group
* @param getDate - Function to extract date string from each item
* @returns Map of formatted date strings to item arrays, sorted chronologically
*/
export function groupByDate<T>(
items: T[],
getDate: (item: T) => string
): Map<string, T[]> {
// Group by day
const itemsByDay = new Map<string, T[]>();
for (const item of items) {
const itemDate = getDate(item);
const day = formatDate(itemDate);
if (!itemsByDay.has(day)) {
itemsByDay.set(day, []);
}
itemsByDay.get(day)!.push(item);
}
// Sort days chronologically
const sortedEntries = Array.from(itemsByDay.entries()).sort((a, b) => {
const aDate = new Date(a[0]).getTime();
const bDate = new Date(b[0]).getTime();
return aDate - bDate;
});
return new Map(sortedEntries);
}
+1 -1
View File
@@ -3,7 +3,7 @@
* Shared across UI components and hooks
*/
export const DEFAULT_SETTINGS = {
CLAUDE_MEM_MODEL: 'claude-haiku-4-5',
CLAUDE_MEM_MODEL: 'claude-sonnet-4-5',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: '37777',
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',