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 ## Skills in This Directory
### version-bump ### 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. **Usage**: Only for claude-mem maintainers releasing new versions.
+7 -10
View File
@@ -1,6 +1,6 @@
--- ---
name: version-bump 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 # Version Bump Skill
@@ -9,11 +9,10 @@ Manage semantic versioning across the claude-mem project with consistent updates
## Quick Reference ## Quick Reference
**Files requiring updates (ALL FOUR):** **Files requiring updates (ALL THREE):**
1. `package.json` (line 3) 1. `package.json` (line 3)
2. `.claude-plugin/marketplace.json` (line 13) 2. `.claude-plugin/marketplace.json` (line 13)
3. `plugin/.claude-plugin/plugin.json` (line 3) 3. `plugin/.claude-plugin/plugin.json` (line 3)
4. `CLAUDE.md` (line 9 ONLY - version number, NOT version history)
**Semantic versioning:** **Semantic versioning:**
- **PATCH** (x.y.Z): Bugfixes only - **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) 1. Determine version type (PATCH/MINOR/MAJOR)
2. Calculate new version from current 2. Calculate new version from current
3. Preview changes to user 3. Preview changes to user
4. Update ALL FOUR files 4. Update ALL THREE files
5. Verify consistency 5. Verify consistency
6. Build and test 6. Build and test
7. Commit and create git tag 7. Commit and create git tag
@@ -54,29 +53,27 @@ See [operations/scenarios.md](operations/scenarios.md) for examples:
## Critical Rules ## Critical Rules
**ALWAYS:** **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 git tag with format `vX.Y.Z`
- Create GitHub release from the tag - Create GitHub release from the tag
- Generate CHANGELOG.md from releases after creating release - Generate CHANGELOG.md from releases after creating release
- Ask user if version type is unclear - Ask user if version type is unclear
**NEVER:** **NEVER:**
- Update only one, two, or three files - Update only one or two files
- Skip the verification step - Skip the verification step
- Forget to create git tag or GitHub release - Forget to create git tag or GitHub release
- Add version history entries to CLAUDE.md (that's managed separately)
## Verification Checklist ## Verification Checklist
Before considering the task complete: Before considering the task complete:
- [ ] All FOUR files have matching version numbers - [ ] All THREE files have matching version numbers
- [ ] `npm run build` succeeds - [ ] `npm run build` succeeds
- [ ] Git commit created with all version files - [ ] Git commit created with all version files
- [ ] Git tag created (format: vX.Y.Z) - [ ] Git tag created (format: vX.Y.Z)
- [ ] Commit and tags pushed to remote - [ ] Commit and tags pushed to remote
- [ ] GitHub release created from the tag - [ ] GitHub release created from the tag
- [ ] CHANGELOG.md generated and committed - [ ] CHANGELOG.md generated and committed
- [ ] CLAUDE.md: ONLY line 9 updated (version number), NOT version history
## Reference Commands ## Reference Commands
@@ -92,7 +89,7 @@ git tag -l -n1
# Check what will be committed # Check what will be committed
git status 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). 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 ## File Locations
### Version-Tracked Files (ALL FOUR) ### Version-Tracked Files (ALL THREE)
1. **package.json** 1. **package.json**
- Path: `package.json` - Path: `package.json`
@@ -21,11 +21,6 @@ Quick reference for version bump commands and file locations.
- Line: 3 - Line: 3
- Format: `"version": "X.Y.Z",` - Format: `"version": "X.Y.Z",`
4. **CLAUDE.md**
- Path: `CLAUDE.md`
- Line: 9
- Format: `**Current Version**: X.Y.Z`
## Essential Commands ## Essential Commands
### View Current Version ### View Current Version
@@ -39,7 +34,6 @@ grep '"version"' package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
# From all version files # From all version files
grep '"version"' package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json grep '"version"' package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json
grep "Current Version" CLAUDE.md
``` ```
### Verify Version Consistency ### 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", # package.json:3: "version": "5.3.0",
# .claude-plugin/marketplace.json:13: "version": "5.3.0", # .claude-plugin/marketplace.json:13: "version": "5.3.0",
# plugin/.claude-plugin/plugin.json:3: "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 ### Git Commands
@@ -96,7 +86,7 @@ npm test
```bash ```bash
# Stage version files # 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 # Commit
git commit -m "Release vX.Y.Z: [Description]" git commit -m "Release vX.Y.Z: [Description]"
@@ -163,11 +153,11 @@ MAJOR: 5.3.2 → 6.0.0 (resets minor and patch)
```bash ```bash
# Example: 5.3.0 → 5.3.1 # 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 # 2. Build and test
npm run build npm run build
# 3. Commit and tag # 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 commit -m "Release v5.3.1: Fixed observer crash"
git tag v5.3.1 -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 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 ```bash
# Example: 5.3.0 → 5.4.0 # 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 # 2. Build and test
npm run build npm run build
# 3. Commit and tag # 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 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 tag v5.4.0 -m "Release v5.4.0: Added dark mode support"
git push && git push --tags git push && git push --tags
@@ -195,11 +185,11 @@ gh release create v5.4.0 --title "v5.4.0" --generate-notes
```bash ```bash
# Example: 5.3.0 → 6.0.0 # 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 # 2. Build and test
npm run build npm run build
# 3. Commit and tag # 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 commit -m "Release v6.0.0: Storage layer redesign"
git tag v6.0.0 -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 git push && git push --tags
@@ -19,7 +19,7 @@ Current: 4.2.8
New: 4.2.9 (PATCH) New: 4.2.9 (PATCH)
Steps: Steps:
1. Update all four files to 4.2.9 1. Update all three files to 4.2.9
2. npm run build 2. npm run build
3. git commit -m "Release v4.2.9: Fixed memory leak in search" 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" 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) New: 4.3.0 (MINOR - reset patch to 0)
Steps: Steps:
1. Update all four files to 4.3.0 1. Update all three files to 4.3.0
2. npm run build 2. npm run build
3. git commit -m "Release v4.3.0: Added web search MCP integration" 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" 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) New: 5.0.0 (MAJOR - reset minor and patch to 0)
Steps: Steps:
1. Update all four files to 5.0.0 1. Update all three files to 5.0.0
2. npm run build 2. npm run build
3. git commit -m "Release v5.0.0: Storage layer redesign with migration required" 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" 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) New: 4.2.9 (PATCH)
Steps: Steps:
1. Update all four files to 4.2.9 1. Update all three files to 4.2.9
2. npm run build 2. npm run build
3. git commit -m "Release v4.2.9: Multiple bug fixes 3. git commit -m "Release v4.2.9: Multiple bug fixes
@@ -122,7 +122,7 @@ Current: 5.1.0
New: 5.2.0 (MINOR) New: 5.2.0 (MINOR)
Steps: Steps:
1. Update all four files to 5.2.0 1. Update all three files to 5.2.0
2. npm run build 2. npm run build
3. git commit -m "Release v5.2.0: Dark mode support + bug fixes 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" - package.json: "version": "4.2.9"
- marketplace.json: "version": "4.2.9" - marketplace.json: "version": "4.2.9"
- plugin.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 - Git tag: v4.2.9
Proceed? (yes/no) Proceed? (yes/no)
@@ -116,18 +115,6 @@ File: `plugin/.claude-plugin/plugin.json`
Update line 3 with new version. 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 ## Step 6: Verify Consistency
```bash ```bash
@@ -155,7 +142,7 @@ Build must succeed before proceeding.
```bash ```bash
# Stage all version files # 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 # Commit with descriptive message
git commit -m "Release vX.Y.Z: [Brief description] 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/). 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 ## [7.2.1] - 2025-12-14
## Translation Script Enhancements ## Translation Script Enhancements
@@ -2392,12 +2418,12 @@ None (patch version)
## [4.3.0] - 2025-10-25 ## [4.3.0] - 2025-10-25
## What's Changed ## 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 * feat: Enhanced context hook with session observations and cross-platform improvements by @thedotmack in https://github.com/thedotmack/claude-mem/pull/25
## New Contributors ## New Contributors
* @thedotmack made their first contribution in https://github.com/thedotmack/claude-mem/pull/25 * @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 **Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v4.2.11...v4.3.0
## [4.2.10] - 2025-10-25 ## [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. 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 ## Architecture
**5 Lifecycle Hooks**: SessionStart → UserPromptSubmit → PostToolUse → Summary → SessionEnd **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 ## 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` **Viewer UI**: http://localhost:37777
**Skills only**: `npm run sync-marketplace`
**Viewer UI**: `npm run build && npm run sync-marketplace && npm run worker:restart`
## Configuration ## Configuration
Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created with defaults on first run. Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created with defaults on first run.
**Core Settings:** **Core Settings:**
- `CLAUDE_MEM_MODEL` - Model for observations/summaries (default: claude-haiku-4-5) - `CLAUDE_MEM_MODEL` - Model for observations/summaries (default: claude-sonnet-4-5)
- `CLAUDE_MEM_CONTEXT_OBSERVATIONS` - Observations injected at SessionStart (default: 50) - `CLAUDE_MEM_CONTEXT_OBSERVATIONS` - Observations injected at SessionStart
- `CLAUDE_MEM_WORKER_PORT` - Worker service port (default: 37777) - `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) - `CLAUDE_MEM_WORKER_HOST` - Worker bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)
**System Configuration:** **System Configuration:**
- `CLAUDE_MEM_DATA_DIR` - Data directory location (default: ~/.claude-mem) - `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_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 ## 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/` - **Installed Plugin**: `~/.claude/plugins/marketplaces/thedotmack/`
- **Database**: `~/.claude-mem/claude-mem.db` - **Database**: `~/.claude-mem/claude-mem.db`
- **Chroma**: `~/.claude-mem/chroma/` - **Chroma**: `~/.claude-mem/chroma/`
- **Usage Logs**: `~/.claude-mem/usage-logs/usage-YYYY-MM-DD.jsonl`
## Requirements ## 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) - **uv** (all platforms - auto-installed if missing, provides Python for Chroma)
- Node.js >= 18 (build tools only) - Node.js (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`
## Documentation ## Documentation
**Public Docs**: https://docs.claude-mem.ai (Mintlify) **Public Docs**: https://docs.claude-mem.ai (Mintlify)
**Source**: `docs/public/` - MDX files, edit `docs.json` for navigation **Source**: `docs/public/` - MDX files, edit `docs.json` for navigation
**Deploy**: Auto-deploys from GitHub on push to main **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 - 🔒 **Privacy Control** - Use `<private>` tags to exclude sensitive content from storage
- ⚙️ **Context Configuration** - Fine-grained control over what context gets injected - ⚙️ **Context Configuration** - Fine-grained control over what context gets injected
- 🤖 **Automatic Operation** - No manual intervention required - 🤖 **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 - 🧪 **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 | | 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_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_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 | | `CLAUDE_MEM_DATA_DIR` | `~/.claude-mem` | Data directory location |
@@ -350,7 +350,7 @@ curl http://localhost:37777/api/settings
```json ```json
{ {
"CLAUDE_MEM_MODEL": "claude-haiku-4-5", "CLAUDE_MEM_MODEL": "claude-sonnet-4-5",
"CLAUDE_MEM_WORKER_PORT": "37777", "CLAUDE_MEM_WORKER_PORT": "37777",
"CLAUDE_MEM_CONTEXT_OBSERVATIONS": "50" "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 ### Environment Variables
```bash ```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_CONTEXT_OBSERVATIONS=50 # Observations injected at SessionStart
CLAUDE_MEM_WORKER_PORT=37777 # Worker service port CLAUDE_MEM_WORKER_PORT=37777 # Worker service port
CLAUDE_MEM_PYTHON_VERSION=3.13 # Python version for chroma-mcp 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({ const queryResult = query({
prompt: messageGenerator, prompt: messageGenerator,
options: { options: {
model: 'claude-haiku-4-5', model: 'claude-sonnet-4-5',
disallowedTools: ['Bash', 'Read', 'Write', ...], // Observer-only disallowedTools: ['Bash', 'Read', 'Write', ...], // Observer-only
abortController: session.abortController 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 | | 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_CONTEXT_OBSERVATIONS` | `50` | Number of observations to inject |
| `CLAUDE_MEM_WORKER_PORT` | `37777` | Worker service port | | `CLAUDE_MEM_WORKER_PORT` | `37777` | Worker service port |
| `CLAUDE_MEM_SKIP_TOOLS` | `ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion` | Comma-separated tools to exclude from observations | | `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: Shorthand model names automatically forward to the latest version:
- `haiku` - Fast, cost-efficient (default) - `haiku` - Fast, cost-efficient
- `sonnet` - Balanced - `sonnet` - Balanced (default)
- `opus` - Most capable - `opus` - Most capable
### Using the Interactive Script ### Using the Interactive Script
@@ -53,7 +53,7 @@ Edit `~/.claude-mem/settings.json`:
```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 | | 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 | | **Worker Port** | 37777 | Port for background worker service |
| **MCP search server** | true | Enable Model Context Protocol search tools | | **MCP search server** | true | Enable Model Context Protocol search tools |
| **Include last summary** | false | Add previous session's summary to context | | **Include last summary** | false | Add previous session's summary to context |
@@ -420,7 +420,7 @@ npm run worker:logs
### Invalid Model Name ### 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): Valid shorthand models (forward to latest version):
- haiku - 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 - ⚙️ **Context Configuration** - Fine-grained control over what context gets injected
- 🤖 **Automatic Operation** - No manual intervention required - 🤖 **Automatic Operation** - No manual intervention required
- 📊 **FTS5 Search** - Fast full-text search across observations - 📊 **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 ## How It Works
+1 -1
View File
@@ -46,7 +46,7 @@ GET /api/context/recent?project=my-project&limit=3
### Environment Variables ### Environment Variables
```bash ```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_CONTEXT_OBSERVATIONS=50 # Observations injected at SessionStart
CLAUDE_MEM_WORKER_PORT=37777 # Worker service port CLAUDE_MEM_WORKER_PORT=37777 # Worker service port
CLAUDE_MEM_PYTHON_VERSION=3.13 # Python version for chroma-mcp 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 ## 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 - `http://localhost:37777/api/observation/{id}` - Get specific observation by ID
- `claude-mem://session/abc-456` - Specific session - View all observations in the web viewer at `http://localhost:37777`
- `claude-mem://user-prompt/789` - Specific user prompt
These citations enable referencing specific historical context in your work. These citations enable referencing specific historical context in your work.
+1
View File
@@ -32,6 +32,7 @@
}, },
"scripts": { "scripts": {
"build": "node scripts/build-hooks.js", "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": "vitest",
"test:parser": "npx tsx src/sdk/parser.test.ts", "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", "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 ## When to Use
Use when users ask about PREVIOUS sessions (not current conversation): Use when users ask about PREVIOUS sessions (not current conversation):
- "Did we already fix this?" - "Did we already fix this?"
- "How did we solve X last time?" - "How did we solve X last time?"
- "What happened last week?" - "What happened last week?"
@@ -19,47 +20,57 @@ Use when users ask about PREVIOUS sessions (not current conversation):
**ALWAYS follow this exact flow:** **ALWAYS follow this exact flow:**
1. **Search** - Get an index of results with IDs 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 3. **Review** - Look at titles/dates/context, pick relevant IDs
4. **Fetch** - Get full details ONLY for those IDs 4. **Fetch** - Get full details ONLY for those IDs
### Step 1: Search Everything ### Step 1: Search Everything
```bash Use the `search` MCP tool:
curl "http://localhost:37777/api/search?query=authentication&format=index&limit=5"
```
**Required parameters:** **Required parameters:**
- `query` - Search term - `query` - Search term
- `format=index` - ALWAYS start with index (lightweight) - `limit: 20` - You can request large indexes as necessary
- `limit=5` - Start small (3-5 results) - `project` - Project name (required)
**Example:**
```
search(query="authentication", limit=20, project="my-project")
```
**Returns:** **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 | Time | T | Title | Read | Work |
ID: 10942 |----|------|---|-------|------|------|
| #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 Use the `timeline` MCP tool:
# Get timeline around an observation ID
curl "http://localhost:37777/api/timeline?anchor=11131&depth_before=3&depth_after=3"
# Or use query to find + get timeline in one step **Example with observation ID:**
curl "http://localhost:37777/api/timeline?query=authentication&depth_before=3&depth_after=3"
```
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. **Returns exactly `depth_before + 1 + depth_after` items** - observations, sessions, and prompts interleaved chronologically around the anchor.
**When to use:** **When to use:**
- User asks "what was happening when..." - User asks "what was happening when..."
- Need to understand sequence of events - Need to understand sequence of events
- Want broader context around a specific observation - 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 ### Step 4: Fetch by ID
For each relevant ID, fetch full details: For each relevant ID, fetch full details using MCP tools:
```bash **Fetch multiple observations (ALWAYS use for 2+ IDs):**
# Fetch observation
curl "http://localhost:37777/api/observation/11131"
# Fetch session ```
curl "http://localhost:37777/api/session/2005" get_batch_observations(ids=[11131, 10942, 10855])
```
# Fetch prompt **With ordering and limit:**
curl "http://localhost:37777/api/prompt/5421"
```
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:** **ID formats:**
- Observations: Just the number (11131) - Observations: Just the number (11131)
- Sessions: Just the number (2005) from "S2005" - Sessions: Just the number (2005) from "S2005"
- Prompts: Just the number (5421) - 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 ## Search Parameters
**Basic:** **Basic:**
- `query` - What to search for (required) - `query` - What to search for (required)
- `format` - "index" or "full" (always use "index" first) - `limit` - How many results (default 20)
- `limit` - How many results (default 5, max 100) - `project` - Filter by project name (required)
**Filters (optional):** **Filters (optional):**
- `type` - Filter to "observations", "sessions", or "prompts" - `type` - Filter to "observations", "sessions", or "prompts"
- `project` - Filter by project name
- `dateStart` - Start date (YYYY-MM-DD or epoch timestamp) - `dateStart` - Start date (YYYY-MM-DD or epoch timestamp)
- `dateEnd` - End 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 - `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 ## Examples
**Find recent bug fixes:** **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:** **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:** **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? ## Why This Workflow?
**Token efficiency:** **Token efficiency:**
- Index format: ~50-100 tokens per result
- Full format: ~500-1000 tokens per result - **Search results:** ~50-100 tokens per result (table index)
- **10x difference** - only fetch full when you know it's relevant - **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:** **Clarity:**
- See everything first
- Pick what matters
- Get details only for what you need
## Error Handling - See everything first (table index)
- Get timeline context around interesting results
If search fails, tell the user the worker isn't available and suggest: - Pick what matters based on context
```bash - Fetch details only for what you need (batch when possible)
pm2 list # Check if worker is running
```
--- ---
**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 # Change AI model
{ {
"env": { "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> = { const TOOL_ENDPOINT_MAP: Record<string, string> = {
'search': '/api/search', 'search': '/api/search',
'timeline': '/api/timeline', '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_recent_context': '/api/context/recent',
'get_context_timeline': '/api/context/timeline', '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 * Verify Worker is accessible
*/ */
@@ -103,24 +182,24 @@ async function verifyWorkerConnection(): Promise<boolean> {
/** /**
* Tool definitions with HTTP-based handlers * Tool definitions with HTTP-based handlers
* Descriptions removed - use progressive_description tool for parameter documentation
*/ */
const tools = [ const tools = [
{ {
name: 'search', 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({ 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).'), query: z.string().optional(),
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(),
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(),
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(),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list). Only applies when type="observations"'), files: z.string().optional(),
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(),
project: z.string().optional().describe('Filter by project name'), dateStart: z.union([z.string(), z.number()]).optional(),
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(),
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),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'), offset: z.number().min(0).default(0),
offset: z.number().min(0).default(0).describe('Number of results to skip'), orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('date_desc')
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('date_desc').describe('Sort order')
}), }),
handler: async (args: any) => { handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['search']; const endpoint = TOOL_ENDPOINT_MAP['search'];
@@ -129,197 +208,33 @@ const tools = [
}, },
{ {
name: 'timeline', 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({ inputSchema: z.object({
query: z.string().optional().describe('Natural language query to find anchor observation (query-based mode). Mutually exclusive with anchor_id.'), query: z.string().optional(),
anchor_id: z.number().optional().describe('Observation ID to use as anchor (anchor-based mode). Mutually exclusive with query.'), anchor: z.number().optional(),
before: z.number().min(0).max(100).default(10).describe('Number of observations to fetch before anchor'), depth_before: z.number().min(0).max(100).default(10),
after: z.number().min(0).max(100).default(10).describe('Number of observations to fetch after anchor'), depth_after: z.number().min(0).max(100).default(10),
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(),
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(),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'), files: z.string().optional(),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'), project: z.string().optional()
project: z.string().optional().describe('Filter by project name')
}), }),
handler: async (args: any) => { handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['timeline']; const endpoint = TOOL_ENDPOINT_MAP['timeline'];
return await callWorkerAPI(endpoint, args); 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', 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({ inputSchema: z.object({
limit: z.number().min(1).max(100).default(30).describe('Maximum number of timeline items to return'), limit: z.number().min(1).max(100).default(30),
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(),
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(),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'), files: z.string().optional(),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'), project: z.string().optional(),
project: z.string().optional().describe('Filter by project name'), dateStart: z.union([z.string(), z.number()]).optional(),
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()
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
}), }),
handler: async (args: any) => { handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['get_recent_context']; const endpoint = TOOL_ENDPOINT_MAP['get_recent_context'];
@@ -328,16 +243,15 @@ const tools = [
}, },
{ {
name: 'get_context_timeline', 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({ inputSchema: z.object({
anchor_id: z.number().describe('Observation ID to use as anchor point'), anchor: z.number(),
before: z.number().min(0).max(100).default(10).describe('Number of observations to fetch before anchor'), depth_before: z.number().min(0).max(100).default(10),
after: z.number().min(0).max(100).default(10).describe('Number of observations to fetch after anchor'), depth_after: z.number().min(0).max(100).default(10),
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(),
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(),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'), files: z.string().optional(),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'), project: z.string().optional()
project: z.string().optional().describe('Filter by project name')
}), }),
handler: async (args: any) => { handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['get_context_timeline']; const endpoint = TOOL_ENDPOINT_MAP['get_context_timeline'];
@@ -345,24 +259,58 @@ const tools = [
} }
}, },
{ {
name: 'get_timeline_by_query', name: 'progressive_description',
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.', description: 'Usage help',
inputSchema: z.object({ inputSchema: z.object({
query: z.string().describe('Natural language query to find anchor observation'), topic: z.enum(['workflow', 'search_params', 'examples', 'all']).default('all')
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)')
}), }),
handler: async (args: any) => { 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); 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'; } from '../constants/observation-metadata.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.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 // 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'); const VERSION_MARKER_PATH = path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'plugin', '.install-version');
@@ -145,57 +153,6 @@ interface SessionSummary {
created_at_epoch: number; 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 // Helper: Render a summary field
function renderSummaryField(label: string, value: string | null, color: string, useColors: boolean): string[] { function renderSummaryField(label: string, value: string | null, color: string, useColors: boolean): string[] {
if (!value) return []; if (!value) return [];
@@ -544,20 +501,16 @@ export async function generateContext(input?: ContextInput, useColors: boolean =
const summary = item.data; const summary = item.data;
const summaryTitle = `${summary.request || 'Session started'} (${formatDateTime(summary.displayTime)})`; const summaryTitle = `${summary.request || 'Session started'} (${formatDateTime(summary.displayTime)})`;
const link = summary.shouldShowLink ? `claude-mem://session-summary/${summary.id}` : '';
if (useColors) { if (useColors) {
const linkPart = link ? `${colors.dim}[${link}]${colors.reset}` : ''; output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle}`);
output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle} ${linkPart}`);
} else { } else {
const linkPart = link ? ` [→](${link})` : ''; output.push(`**🎯 #S${summary.id}** ${summaryTitle}`);
output.push(`**🎯 #S${summary.id}** ${summaryTitle}${linkPart}`);
} }
output.push(''); output.push('');
} else { } else {
const obs = item.data; const obs = item.data;
const files = parseJsonArray(obs.files_modified); const file = extractFirstFile(obs.files_modified, cwd);
const file = (files.length > 0 && files[0]) ? toRelativePath(files[0], cwd) : 'General';
if (file !== currentFile) { if (file !== currentFile) {
if (tableOpen) { if (tableOpen) {
+108 -1
View File
@@ -1614,8 +1614,9 @@ export class SessionStore {
prompts: prompts.map(p => ({ prompts: prompts.map(p => ({
id: p.id, id: p.id,
claude_session_id: p.claude_session_id, claude_session_id: p.claude_session_id,
prompt_number: p.prompt_number,
prompt_text: p.prompt_text,
project: p.project, project: p.project,
prompt: p.prompt_text,
created_at: p.created_at, created_at: p.created_at,
created_at_epoch: p.created_at_epoch 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 * Close the database connection
*/ */
+63
View File
@@ -9,6 +9,7 @@
import express from 'express'; import express from 'express';
import http from 'http'; import http from 'http';
import path from 'path'; import path from 'path';
import * as fs from 'fs';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.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 // Admin endpoints for process management
this.app.post('/api/admin/restart', async (_req, res) => { this.app.post('/api/admin/restart', async (_req, res) => {
res.json({ status: 'restarting' }); 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 * Shutdown the worker service
*/ */
+128 -204
View File
@@ -1,12 +1,13 @@
/** /**
* FormattingService - Handles all formatting logic for search results * 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 { 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 { export class FormattingService {
/** /**
@@ -15,232 +16,155 @@ export class FormattingService {
formatSearchTips(): string { formatSearchTips(): string {
return `\n--- return `\n---
💡 Search Strategy: 💡 Search Strategy:
ALWAYS search with index format FIRST to get an overview and identify relevant results. 1. Search with index to see titles, dates, IDs
This is critical for token efficiency - index format uses ~10x fewer tokens than full format. 2. Use timeline to get context around interesting results
3. Batch fetch full details: get_batch_observations(ids=[...])
Search workflow: Tips:
1. Initial search: Use default (index) format to see titles, dates, and sources Filter by type: obs_type="bugfix,feature"
2. Review results: Identify which items are most relevant to your needs Filter by date: dateStart="2025-01-01"
3. Deep dive: Only then use format: "full" on specific items of interest Sort: orderBy="date_desc" or "date_asc"`;
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"`;
} }
/** /**
* Format observation as index entry (title, date, ID only) * Format time from epoch (matches context-generator formatTime)
*/ */
formatObservationIndex(obs: ObservationSearchResult, index: number): string { private formatTime(epoch: number): string {
const title = obs.title || `Observation #${obs.id}`; return new Date(epoch).toLocaleString('en-US', {
const date = new Date(obs.created_at_epoch).toLocaleString(); hour: 'numeric',
const type = obs.type ? `[${obs.type}]` : ''; minute: '2-digit',
hour12: true
return `${index + 1}. ${type} ${title} });
Date: ${date}
Source: claude-mem://observation/${obs.id}`;
} }
/** /**
* Format session summary as index entry (title, date, ID only) * Estimate read tokens for an observation
*/ */
formatSessionIndex(session: SessionSummarySearchResult, index: number): string { private estimateReadTokens(obs: ObservationSearchResult): number {
const title = session.request || `Session ${session.sdk_session_id?.substring(0, 8) || 'unknown'}`; const size = (obs.title?.length || 0) +
const date = new Date(session.created_at_epoch).toLocaleString(); (obs.subtitle?.length || 0) +
(obs.narrative?.length || 0) +
return `${index + 1}. ${title} (obs.facts?.length || 0);
Date: ${date} return Math.ceil(size / CHARS_PER_TOKEN_ESTIMATE);
Source: claude-mem://session/${session.sdk_session_id}`;
} }
/** /**
* 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 { formatObservationIndex(obs: ObservationSearchResult, _index: number): string {
const date = new Date(prompt.created_at_epoch).toLocaleString(); 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}" return `| ${id} | ${time} | ${icon} | ${title} | ~${readTokens} | ${workDisplay} |`;
Date: ${date} | Prompt #${prompt.prompt_number}
Source: claude-mem://user-prompt/${prompt.id}`;
} }
/** /**
* Format observation as text content with metadata * Format session summary as table row
* | ID | Time | T | Title | - | - |
*/ */
formatObservationResult(obs: ObservationSearchResult): string { formatSessionIndex(session: SessionSummarySearchResult, _index: number): string {
const title = obs.title || `Observation #${obs.id}`; const id = `#S${session.id}`;
const time = this.formatTime(session.created_at_epoch);
// Build content from available fields const icon = '🎯';
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 {
const title = session.request || `Session ${session.sdk_session_id?.substring(0, 8) || 'unknown'}`; const title = session.request || `Session ${session.sdk_session_id?.substring(0, 8) || 'unknown'}`;
// Build content from available fields return `| ${id} | ${time} | ${icon} | ${title} | - | - |`;
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');
} }
/** /**
* Format user prompt as text content with metadata * Format user prompt as table row
* | ID | Time | T | Title | - | - |
*/ */
formatUserPromptResult(prompt: UserPromptSearchResult): string { formatUserPromptIndex(prompt: UserPromptSearchResult, _index: number): string {
const contentParts: string[] = []; const id = `#P${prompt.id}`;
contentParts.push(`## User Prompt #${prompt.prompt_number}`); const time = this.formatTime(prompt.created_at_epoch);
contentParts.push(`*Source: claude-mem://user-prompt/${prompt.id}*`); const icon = '💬';
contentParts.push(''); // Truncate long prompts for table display
contentParts.push(prompt.prompt_text); const title = prompt.prompt_text.length > 60
contentParts.push(''); ? prompt.prompt_text.substring(0, 57) + '...'
contentParts.push('---'); : prompt.prompt_text;
const date = new Date(prompt.created_at_epoch).toLocaleString(); return `| ${id} | ${time} | ${icon} | ${title} | - | - |`;
contentParts.push(`Date: ${date}`); }
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 { TimelineService, TimelineItem } from './TimelineService.js';
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js'; import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
import { logger } from '../../utils/logger.js'; import { logger } from '../../utils/logger.js';
import { formatDate, formatTime, extractFirstFile, groupByDate } from '../../shared/timeline-formatting.js';
const COLLECTION_NAME = 'cm__claude-mem'; 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 { export class SearchManager {
constructor( constructor(
@@ -84,7 +87,7 @@ export class SearchManager {
try { try {
// Normalize URL-friendly params to internal format // Normalize URL-friendly params to internal format
const normalized = this.normalizeParams(args); 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 observations: ObservationSearchResult[] = [];
let sessions: SessionSummarySearchResult[] = []; let sessions: SessionSummarySearchResult[] = [];
let prompts: UserPromptSearchResult[] = []; let prompts: UserPromptSearchResult[] = [];
@@ -132,7 +135,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) { if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days) // 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) => ({ const recentMetadata = chromaResults.metadatas.map((meta, idx) => ({
id: chromaResults.ids[idx], id: chromaResults.ids[idx],
meta, meta,
@@ -198,13 +201,6 @@ export class SearchManager {
const totalResults = observations.length + sessions.length + prompts.length; const totalResults = observations.length + sessions.length + prompts.length;
if (totalResults === 0) { if (totalResults === 0) {
if (format === 'json') {
return {
observations: [],
sessions: [],
prompts: []
};
}
return { return {
content: [{ content: [{
type: 'text' as const, type: 'text' as const,
@@ -218,15 +214,31 @@ export class SearchManager {
type: 'observation' | 'session' | 'prompt'; type: 'observation' | 'session' | 'prompt';
data: any; data: any;
epoch: number; epoch: number;
created_at: string;
} }
const allResults: CombinedResult[] = [ const allResults: CombinedResult[] = [
...observations.map(obs => ({ type: 'observation' as const, data: obs, epoch: obs.created_at_epoch })), ...observations.map(obs => ({
...sessions.map(sess => ({ type: 'session' as const, data: sess, epoch: sess.created_at_epoch })), type: 'observation' as const,
...prompts.map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch })) 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') { if (options.orderBy === 'date_desc') {
allResults.sort((a, b) => b.epoch - a.epoch); allResults.sort((a, b) => b.epoch - a.epoch);
} else if (options.orderBy === 'date_asc') { } else if (options.orderBy === 'date_asc') {
@@ -236,46 +248,62 @@ export class SearchManager {
// Apply limit across all types // Apply limit across all types
const limitedResults = allResults.slice(0, options.limit || 20); const limitedResults = allResults.slice(0, options.limit || 20);
// Format based on requested format // Group by date, then by file within each day
if (format === 'json') { const cwd = process.cwd();
// Raw JSON format for exports const resultsByDate = groupByDate(limitedResults, item => item.created_at);
return {
observations,
sessions,
prompts
};
}
let combinedText: string; // Build output with date/file grouping
if (format === 'index') { const lines: string[] = [];
const header = `Found ${totalResults} result(s) matching "${query}" (${observations.length} obs, ${sessions.length} sessions, ${prompts.length} prompts):\n\n`; lines.push(`Found ${totalResults} result(s) matching "${query}" (${observations.length} obs, ${sessions.length} sessions, ${prompts.length} prompts)`);
const formattedResults = limitedResults.map((item, i) => { lines.push('');
if (item.type === 'observation') {
return this.formatter.formatObservationIndex(item.data, i); for (const [day, dayResults] of resultsByDate) {
} else if (item.type === 'session') { lines.push(`### ${day}`);
return this.formatter.formatSessionIndex(item.data, i); lines.push('');
} else {
return this.formatter.formatUserPromptIndex(item.data, i); // 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);
} }
}); if (!resultsByFile.has(file)) {
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips(); resultsByFile.set(file, []);
} 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);
} }
}); resultsByFile.get(file)!.push(result);
combinedText = formattedResults.join('\n\n---\n\n'); }
// 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 { return {
content: [{ content: [{
type: 'text' as const, type: 'text' as const,
text: combinedText text: lines.join('\n')
}] }]
}; };
} catch (error: any) { } catch (error: any) {
@@ -295,6 +323,7 @@ export class SearchManager {
async timeline(args: any): Promise<any> { async timeline(args: any): Promise<any> {
try { try {
const { anchor, query, depth_before = 10, depth_after = 10, project } = args; const { anchor, query, depth_before = 10, depth_after = 10, project } = args;
const cwd = process.cwd();
// Validate: must provide either anchor or query, not both // Validate: must provide either anchor or query, not both
if (!anchor && !query) { if (!anchor && !query) {
@@ -333,7 +362,7 @@ export class SearchManager {
logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults?.ids?.length ?? 0 }); logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults?.ids?.length ?? 0 });
if (chromaResults?.ids && 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 recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx]; const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo; 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(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`);
lines.push(''); lines.push('');
// Legend
lines.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | 🧠 decision`);
lines.push('');
// Group by day // Group by day
const dayMap = new Map<string, TimelineItem[]>(); const dayMap = new Map<string, TimelineItem[]>();
@@ -541,10 +567,9 @@ export class SearchManager {
const sess = item.data as SessionSummarySearchResult; const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary'; const title = sess.request || 'Session summary';
const link = `claude-mem://session-summary/${sess.id}`;
const marker = isAnchor ? ' ← **ANCHOR**' : ''; 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(''); lines.push('');
} else if (item.type === 'prompt') { } else if (item.type === 'prompt') {
if (tableOpen) { if (tableOpen) {
@@ -562,7 +587,7 @@ export class SearchManager {
lines.push(''); lines.push('');
} else if (item.type === 'observation') { } else if (item.type === 'observation') {
const obs = item.data as ObservationSearchResult; const obs = item.data as ObservationSearchResult;
const file = 'General'; const file = extractFirstFile(obs.files_modified, cwd);
if (file !== currentFile) { if (file !== currentFile) {
if (tableOpen) { if (tableOpen) {
@@ -629,7 +654,7 @@ export class SearchManager {
async decisions(args: any): Promise<any> { async decisions(args: any): Promise<any> {
try { try {
const normalized = this.normalizeParams(args); const normalized = this.normalizeParams(args);
const { query, format = 'index', ...filters } = normalized; const { query, ...filters } = normalized;
let results: ObservationSearchResult[] = []; let results: ObservationSearchResult[] = [];
// Search for decision-type observations // Search for decision-type observations
@@ -686,20 +711,14 @@ export class SearchManager {
}; };
} }
let combinedText: string; // Format as table
if (format === 'index') { const header = `Found ${results.length} decision(s)\n\n${this.formatter.formatTableHeader()}`;
const header = `Found ${results.length} decision(s):\n\n`; const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
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');
}
return { return {
content: [{ content: [{
type: 'text' as const, type: 'text' as const,
text: combinedText text: header + '\n' + formattedResults.join('\n')
}] }]
}; };
} catch (error: any) { } catch (error: any) {
@@ -719,7 +738,7 @@ export class SearchManager {
async changes(args: any): Promise<any> { async changes(args: any): Promise<any> {
try { try {
const normalized = this.normalizeParams(args); const normalized = this.normalizeParams(args);
const { format = 'index', ...filters } = normalized; const { ...filters } = normalized;
let results: ObservationSearchResult[] = []; let results: ObservationSearchResult[] = [];
// Search for change-type observations and change-related concepts // Search for change-type observations and change-related concepts
@@ -784,20 +803,14 @@ export class SearchManager {
}; };
} }
let combinedText: string; // Format as table
if (format === 'index') { const header = `Found ${results.length} change-related observation(s)\n\n${this.formatter.formatTableHeader()}`;
const header = `Found ${results.length} change-related observation(s):\n\n`; const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
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');
}
return { return {
content: [{ content: [{
type: 'text' as const, type: 'text' as const,
text: combinedText text: header + '\n' + formattedResults.join('\n')
}] }]
}; };
} catch (error: any) { } catch (error: any) {
@@ -817,7 +830,7 @@ export class SearchManager {
async howItWorks(args: any): Promise<any> { async howItWorks(args: any): Promise<any> {
try { try {
const normalized = this.normalizeParams(args); const normalized = this.normalizeParams(args);
const { format = 'index', ...filters } = normalized; const { ...filters } = normalized;
let results: ObservationSearchResult[] = []; let results: ObservationSearchResult[] = [];
// Search for how-it-works concept observations // Search for how-it-works concept observations
@@ -860,20 +873,14 @@ export class SearchManager {
}; };
} }
let combinedText: string; // Format as table
if (format === 'index') { const header = `Found ${results.length} "how it works" observation(s)\n\n${this.formatter.formatTableHeader()}`;
const header = `Found ${results.length} "how it works" observation(s):\n\n`; const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
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');
}
return { return {
content: [{ content: [{
type: 'text' as const, type: 'text' as const,
text: combinedText text: header + '\n' + formattedResults.join('\n')
}] }]
}; };
} catch (error: any) { } catch (error: any) {
@@ -893,7 +900,7 @@ export class SearchManager {
async searchObservations(args: any): Promise<any> { async searchObservations(args: any): Promise<any> {
try { try {
const normalized = this.normalizeParams(args); const normalized = this.normalizeParams(args);
const { query, format = 'index', ...options } = normalized; const { query, ...options } = normalized;
let results: ObservationSearchResult[] = []; let results: ObservationSearchResult[] = [];
// Vector-first search via ChromaDB // Vector-first search via ChromaDB
@@ -907,7 +914,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) { if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days) // 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 recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx]; const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo; return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -936,21 +943,14 @@ export class SearchManager {
}; };
} }
// Format based on requested format // Format as table
let combinedText: string; const header = `Found ${results.length} observation(s) matching "${query}"\n\n${this.formatter.formatTableHeader()}`;
if (format === 'index') { const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
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');
}
return { return {
content: [{ content: [{
type: 'text' as const, type: 'text' as const,
text: combinedText text: header + '\n' + formattedResults.join('\n')
}] }]
}; };
} catch (error: any) { } catch (error: any) {
@@ -970,7 +970,7 @@ export class SearchManager {
async searchSessions(args: any): Promise<any> { async searchSessions(args: any): Promise<any> {
try { try {
const normalized = this.normalizeParams(args); const normalized = this.normalizeParams(args);
const { query, format = 'index', ...options } = normalized; const { query, ...options } = normalized;
let results: SessionSummarySearchResult[] = []; let results: SessionSummarySearchResult[] = [];
// Vector-first search via ChromaDB // Vector-first search via ChromaDB
@@ -984,7 +984,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) { if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days) // 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 recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx]; const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo; return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -1013,21 +1013,14 @@ export class SearchManager {
}; };
} }
// Format based on requested format // Format as table
let combinedText: string; const header = `Found ${results.length} session(s) matching "${query}"\n\n${this.formatter.formatTableHeader()}`;
if (format === 'index') { const formattedResults = results.map((session, i) => this.formatter.formatSessionIndex(session, i));
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');
}
return { return {
content: [{ content: [{
type: 'text' as const, type: 'text' as const,
text: combinedText text: header + '\n' + formattedResults.join('\n')
}] }]
}; };
} catch (error: any) { } catch (error: any) {
@@ -1047,7 +1040,7 @@ export class SearchManager {
async searchUserPrompts(args: any): Promise<any> { async searchUserPrompts(args: any): Promise<any> {
try { try {
const normalized = this.normalizeParams(args); const normalized = this.normalizeParams(args);
const { query, format = 'index', ...options } = normalized; const { query, ...options } = normalized;
let results: UserPromptSearchResult[] = []; let results: UserPromptSearchResult[] = [];
// Vector-first search via ChromaDB // Vector-first search via ChromaDB
@@ -1061,7 +1054,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) { if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days) // 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 recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx]; const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo; return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -1090,21 +1083,14 @@ export class SearchManager {
}; };
} }
// Format based on requested format // Format as table
let combinedText: string; const header = `Found ${results.length} user prompt(s) matching "${query}"\n\n${this.formatter.formatTableHeader()}`;
if (format === 'index') { const formattedResults = results.map((prompt, i) => this.formatter.formatUserPromptIndex(prompt, i));
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');
}
return { return {
content: [{ content: [{
type: 'text' as const, type: 'text' as const,
text: combinedText text: header + '\n' + formattedResults.join('\n')
}] }]
}; };
} catch (error: any) { } catch (error: any) {
@@ -1124,7 +1110,7 @@ export class SearchManager {
async findByConcept(args: any): Promise<any> { async findByConcept(args: any): Promise<any> {
try { try {
const normalized = this.normalizeParams(args); const normalized = this.normalizeParams(args);
const { concepts: concept, format = 'index', ...filters } = normalized; const { concepts: concept, ...filters } = normalized;
let results: ObservationSearchResult[] = []; let results: ObservationSearchResult[] = [];
// Metadata-first, semantic-enhanced search // Metadata-first, semantic-enhanced search
@@ -1179,21 +1165,14 @@ export class SearchManager {
}; };
} }
// Format based on requested format // Format as table
let combinedText: string; const header = `Found ${results.length} observation(s) with concept "${concept}"\n\n${this.formatter.formatTableHeader()}`;
if (format === 'index') { const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
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');
}
return { return {
content: [{ content: [{
type: 'text' as const, type: 'text' as const,
text: combinedText text: header + '\n' + formattedResults.join('\n')
}] }]
}; };
} catch (error: any) { } catch (error: any) {
@@ -1213,7 +1192,7 @@ export class SearchManager {
async findByFile(args: any): Promise<any> { async findByFile(args: any): Promise<any> {
try { try {
const normalized = this.normalizeParams(args); const normalized = this.normalizeParams(args);
const { files: filePath, format = 'index', ...filters } = normalized; const { files: filePath, ...filters } = normalized;
let observations: ObservationSearchResult[] = []; let observations: ObservationSearchResult[] = [];
let sessions: SessionSummarySearchResult[] = []; let sessions: SessionSummarySearchResult[] = [];
@@ -1277,42 +1256,24 @@ export class SearchManager {
}; };
} }
let combinedText: string; // Format as table
if (format === 'index') { const header = `Found ${totalResults} result(s) for file "${filePath}"\n\n${this.formatter.formatTableHeader()}`;
const header = `Found ${totalResults} result(s) for file "${filePath}":\n\n`; const formattedResults: string[] = [];
const formattedResults: string[] = [];
// Add observations // Add observations
observations.forEach((obs, i) => { observations.forEach((obs, i) => {
formattedResults.push(this.formatter.formatObservationIndex(obs, i)); formattedResults.push(this.formatter.formatObservationIndex(obs, i));
}); });
// Add sessions // Add sessions
sessions.forEach((session, i) => { sessions.forEach((session, i) => {
formattedResults.push(this.formatter.formatSessionIndex(session, i + observations.length)); 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');
}
return { return {
content: [{ content: [{
type: 'text' as const, type: 'text' as const,
text: combinedText text: header + '\n' + formattedResults.join('\n')
}] }]
}; };
} catch (error: any) { } catch (error: any) {
@@ -1332,7 +1293,7 @@ export class SearchManager {
async findByType(args: any): Promise<any> { async findByType(args: any): Promise<any> {
try { try {
const normalized = this.normalizeParams(args); const normalized = this.normalizeParams(args);
const { type, format = 'index', ...filters } = normalized; const { type, ...filters } = normalized;
const typeStr = Array.isArray(type) ? type.join(', ') : type; const typeStr = Array.isArray(type) ? type.join(', ') : type;
let results: ObservationSearchResult[] = []; let results: ObservationSearchResult[] = [];
@@ -1388,21 +1349,14 @@ export class SearchManager {
}; };
} }
// Format based on requested format // Format as table
let combinedText: string; const header = `Found ${results.length} observation(s) with type "${typeStr}"\n\n${this.formatter.formatTableHeader()}`;
if (format === 'index') { const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
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');
}
return { return {
content: [{ content: [{
type: 'text' as const, type: 'text' as const,
text: combinedText text: header + '\n' + formattedResults.join('\n')
}] }]
}; };
} catch (error: any) { } catch (error: any) {
@@ -1556,6 +1510,7 @@ export class SearchManager {
async getContextTimeline(args: any): Promise<any> { async getContextTimeline(args: any): Promise<any> {
try { try {
const { anchor, depth_before = 10, depth_after = 10, project } = args; const { anchor, depth_before = 10, depth_after = 10, project } = args;
const cwd = process.cwd();
let anchorEpoch: number; let anchorEpoch: number;
let anchorId: string | number = anchor; 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(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`);
lines.push(''); lines.push('');
// Legend
lines.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | 🧠 decision`);
lines.push('');
// Group by day // Group by day
const dayMap = new Map<string, TimelineItem[]>(); const dayMap = new Map<string, TimelineItem[]>();
@@ -1728,10 +1680,9 @@ export class SearchManager {
// Render session // Render session
const sess = item.data as SessionSummarySearchResult; const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary'; const title = sess.request || 'Session summary';
const link = `claude-mem://session-summary/${sess.id}`;
const marker = isAnchor ? ' ← **ANCHOR**' : ''; 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(''); lines.push('');
} else if (item.type === 'prompt') { } else if (item.type === 'prompt') {
// Close any open table // Close any open table
@@ -1752,7 +1703,7 @@ export class SearchManager {
} else if (item.type === 'observation') { } else if (item.type === 'observation') {
// Render observation in table // Render observation in table
const obs = item.data as ObservationSearchResult; 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 // Check if we need a new file section
if (file !== currentFile) { if (file !== currentFile) {
@@ -1824,6 +1775,7 @@ export class SearchManager {
async getTimelineByQuery(args: any): Promise<any> { async getTimelineByQuery(args: any): Promise<any> {
try { try {
const { query, mode = 'auto', depth_before = 10, depth_after = 10, limit = 5, project } = args; const { query, mode = 'auto', depth_before = 10, depth_after = 10, limit = 5, project } = args;
const cwd = process.cwd();
// Step 1: Search for observations // Step 1: Search for observations
let results: ObservationSearchResult[] = []; let results: ObservationSearchResult[] = [];
@@ -1837,7 +1789,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) { if (chromaResults.ids.length > 0) {
// Filter by recency (90 days) // 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 recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx]; const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo; return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -1889,7 +1841,6 @@ export class SearchManager {
if (obs.subtitle) { if (obs.subtitle) {
lines.push(` - ${obs.subtitle}`); lines.push(` - ${obs.subtitle}`);
} }
lines.push(` - Source: claude-mem://observation/${obs.id}`);
lines.push(''); 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(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`);
lines.push(''); lines.push('');
// Legend
lines.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | 🧠 decision`);
lines.push('');
// Group by day // Group by day
const dayMap = new Map<string, TimelineItem[]>(); const dayMap = new Map<string, TimelineItem[]>();
@@ -2020,9 +1968,8 @@ export class SearchManager {
// Render session // Render session
const sess = item.data as SessionSummarySearchResult; const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary'; 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(''); lines.push('');
} else if (item.type === 'prompt') { } else if (item.type === 'prompt') {
// Close any open table // Close any open table
@@ -2043,7 +1990,7 @@ export class SearchManager {
} else if (item.type === 'observation') { } else if (item.type === 'observation') {
// Render observation in table // Render observation in table
const obs = item.data as ObservationSearchResult; 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 // Check if we need a new file section
if (file !== currentFile) { if (file !== currentFile) {
+1 -2
View File
@@ -148,10 +148,9 @@ export class TimelineService {
const sess = item.data as SessionSummarySearchResult; const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary'; const title = sess.request || 'Session summary';
const link = `claude-mem://session-summary/${sess.id}`;
const marker = isAnchor ? ' ← **ANCHOR**' : ''; 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(''); lines.push('');
} else if (item.type === 'prompt') { } else if (item.type === 'prompt') {
if (tableOpen) { if (tableOpen) {
@@ -38,6 +38,7 @@ export class DataRoutes extends BaseRouteHandler {
// Fetch by ID endpoints // Fetch by ID endpoints
app.get('/api/observation/:id', this.handleGetObservationById.bind(this)); 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/session/:id', this.handleGetSessionById.bind(this));
app.get('/api/prompt/:id', this.handleGetPromptById.bind(this)); app.get('/api/prompt/:id', this.handleGetPromptById.bind(this));
@@ -96,6 +97,36 @@ export class DataRoutes extends BaseRouteHandler {
res.json(observation); 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 session by ID
* GET /api/session/:id * GET /api/session/:id
+11 -17
View File
@@ -45,7 +45,7 @@ export class SearchRoutes extends BaseRouteHandler {
/** /**
* Unified search (observations + sessions + prompts) * 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> => { private handleUnifiedSearch = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.search(req.query); const result = await this.searchManager.search(req.query);
@@ -63,7 +63,7 @@ export class SearchRoutes extends BaseRouteHandler {
/** /**
* Semantic shortcut for finding decision observations * 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> => { private handleDecisions = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.decisions(req.query); const result = await this.searchManager.decisions(req.query);
@@ -72,7 +72,7 @@ export class SearchRoutes extends BaseRouteHandler {
/** /**
* Semantic shortcut for finding change-related observations * 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> => { private handleChanges = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.changes(req.query); 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 * 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> => { private handleHowItWorks = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.howItWorks(req.query); 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) * 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> => { private handleSearchObservations = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.searchObservations(req.query); const result = await this.searchManager.searchObservations(req.query);
@@ -99,7 +99,7 @@ export class SearchRoutes extends BaseRouteHandler {
/** /**
* Search session summaries * 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> => { private handleSearchSessions = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.searchSessions(req.query); const result = await this.searchManager.searchSessions(req.query);
@@ -108,7 +108,7 @@ export class SearchRoutes extends BaseRouteHandler {
/** /**
* Search user prompts * 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> => { private handleSearchPrompts = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.searchUserPrompts(req.query); const result = await this.searchManager.searchUserPrompts(req.query);
@@ -117,7 +117,7 @@ export class SearchRoutes extends BaseRouteHandler {
/** /**
* Search observations by concept * 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> => { private handleSearchByConcept = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.findByConcept(req.query); const result = await this.searchManager.findByConcept(req.query);
@@ -126,7 +126,7 @@ export class SearchRoutes extends BaseRouteHandler {
/** /**
* Search by file path * 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> => { private handleSearchByFile = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.findByFile(req.query); const result = await this.searchManager.findByFile(req.query);
@@ -135,7 +135,7 @@ export class SearchRoutes extends BaseRouteHandler {
/** /**
* Search observations by type * 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> => { private handleSearchByType = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.findByType(req.query); const result = await this.searchManager.findByType(req.query);
@@ -252,7 +252,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Search observations using full-text search', description: 'Search observations using full-text search',
parameters: { parameters: {
query: 'Search query (required)', query: 'Search query (required)',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 20)', limit: 'Number of results (default: 20)',
project: 'Filter by project name (optional)' project: 'Filter by project name (optional)'
} }
@@ -263,7 +262,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Search session summaries using full-text search', description: 'Search session summaries using full-text search',
parameters: { parameters: {
query: 'Search query (required)', query: 'Search query (required)',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 20)' limit: 'Number of results (default: 20)'
} }
}, },
@@ -273,7 +271,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Search user prompts using full-text search', description: 'Search user prompts using full-text search',
parameters: { parameters: {
query: 'Search query (required)', query: 'Search query (required)',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 20)', limit: 'Number of results (default: 20)',
project: 'Filter by project name (optional)' project: 'Filter by project name (optional)'
} }
@@ -284,7 +281,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Find observations by concept tag', description: 'Find observations by concept tag',
parameters: { parameters: {
concept: 'Concept tag (required): discovery, decision, bugfix, feature, refactor', concept: 'Concept tag (required): discovery, decision, bugfix, feature, refactor',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 10)', limit: 'Number of results (default: 10)',
project: 'Filter by project name (optional)' project: 'Filter by project name (optional)'
} }
@@ -295,7 +291,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Find observations and sessions by file path', description: 'Find observations and sessions by file path',
parameters: { parameters: {
filePath: 'File path or partial path (required)', filePath: 'File path or partial path (required)',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results per type (default: 10)', limit: 'Number of results per type (default: 10)',
project: 'Filter by project name (optional)' project: 'Filter by project name (optional)'
} }
@@ -306,7 +301,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Find observations by type', description: 'Find observations by type',
parameters: { parameters: {
type: 'Observation type (required): discovery, decision, bugfix, feature, refactor', type: 'Observation type (required): discovery, decision, bugfix, feature, refactor',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 10)', limit: 'Number of results (default: 10)',
project: 'Filter by project name (optional)' project: 'Filter by project name (optional)'
} }
@@ -350,7 +344,7 @@ export class SearchRoutes extends BaseRouteHandler {
} }
], ],
examples: [ 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/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/recent?project=claude-mem&limit=3"',
'curl "http://localhost:37777/api/context/timeline?anchor=123&depth_before=5&depth_after=5"' '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 * Default values for all settings
*/ */
private static readonly DEFAULTS: SettingsDefaults = { 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_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: '37777', CLAUDE_MEM_WORKER_PORT: '37777',
CLAUDE_MEM_WORKER_HOST: '127.0.0.1', 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 * Shared across UI components and hooks
*/ */
export const DEFAULT_SETTINGS = { 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_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: '37777', CLAUDE_MEM_WORKER_PORT: '37777',
CLAUDE_MEM_WORKER_HOST: '127.0.0.1', CLAUDE_MEM_WORKER_HOST: '127.0.0.1',