Compare commits

...

39 Commits

Author SHA1 Message Date
Alex Newman 79789bb558 Merge branch 'feature/mem-search-enhancements' for v7.3.0 release 2025-12-16 15:47:18 -05:00
Alex Newman 2e919df2b4 chore: bump version to 7.2.4 2025-12-15 18:35:18 -05:00
Alex Newman 75cd1335cc docs: update CHANGELOG.md for v7.2.4 2025-12-15 18:34:52 -05:00
Alex Newman cd103ccf73 chore: bump version to 7.2.4
Update endless mode setup instructions with improved configuration guidance.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-15 18:33:54 -05:00
Alex Newman d0ff9738eb Refactor ContextSettingsModal and remove Sidebar component
- Removed MCP toggle state and related logic from ContextSettingsModal.
- Eliminated the Sidebar component entirely, consolidating its functionality elsewhere.
- Cleaned up unused imports and effects related to MCP status fetching and toggling.
2025-12-15 18:32:25 -05:00
Alex Newman 00c1cd7db7 docs: update token savings to token efficiency and add endless mode documentation 2025-12-15 18:25:54 -05:00
Alex Newman 1e091b8871 docs: remove token savings mention from mem-search skill description 2025-12-15 18:23:24 -05:00
Alex Newman 1295b98fcc docs: update beta features documentation with important caveats and projected results 2025-12-15 17:27:03 -05:00
Alex Newman 7375c11ecd docs: update CHANGELOG.md for v7.2.3
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 23:39:43 -05:00
Alex Newman 47cb403889 fix: add pre-restart delay to prevent MCP server failures on plugin updates
Add 2-second delay before worker restart in ensureWorkerVersionMatches() to
give files time to sync. Fixes issue where MCP server would fail after plugin
updates because restart happened too quickly.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 23:38:40 -05:00
Alex Newman a6737c122f docs: update CHANGELOG.md for v7.2.2
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 22:56:24 -05:00
Alex Newman 6ea5869589 chore: bump version to 7.2.2
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 22:55:39 -05:00
Alex Newman 32be34505a refactor: consolidate mem-search skill, remove desktop-skill duplication
- Delete separate desktop-skill/ directory (was outdated)
- Generate mem-search.zip during build from plugin/skills/mem-search/
- Update docs with correct MCP tool list and new download path
- Single source of truth for Claude Desktop skill

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 22:55:09 -05:00
Alex Newman e5aa60b742 chore: update version to 7.3.0 in package.json 2025-12-14 22:03:10 -05:00
Alex Newman d9133465eb chore: bump version to 7.3.0 2025-12-14 22:00:54 -05:00
Alex Newman 61488042d8 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>
2025-12-14 21:58:11 -05:00
Alex Newman 3f5c61c327 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>
2025-12-14 21:44:42 -05:00
Alex Newman ace12f8cd7 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>
2025-12-14 21:40:23 -05:00
Alex Newman 9d509e07f5 Merge branch 'main' into feature/mem-search-enhancements
Resolved conflicts in built files by rebuilding from merged source.
All plugin/scripts files regenerated from current source code.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 21:33:55 -05:00
Alex Newman 305e52010c 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>
2025-12-14 21:33:07 -05:00
Alex Newman 6dd13c00ba 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.
2025-12-14 21:05:14 -05:00
Alex Newman 8703e0ee13 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>
2025-12-14 20:55:50 -05:00
Alex Newman 9bac3faae9 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>
2025-12-14 20:54:32 -05:00
Alex Newman 7ef93343a4 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>
2025-12-14 20:20:23 -05:00
Alex Newman f07eb17a33 Refactor code structure for improved readability and maintainability 2025-12-14 20:13:48 -05:00
Alex Newman b97579dfec refactor: update documentation and API references for version bump and search functionalities 2025-12-14 20:12:39 -05:00
Alex Newman 2ec72f948d 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.
2025-12-14 19:48:53 -05:00
Alex Newman b45e8b2a29 Merge remote-tracking branch 'refs/remotes/origin/feature/mem-search-enhancements' into feature/mem-search-enhancements 2025-12-14 19:24:01 -05:00
Alex Newman 29e6441d32 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.
2025-12-14 19:23:48 -05:00
claude[bot] 445ee723c2 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>
2025-12-14 23:43:57 +00:00
Alex Newman 01e235c058 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>
2025-12-14 18:36:10 -05:00
Alex Newman 7fdf5e75ab refactor: replace happy_path_error__with_fallback with logger.happyPathError (#313)
- Removed all instances of happy_path_error__with_fallback from various hooks, services, and utilities.
- Introduced logger.happyPathError for consistent logging of unexpected nulls and fallback values.
- Updated the logger utility to include a new happyPathError method with enhanced context and stack trace.
- Deprecated silent-debug utility as all logging functionality has been migrated to the logger.
2025-12-14 16:56:31 -05:00
Alex Newman 43db22728e fix: improve error handling and logging in summary and transcript processing
- Enhanced error handling in summary generation by using fallback messages for missing assistant messages.
- Updated the `buildSummaryPrompt` function to streamline the retrieval of the last assistant message.
- Improved the `extractLastMessage` function to log errors when transcript files are missing or empty, and to ensure proper handling of messages without content.
- Added checks to ensure that messages of the specified role are found in the transcript, with appropriate logging for missing messages.
- Refactored the logging mechanism to provide clearer insights into processing failures and successes.
2025-12-14 16:28:57 -05:00
Alex Newman fad2dc9a15 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.
2025-12-14 16:21:25 -05:00
Alex Newman eb76a76a5b removed double package 2025-12-14 15:57:39 -05:00
Alex Newman 4949ae333d chore: update CHANGELOG.md for v7.2.1 2025-12-14 15:46:17 -05:00
Alex Newman 7f88b7fa5e chore: bump version to 7.2.1
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 15:45:12 -05:00
Alex Newman 4ddc5a01bb feat: cherry-pick translation script improvements from PR #250
Add caching, parallel processing, and tier-based translation scripts:
- Caching system via .translation-cache.json to skip unchanged content
- --force flag to override cache and re-translate
- --parallel flag for concurrent translations
- Tier-based npm scripts (translate:tier1-4, translate:all)
- Better markdown wrapper stripping
- Translation disclaimer at top of files
- Uses Bun for better performance

Changes cherry-picked from PR #250 while preserving current version
(7.2.0) and worker scripts. Does not include translated README files.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 15:40:39 -05:00
Alex Newman c422ea133f chore: update CHANGELOG.md for v7.2.0 2025-12-14 15:34:52 -05:00
71 changed files with 2367 additions and 1859 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"plugins": [
{
"name": "claude-mem",
"version": "7.2.0",
"version": "7.3.0",
"source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions"
}
+1 -1
View File
@@ -19,7 +19,7 @@ This directory contains skills **for developing and maintaining the claude-mem p
## Skills in This Directory
### version-bump
Manages semantic versioning for the claude-mem project itself. Handles updating all four version files (package.json, marketplace.json, plugin.json, CLAUDE.md), creating git tags, and GitHub releases.
Manages semantic versioning for the claude-mem project itself. Handles updating all three version files (package.json, marketplace.json, plugin.json), creating git tags, and GitHub releases.
**Usage**: Only for claude-mem maintainers releasing new versions.
+7 -10
View File
@@ -1,6 +1,6 @@
---
name: version-bump
description: Manage semantic version updates for claude-mem project. Handles patch, minor, and major version increments following semantic versioning. Updates package.json, marketplace.json, plugin.json, and CLAUDE.md version number (NOT version history). Creates git tags and GitHub releases. Auto-generates CHANGELOG.md from releases.
description: Manage semantic version updates for claude-mem project. Handles patch, minor, and major version increments following semantic versioning. Updates package.json, marketplace.json, and plugin.json. Creates git tags and GitHub releases. Auto-generates CHANGELOG.md from releases.
---
# Version Bump Skill
@@ -9,11 +9,10 @@ Manage semantic versioning across the claude-mem project with consistent updates
## Quick Reference
**Files requiring updates (ALL FOUR):**
**Files requiring updates (ALL THREE):**
1. `package.json` (line 3)
2. `.claude-plugin/marketplace.json` (line 13)
3. `plugin/.claude-plugin/plugin.json` (line 3)
4. `CLAUDE.md` (line 9 ONLY - version number, NOT version history)
**Semantic versioning:**
- **PATCH** (x.y.Z): Bugfixes only
@@ -37,7 +36,7 @@ See [operations/workflow.md](operations/workflow.md) for detailed step-by-step p
1. Determine version type (PATCH/MINOR/MAJOR)
2. Calculate new version from current
3. Preview changes to user
4. Update ALL FOUR files
4. Update ALL THREE files
5. Verify consistency
6. Build and test
7. Commit and create git tag
@@ -54,29 +53,27 @@ See [operations/scenarios.md](operations/scenarios.md) for examples:
## Critical Rules
**ALWAYS:**
- Update ALL FOUR files with matching version numbers
- Update ALL THREE files with matching version numbers
- Create git tag with format `vX.Y.Z`
- Create GitHub release from the tag
- Generate CHANGELOG.md from releases after creating release
- Ask user if version type is unclear
**NEVER:**
- Update only one, two, or three files
- Update only one or two files
- Skip the verification step
- Forget to create git tag or GitHub release
- Add version history entries to CLAUDE.md (that's managed separately)
## Verification Checklist
Before considering the task complete:
- [ ] All FOUR files have matching version numbers
- [ ] All THREE files have matching version numbers
- [ ] `npm run build` succeeds
- [ ] Git commit created with all version files
- [ ] Git tag created (format: vX.Y.Z)
- [ ] Commit and tags pushed to remote
- [ ] GitHub release created from the tag
- [ ] CHANGELOG.md generated and committed
- [ ] CLAUDE.md: ONLY line 9 updated (version number), NOT version history
## Reference Commands
@@ -92,7 +89,7 @@ git tag -l -n1
# Check what will be committed
git status
git diff package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json CLAUDE.md
git diff package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json
```
For more commands, see [operations/reference.md](operations/reference.md).
@@ -4,7 +4,7 @@ Quick reference for version bump commands and file locations.
## File Locations
### Version-Tracked Files (ALL FOUR)
### Version-Tracked Files (ALL THREE)
1. **package.json**
- Path: `package.json`
@@ -21,11 +21,6 @@ Quick reference for version bump commands and file locations.
- Line: 3
- Format: `"version": "X.Y.Z",`
4. **CLAUDE.md**
- Path: `CLAUDE.md`
- Line: 9
- Format: `**Current Version**: X.Y.Z`
## Essential Commands
### View Current Version
@@ -39,7 +34,6 @@ grep '"version"' package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
# From all version files
grep '"version"' package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json
grep "Current Version" CLAUDE.md
```
### Verify Version Consistency
@@ -52,10 +46,6 @@ grep '"version"' package.json .claude-plugin/marketplace.json plugin/.claude-plu
# package.json:3: "version": "5.3.0",
# .claude-plugin/marketplace.json:13: "version": "5.3.0",
# plugin/.claude-plugin/plugin.json:3: "version": "5.3.0",
# Check CLAUDE.md
grep "Current Version" CLAUDE.md
# Should output: **Current Version**: 5.3.0
```
### Git Commands
@@ -96,7 +86,7 @@ npm test
```bash
# Stage version files
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json CLAUDE.md plugin/scripts/
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin/scripts/
# Commit
git commit -m "Release vX.Y.Z: [Description]"
@@ -163,11 +153,11 @@ MAJOR: 5.3.2 → 6.0.0 (resets minor and patch)
```bash
# Example: 5.3.0 → 5.3.1
# 1. Update all four files to 5.3.1
# 1. Update all three files to 5.3.1
# 2. Build and test
npm run build
# 3. Commit and tag
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json CLAUDE.md plugin/scripts/
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin/scripts/
git commit -m "Release v5.3.1: Fixed observer crash"
git tag v5.3.1 -m "Release v5.3.1: Fixed observer crash"
git push && git push --tags
@@ -179,11 +169,11 @@ gh release create v5.3.1 --title "v5.3.1" --notes "Fixed observer crash on empty
```bash
# Example: 5.3.0 → 5.4.0
# 1. Update all four files to 5.4.0
# 1. Update all three files to 5.4.0
# 2. Build and test
npm run build
# 3. Commit and tag
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json CLAUDE.md plugin/scripts/
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin/scripts/
git commit -m "Release v5.4.0: Added dark mode support"
git tag v5.4.0 -m "Release v5.4.0: Added dark mode support"
git push && git push --tags
@@ -195,11 +185,11 @@ gh release create v5.4.0 --title "v5.4.0" --generate-notes
```bash
# Example: 5.3.0 → 6.0.0
# 1. Update all four files to 6.0.0
# 1. Update all three files to 6.0.0
# 2. Build and test
npm run build
# 3. Commit and tag
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json CLAUDE.md plugin/scripts/
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin/scripts/
git commit -m "Release v6.0.0: Storage layer redesign"
git tag v6.0.0 -m "Release v6.0.0: Storage layer redesign"
git push && git push --tags
@@ -19,7 +19,7 @@ Current: 4.2.8
New: 4.2.9 (PATCH)
Steps:
1. Update all four files to 4.2.9
1. Update all three files to 4.2.9
2. npm run build
3. git commit -m "Release v4.2.9: Fixed memory leak in search"
4. git tag v4.2.9 -m "Release v4.2.9: Fixed memory leak in search"
@@ -44,7 +44,7 @@ Current: 4.2.8
New: 4.3.0 (MINOR - reset patch to 0)
Steps:
1. Update all four files to 4.3.0
1. Update all three files to 4.3.0
2. npm run build
3. git commit -m "Release v4.3.0: Added web search MCP integration"
4. git tag v4.3.0 -m "Release v4.3.0: Added web search MCP integration"
@@ -69,7 +69,7 @@ Current: 4.2.8
New: 5.0.0 (MAJOR - reset minor and patch to 0)
Steps:
1. Update all four files to 5.0.0
1. Update all three files to 5.0.0
2. npm run build
3. git commit -m "Release v5.0.0: Storage layer redesign with migration required"
4. git tag v5.0.0 -m "Release v5.0.0: Storage layer redesign"
@@ -94,7 +94,7 @@ Current: 4.2.8
New: 4.2.9 (PATCH)
Steps:
1. Update all four files to 4.2.9
1. Update all three files to 4.2.9
2. npm run build
3. git commit -m "Release v4.2.9: Multiple bug fixes
@@ -122,7 +122,7 @@ Current: 5.1.0
New: 5.2.0 (MINOR)
Steps:
1. Update all four files to 5.2.0
1. Update all three files to 5.2.0
2. npm run build
3. git commit -m "Release v5.2.0: Dark mode support + bug fixes
@@ -64,7 +64,6 @@ Files to update:
- package.json: "version": "4.2.9"
- marketplace.json: "version": "4.2.9"
- plugin.json: "version": "4.2.9"
- CLAUDE.md line 9: "**Current Version**: 4.2.9" (version number ONLY)
- Git tag: v4.2.9
Proceed? (yes/no)
@@ -116,18 +115,6 @@ File: `plugin/.claude-plugin/plugin.json`
Update line 3 with new version.
### Update CLAUDE.md
File: `CLAUDE.md`
**ONLY update line 9 with the version number:**
```markdown
**Current Version**: 4.2.9
```
**CRITICAL:** DO NOT add version history entries to CLAUDE.md. Version history is managed separately outside this skill.
## Step 6: Verify Consistency
```bash
@@ -155,7 +142,7 @@ Build must succeed before proceeding.
```bash
# Stage all version files
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json CLAUDE.md plugin/scripts/
git add package.json .claude-plugin/marketplace.json plugin/.claude-plugin/plugin.json plugin/scripts/
# Commit with descriptive message
git commit -m "Release vX.Y.Z: [Brief description]
+165 -6
View File
@@ -4,6 +4,165 @@ 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/).
## [7.3.0] - 2025-12-15
## Features
- **Table-based search output**: Unified timeline formatting with cleaner, more organized presentation of search results grouped by date and file
- **Simplified API**: Removed unused format parameter from MCP search tools for cleaner interface
- **Shared formatting utilities**: Extracted common timeline formatting logic into reusable module
## Changes
- **Default model upgrade**: Changed default model from Haiku to Sonnet for better observation quality
- **Removed fake URIs**: Replaced claude-mem:// pseudo-protocol with actual HTTP API endpoints for citations
## Bug Fixes
- Fixed undefined debug function calls in MCP server
- Fixed skillPath variable scoping bug in instructions endpoint
- Extracted magic numbers to named constants for better code maintainability
## [7.2.4] - 2025-12-15
## What's Changed
### Documentation
- Updated endless mode setup instructions with improved configuration guidance for better user experience
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.2.3...v7.2.4
## [7.2.3] - 2025-12-15
## Bug Fixes
- **Fix MCP server failures on plugin updates**: Add 2-second pre-restart delay in `ensureWorkerVersionMatches()` to give files time to sync before killing the old worker. This prevents the race condition where the worker restart happened too quickly after plugin file updates, causing "Worker service connection failed" errors.
## Changes
- Add `PRE_RESTART_SETTLE_DELAY` constant (2000ms) to `hook-constants.ts`
- Add delay before `ProcessManager.restart()` call in `worker-utils.ts`
- Fix pre-existing bug where `port` variable was undefined in error logging
## [7.2.2] - 2025-12-15
## Changes
- **Refactor:** Consolidate mem-search skill, remove desktop-skill duplication
- Delete separate `desktop-skill/` directory (was outdated)
- Generate `mem-search.zip` during build from `plugin/skills/mem-search/`
- Update docs with correct MCP tool list and new download path
- Single source of truth for Claude Desktop skill
## [7.2.1] - 2025-12-14
## Translation Script Enhancements
This release adds powerful enhancements to the README translation system, supporting 35 languages with improved efficiency and caching.
### What's New
**Translation Script Improvements:**
- **Caching System**: Smart `.translation-cache.json` tracks content hashes to skip re-translating unchanged content
- **Parallel Processing**: `--parallel <n>` flag enables concurrent translations for faster execution
- **Force Re-translation**: `--force` flag to override cache when needed
- **Tier-Based Scripts**: Organized translation workflows by language priority
- `npm run translate:tier1` - 7 major languages (Chinese, Japanese, Korean, etc.)
- `npm run translate:tier2` - 8 strong tech scene languages (Hebrew, Arabic, Russian, etc.)
- `npm run translate:tier3` - 7 emerging markets (Vietnamese, Indonesian, Thai, etc.)
- `npm run translate:tier4` - 6 additional languages (Italian, Greek, Hungarian, etc.)
- `npm run translate:all` - All 35 languages sequentially
- **Better Output Handling**: Automatically strips markdown code fences if Claude wraps output
- **Translation Disclaimer**: Adds community correction notice at top of translated files
- **Performance**: Uses Bun runtime for faster execution
### Supported Languages (35 Total)
Arabic, Bengali, Brazilian Portuguese, Bulgarian, Chinese (Simplified), Chinese (Traditional), Czech, Danish, Dutch, Estonian, Finnish, French, German, Greek, Hebrew, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Latvian, Lithuanian, Norwegian, Polish, Portuguese, Romanian, Russian, Slovak, Slovenian, Spanish, Swedish, Thai, Turkish, Ukrainian, Vietnamese
### Breaking Changes
None - fully backward compatible.
### Installation
```bash
# Update via npm
npm install -g claude-mem@7.2.1
# Or reinstall plugin
claude plugin install thedotmack/claude-mem
```
---
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.2.0...v7.2.1
## [7.2.0] - 2025-12-14
## 🎉 New Features
### Automated Bug Report Generator
Added comprehensive bug report tool that streamlines issue reporting with AI assistance:
- **Command**: `npm run bug-report`
- **🌎 Multi-language Support**: Write in ANY language, auto-translates to English
- **📊 Smart Diagnostics**: Automatically collects:
- Version information (claude-mem, Claude Code, Node.js, Bun)
- Platform details (OS, version, architecture)
- Worker status (running state, PID, port, uptime, stats)
- Last 50 lines of logs (worker + silent debug)
- Database info and configuration settings
- **🤖 AI-Powered**: Uses Claude Agent SDK to generate professional GitHub issues
- **📝 Interactive**: Multiline input support with intuitive prompts
- **🔒 Privacy-Safe**:
- Auto-sanitizes all file paths (replaces home directory with ~)
- Optional `--no-logs` flag to exclude logs
- **⚡ Streaming Progress**: Real-time character count and animated spinner
- **🌐 One-Click Submit**: Auto-opens GitHub with pre-filled title and body
### Usage
From the plugin directory:
```bash
cd ~/.claude/plugins/marketplaces/thedotmack
npm run bug-report
```
**Plugin Paths:**
- macOS/Linux: `~/.claude/plugins/marketplaces/thedotmack`
- Windows: `%USERPROFILE%\.claude\plugins\marketplaces\thedotmack`
**Options:**
```bash
npm run bug-report --no-logs # Skip logs for privacy
npm run bug-report --verbose # Show all diagnostics
npm run bug-report --help # Show help
```
## 📚 Documentation
- Updated README with bug report section and usage instructions
- Enhanced GitHub issue template to feature automated tool
- Added platform-specific directory paths
## 🔧 Technical Details
**Files Added:**
- `scripts/bug-report/cli.ts` - Interactive CLI entry point
- `scripts/bug-report/index.ts` - Core logic with Agent SDK integration
- `scripts/bug-report/collector.ts` - System diagnostics collector
**Files Modified:**
- `package.json` - Added bug-report script
- `README.md` - New Bug Reports section
- `.github/ISSUE_TEMPLATE/bug_report.md` - Updated with automated tool instructions
---
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v7.1.15...v7.2.0
## [7.1.15] - 2025-12-14
## 🐛 Bug Fixes
@@ -2283,12 +2442,12 @@ None (patch version)
## [4.3.0] - 2025-10-25
## What's Changed
* feat: Enhanced context hook with session observations and cross-platform improvements by @thedotmack in https://github.com/thedotmack/claude-mem/pull/25
## New Contributors
* @thedotmack made their first contribution in https://github.com/thedotmack/claude-mem/pull/25
## What's Changed
* feat: Enhanced context hook with session observations and cross-platform improvements by @thedotmack in https://github.com/thedotmack/claude-mem/pull/25
## New Contributors
* @thedotmack made their first contribution in https://github.com/thedotmack/claude-mem/pull/25
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v4.2.11...v4.3.0
## [4.2.10] - 2025-10-25
+13 -37
View File
@@ -6,8 +6,6 @@
Claude-mem is a Claude Code plugin providing persistent memory across sessions. It captures tool usage, compresses observations using the Claude Agent SDK, and injects relevant context into future sessions.
**Current Version**: 7.2.0
## Architecture
**5 Lifecycle Hooks**: SessionStart → UserPromptSubmit → PostToolUse → Summary → SessionEnd
@@ -34,37 +32,30 @@ Claude-mem is a Claude Code plugin providing persistent memory across sessions.
## Build Commands
**Hooks only**: `npm run build && npm run sync-marketplace`
```bash
npm run build-and-sync # Build, sync to marketplace, restart worker (most common)
npm run build # Compile TypeScript only
npm run sync-marketplace # Copy to ~/.claude/plugins only
npm run worker:restart # Restart worker service only
npm run worker:status # Check worker status
npm run worker:logs # View worker logs
```
**Worker changes**: `npm run build && npm run sync-marketplace && npm run worker:restart`
**Skills only**: `npm run sync-marketplace`
**Viewer UI**: `npm run build && npm run sync-marketplace && npm run worker:restart`
**Viewer UI**: http://localhost:37777
## Configuration
Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created with defaults on first run.
**Core Settings:**
- `CLAUDE_MEM_MODEL` - Model for observations/summaries (default: claude-haiku-4-5)
- `CLAUDE_MEM_CONTEXT_OBSERVATIONS` - Observations injected at SessionStart (default: 50)
- `CLAUDE_MEM_MODEL` - Model for observations/summaries (default: claude-sonnet-4-5)
- `CLAUDE_MEM_CONTEXT_OBSERVATIONS` - Observations injected at SessionStart
- `CLAUDE_MEM_WORKER_PORT` - Worker service port (default: 37777)
- `CLAUDE_MEM_WORKER_HOST` - Worker bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)
**System Configuration:**
- `CLAUDE_MEM_DATA_DIR` - Data directory location (default: ~/.claude-mem)
- `CLAUDE_MEM_LOG_LEVEL` - Log verbosity: DEBUG, INFO, WARN, ERROR, SILENT (default: INFO)
- `CLAUDE_MEM_PYTHON_VERSION` - Python version for uvx/chroma-mcp (default: 3.13, avoids onnxruntime compatibility issues with Python 3.14+)
- `CLAUDE_CODE_PATH` - Path to Claude executable (default: auto-detect via 'which claude')
**Settings File Format:**
```json
{
"CLAUDE_MEM_MODEL": "claude-haiku-4-5",
"CLAUDE_MEM_WORKER_PORT": "37777"
}
```
## File Locations
@@ -73,30 +64,15 @@ Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created
- **Installed Plugin**: `~/.claude/plugins/marketplaces/thedotmack/`
- **Database**: `~/.claude-mem/claude-mem.db`
- **Chroma**: `~/.claude-mem/chroma/`
- **Usage Logs**: `~/.claude-mem/usage-logs/usage-YYYY-MM-DD.jsonl`
## Requirements
- **Bun** >= 1.0 (all platforms - auto-installed if missing)
- **Bun** (all platforms - auto-installed if missing)
- **uv** (all platforms - auto-installed if missing, provides Python for Chroma)
- Node.js >= 18 (build tools only)
## Quick Reference
```bash
npm run build # Compile TypeScript
npm run sync-marketplace # Copy to ~/.claude/plugins
npm run worker:restart # Restart worker service
npm run worker:status # Check worker status
npm run worker:logs # View worker logs
```
**Viewer UI**: http://localhost:37777
**Worker Logs**: `~/.claude-mem/logs/worker-YYYY-MM-DD.log`
- Node.js (build tools only)
## Documentation
**Public Docs**: https://docs.claude-mem.ai (Mintlify)
**Source**: `docs/public/` - MDX files, edit `docs.json` for navigation
**Deploy**: Auto-deploys from GitHub on push to main
**Local Dev**: `cd docs/public && npx mintlify dev`
+15 -10
View File
@@ -79,13 +79,13 @@ Restart Claude Code. Context from previous sessions will automatically appear in
- 🧠 **Persistent Memory** - Context survives across sessions
- 📊 **Progressive Disclosure** - Layered memory retrieval with token cost visibility
- 🔍 **Skill-Based Search** - Query your project history with mem-search skill (~2,250 token savings)
- 🔍 **Skill-Based Search** - Query your project history with mem-search skill
- 🖥️ **Web Viewer UI** - Real-time memory stream at http://localhost:37777
- 💻 **Claude Desktop Skill** - Search memory from Claude Desktop conversations
- 🔒 **Privacy Control** - Use `<private>` tags to exclude sensitive content from storage
- ⚙️ **Context Configuration** - Fine-grained control over what context gets injected
- 🤖 **Automatic Operation** - No manual intervention required
- 🔗 **Citations** - Reference past decisions with `claude-mem://` URIs
- 🔗 **Citations** - Reference past observations with IDs (access via http://localhost:37777/api/observation/{id} or view all in the web viewer at http://localhost:37777)
- 🧪 **Beta Channel** - Try experimental features like Endless Mode via version switching
---
@@ -161,7 +161,7 @@ npx mintlify dev
2. **Smart Install** - Cached dependency checker (pre-hook script, not a lifecycle hook)
3. **Worker Service** - HTTP API on port 37777 with web viewer UI and 10 search endpoints, managed by Bun
4. **SQLite Database** - Stores sessions, observations, summaries with FTS5 full-text search
5. **mem-search Skill** - Natural language queries with progressive disclosure (~2,250 token savings vs MCP)
5. **mem-search Skill** - Natural language queries with progressive disclosure
6. **Chroma Vector Database** - Hybrid semantic + keyword search for intelligent context retrieval
See [Architecture Overview](https://docs.claude-mem.ai/architecture/overview) for details.
@@ -175,7 +175,6 @@ Claude-Mem provides intelligent search through the mem-search skill that auto-in
**How It Works:**
- Just ask naturally: *"What did we do last session?"* or *"Did we fix this bug before?"*
- Claude automatically invokes the mem-search skill to find relevant context
- ~2,250 token savings per session start vs MCP approach
**Available Search Operations:**
@@ -206,6 +205,8 @@ See [Search Tools Guide](https://docs.claude-mem.ai/usage/search-tools) for deta
## Beta Features & Endless Mode
> **Note**: Endless Mode is an **experimental feature in the beta branch only**. It is not included in the stable release you install via the marketplace. You must manually switch to the beta channel to try it, and it comes with significant caveats (see below).
Claude-Mem offers a **beta channel** with experimental features. Switch between stable and beta versions directly from the web viewer UI.
### How to Try Beta
@@ -230,13 +231,17 @@ Working Memory (Context): Compressed observations (~500 tokens each)
Archive Memory (Disk): Full tool outputs preserved for recall
```
**Expected Results**:
- ~95% token reduction in context window
- ~20x more tool uses before context exhaustion
**Projected Results** (based on theoretical modeling, not production measurements):
- Significant token reduction in context window
- More tool uses before context exhaustion
- Linear O(N) scaling instead of quadratic O(N²)
- Full transcripts preserved for perfect recall
**Caveats**: Adds latency (60-90s per tool for observation generation), still experimental.
**Important Caveats**:
- **Not in stable release** - You must switch to beta branch to use this feature
- **Still in development** - May have bugs, breaking changes, or incomplete functionality
- **Slower than standard mode** - Blocking observation generation adds latency to each tool use
- **Theoretical projections** - The efficiency claims above are based on simulations, not real-world production data
See [Beta Features Documentation](https://docs.claude-mem.ai/beta-features) for details.
@@ -324,7 +329,7 @@ Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created
| Setting | Default | Description |
|---------|---------|-------------|
| `CLAUDE_MEM_MODEL` | `claude-haiku-4-5` | AI model for observations |
| `CLAUDE_MEM_MODEL` | `claude-sonnet-4-5` | AI model for observations |
| `CLAUDE_MEM_WORKER_PORT` | `37777` | Worker service port |
| `CLAUDE_MEM_WORKER_HOST` | `127.0.0.1` | Worker bind address (use `0.0.0.0` for remote access) |
| `CLAUDE_MEM_DATA_DIR` | `~/.claude-mem` | Data directory location |
@@ -350,7 +355,7 @@ curl http://localhost:37777/api/settings
```json
{
"CLAUDE_MEM_MODEL": "claude-haiku-4-5",
"CLAUDE_MEM_MODEL": "claude-sonnet-4-5",
"CLAUDE_MEM_WORKER_PORT": "37777",
"CLAUDE_MEM_CONTEXT_OBSERVATIONS": "50"
}
-44
View File
@@ -1,44 +0,0 @@
---
name: mem-search
description: Search your persistent memory database from previous coding sessions. Use when asked about past work, decisions, bugs fixed, or development history.
---
## Overview
Search your local memory database for past sessions, decisions, code changes, and development history. This skill uses the `mem-search` MCP server tools.
## Available MCP tools
Use these tools from the `mem-search` MCP server:
| Tool | Description |
|------|-------------|
| `search` | Unified search across all memory types |
| `decisions` | Find architectural/design decisions |
| `changes` | Find code changes and refactorings |
| `timeline` | Get observations around a specific point in time |
| `find_by_file` | Find observations for specific files |
| `find_by_type` | Filter by type (decision, bugfix, feature, refactor, discovery, change) |
| `find_by_concept` | Find by concept tags |
| `how_it_works` | Understand system architecture and design patterns |
## Common parameters
- `query` - Natural language search query
- `limit` - Max results (1-100, default 20)
- `format` - `index` for titles only (recommended), `full` for complete content
- `type` - Filter: observations, sessions, or prompts
- `obs_type` - Filter observation type: decision, bugfix, feature, refactor, discovery, change
## When to use
- "Did we already solve this?"
- "How did we do X last time?"
- "Find the bug fix for..."
- "What decisions did we make about..."
- "Show me changes to [file]"
- "What work did we do on [project]?"
## Setup requirement
The `mem-search` MCP server must be configured in Claude Desktop settings. See MCP configuration docs.
Binary file not shown.
+390
View File
@@ -0,0 +1,390 @@
# TypeScript SDK V2 interface (preview)
Preview of the simplified V2 TypeScript Agent SDK, with session-based send/receive patterns for multi-turn conversations.
---
<Warning>
The V2 interface is an **unstable preview**. APIs may change based on feedback before becoming stable. Some features like session forking are only available in the [V1 SDK](/docs/en/agent-sdk/typescript).
</Warning>
The V2 Claude Agent TypeScript SDK removes the need for async generators and yield coordination. This makes multi-turn conversations simpler—instead of managing generator state across turns, each turn is a separate `send()`/`receive()` cycle. The API surface reduces to three concepts:
- `createSession()` / `resumeSession()`: Start or continue a conversation
- `session.send()`: Send a message
- `session.receive()`: Get the response
## Installation
The V2 interface is included in the existing SDK package:
```bash
npm install @anthropic-ai/claude-agent-sdk
```
## Quick start
### One-shot prompt
For simple single-turn queries where you don't need to maintain a session, use `unstable_v2_prompt()`. This example sends a math question and logs the answer:
```typescript
import { unstable_v2_prompt } from '@anthropic-ai/claude-agent-sdk'
const result = await unstable_v2_prompt('What is 2 + 2?', {
model: 'claude-sonnet-4-5-20250929'
})
console.log(result.result)
```
<details>
<summary>See the same operation in V1</summary>
```typescript
import { query } from '@anthropic-ai/claude-agent-sdk'
const q = query({
prompt: 'What is 2 + 2?',
options: { model: 'claude-sonnet-4-5-20250929' }
})
for await (const msg of q) {
if (msg.type === 'result') {
console.log(msg.result)
}
}
```
</details>
### Basic session
For interactions beyond a single prompt, create a session. V2 separates sending and receiving into distinct steps:
- `send()` dispatches your message
- `receive()` streams back the response
This explicit separation makes it easier to add logic between turns (like processing responses before sending follow-ups).
The example below creates a session, sends "Hello!" to Claude, and prints the text response. It uses [`await using`](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management) (TypeScript 5.2+) to automatically close the session when the block exits. You can also call `session.close()` manually.
```typescript
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
await using session = unstable_v2_createSession({
model: 'claude-sonnet-4-5-20250929'
})
await session.send('Hello!')
for await (const msg of session.receive()) {
// Filter for assistant messages to get human-readable output
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log(text)
}
}
```
<details>
<summary>See the same operation in V1</summary>
In V1, both input and output flow through a single async generator. For a basic prompt this looks similar, but adding multi-turn logic requires restructuring to use an input generator.
```typescript
import { query } from '@anthropic-ai/claude-agent-sdk'
const q = query({
prompt: 'Hello!',
options: { model: 'claude-sonnet-4-5-20250929' }
})
for await (const msg of q) {
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log(text)
}
}
```
</details>
### Multi-turn conversation
Sessions persist context across multiple exchanges. To continue a conversation, call `send()` again on the same session. Claude remembers the previous turns.
This example asks a math question, then asks a follow-up that references the previous answer:
```typescript
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
await using session = unstable_v2_createSession({
model: 'claude-sonnet-4-5-20250929'
})
// Turn 1
await session.send('What is 5 + 3?')
for await (const msg of session.receive()) {
// Filter for assistant messages to get human-readable output
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log(text)
}
}
// Turn 2
await session.send('Multiply that by 2')
for await (const msg of session.receive()) {
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log(text)
}
}
```
<details>
<summary>See the same operation in V1</summary>
```typescript
import { query } from '@anthropic-ai/claude-agent-sdk'
// Must create an async iterable to feed messages
async function* createInputStream() {
yield {
type: 'user',
session_id: '',
message: { role: 'user', content: [{ type: 'text', text: 'What is 5 + 3?' }] },
parent_tool_use_id: null
}
// Must coordinate when to yield next message
yield {
type: 'user',
session_id: '',
message: { role: 'user', content: [{ type: 'text', text: 'Multiply by 2' }] },
parent_tool_use_id: null
}
}
const q = query({
prompt: createInputStream(),
options: { model: 'claude-sonnet-4-5-20250929' }
})
for await (const msg of q) {
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log(text)
}
}
```
</details>
### Session resume
If you have a session ID from a previous interaction, you can resume it later. This is useful for long-running workflows or when you need to persist conversations across application restarts.
This example creates a session, stores its ID, closes it, then resumes the conversation:
```typescript
import {
unstable_v2_createSession,
unstable_v2_resumeSession,
type SDKMessage
} from '@anthropic-ai/claude-agent-sdk'
// Helper to extract text from assistant messages
function getAssistantText(msg: SDKMessage): string | null {
if (msg.type !== 'assistant') return null
return msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
// Create initial session and have a conversation
const session = unstable_v2_createSession({
model: 'claude-sonnet-4-5-20250929'
})
await session.send('Remember this number: 42')
// Get the session ID from any received message
let sessionId: string | undefined
for await (const msg of session.receive()) {
sessionId = msg.session_id
const text = getAssistantText(msg)
if (text) console.log('Initial response:', text)
}
console.log('Session ID:', sessionId)
session.close()
// Later: resume the session using the stored ID
await using resumedSession = unstable_v2_resumeSession(sessionId!, {
model: 'claude-sonnet-4-5-20250929'
})
await resumedSession.send('What number did I ask you to remember?')
for await (const msg of resumedSession.receive()) {
const text = getAssistantText(msg)
if (text) console.log('Resumed response:', text)
}
```
<details>
<summary>See the same operation in V1</summary>
```typescript
import { query } from '@anthropic-ai/claude-agent-sdk'
// Create initial session
const initialQuery = query({
prompt: 'Remember this number: 42',
options: { model: 'claude-sonnet-4-5-20250929' }
})
// Get session ID from any message
let sessionId: string | undefined
for await (const msg of initialQuery) {
sessionId = msg.session_id
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log('Initial response:', text)
}
}
console.log('Session ID:', sessionId)
// Later: resume the session
const resumedQuery = query({
prompt: 'What number did I ask you to remember?',
options: {
model: 'claude-sonnet-4-5-20250929',
resume: sessionId
}
})
for await (const msg of resumedQuery) {
if (msg.type === 'assistant') {
const text = msg.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
console.log('Resumed response:', text)
}
}
```
</details>
### Cleanup
Sessions can be closed manually or automatically using [`await using`](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management), a TypeScript 5.2+ feature for automatic resource cleanup. If you're using an older TypeScript version or encounter compatibility issues, use manual cleanup instead.
**Automatic cleanup (TypeScript 5.2+):**
```typescript
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
await using session = unstable_v2_createSession({
model: 'claude-sonnet-4-5-20250929'
})
// Session closes automatically when the block exits
```
**Manual cleanup:**
```typescript
import { unstable_v2_createSession } from '@anthropic-ai/claude-agent-sdk'
const session = unstable_v2_createSession({
model: 'claude-sonnet-4-5-20250929'
})
// ... use the session ...
session.close()
```
## API reference
### `unstable_v2_createSession()`
Creates a new session for multi-turn conversations.
```typescript
function unstable_v2_createSession(options: {
model: string;
// Additional options supported
}): Session
```
### `unstable_v2_resumeSession()`
Resumes an existing session by ID.
```typescript
function unstable_v2_resumeSession(
sessionId: string,
options: {
model: string;
// Additional options supported
}
): Session
```
### `unstable_v2_prompt()`
One-shot convenience function for single-turn queries.
```typescript
function unstable_v2_prompt(
prompt: string,
options: {
model: string;
// Additional options supported
}
): Promise<Result>
```
### Session interface
```typescript
interface Session {
send(message: string): Promise<void>;
receive(): AsyncGenerator<SDKMessage>;
close(): void;
}
```
## Feature availability
Not all V1 features are available in V2 yet. The following require using the [V1 SDK](/docs/en/agent-sdk/typescript):
- Session forking (`forkSession` option)
- Some advanced streaming input patterns
## Feedback
Share your feedback on the V2 interface before it becomes stable. Report issues and suggestions through [GitHub Issues](https://github.com/anthropics/claude-code/issues).
## See also
- [TypeScript SDK reference (V1)](/docs/en/agent-sdk/typescript) - Full V1 SDK documentation
- [SDK overview](/docs/en/agent-sdk/overview) - General SDK concepts
- [V2 examples on GitHub](https://github.com/anthropics/claude-agent-sdk-demos/tree/main/hello-world-v2) - Working code examples
+1 -1
View File
@@ -57,7 +57,7 @@ GET /api/context/recent?project=my-project&limit=3
### Environment Variables
```bash
CLAUDE_MEM_MODEL=claude-haiku-4-5 # Model for observations/summaries
CLAUDE_MEM_MODEL=claude-sonnet-4-5 # Model for observations/summaries
CLAUDE_MEM_CONTEXT_OBSERVATIONS=50 # Observations injected at SessionStart
CLAUDE_MEM_WORKER_PORT=37777 # Worker service port
CLAUDE_MEM_PYTHON_VERSION=3.13 # Python version for chroma-mcp
+1 -1
View File
@@ -864,7 +864,7 @@ async startSession(session: ActiveSession, worker?: any) {
const queryResult = query({
prompt: messageGenerator,
options: {
model: 'claude-haiku-4-5',
model: 'claude-sonnet-4-5',
disallowedTools: ['Bash', 'Read', 'Write', ...], // Observer-only
abortController: session.abortController
}
@@ -5,7 +5,7 @@ description: "mem-search skill with HTTP API and progressive disclosure"
# Search Architecture
Claude-Mem uses a skill-based search architecture that provides intelligent memory retrieval through natural language queries. This replaced the MCP-based approach in v5.4.0, saving ~2,250 tokens per session start. The skill was enhanced and renamed to "mem-search" in v5.5.0 for better scope differentiation.
Claude-Mem uses a skill-based search architecture that provides intelligent memory retrieval through natural language queries. This replaced the MCP-based approach in v5.4.0 with a more efficient implementation. The skill was enhanced and renamed to "mem-search" in v5.5.0 for better scope differentiation.
## Overview
@@ -133,7 +133,7 @@ Invoke this skill when users ask about:
...
```
**Token Savings**: ~2,250 tokens per session start (90% reduction)
**Token Efficiency**: Minimal frontmatter at session start with progressive disclosure
## HTTP API Endpoints
@@ -341,14 +341,14 @@ All user-provided search queries are properly escaped to prevent SQL injection.
### 1. Token Efficiency
**Before (MCP)**:
- Session start: ~2,500 tokens for tool definitions
- Session start: All tool definitions loaded upfront
- Every session pays this cost
- No progressive disclosure
**After (Skill)**:
- Session start: ~250 tokens for skill frontmatter
- Full instructions: ~2,500 tokens (only when invoked)
- Net savings: ~2,250 tokens per session (~90% reduction)
- Session start: Minimal token cost for skill frontmatter
- Full instructions loaded only when invoked (progressive disclosure)
- More efficient than loading all tool definitions upfront
### 2. Natural Language Interface
+14 -7
View File
@@ -5,6 +5,10 @@ description: "Try experimental features like Endless Mode before they're release
# Beta Features
<Warning>
**Endless Mode is experimental and not included in the stable release.** You must manually switch to the beta branch to try it. The efficiency projections below are based on theoretical modeling, not production measurements. Expect slower performance than standard mode and potential bugs.
</Warning>
Claude-Mem offers a beta channel for users who want to try experimental features before they're released to the stable channel.
## Version Channel Switching
@@ -77,19 +81,22 @@ Archive Memory (Transcript File):
This transforms O(N²) scaling into O(N) - linear instead of quadratic.
### Expected Results
### Projected Results
Based on analysis of real sessions:
Based on theoretical modeling (not production measurements):
- **Token savings**: ~95% reduction in context window usage
- **Efficiency gain**: ~20x more tool uses before context exhaustion
- **Token savings**: Significant reduction in context window usage
- **Efficiency gain**: More tool uses before context exhaustion
- **Quality preservation**: Observations cache the synthesis result, so no information is lost
### Caveats
### Important Caveats
Endless Mode is experimental:
Endless Mode is experimental and has significant limitations:
- **Adds latency** - Blocking hooks wait for observation generation (60-90s per tool use)
- **Not in stable release** - You must manually switch to the beta branch to use this feature
- **Still in development** - May have bugs, breaking changes, or incomplete functionality
- **Slower than standard mode** - Blocking observation generation adds latency to each tool use
- **Theoretical projections** - The efficiency claims above are based on simulations, not real-world production data
- **Requires working database** - Observations must save successfully for transformation
- **New architecture** - Less battle-tested than standard mode
+9 -7
View File
@@ -13,7 +13,7 @@ Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created
| Setting | Default | Description |
|-------------------------------|---------------------------------|---------------------------------------|
| `CLAUDE_MEM_MODEL` | `haiku` | AI model for processing observations |
| `CLAUDE_MEM_MODEL` | `sonnet` | AI model for processing observations |
| `CLAUDE_MEM_CONTEXT_OBSERVATIONS` | `50` | Number of observations to inject |
| `CLAUDE_MEM_WORKER_PORT` | `37777` | Worker service port |
| `CLAUDE_MEM_SKIP_TOOLS` | `ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion` | Comma-separated tools to exclude from observations |
@@ -35,8 +35,8 @@ Configure which AI model processes your observations.
Shorthand model names automatically forward to the latest version:
- `haiku` - Fast, cost-efficient (default)
- `sonnet` - Balanced
- `haiku` - Fast, cost-efficient
- `sonnet` - Balanced (default)
- `opus` - Most capable
### Using the Interactive Script
@@ -53,7 +53,7 @@ Edit `~/.claude-mem/settings.json`:
```json
{
"CLAUDE_MEM_MODEL": "haiku"
"CLAUDE_MEM_MODEL": "sonnet"
}
```
@@ -179,7 +179,9 @@ Claude-Mem supports switching between stable and beta versions via the web viewe
**Your memory data is preserved** when switching versions. Only the plugin code changes.
See [Beta Features](beta-features) for details on what's available in beta.
<Note>
Endless Mode is experimental and slower than standard mode. See [Beta Features](beta-features) for full details and important limitations.
</Note>
## Worker Service Management
@@ -262,7 +264,7 @@ Token economics help you understand the value of cached observations vs. re-read
| Setting | Default | Description |
|---------|---------|-------------|
| **Model** | haiku | AI model for generating observations |
| **Model** | sonnet | AI model for generating observations |
| **Worker Port** | 37777 | Port for background worker service |
| **MCP search server** | true | Enable Model Context Protocol search tools |
| **Include last summary** | false | Add previous session's summary to context |
@@ -420,7 +422,7 @@ npm run worker:logs
### Invalid Model Name
If you specify an invalid model name, the worker will fall back to `haiku` and log a warning.
If you specify an invalid model name, the worker will fall back to `sonnet` and log a warning.
Valid shorthand models (forward to latest version):
- haiku
+2 -1
View File
@@ -40,7 +40,8 @@
"usage/claude-desktop",
"usage/private-tags",
"usage/export-import",
"beta-features"
"beta-features",
"endless-mode"
]
},
{
+111
View File
@@ -0,0 +1,111 @@
---
title: "Endless Mode (Beta)"
description: "Experimental biomimetic memory architecture for extended sessions"
---
# Current State of Endless Mode
## Core Concept
Endless Mode is a **biomimetic memory architecture** that solves Claude's context window exhaustion problem. Instead of keeping full tool outputs in the context window (O(N²) complexity), it:
- Captures compressed observations after each tool use
- Replaces transcripts with low token summaries
- Achieves O(N) linear complexity
- Maintains two-tier memory: working memory (compressed) + archive memory (full transcript on disk, maintained by default claude code functionality)
## Implementation Status
**Status**: FUNCTIONAL BUT EXPERIMENTAL
**Current Branch**: `beta/endless-mode` (ahead of main)
**Recent Activity**:
- Merged main branch changes
- Resolved merge conflicts in save-hook, SessionStore, SessionRoutes
- Updated documentation to remove misleading token reduction claims
- Added important caveats about beta status
## Key Architecture Components
1. **Pre-Tool-Use Hook** - Tracks tool execution start, sends tool_use_id to worker
2. **Save Hook (PostToolUse)** - **CRITICAL**: Blocks until observation is generated (110s timeout), injects compressed observation back into context
3. **SessionManager.waitForNextObservation()** - Event-driven wait mechanism (no polling)
4. **SDKAgent** - Generates observations via Agent SDK, emits completion events
5. **Database** - Added `tool_use_id` column for observation correlation
## Configuration
```json
{
"CLAUDE_MEM_ENDLESS_MODE": "false", // Default: disabled
"CLAUDE_MEM_ENDLESS_WAIT_TIMEOUT_MS": "90000" // 90 second timeout
}
```
**Enable via**: Manual checkout of beta branch (see instructions below)
## Flow
```
Tool Executes → Pre-Hook (track ID) → Tool Completes →
Save-Hook (BLOCKS) → Worker processes → SDK generates observation →
Event fired → Hook receives observation → Injects markdown →
Clears input → Context reduced
```
## Known Limitations
From the documentation:
- ⚠️ **Slower than standard mode** - Blocking adds latency
- ⚠️ **Still in development** - May have bugs
- ⚠️ **Not battle-tested** - New architecture
- ⚠️ **Theoretical projections** - Efficiency gains not yet validated in production
## What's Working
- ✅ Synchronous observation injection
- ✅ Event-driven wait mechanism
- ✅ Token reduction via input clearing
- ✅ Database schema with tool_use_id
- ✅ Web UI for version switching
- ✅ Graceful timeout fallbacks
## What's Not Ready
- ❌ Production validation of token savings
- ❌ Comprehensive test coverage
- ❌ Stable channel release
- ❌ Performance benchmarks
- ❌ Long-running session data
## How to Try Endless Mode
Endless Mode is currently only available on the beta branch. To try it:
```bash
# Navigate to your claude-mem installation
cd ~/.claude/plugins/marketplaces/thedotmack/
# Checkout the beta branch
git checkout beta/endless-mode
# Install dependencies
npm install
# Restart the worker
npm run worker:restart
```
**To return to stable:**
```bash
cd ~/.claude/plugins/marketplaces/thedotmack/
git checkout main
npm install
npm run worker:restart
```
## Summary
The implementation is architecturally complete and functional, but remains experimental pending production validation of the theoretical efficiency gains.
+1 -1
View File
@@ -29,7 +29,7 @@ Restart Claude Code. Context from previous sessions will automatically appear in
- ⚙️ **Context Configuration** - Fine-grained control over what context gets injected
- 🤖 **Automatic Operation** - No manual intervention required
- 📊 **FTS5 Search** - Fast full-text search across observations
- 🔗 **Citations** - Reference past decisions with `claude-mem://` URIs
- 🔗 **Citations** - Reference past observations with IDs (access via http://localhost:37777/api/observation/{id} or view all in the web viewer at http://localhost:37777)
## How It Works
+1 -1
View File
@@ -46,7 +46,7 @@ GET /api/context/recent?project=my-project&limit=3
### Environment Variables
```bash
CLAUDE_MEM_MODEL=claude-haiku-4-5 # Model for observations/summaries
CLAUDE_MEM_MODEL=claude-sonnet-4-5 # Model for observations/summaries
CLAUDE_MEM_CONTEXT_OBSERVATIONS=50 # Observations injected at SessionStart
CLAUDE_MEM_WORKER_PORT=37777 # Worker service port
CLAUDE_MEM_PYTHON_VERSION=3.13 # Python version for chroma-mcp
+12 -12
View File
@@ -32,15 +32,14 @@ curl http://localhost:37777/api/health
Download the skill package from the repository:
<Card title="mem-search.zip" icon="download" href="https://github.com/thedotmack/claude-mem/raw/main/desktop-skill/mem-search.zip">
<Card title="mem-search.zip" icon="download" href="https://github.com/thedotmack/claude-mem/raw/main/plugin/skills/mem-search.zip">
Download the mem-search skill for Claude Desktop
</Card>
Or build from source:
```bash
cd desktop-skill
zip -r mem-search.zip Skill.md
npm run build # Generates plugin/skills/mem-search.zip
```
### Step 2: Install in Claude Desktop
@@ -110,20 +109,21 @@ Once installed, the skill auto-activates when you ask about past work:
"Show me changes to worker-service.ts"
```
## Available Search Tools
## Available MCP Tools
The skill provides access to these MCP tools:
| Tool | Description |
|------|-------------|
| `search` | Unified search across all memory types |
| `decisions` | Find architectural/design decisions |
| `changes` | Find code changes and refactorings |
| `timeline` | Get observations around a specific point in time |
| `find_by_file` | Find observations for specific files |
| `find_by_type` | Filter by type (decision, bugfix, feature, refactor, discovery, change) |
| `find_by_concept` | Find by concept tags |
| `how_it_works` | Understand system architecture and design patterns |
| `search` | Unified search across observations, sessions, and prompts |
| `timeline` | Get chronological context around a query or observation ID |
| `get_observation` | Fetch a single observation by ID |
| `get_batch_observations` | Fetch multiple observations efficiently |
| `get_session` | Fetch session summary by ID |
| `get_prompt` | Fetch user prompt by ID |
| `get_recent_context` | Get recent timeline items |
| `get_context_timeline` | Get timeline around a specific observation |
| `progressive_description` | Load detailed usage instructions |
## Troubleshooting
+3 -4
View File
@@ -246,11 +246,10 @@ authentication for better scalability and stateless design...
## Citations
All search results include citations using the `claude-mem://` URI scheme:
All search results include observation IDs that can be accessed via the HTTP API:
- `claude-mem://observation/123` - Specific observation
- `claude-mem://session/abc-456` - Specific session
- `claude-mem://user-prompt/789` - Specific user prompt
- `http://localhost:37777/api/observation/{id}` - Get specific observation by ID
- View all observations in the web viewer at `http://localhost:37777`
These citations enable referencing specific historical context in your work.
+9 -3
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "7.2.0",
"version": "7.3.0",
"description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [
"claude",
@@ -32,6 +32,7 @@
},
"scripts": {
"build": "node scripts/build-hooks.js",
"build-and-sync": "npm run build && npm run sync-marketplace && sleep 1 && cd ~/.claude/plugins/marketplaces/thedotmack && npm run worker:restart",
"test": "vitest",
"test:parser": "npx tsx src/sdk/parser.test.ts",
"test:context": "echo '{\"session_id\":\"test-'$(date +%s)'\",\"cwd\":\"'$(pwd)'\",\"source\":\"startup\"}' | node plugin/scripts/context-hook.js 2>/dev/null",
@@ -43,11 +44,16 @@
"worker:stop": "bun plugin/scripts/worker-cli.js stop",
"worker:restart": "bun plugin/scripts/worker-cli.js restart",
"worker:status": "bun plugin/scripts/worker-cli.js status",
"worker:logs": "tail -f ~/.claude-mem/logs/worker-$(date +%Y-%m-%d).log",
"worker:logs": "tail -n 50 ~/.claude-mem/logs/worker-$(date +%Y-%m-%d).log",
"changelog:generate": "node scripts/generate-changelog.js",
"usage:analyze": "node scripts/analyze-usage.js",
"usage:today": "node scripts/analyze-usage.js $(date +%Y-%m-%d)",
"translate-readme": "npx tsx scripts/translate-readme/cli.ts -v README.md zh ko ja",
"translate-readme": "bun scripts/translate-readme/cli.ts -v -o docs/i18n README.md",
"translate:tier1": "npm run translate-readme -- zh ja pt-br ko es de fr",
"translate:tier2": "npm run translate-readme -- he ar ru pl cs nl tr uk",
"translate:tier3": "npm run translate-readme -- vi id th hi bn ro sv",
"translate:tier4": "npm run translate-readme -- it el hu fi da no",
"translate:all": "npm run translate:tier1 && npm run translate:tier2 && npm run translate:tier3 && npm run translate:tier4",
"bug-report": "npx tsx scripts/bug-report/cli.ts"
},
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "7.2.0",
"version": "7.3.0",
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
"author": {
"name": "Alex Newman"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem-plugin",
"version": "7.1.15",
"version": "7.3.0",
"private": true,
"description": "Runtime dependencies for claude-mem bundled hooks",
"type": "module",
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
Binary file not shown.
+121 -50
View File
@@ -10,6 +10,7 @@ Search past work across all sessions. Simple workflow: search → get IDs → fe
## When to Use
Use when users ask about PREVIOUS sessions (not current conversation):
- "Did we already fix this?"
- "How did we solve X last time?"
- "What happened last week?"
@@ -19,47 +20,57 @@ Use when users ask about PREVIOUS sessions (not current conversation):
**ALWAYS follow this exact flow:**
1. **Search** - Get an index of results with IDs
2. **Timeline** (optional) - Get context around top results to understand what was happening
2. **Timeline** - Get context around top results to understand what was happening
3. **Review** - Look at titles/dates/context, pick relevant IDs
4. **Fetch** - Get full details ONLY for those IDs
### Step 1: Search Everything
```bash
curl "http://localhost:37777/api/search?query=authentication&format=index&limit=5"
```
Use the `search` MCP tool:
**Required parameters:**
- `query` - Search term
- `format=index` - ALWAYS start with index (lightweight)
- `limit=5` - Start small (3-5 results)
- `limit: 20` - You can request large indexes as necessary
- `project` - Project name (required)
**Example:**
```
search(query="authentication", limit=20, project="my-project")
```
**Returns:**
```
1. [feature] Added JWT authentication
Date: 11/17/2025, 3:48:45 PM
ID: 11131
2. [bugfix] Fixed auth token expiration
Date: 11/16/2025, 2:15:22 PM
ID: 10942
```
| ID | Time | T | Title | Read | Work |
|----|------|---|-------|------|------|
| #11131 | 3:48 PM | 🟣 | Added JWT authentication | ~75 | 🛠️ 450 |
| #10942 | 2:15 PM | 🔴 | Fixed auth token expiration | ~50 | 🛠️ 200 |
```
### Step 2: Get Timeline Context (Optional)
### Step 2: Get Timeline Context
When you need to understand "what was happening" around a result:
You MUST understand "what was happening" around a result.
```bash
# Get timeline around an observation ID
curl "http://localhost:37777/api/timeline?anchor=11131&depth_before=3&depth_after=3"
Use the `timeline` MCP tool:
# Or use query to find + get timeline in one step
curl "http://localhost:37777/api/timeline?query=authentication&depth_before=3&depth_after=3"
**Example with observation ID:**
```
timeline(anchor=11131, depth_before=3, depth_after=3, project="my-project")
```
**Example with query (finds anchor automatically):**
```
timeline(query="authentication", depth_before=3, depth_after=3, project="my-project")
```
**Returns exactly `depth_before + 1 + depth_after` items** - observations, sessions, and prompts interleaved chronologically around the anchor.
**When to use:**
- User asks "what was happening when..."
- Need to understand sequence of events
- Want broader context around a specific observation
@@ -70,34 +81,68 @@ Review the index results (and timeline if used). Identify which IDs are actually
### Step 4: Fetch by ID
For each relevant ID, fetch full details:
For each relevant ID, fetch full details using MCP tools:
```bash
# Fetch observation
curl "http://localhost:37777/api/observation/11131"
**Fetch multiple observations (ALWAYS use for 2+ IDs):**
# Fetch session
curl "http://localhost:37777/api/session/2005"
```
get_batch_observations(ids=[11131, 10942, 10855])
```
# Fetch prompt
curl "http://localhost:37777/api/prompt/5421"
**With ordering and limit:**
```
get_batch_observations(
ids=[11131, 10942, 10855],
orderBy="date_desc",
limit=10,
project="my-project"
)
```
**Fetch single observation (only when fetching exactly 1):**
```
get_observation(id=11131)
```
**Fetch session:**
```
get_session(id=2005) # Just the number from S2005
```
**Fetch prompt:**
```
get_prompt(id=5421)
```
**ID formats:**
- Observations: Just the number (11131)
- Sessions: Just the number (2005) from "S2005"
- Prompts: Just the number (5421)
**Batch optimization:**
- **ALWAYS use `get_batch_observations` for 2+ observations**
- 10-100x more efficient than individual fetches
- Single HTTP request vs N requests
- Returns all results in one response
- Supports ordering and filtering
## Search Parameters
**Basic:**
- `query` - What to search for (required)
- `format` - "index" or "full" (always use "index" first)
- `limit` - How many results (default 5, max 100)
- `limit` - How many results (default 20)
- `project` - Filter by project name (required)
**Filters (optional):**
- `type` - Filter to "observations", "sessions", or "prompts"
- `project` - Filter by project name
- `dateStart` - Start date (YYYY-MM-DD or epoch timestamp)
- `dateEnd` - End date (YYYY-MM-DD or epoch timestamp)
- `obs_type` - Filter observations by type (comma-separated): bugfix, feature, decision, discovery, change
@@ -105,39 +150,65 @@ curl "http://localhost:37777/api/prompt/5421"
## Examples
**Find recent bug fixes:**
```bash
curl "http://localhost:37777/api/search?query=bug&type=observations&obs_type=bugfix&format=index&limit=5"
Use the `search` MCP tool with filters:
```
search(query="bug", type="observations", obs_type="bugfix", limit=20, project="my-project")
```
**Find what happened last week:**
```bash
curl "http://localhost:37777/api/search?query=&type=observations&dateStart=2025-11-11&format=index&limit=10"
Use date filters:
```
search(type="observations", dateStart="2025-11-11", limit=20, project="my-project")
```
**Search everything:**
```bash
curl "http://localhost:37777/api/search?query=database+migration&format=index&limit=5"
Simple query search:
```
search(query="database migration", limit=20, project="my-project")
```
**Get detailed instructions:**
Use the `progressive_description` tool to load full instructions on-demand:
```
progressive_description(topic="workflow") # Get 4-step workflow
progressive_description(topic="search_params") # Get parameters reference
progressive_description(topic="examples") # Get usage examples
progressive_description(topic="all") # Get complete guide
```
## Why This Workflow?
**Token efficiency:**
- Index format: ~50-100 tokens per result
- Full format: ~500-1000 tokens per result
- **10x difference** - only fetch full when you know it's relevant
- **Search results:** ~50-100 tokens per result (table index)
- **Full observation:** ~500-1000 tokens each
- **10x savings** - only fetch full when you know it's relevant
**Batch fetching:**
- **Individual fetches:** 10 HTTP requests, ~5-10s latency
- **Batch fetch:** 1 HTTP request, ~0.5-1s latency
- **10-100x faster** for multi-observation queries
**Clarity:**
- See everything first
- Pick what matters
- Get details only for what you need
## Error Handling
If search fails, tell the user the worker isn't available and suggest:
```bash
pm2 list # Check if worker is running
```
- See everything first (table index)
- Get timeline context around interesting results
- Pick what matters based on context
- Fetch details only for what you need (batch when possible)
---
**Remember:** ALWAYS search with format=index first. ALWAYS fetch by ID for details. The IDs are there for a reason - USE THEM.
**Remember:**
- ALWAYS get timeline context to understand what was happening
- ALWAYS use `get_batch_observations` when fetching 2+ observations
- The workflow is optimized: search → timeline → batch fetch = 10-100x faster
@@ -95,7 +95,7 @@ echo '{"env":{"CLAUDE_MEM_WORKER_PORT":"37778"}}' > ~/.claude-mem/settings.json
# Change AI model
{
"env": {
"CLAUDE_MEM_MODEL": "claude-haiku-4-5"
"CLAUDE_MEM_MODEL": "claude-sonnet-4-5"
}
}
```
File diff suppressed because one or more lines are too long
+16
View File
@@ -222,12 +222,28 @@ async function buildHooks() {
console.log(`${hook.name} built (${sizeInKB} KB)`);
}
// Build mem-search skill zip for Claude Desktop
console.log('\n📦 Building mem-search skill zip for Claude Desktop...');
const { execSync } = await import('child_process');
const zipOutput = 'plugin/skills/mem-search.zip';
// Remove old zip if exists
if (fs.existsSync(zipOutput)) {
fs.unlinkSync(zipOutput);
}
// Create zip from mem-search skill directory
execSync(`cd plugin/skills && zip -r mem-search.zip mem-search/`, { stdio: 'pipe' });
const zipStats = fs.statSync(zipOutput);
console.log(`✓ mem-search.zip built (${(zipStats.size / 1024).toFixed(2)} KB)`);
console.log('\n✅ All hooks, worker service, and MCP server built successfully!');
console.log(` Output: ${hooksDir}/`);
console.log(` - Hooks: *-hook.js`);
console.log(` - Worker: worker-service.cjs`);
console.log(` - MCP Server: mcp-server.cjs`);
console.log(` - Skills: plugin/skills/`);
console.log(` - Desktop Skill: plugin/skills/mem-search.zip`);
console.log('\n💡 Note: Dependencies will be auto-installed on first hook execution');
} catch (error) {
+54 -29
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env npx tsx
#!/usr/bin/env bun
import { translateReadme, SUPPORTED_LANGUAGES } from "./index.ts";
@@ -11,6 +11,8 @@ interface CliArgs {
model?: string;
maxBudget?: number;
verbose: boolean;
force: boolean;
parallel: number;
help: boolean;
listLanguages: boolean;
}
@@ -39,6 +41,8 @@ OPTIONS:
-m, --model <model> Claude model to use (default: sonnet)
--max-budget <usd> Maximum budget in USD
-v, --verbose Show detailed progress
-f, --force Force re-translation ignoring cache
--parallel <n> Run n translations concurrently (default: 1)
-h, --help Show this help message
--list-languages List all supported language codes
@@ -59,40 +63,46 @@ SUPPORTED LANGUAGES:
function printLanguages(): void {
const LANGUAGE_NAMES: Record<string, string> = {
ar: "Arabic",
bg: "Bulgarian",
cs: "Czech",
da: "Danish",
de: "German",
el: "Greek",
es: "Spanish",
et: "Estonian",
fi: "Finnish",
fr: "French",
he: "Hebrew",
hi: "Hindi",
hu: "Hungarian",
id: "Indonesian",
it: "Italian",
// Tier 1 - No-brainers
zh: "Chinese (Simplified)",
ja: "Japanese",
ko: "Korean",
lt: "Lithuanian",
lv: "Latvian",
nl: "Dutch",
no: "Norwegian",
pl: "Polish",
pt: "Portuguese",
"pt-br": "Brazilian Portuguese",
ro: "Romanian",
ko: "Korean",
es: "Spanish",
de: "German",
fr: "French",
// Tier 2 - Strong tech scenes
he: "Hebrew",
ar: "Arabic",
ru: "Russian",
sk: "Slovak",
sl: "Slovenian",
sv: "Swedish",
th: "Thai",
pl: "Polish",
cs: "Czech",
nl: "Dutch",
tr: "Turkish",
uk: "Ukrainian",
// Tier 3 - Emerging/Growing fast
vi: "Vietnamese",
zh: "Chinese (Simplified)",
id: "Indonesian",
th: "Thai",
hi: "Hindi",
bn: "Bengali",
ro: "Romanian",
sv: "Swedish",
// Tier 4 - Why not
it: "Italian",
el: "Greek",
hu: "Hungarian",
fi: "Finnish",
da: "Danish",
no: "Norwegian",
// Other supported
bg: "Bulgarian",
et: "Estonian",
lt: "Lithuanian",
lv: "Latvian",
pt: "Portuguese",
sk: "Slovak",
sl: "Slovenian",
"zh-tw": "Chinese (Traditional)",
};
@@ -112,6 +122,8 @@ function parseArgs(argv: string[]): CliArgs {
languages: [],
preserveCode: true,
verbose: false,
force: false,
parallel: 1,
help: false,
listLanguages: false,
};
@@ -134,6 +146,10 @@ function parseArgs(argv: string[]): CliArgs {
case "--verbose":
args.verbose = true;
break;
case "-f":
case "--force":
args.force = true;
break;
case "--no-preserve-code":
args.preserveCode = false;
break;
@@ -152,6 +168,13 @@ function parseArgs(argv: string[]): CliArgs {
case "--max-budget":
args.maxBudget = parseFloat(argv[++i]);
break;
case "--parallel":
args.parallel = parseInt(argv[++i], 10);
if (isNaN(args.parallel) || args.parallel < 1) {
console.error("Error: --parallel must be a positive integer");
process.exit(1);
}
break;
default:
if (arg.startsWith("-")) {
console.error(`Unknown option: ${arg}`);
@@ -215,6 +238,8 @@ async function main(): Promise<void> {
model: args.model,
maxBudgetUsd: args.maxBudget,
verbose: args.verbose,
force: args.force,
parallel: args.parallel,
});
// Exit with error code if any translations failed
+175 -59
View File
@@ -1,6 +1,34 @@
import { query, type SDKMessage, type SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
import * as fs from "fs/promises";
import * as path from "path";
import { createHash } from "crypto";
interface TranslationCache {
sourceHash: string;
lastUpdated: string;
translations: Record<string, {
hash: string;
translatedAt: string;
costUsd: number;
}>;
}
function hashContent(content: string): string {
return createHash("sha256").update(content).digest("hex").slice(0, 16);
}
async function readCache(cachePath: string): Promise<TranslationCache | null> {
try {
const data = await fs.readFile(cachePath, "utf-8");
return JSON.parse(data);
} catch {
return null;
}
}
async function writeCache(cachePath: string, cache: TranslationCache): Promise<void> {
await fs.writeFile(cachePath, JSON.stringify(cache, null, 2), "utf-8");
}
export interface TranslationOptions {
/** Source README file path */
@@ -19,6 +47,10 @@ export interface TranslationOptions {
maxBudgetUsd?: number;
/** Verbose output */
verbose?: boolean;
/** Force re-translation even if cached */
force?: boolean;
/** Number of concurrent translations (default: 1) */
parallel?: number;
}
export interface TranslationResult {
@@ -27,6 +59,8 @@ export interface TranslationResult {
success: boolean;
error?: string;
costUsd?: number;
/** Whether this was served from cache */
cached?: boolean;
}
export interface TranslationJobResult {
@@ -37,40 +71,46 @@ export interface TranslationJobResult {
}
const LANGUAGE_NAMES: Record<string, string> = {
ar: "Arabic",
bg: "Bulgarian",
cs: "Czech",
da: "Danish",
de: "German",
el: "Greek",
es: "Spanish",
et: "Estonian",
fi: "Finnish",
fr: "French",
he: "Hebrew",
hi: "Hindi",
hu: "Hungarian",
id: "Indonesian",
it: "Italian",
// Tier 1 - No-brainers
zh: "Chinese (Simplified)",
ja: "Japanese",
ko: "Korean",
lt: "Lithuanian",
lv: "Latvian",
nl: "Dutch",
no: "Norwegian",
pl: "Polish",
pt: "Portuguese",
"pt-br": "Brazilian Portuguese",
ro: "Romanian",
ko: "Korean",
es: "Spanish",
de: "German",
fr: "French",
// Tier 2 - Strong tech scenes
he: "Hebrew",
ar: "Arabic",
ru: "Russian",
sk: "Slovak",
sl: "Slovenian",
sv: "Swedish",
th: "Thai",
pl: "Polish",
cs: "Czech",
nl: "Dutch",
tr: "Turkish",
uk: "Ukrainian",
// Tier 3 - Emerging/Growing fast
vi: "Vietnamese",
zh: "Chinese (Simplified)",
id: "Indonesian",
th: "Thai",
hi: "Hindi",
bn: "Bengali",
ro: "Romanian",
sv: "Swedish",
// Tier 4 - Why not
it: "Italian",
el: "Greek",
hu: "Hungarian",
fi: "Finnish",
da: "Danish",
no: "Norwegian",
// Other supported
bg: "Bulgarian",
et: "Estonian",
lt: "Lithuanian",
lv: "Latvian",
pt: "Portuguese",
sk: "Slovak",
sl: "Slovenian",
"zh-tw": "Chinese (Traditional)",
};
@@ -107,6 +147,7 @@ Guidelines:
- Preserve technical accuracy
- Use appropriate technical terminology for ${languageName}
- Keep proper nouns (product names, company names) unchanged unless they have official translations
- Add a small note at the very top of the document (before any other content) in ${languageName}: "🌐 This is an automated translation. Community corrections are welcome!"
Here is the README content to translate:
@@ -114,7 +155,12 @@ Here is the README content to translate:
${content}
---
Output ONLY the translated README content, nothing else. Do not include any preamble or explanation.`;
CRITICAL OUTPUT RULES:
- Output ONLY the raw translated markdown content
- Do NOT wrap output in \`\`\`markdown code fences
- Do NOT add any preamble, explanation, or commentary
- Start directly with the translation note, then the content
- The output will be saved directly to a .md file`;
let translation = "";
let costUsd = 0;
@@ -182,7 +228,21 @@ Always output only the translated content without any surrounding explanation.`,
process.stdout.write("\r" + " ".repeat(60) + "\r");
}
return { translation: translation.trim(), costUsd };
// Strip markdown code fences if Claude wrapped the output
let cleaned = translation.trim();
if (cleaned.startsWith("```markdown")) {
cleaned = cleaned.slice("```markdown".length);
} else if (cleaned.startsWith("```md")) {
cleaned = cleaned.slice("```md".length);
} else if (cleaned.startsWith("```")) {
cleaned = cleaned.slice(3);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.slice(0, -3);
}
cleaned = cleaned.trim();
return { translation: cleaned, costUsd };
}
export async function translateReadme(
@@ -197,6 +257,8 @@ export async function translateReadme(
model,
maxBudgetUsd,
verbose = false,
force = false,
parallel = 1,
} = options;
// Read source file
@@ -207,6 +269,12 @@ export async function translateReadme(
const outDir = outputDir ? path.resolve(outputDir) : path.dirname(sourcePath);
await fs.mkdir(outDir, { recursive: true });
// Compute content hash and load cache
const sourceHash = hashContent(content);
const cachePath = path.join(outDir, ".translation-cache.json");
const cache = await readCache(cachePath);
const isHashMatch = cache?.sourceHash === sourceHash;
const results: TranslationResult[] = [];
let totalCostUsd = 0;
@@ -214,24 +282,28 @@ export async function translateReadme(
console.log(`📖 Source: ${sourcePath}`);
console.log(`📂 Output: ${outDir}`);
console.log(`🌍 Languages: ${languages.join(", ")}`);
if (parallel > 1) {
console.log(`⚡ Parallel: ${parallel} concurrent translations`);
}
console.log("");
}
for (const lang of languages) {
// Check budget
if (maxBudgetUsd && totalCostUsd >= maxBudgetUsd) {
results.push({
language: lang,
outputPath: "",
success: false,
error: "Budget exceeded",
});
continue;
}
// Worker function for a single language
async function translateLang(lang: string): Promise<TranslationResult> {
const outputFilename = pattern.replace("{lang}", lang);
const outputPath = path.join(outDir, outputFilename);
// Check cache (unless --force)
if (!force && isHashMatch && cache?.translations[lang]) {
const outputExists = await fs.access(outputPath).then(() => true).catch(() => false);
if (outputExists) {
if (verbose) {
console.log(`${outputFilename} (cached, unchanged)`);
}
return { language: lang, outputPath, success: true, cached: true, costUsd: 0 };
}
}
if (verbose) {
console.log(`🔄 Translating to ${getLanguageName(lang)} (${lang})...`);
}
@@ -240,37 +312,81 @@ export async function translateReadme(
const { translation, costUsd } = await translateToLanguage(content, lang, {
preserveCode,
model,
verbose,
verbose: verbose && parallel === 1, // Only show progress spinner for sequential
});
await fs.writeFile(outputPath, translation, "utf-8");
totalCostUsd += costUsd;
results.push({
language: lang,
outputPath,
success: true,
costUsd,
});
if (verbose) {
console.log(` ✅ Saved to ${outputFilename} ($${costUsd.toFixed(4)})`);
}
return { language: lang, outputPath, success: true, costUsd };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
results.push({
language: lang,
outputPath,
success: false,
error: errorMessage,
});
if (verbose) {
console.log(`Failed: ${errorMessage}`);
console.log(`${lang} failed: ${errorMessage}`);
}
return { language: lang, outputPath, success: false, error: errorMessage };
}
}
// Run with concurrency limit
async function runWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<TranslationResult>): Promise<TranslationResult[]> {
const results: TranslationResult[] = [];
const executing: Promise<void>[] = [];
for (const item of items) {
// Check budget before starting new translation
if (maxBudgetUsd && totalCostUsd >= maxBudgetUsd) {
results.push({
language: String(item),
outputPath: "",
success: false,
error: "Budget exceeded",
});
continue;
}
const p = fn(item).then((result) => {
results.push(result);
if (result.costUsd) {
totalCostUsd += result.costUsd;
}
});
executing.push(p.then(() => {
executing.splice(executing.indexOf(p.then(() => {})), 1);
}));
if (executing.length >= limit) {
await Promise.race(executing);
}
}
await Promise.all(executing);
return results;
}
const translationResults = await runWithConcurrency(languages, parallel, translateLang);
results.push(...translationResults);
// Save updated cache
const newCache: TranslationCache = {
sourceHash,
lastUpdated: new Date().toISOString(),
translations: {
...(isHashMatch ? cache?.translations : {}),
...Object.fromEntries(
results.filter(r => r.success && !r.cached).map(r => [
r.language,
{ hash: sourceHash, translatedAt: new Date().toISOString(), costUsd: r.costUsd || 0 }
])
),
},
};
await writeCache(cachePath, newCache);
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
+3 -15
View File
@@ -8,7 +8,6 @@
import { stdin } from 'process';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { happy_path_error__with_fallback } from '../utils/silent-debug.js';
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
export interface SessionEndInput {
@@ -23,11 +22,6 @@ async function cleanupHook(input?: SessionEndInput): Promise<void> {
// Ensure worker is running before any other logic
await ensureWorkerRunning();
happy_path_error__with_fallback('[cleanup-hook] Hook fired', {
session_id: input?.session_id,
reason: input?.reason
});
if (!input) {
throw new Error('cleanup-hook requires input from Claude Code');
}
@@ -48,18 +42,12 @@ async function cleanupHook(input?: SessionEndInput): Promise<void> {
signal: AbortSignal.timeout(HOOK_TIMEOUTS.DEFAULT)
});
if (response.ok) {
const result = await response.json();
happy_path_error__with_fallback('[cleanup-hook] Session cleanup completed', result);
} else {
if (!response.ok) {
// Non-fatal - session might not exist
happy_path_error__with_fallback('[cleanup-hook] Session not found or already cleaned up');
console.error('[cleanup-hook] Session not found or already cleaned up');
}
} catch (error: any) {
// Worker might not be running - that's okay
happy_path_error__with_fallback('[cleanup-hook] Worker not reachable (non-critical)', {
error: error.message
});
// Worker might not be running - that's okay (non-critical)
}
console.log('{"continue": true, "suppressOutput": true}');
-7
View File
@@ -2,7 +2,6 @@ import path from 'path';
import { stdin } from 'process';
import { createHookResponse } from './hook-response.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { happy_path_error__with_fallback } from '../utils/silent-debug.js';
import { handleWorkerError } from '../shared/hook-error-handler.js';
import { handleFetchError } from './shared/error-handler.js';
@@ -27,12 +26,6 @@ async function newHook(input?: UserPromptSubmitInput): Promise<void> {
const { session_id, cwd, prompt } = input;
const project = path.basename(cwd);
happy_path_error__with_fallback('[new-hook] Input received', {
session_id,
project,
prompt_length: prompt?.length
});
const port = getWorkerPort();
// Initialize session via HTTP - handles DB operations and privacy checks
+3 -2
View File
@@ -11,7 +11,6 @@ import { createHookResponse } from './hook-response.js';
import { logger } from '../utils/logger.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
import { happy_path_error__with_fallback } from '../utils/silent-debug.js';
import { handleWorkerError } from '../shared/hook-error-handler.js';
import { handleFetchError } from './shared/error-handler.js';
@@ -54,8 +53,10 @@ async function saveHook(input?: PostToolUseInput): Promise<void> {
tool_name,
tool_input,
tool_response,
cwd: cwd || happy_path_error__with_fallback(
cwd: cwd || logger.happyPathError(
'HOOK',
'Missing cwd in PostToolUse hook input',
undefined,
{ session_id, tool_name },
''
)
+3 -2
View File
@@ -14,7 +14,6 @@ import { createHookResponse } from './hook-response.js';
import { logger } from '../utils/logger.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
import { happy_path_error__with_fallback } from '../utils/silent-debug.js';
import { handleWorkerError } from '../shared/hook-error-handler.js';
import { handleFetchError } from './shared/error-handler.js';
import { extractLastMessage } from '../shared/transcript-parser.js';
@@ -41,8 +40,10 @@ async function summaryHook(input?: StopInput): Promise<void> {
const port = getWorkerPort();
// Extract last user AND assistant messages from transcript
const transcriptPath = input.transcript_path || happy_path_error__with_fallback(
const transcriptPath = input.transcript_path || logger.happyPathError(
'HOOK',
'Missing transcript_path in Stop hook input',
undefined,
{ session_id },
''
);
+6 -4
View File
@@ -3,7 +3,7 @@
* Generates prompts for the Claude Agent SDK memory worker
*/
import { happy_path_error__with_fallback } from '../utils/silent-debug.js';
import { logger } from '../utils/logger.js';
export interface Observation {
id: number;
@@ -177,10 +177,12 @@ export function buildObservationPrompt(obs: Observation): string {
* Build prompt to generate progress summary
*/
export function buildSummaryPrompt(session: SDKSession): string {
const lastAssistantMessage = happy_path_error__with_fallback(
const lastAssistantMessage = session.last_assistant_message || logger.happyPathError(
'SDK',
'Missing last_assistant_message in session for summary prompt',
session,
session.last_assistant_message || ''
{ sessionId: session.id },
undefined,
''
);
return `PROGRESS SUMMARY CHECKPOINT
+185 -237
View File
@@ -14,7 +14,7 @@ import {
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { happy_path_error__with_fallback } from '../utils/silent-debug.js';
import { logger } from '../utils/logger.js';
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
/**
@@ -30,18 +30,9 @@ const WORKER_BASE_URL = `http://${WORKER_HOST}:${WORKER_PORT}`;
const TOOL_ENDPOINT_MAP: Record<string, string> = {
'search': '/api/search',
'timeline': '/api/timeline',
'decisions': '/api/decisions',
'changes': '/api/changes',
'how_it_works': '/api/how-it-works',
'search_observations': '/api/search/observations',
'search_sessions': '/api/search/sessions',
'search_user_prompts': '/api/search/prompts',
'find_by_concept': '/api/search/by-concept',
'find_by_file': '/api/search/by-file',
'find_by_type': '/api/search/by-type',
'get_recent_context': '/api/context/recent',
'get_context_timeline': '/api/context/timeline',
'get_timeline_by_query': '/api/timeline/by-query'
'progressive_description': '/api/instructions'
};
/**
@@ -51,7 +42,7 @@ async function callWorkerAPI(
endpoint: string,
params: Record<string, any>
): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> {
happy_path_error__with_fallback('[mcp-server] → Worker API', { endpoint, params });
logger.debug('SYSTEM', '→ Worker API', undefined, { endpoint, params });
try {
const searchParams = new URLSearchParams();
@@ -73,12 +64,100 @@ async function callWorkerAPI(
const data = await response.json() as { content: Array<{ type: 'text'; text: string }>; isError?: boolean };
happy_path_error__with_fallback('[mcp-server] ← Worker API success', { endpoint });
logger.debug('SYSTEM', '← Worker API success', undefined, { endpoint });
// Worker returns { content: [...] } format directly
return data;
} catch (error: any) {
happy_path_error__with_fallback('[mcp-server] ← Worker API error', { endpoint, error: error.message });
logger.error('SYSTEM', '← Worker API error', undefined, { endpoint, error: error.message });
return {
content: [{
type: 'text' as const,
text: `Error calling Worker API: ${error.message}`
}],
isError: true
};
}
}
/**
* 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,
@@ -103,24 +182,24 @@ async function verifyWorkerConnection(): Promise<boolean> {
/**
* Tool definitions with HTTP-based handlers
* Descriptions removed - use progressive_description tool for parameter documentation
*/
const tools = [
{
name: 'search',
description: 'Unified search across all memory types (observations, sessions, and user prompts) using vector-first semantic search (ChromaDB). Returns combined results from all document types. IMPORTANT: Always use index format first (default) to get an overview with minimal token usage, then use format: "full" only for specific items of interest.',
description: 'Search memory',
inputSchema: z.object({
query: z.string().optional().describe('Natural language search query for semantic ranking via ChromaDB vector search. Optional - omit for date-filtered queries only (Chroma cannot filter by date, requires direct SQLite).'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED for initial search), "full" for complete details (use only after reviewing index results)'),
type: z.enum(['observations', 'sessions', 'prompts']).optional().describe('Filter by document type (observations, sessions, or prompts). Omit to search all types.'),
obs_type: z.string().optional().describe('Filter observations by type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change). Only applies when type="observations"'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list). Only applies when type="observations"'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match). Only applies when type="observations"'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('date_desc').describe('Sort order')
query: z.string().optional(),
type: z.enum(['observations', 'sessions', 'prompts']).optional(),
obs_type: z.string().optional(),
concepts: z.string().optional(),
files: z.string().optional(),
project: z.string().optional(),
dateStart: z.union([z.string(), z.number()]).optional(),
dateEnd: z.union([z.string(), z.number()]).optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('date_desc')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['search'];
@@ -129,197 +208,33 @@ const tools = [
},
{
name: 'timeline',
description: 'Fetch timeline of observations around a specific point in time. Supports two modes: anchor-based (fetch observations before/after a specific observation ID) and query-based (semantic search for anchor point). IMPORTANT: Use anchor_id when you know the specific observation, or query to find an anchor point first.',
description: 'Timeline context',
inputSchema: z.object({
query: z.string().optional().describe('Natural language query to find anchor observation (query-based mode). Mutually exclusive with anchor_id.'),
anchor_id: z.number().optional().describe('Observation ID to use as anchor (anchor-based mode). Mutually exclusive with query.'),
before: z.number().min(0).max(100).default(10).describe('Number of observations to fetch before anchor'),
after: z.number().min(0).max(100).default(10).describe('Number of observations to fetch after anchor'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
obs_type: z.string().optional().describe('Filter observations by type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name')
query: z.string().optional(),
anchor: z.number().optional(),
depth_before: z.number().min(0).max(100).default(10),
depth_after: z.number().min(0).max(100).default(10),
type: z.string().optional(),
concepts: z.string().optional(),
files: z.string().optional(),
project: z.string().optional()
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['timeline'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'decisions',
description: 'Semantic shortcut for finding architectural, design, and implementation decisions. Optimized for decision-type observations with relevant keyword boosting.',
inputSchema: z.object({
query: z.string().describe('Natural language query for finding decisions'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['decisions'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'changes',
description: 'Semantic shortcut for finding code changes, refactorings, and modifications. Optimized for change-type observations with relevant keyword boosting.',
inputSchema: z.object({
query: z.string().describe('Natural language query for finding changes'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['changes'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'how_it_works',
description: 'Semantic shortcut for understanding system architecture, design patterns, and implementation details. Optimized for discovery-type observations with architecture/design keyword boosting.',
inputSchema: z.object({
query: z.string().describe('Natural language query for understanding how something works'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['how_it_works'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'search_observations',
description: '[DEPRECATED - Use "search" with type="observations" instead] Search observations (facts/narratives) using FTS5 full-text search. Supports filtering by type, concepts, files, and date range.',
inputSchema: z.object({
query: z.string().optional().describe('Full-text search query (FTS5)'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('relevance').describe('Sort order (relevance only when query provided)')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['search_observations'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'search_sessions',
description: '[DEPRECATED - Use "search" with type="sessions" instead] Search session summaries using FTS5 full-text search. Returns both request_summary and learned_summary fields.',
inputSchema: z.object({
query: z.string().optional().describe('Full-text search query (FTS5)'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('relevance').describe('Sort order (relevance only when query provided)')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['search_sessions'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'search_user_prompts',
description: '[DEPRECATED - Use "search" with type="prompts" instead] Search user prompts using FTS5 full-text search. Searches prompt text only.',
inputSchema: z.object({
query: z.string().optional().describe('Full-text search query (FTS5)'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('relevance').describe('Sort order (relevance only when query provided)')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['search_user_prompts'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'find_by_concept',
description: 'Find observations tagged with specific concepts. Returns observations that match any of the provided concept tags.',
inputSchema: z.object({
concepts: z.string().describe('Concept tag(s) to filter by (single value or comma-separated list)'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['date_desc', 'date_asc']).default('date_desc').describe('Sort order')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['find_by_concept'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'find_by_file',
description: 'Find observations related to specific file paths. Uses partial matching - searches for file paths containing the provided string.',
inputSchema: z.object({
files: z.string().describe('File path(s) to filter by (single value or comma-separated list for partial match)'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['date_desc', 'date_asc']).default('date_desc').describe('Sort order')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['find_by_file'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'find_by_type',
description: 'Find observations of specific types. Returns observations matching any of the provided observation types.',
inputSchema: z.object({
type: z.string().describe('Observation type(s) to filter by (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
offset: z.number().min(0).default(0).describe('Number of results to skip'),
orderBy: z.enum(['date_desc', 'date_asc']).default('date_desc').describe('Sort order')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['find_by_type'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'get_recent_context',
description: 'Get recent session context for timeline display. Returns recent observations, sessions, and user prompts with metadata for building timeline UI.',
description: 'Recent context',
inputSchema: z.object({
limit: z.number().min(1).max(100).default(30).describe('Maximum number of timeline items to return'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
limit: z.number().min(1).max(100).default(30),
type: z.string().optional(),
concepts: z.string().optional(),
files: z.string().optional(),
project: z.string().optional(),
dateStart: z.union([z.string(), z.number()]).optional(),
dateEnd: z.union([z.string(), z.number()]).optional()
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['get_recent_context'];
@@ -328,16 +243,15 @@ const tools = [
},
{
name: 'get_context_timeline',
description: 'Get timeline of observations around a specific observation ID. Returns observations before and after the anchor point with metadata for timeline display.',
description: 'Timeline around ID',
inputSchema: z.object({
anchor_id: z.number().describe('Observation ID to use as anchor point'),
before: z.number().min(0).max(100).default(10).describe('Number of observations to fetch before anchor'),
after: z.number().min(0).max(100).default(10).describe('Number of observations to fetch after anchor'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name')
anchor: z.number(),
depth_before: z.number().min(0).max(100).default(10),
depth_after: z.number().min(0).max(100).default(10),
type: z.string().optional(),
concepts: z.string().optional(),
files: z.string().optional(),
project: z.string().optional()
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['get_context_timeline'];
@@ -345,24 +259,58 @@ const tools = [
}
},
{
name: 'get_timeline_by_query',
description: 'Combined search + timeline tool. First searches for observations matching the query, then returns timeline around the best match. Useful for finding specific observations and viewing their context.',
name: 'progressive_description',
description: 'Usage help',
inputSchema: z.object({
query: z.string().describe('Natural language query to find anchor observation'),
before: z.number().min(0).max(100).default(10).describe('Number of observations to fetch before anchor'),
after: z.number().min(0).max(100).default(10).describe('Number of observations to fetch after anchor'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
project: z.string().optional().describe('Filter by project name'),
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
topic: z.enum(['workflow', 'search_params', 'examples', 'all']).default('all')
}),
handler: async (args: any) => {
const endpoint = TOOL_ENDPOINT_MAP['get_timeline_by_query'];
const endpoint = TOOL_ENDPOINT_MAP['progressive_description'];
return await callWorkerAPI(endpoint, args);
}
},
{
name: 'get_observation',
description: 'Fetch by ID',
inputSchema: z.object({
id: z.number()
}),
handler: async (args: any) => {
return await callWorkerAPIWithPath('/api/observation', args.id);
}
},
{
name: 'get_batch_observations',
description: 'Batch fetch',
inputSchema: z.object({
ids: z.array(z.number()),
orderBy: z.enum(['date_desc', 'date_asc']).optional(),
limit: z.number().optional(),
project: z.string().optional()
}),
handler: async (args: any) => {
return await callWorkerAPIPost('/api/observations/batch', args);
}
},
{
name: 'get_session',
description: 'Session by ID',
inputSchema: z.object({
id: z.number()
}),
handler: async (args: any) => {
return await callWorkerAPIWithPath('/api/session', args.id);
}
},
{
name: 'get_prompt',
description: 'Prompt by ID',
inputSchema: z.object({
id: z.number()
}),
handler: async (args: any) => {
return await callWorkerAPIWithPath('/api/prompt', args.id);
}
}
];
@@ -413,7 +361,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
// Cleanup function
async function cleanup() {
happy_path_error__with_fallback('[mcp-server] Shutting down...');
logger.info('SYSTEM', 'MCP server shutting down');
process.exit(0);
}
@@ -426,22 +374,22 @@ async function main() {
// Start the MCP server
const transport = new StdioServerTransport();
await server.connect(transport);
happy_path_error__with_fallback('[mcp-server] Claude-mem search server started');
logger.info('SYSTEM', 'Claude-mem search server started');
// Check Worker availability in background
setTimeout(async () => {
const workerAvailable = await verifyWorkerConnection();
if (!workerAvailable) {
happy_path_error__with_fallback('[mcp-server] WARNING: Worker not available at', WORKER_BASE_URL);
happy_path_error__with_fallback('[mcp-server] Tools will fail until Worker is started');
happy_path_error__with_fallback('[mcp-server] Start Worker with: npm run worker:restart');
logger.warn('SYSTEM', 'Worker not available', undefined, { workerUrl: WORKER_BASE_URL });
logger.warn('SYSTEM', 'Tools will fail until Worker is started');
logger.warn('SYSTEM', 'Start Worker with: npm run worker:restart');
} else {
happy_path_error__with_fallback('[mcp-server] Worker available at', WORKER_BASE_URL);
logger.info('SYSTEM', 'Worker available', undefined, { workerUrl: WORKER_BASE_URL });
}
}, 0);
}
main().catch((error) => {
happy_path_error__with_fallback('[mcp-server] Fatal error:', error);
logger.error('SYSTEM', 'Fatal error', undefined, error);
process.exit(1);
});
+11 -58
View File
@@ -17,6 +17,14 @@ import {
} from '../constants/observation-metadata.js';
import { logger } from '../utils/logger.js';
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
import {
parseJsonArray,
formatDateTime,
formatTime,
formatDate,
toRelativePath,
extractFirstFile
} from '../shared/timeline-formatting.js';
// Version marker path - use homedir-based path that works in both CJS and ESM contexts
const VERSION_MARKER_PATH = path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'plugin', '.install-version');
@@ -145,57 +153,6 @@ interface SessionSummary {
created_at_epoch: number;
}
// Helper: Parse JSON array safely
function parseJsonArray(json: string | null): string[] {
if (!json) return [];
try {
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? parsed : [];
} catch (err) {
return [];
}
}
// Helper: Format date with time
function formatDateTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
// Helper: Format just time (no date)
function formatTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
// Helper: Format just date
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
// Helper: Convert absolute paths to relative paths
function toRelativePath(filePath: string, cwd: string): string {
if (path.isAbsolute(filePath)) {
return path.relative(cwd, filePath);
}
return filePath;
}
// Helper: Render a summary field
function renderSummaryField(label: string, value: string | null, color: string, useColors: boolean): string[] {
if (!value) return [];
@@ -544,20 +501,16 @@ export async function generateContext(input?: ContextInput, useColors: boolean =
const summary = item.data;
const summaryTitle = `${summary.request || 'Session started'} (${formatDateTime(summary.displayTime)})`;
const link = summary.shouldShowLink ? `claude-mem://session-summary/${summary.id}` : '';
if (useColors) {
const linkPart = link ? `${colors.dim}[${link}]${colors.reset}` : '';
output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle} ${linkPart}`);
output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle}`);
} else {
const linkPart = link ? ` [→](${link})` : '';
output.push(`**🎯 #S${summary.id}** ${summaryTitle}${linkPart}`);
output.push(`**🎯 #S${summary.id}** ${summaryTitle}`);
}
output.push('');
} else {
const obs = item.data;
const files = parseJsonArray(obs.files_modified);
const file = (files.length > 0 && files[0]) ? toRelativePath(files[0], cwd) : 'General';
const file = extractFirstFile(obs.files_modified, cwd);
if (file !== currentFile) {
if (tableOpen) {
+108 -1
View File
@@ -1614,8 +1614,9 @@ export class SessionStore {
prompts: prompts.map(p => ({
id: p.id,
claude_session_id: p.claude_session_id,
prompt_number: p.prompt_number,
prompt_text: p.prompt_text,
project: p.project,
prompt: p.prompt_text,
created_at: p.created_at,
created_at_epoch: p.created_at_epoch
}))
@@ -1626,6 +1627,112 @@ export class SessionStore {
}
}
/**
* Get a single user prompt by ID
*/
getPromptById(id: number): {
id: number;
claude_session_id: string;
prompt_number: number;
prompt_text: string;
project: string;
created_at: string;
created_at_epoch: number;
} | null {
const stmt = this.db.prepare(`
SELECT
p.id,
p.claude_session_id,
p.prompt_number,
p.prompt_text,
s.project,
p.created_at,
p.created_at_epoch
FROM user_prompts p
LEFT JOIN sdk_sessions s ON p.claude_session_id = s.claude_session_id
WHERE p.id = ?
LIMIT 1
`);
return stmt.get(id) || null;
}
/**
* Get multiple user prompts by IDs
*/
getPromptsByIds(ids: number[]): Array<{
id: number;
claude_session_id: string;
prompt_number: number;
prompt_text: string;
project: string;
created_at: string;
created_at_epoch: number;
}> {
if (ids.length === 0) return [];
const placeholders = ids.map(() => '?').join(',');
const stmt = this.db.prepare(`
SELECT
p.id,
p.claude_session_id,
p.prompt_number,
p.prompt_text,
s.project,
p.created_at,
p.created_at_epoch
FROM user_prompts p
LEFT JOIN sdk_sessions s ON p.claude_session_id = s.claude_session_id
WHERE p.id IN (${placeholders})
ORDER BY p.created_at_epoch DESC
`);
return stmt.all(...ids) as Array<{
id: number;
claude_session_id: string;
prompt_number: number;
prompt_text: string;
project: string;
created_at: string;
created_at_epoch: number;
}>;
}
/**
* Get full session summary by ID (includes request_summary and learned_summary)
*/
getSessionSummaryById(id: number): {
id: number;
sdk_session_id: string | null;
claude_session_id: string;
project: string;
user_prompt: string;
request_summary: string | null;
learned_summary: string | null;
status: string;
created_at: string;
created_at_epoch: number;
} | null {
const stmt = this.db.prepare(`
SELECT
id,
sdk_session_id,
claude_session_id,
project,
user_prompt,
request_summary,
learned_summary,
status,
created_at,
created_at_epoch
FROM sdk_sessions
WHERE id = ?
LIMIT 1
`);
return stmt.get(id) || null;
}
/**
* Close the database connection
*/
+4 -3
View File
@@ -15,7 +15,6 @@ import { SessionStore } from '../sqlite/SessionStore.js';
import { logger } from '../../utils/logger.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import { happy_path_error__with_fallback } from '../../utils/silent-debug.js';
import path from 'path';
import os from 'os';
@@ -780,9 +779,11 @@ export class ChromaSync {
arguments: arguments_obj
});
const resultText = happy_path_error__with_fallback(
const resultText = logger.happyPathError(
'CHROMA',
'Missing text in MCP chroma_query_documents result',
{ project: this.project, query_text: query },
{ project: this.project },
{ query_text: query },
result.content[0]?.text || ''
);
+63
View File
@@ -9,6 +9,7 @@
import express from 'express';
import http from 'http';
import path from 'path';
import * as fs from 'fs';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
@@ -142,6 +143,39 @@ export class WorkerService {
}
});
// Instructions endpoint - loads SKILL.md sections on-demand for progressive instruction loading
this.app.get('/api/instructions', async (req, res) => {
const topic = (req.query.topic as string) || 'all';
// Read SKILL.md from plugin directory
// Path resolution: __dirname is build output directory (plugin/scripts/)
// SKILL.md is at plugin/skills/mem-search/SKILL.md
const skillPath = path.join(__dirname, '../skills/mem-search/SKILL.md');
try {
const fullContent = await fs.promises.readFile(skillPath, 'utf-8');
// Extract section based on topic
const section = this.extractInstructionSection(fullContent, topic);
// Return in MCP format
res.json({
content: [{
type: 'text',
text: section
}]
});
} catch (error) {
logger.error('WORKER', 'Failed to load instructions', { topic, skillPath }, error as Error);
res.status(500).json({
content: [{
type: 'text',
text: `Error loading instructions: ${error instanceof Error ? error.message : 'Unknown error'}`
}],
isError: true
});
}
});
// Admin endpoints for process management
this.app.post('/api/admin/restart', async (_req, res) => {
res.json({ status: 'restarting' });
@@ -334,6 +368,35 @@ export class WorkerService {
}
}
/**
* Extract a specific section from instruction content
* Used by /api/instructions endpoint for progressive instruction loading
*/
private extractInstructionSection(content: string, topic: string): string {
const sections: Record<string, string> = {
'workflow': this.extractBetween(content, '## The Workflow', '## Search Parameters'),
'search_params': this.extractBetween(content, '## Search Parameters', '## Examples'),
'examples': this.extractBetween(content, '## Examples', '## Why This Workflow'),
'all': content
};
return sections[topic] || sections['all'];
}
/**
* Extract text between two markers
* Helper for extractInstructionSection
*/
private extractBetween(content: string, startMarker: string, endMarker: string): string {
const startIdx = content.indexOf(startMarker);
const endIdx = content.indexOf(endMarker);
if (startIdx === -1) return content;
if (endIdx === -1) return content.substring(startIdx);
return content.substring(startIdx, endIdx).trim();
}
/**
* Shutdown the worker service
*/
+128 -204
View File
@@ -1,12 +1,13 @@
/**
* FormattingService - Handles all formatting logic for search results
* Extracted from mcp-server.ts to follow worker service organization pattern
* Uses table format matching context-generator style for visual consistency
*/
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
import { logger } from '../../utils/logger.js';
import { TYPE_ICON_MAP, TYPE_WORK_EMOJI_MAP } from '../../constants/observation-metadata.js';
export type FormatType = 'index' | 'full';
// Token estimation constant (matches context-generator)
const CHARS_PER_TOKEN_ESTIMATE = 4;
export class FormattingService {
/**
@@ -15,232 +16,155 @@ export class FormattingService {
formatSearchTips(): string {
return `\n---
💡 Search Strategy:
ALWAYS search with index format FIRST to get an overview and identify relevant results.
This is critical for token efficiency - index format uses ~10x fewer tokens than full format.
1. Search with index to see titles, dates, IDs
2. Use timeline to get context around interesting results
3. Batch fetch full details: get_batch_observations(ids=[...])
Search workflow:
1. Initial search: Use default (index) format to see titles, dates, and sources
2. Review results: Identify which items are most relevant to your needs
3. Deep dive: Only then use format: "full" on specific items of interest
4. Narrow down: Use filters (type, dateStart/dateEnd, concepts, files) to refine results
Other tips:
To search by concept: Use find_by_concept tool
To browse by type: Use find_by_type with ["decision", "feature", etc.]
To sort by date: Use orderBy: "date_desc" or "date_asc"`;
Tips:
Filter by type: obs_type="bugfix,feature"
Filter by date: dateStart="2025-01-01"
Sort: orderBy="date_desc" or "date_asc"`;
}
/**
* Format observation as index entry (title, date, ID only)
* Format time from epoch (matches context-generator formatTime)
*/
formatObservationIndex(obs: ObservationSearchResult, index: number): string {
const title = obs.title || `Observation #${obs.id}`;
const date = new Date(obs.created_at_epoch).toLocaleString();
const type = obs.type ? `[${obs.type}]` : '';
return `${index + 1}. ${type} ${title}
Date: ${date}
Source: claude-mem://observation/${obs.id}`;
private formatTime(epoch: number): string {
return new Date(epoch).toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
/**
* Format session summary as index entry (title, date, ID only)
* Estimate read tokens for an observation
*/
formatSessionIndex(session: SessionSummarySearchResult, index: number): string {
const title = session.request || `Session ${session.sdk_session_id?.substring(0, 8) || 'unknown'}`;
const date = new Date(session.created_at_epoch).toLocaleString();
return `${index + 1}. ${title}
Date: ${date}
Source: claude-mem://session/${session.sdk_session_id}`;
private estimateReadTokens(obs: ObservationSearchResult): number {
const size = (obs.title?.length || 0) +
(obs.subtitle?.length || 0) +
(obs.narrative?.length || 0) +
(obs.facts?.length || 0);
return Math.ceil(size / CHARS_PER_TOKEN_ESTIMATE);
}
/**
* Format user prompt as index entry (full text - don't truncate context!)
* Format observation as table row
* | ID | Time | T | Title | Read | Work |
*/
formatUserPromptIndex(prompt: UserPromptSearchResult, index: number): string {
const date = new Date(prompt.created_at_epoch).toLocaleString();
formatObservationIndex(obs: ObservationSearchResult, _index: number): string {
const id = `#${obs.id}`;
const time = this.formatTime(obs.created_at_epoch);
const icon = TYPE_ICON_MAP[obs.type as keyof typeof TYPE_ICON_MAP] || '•';
const title = obs.title || 'Untitled';
const readTokens = this.estimateReadTokens(obs);
const workEmoji = TYPE_WORK_EMOJI_MAP[obs.type as keyof typeof TYPE_WORK_EMOJI_MAP] || '🔍';
const workTokens = obs.discovery_tokens || 0;
const workDisplay = workTokens > 0 ? `${workEmoji} ${workTokens}` : '-';
return `${index + 1}. "${prompt.prompt_text}"
Date: ${date} | Prompt #${prompt.prompt_number}
Source: claude-mem://user-prompt/${prompt.id}`;
return `| ${id} | ${time} | ${icon} | ${title} | ~${readTokens} | ${workDisplay} |`;
}
/**
* Format observation as text content with metadata
* Format session summary as table row
* | ID | Time | T | Title | - | - |
*/
formatObservationResult(obs: ObservationSearchResult): string {
const title = obs.title || `Observation #${obs.id}`;
// Build content from available fields
const contentParts: string[] = [];
contentParts.push(`## ${title}`);
contentParts.push(`*Source: claude-mem://observation/${obs.id}*`);
contentParts.push('');
if (obs.subtitle) {
contentParts.push(`**${obs.subtitle}**`);
contentParts.push('');
}
if (obs.narrative) {
contentParts.push(obs.narrative);
contentParts.push('');
}
if (obs.text) {
contentParts.push(obs.text);
contentParts.push('');
}
// Add metadata
const metadata: string[] = [];
metadata.push(`Type: ${obs.type}`);
if (obs.facts) {
try {
const facts = JSON.parse(obs.facts);
if (facts.length > 0) {
metadata.push(`Facts: ${facts.join('; ')}`);
}
} catch (e) {
logger.warn('FORMAT', 'Invalid JSON in facts field', { obsId: obs.id });
}
}
if (obs.concepts) {
try {
const concepts = JSON.parse(obs.concepts);
if (concepts.length > 0) {
metadata.push(`Concepts: ${concepts.join(', ')}`);
}
} catch (e) {
logger.warn('FORMAT', 'Invalid JSON in concepts field', { obsId: obs.id });
}
}
if (obs.files_read || obs.files_modified) {
const files: string[] = [];
if (obs.files_read) {
try {
files.push(...JSON.parse(obs.files_read));
} catch (e) {
logger.warn('FORMAT', 'Invalid JSON in files_read field', { obsId: obs.id });
}
}
if (obs.files_modified) {
try {
files.push(...JSON.parse(obs.files_modified));
} catch (e) {
logger.warn('FORMAT', 'Invalid JSON in files_modified field', { obsId: obs.id });
}
}
if (files.length > 0) {
metadata.push(`Files: ${[...new Set(files)].join(', ')}`);
}
}
if (metadata.length > 0) {
contentParts.push('---');
contentParts.push(metadata.join(' | '));
}
// Add date
const date = new Date(obs.created_at_epoch).toLocaleString();
contentParts.push('');
contentParts.push(`---`);
contentParts.push(`Date: ${date}`);
return contentParts.join('\n');
}
/**
* Format session summary as text content with metadata
*/
formatSessionResult(session: SessionSummarySearchResult): string {
formatSessionIndex(session: SessionSummarySearchResult, _index: number): string {
const id = `#S${session.id}`;
const time = this.formatTime(session.created_at_epoch);
const icon = '🎯';
const title = session.request || `Session ${session.sdk_session_id?.substring(0, 8) || 'unknown'}`;
// Build content from available fields
const contentParts: string[] = [];
contentParts.push(`## ${title}`);
contentParts.push(`*Source: claude-mem://session/${session.sdk_session_id}*`);
contentParts.push('');
if (session.completed) {
contentParts.push(`**Completed:** ${session.completed}`);
contentParts.push('');
}
if (session.learned) {
contentParts.push(`**Learned:** ${session.learned}`);
contentParts.push('');
}
if (session.investigated) {
contentParts.push(`**Investigated:** ${session.investigated}`);
contentParts.push('');
}
if (session.next_steps) {
contentParts.push(`**Next Steps:** ${session.next_steps}`);
contentParts.push('');
}
if (session.notes) {
contentParts.push(`**Notes:** ${session.notes}`);
contentParts.push('');
}
// Add metadata
const metadata: string[] = [];
if (session.files_read || session.files_edited) {
const files: string[] = [];
if (session.files_read) {
try {
files.push(...JSON.parse(session.files_read));
} catch (e) {
logger.warn('FORMAT', 'Invalid JSON in session files_read field', { sessionId: session.sdk_session_id });
}
}
if (session.files_edited) {
try {
files.push(...JSON.parse(session.files_edited));
} catch (e) {
logger.warn('FORMAT', 'Invalid JSON in session files_edited field', { sessionId: session.sdk_session_id });
}
}
if (files.length > 0) {
metadata.push(`Files: ${[...new Set(files)].join(', ')}`);
}
}
const date = new Date(session.created_at_epoch).toLocaleDateString();
metadata.push(`Date: ${date}`);
if (metadata.length > 0) {
contentParts.push('---');
contentParts.push(metadata.join(' | '));
}
return contentParts.join('\n');
return `| ${id} | ${time} | ${icon} | ${title} | - | - |`;
}
/**
* Format user prompt as text content with metadata
* Format user prompt as table row
* | ID | Time | T | Title | - | - |
*/
formatUserPromptResult(prompt: UserPromptSearchResult): string {
const contentParts: string[] = [];
contentParts.push(`## User Prompt #${prompt.prompt_number}`);
contentParts.push(`*Source: claude-mem://user-prompt/${prompt.id}*`);
contentParts.push('');
contentParts.push(prompt.prompt_text);
contentParts.push('');
contentParts.push('---');
formatUserPromptIndex(prompt: UserPromptSearchResult, _index: number): string {
const id = `#P${prompt.id}`;
const time = this.formatTime(prompt.created_at_epoch);
const icon = '💬';
// Truncate long prompts for table display
const title = prompt.prompt_text.length > 60
? prompt.prompt_text.substring(0, 57) + '...'
: prompt.prompt_text;
const date = new Date(prompt.created_at_epoch).toLocaleString();
contentParts.push(`Date: ${date}`);
return `| ${id} | ${time} | ${icon} | ${title} | - | - |`;
}
return contentParts.join('\n');
/**
* Generate table header for observations
*/
formatTableHeader(): string {
return `| ID | Time | T | Title | Read | Work |
|-----|------|---|-------|------|------|`;
}
/**
* Generate table header for search results (no Work column)
*/
formatSearchTableHeader(): string {
return `| ID | Time | T | Title | Read |
|----|------|---|-------|------|`;
}
/**
* Format observation as table row for search results (no Work column)
*/
formatObservationSearchRow(obs: ObservationSearchResult, lastTime: string): { row: string; time: string } {
const id = `#${obs.id}`;
const time = this.formatTime(obs.created_at_epoch);
const icon = TYPE_ICON_MAP[obs.type as keyof typeof TYPE_ICON_MAP] || '•';
const title = obs.title || 'Untitled';
const readTokens = this.estimateReadTokens(obs);
// Use ditto mark if same time as previous row
const timeDisplay = time === lastTime ? '″' : time;
return {
row: `| ${id} | ${timeDisplay} | ${icon} | ${title} | ~${readTokens} |`,
time
};
}
/**
* Format session summary as table row for search results (no Work column)
*/
formatSessionSearchRow(session: SessionSummarySearchResult, lastTime: string): { row: string; time: string } {
const id = `#S${session.id}`;
const time = this.formatTime(session.created_at_epoch);
const icon = '🎯';
const title = session.request || `Session ${session.sdk_session_id?.substring(0, 8) || 'unknown'}`;
// Use ditto mark if same time as previous row
const timeDisplay = time === lastTime ? '″' : time;
return {
row: `| ${id} | ${timeDisplay} | ${icon} | ${title} | - |`,
time
};
}
/**
* Format user prompt as table row for search results (no Work column)
*/
formatUserPromptSearchRow(prompt: UserPromptSearchResult, lastTime: string): { row: string; time: string } {
const id = `#P${prompt.id}`;
const time = this.formatTime(prompt.created_at_epoch);
const icon = '💬';
// Truncate long prompts for table display
const title = prompt.prompt_text.length > 60
? prompt.prompt_text.substring(0, 57) + '...'
: prompt.prompt_text;
// Use ditto mark if same time as previous row
const timeDisplay = time === lastTime ? '″' : time;
return {
row: `| ${id} | ${timeDisplay} | ${icon} | ${title} | - |`,
time
};
}
}
-1
View File
@@ -14,7 +14,6 @@ import path from 'path';
import { DatabaseManager } from './DatabaseManager.js';
import { SessionManager } from './SessionManager.js';
import { logger } from '../../utils/logger.js';
import { happy_path_error__with_fallback } from '../../utils/silent-debug.js';
import { parseObservations, parseSummary } from '../../sdk/parser.js';
import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildContinuationPrompt } from '../../sdk/prompts.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
+140 -193
View File
@@ -14,8 +14,11 @@ import { FormattingService } from './FormattingService.js';
import { TimelineService, TimelineItem } from './TimelineService.js';
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
import { logger } from '../../utils/logger.js';
import { formatDate, formatTime, extractFirstFile, groupByDate } from '../../shared/timeline-formatting.js';
const COLLECTION_NAME = 'cm__claude-mem';
const RECENCY_WINDOW_DAYS = 90;
const RECENCY_WINDOW_MS = RECENCY_WINDOW_DAYS * 24 * 60 * 60 * 1000;
export class SearchManager {
constructor(
@@ -84,7 +87,7 @@ export class SearchManager {
try {
// Normalize URL-friendly params to internal format
const normalized = this.normalizeParams(args);
const { query, format = 'index', type, obs_type, concepts, files, ...options } = normalized;
const { query, type, obs_type, concepts, files, ...options } = normalized;
let observations: ObservationSearchResult[] = [];
let sessions: SessionSummarySearchResult[] = [];
let prompts: UserPromptSearchResult[] = [];
@@ -132,7 +135,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
const recentMetadata = chromaResults.metadatas.map((meta, idx) => ({
id: chromaResults.ids[idx],
meta,
@@ -198,13 +201,6 @@ export class SearchManager {
const totalResults = observations.length + sessions.length + prompts.length;
if (totalResults === 0) {
if (format === 'json') {
return {
observations: [],
sessions: [],
prompts: []
};
}
return {
content: [{
type: 'text' as const,
@@ -218,15 +214,31 @@ export class SearchManager {
type: 'observation' | 'session' | 'prompt';
data: any;
epoch: number;
created_at: string;
}
const allResults: CombinedResult[] = [
...observations.map(obs => ({ type: 'observation' as const, data: obs, epoch: obs.created_at_epoch })),
...sessions.map(sess => ({ type: 'session' as const, data: sess, epoch: sess.created_at_epoch })),
...prompts.map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch }))
...observations.map(obs => ({
type: 'observation' as const,
data: obs,
epoch: obs.created_at_epoch,
created_at: obs.created_at
})),
...sessions.map(sess => ({
type: 'session' as const,
data: sess,
epoch: sess.created_at_epoch,
created_at: sess.created_at
})),
...prompts.map(prompt => ({
type: 'prompt' as const,
data: prompt,
epoch: prompt.created_at_epoch,
created_at: prompt.created_at
}))
];
// Sort by date (most recent first)
// Sort by date
if (options.orderBy === 'date_desc') {
allResults.sort((a, b) => b.epoch - a.epoch);
} else if (options.orderBy === 'date_asc') {
@@ -236,46 +248,62 @@ export class SearchManager {
// Apply limit across all types
const limitedResults = allResults.slice(0, options.limit || 20);
// Format based on requested format
if (format === 'json') {
// Raw JSON format for exports
return {
observations,
sessions,
prompts
};
}
// Group by date, then by file within each day
const cwd = process.cwd();
const resultsByDate = groupByDate(limitedResults, item => item.created_at);
let combinedText: string;
if (format === 'index') {
const header = `Found ${totalResults} result(s) matching "${query}" (${observations.length} obs, ${sessions.length} sessions, ${prompts.length} prompts):\n\n`;
const formattedResults = limitedResults.map((item, i) => {
if (item.type === 'observation') {
return this.formatter.formatObservationIndex(item.data, i);
} else if (item.type === 'session') {
return this.formatter.formatSessionIndex(item.data, i);
} else {
return this.formatter.formatUserPromptIndex(item.data, i);
// Build output with date/file grouping
const lines: string[] = [];
lines.push(`Found ${totalResults} result(s) matching "${query}" (${observations.length} obs, ${sessions.length} sessions, ${prompts.length} prompts)`);
lines.push('');
for (const [day, dayResults] of resultsByDate) {
lines.push(`### ${day}`);
lines.push('');
// Group by file within this day
const resultsByFile = new Map<string, CombinedResult[]>();
for (const result of dayResults) {
let file = 'General';
if (result.type === 'observation') {
file = extractFirstFile(result.data.files_modified, cwd);
}
});
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults = limitedResults.map(item => {
if (item.type === 'observation') {
return this.formatter.formatObservationResult(item.data);
} else if (item.type === 'session') {
return this.formatter.formatSessionResult(item.data);
} else {
return this.formatter.formatUserPromptResult(item.data);
if (!resultsByFile.has(file)) {
resultsByFile.set(file, []);
}
});
combinedText = formattedResults.join('\n\n---\n\n');
resultsByFile.get(file)!.push(result);
}
// Render each file section
for (const [file, fileResults] of resultsByFile) {
lines.push(`**${file}**`);
lines.push(this.formatter.formatSearchTableHeader());
let lastTime = '';
for (const result of fileResults) {
if (result.type === 'observation') {
const formatted = this.formatter.formatObservationSearchRow(result.data as ObservationSearchResult, lastTime);
lines.push(formatted.row);
lastTime = formatted.time;
} else if (result.type === 'session') {
const formatted = this.formatter.formatSessionSearchRow(result.data as SessionSummarySearchResult, lastTime);
lines.push(formatted.row);
lastTime = formatted.time;
} else {
const formatted = this.formatter.formatUserPromptSearchRow(result.data as UserPromptSearchResult, lastTime);
lines.push(formatted.row);
lastTime = formatted.time;
}
}
lines.push('');
}
}
return {
content: [{
type: 'text' as const,
text: combinedText
text: lines.join('\n')
}]
};
} catch (error: any) {
@@ -295,6 +323,7 @@ export class SearchManager {
async timeline(args: any): Promise<any> {
try {
const { anchor, query, depth_before = 10, depth_after = 10, project } = args;
const cwd = process.cwd();
// Validate: must provide either anchor or query, not both
if (!anchor && !query) {
@@ -333,7 +362,7 @@ export class SearchManager {
logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults?.ids?.length ?? 0 });
if (chromaResults?.ids && chromaResults.ids.length > 0) {
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -495,9 +524,6 @@ export class SearchManager {
lines.push(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`);
lines.push('');
// Legend
lines.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | 🧠 decision`);
lines.push('');
// Group by day
const dayMap = new Map<string, TimelineItem[]>();
@@ -541,10 +567,9 @@ export class SearchManager {
const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary';
const link = `claude-mem://session-summary/${sess.id}`;
const marker = isAnchor ? ' ← **ANCHOR**' : '';
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)}) [→](${link})${marker}`);
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)})${marker}`);
lines.push('');
} else if (item.type === 'prompt') {
if (tableOpen) {
@@ -562,7 +587,7 @@ export class SearchManager {
lines.push('');
} else if (item.type === 'observation') {
const obs = item.data as ObservationSearchResult;
const file = 'General';
const file = extractFirstFile(obs.files_modified, cwd);
if (file !== currentFile) {
if (tableOpen) {
@@ -629,7 +654,7 @@ export class SearchManager {
async decisions(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { query, format = 'index', ...filters } = normalized;
const { query, ...filters } = normalized;
let results: ObservationSearchResult[] = [];
// Search for decision-type observations
@@ -686,20 +711,14 @@ export class SearchManager {
};
}
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} decision(s):\n\n`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
combinedText = header + formattedResults.join('\n\n');
} else {
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} decision(s)\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -719,7 +738,7 @@ export class SearchManager {
async changes(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { format = 'index', ...filters } = normalized;
const { ...filters } = normalized;
let results: ObservationSearchResult[] = [];
// Search for change-type observations and change-related concepts
@@ -784,20 +803,14 @@ export class SearchManager {
};
}
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} change-related observation(s):\n\n`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
combinedText = header + formattedResults.join('\n\n');
} else {
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} change-related observation(s)\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -817,7 +830,7 @@ export class SearchManager {
async howItWorks(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { format = 'index', ...filters } = normalized;
const { ...filters } = normalized;
let results: ObservationSearchResult[] = [];
// Search for how-it-works concept observations
@@ -860,20 +873,14 @@ export class SearchManager {
};
}
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} "how it works" observation(s):\n\n`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
combinedText = header + formattedResults.join('\n\n');
} else {
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} "how it works" observation(s)\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -893,7 +900,7 @@ export class SearchManager {
async searchObservations(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { query, format = 'index', ...options } = normalized;
const { query, ...options } = normalized;
let results: ObservationSearchResult[] = [];
// Vector-first search via ChromaDB
@@ -907,7 +914,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -936,21 +943,14 @@ export class SearchManager {
};
}
// Format based on requested format
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} observation(s) matching "${query}":\n\n`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} observation(s) matching "${query}"\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -970,7 +970,7 @@ export class SearchManager {
async searchSessions(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { query, format = 'index', ...options } = normalized;
const { query, ...options } = normalized;
let results: SessionSummarySearchResult[] = [];
// Vector-first search via ChromaDB
@@ -984,7 +984,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -1013,21 +1013,14 @@ export class SearchManager {
};
}
// Format based on requested format
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} session(s) matching "${query}":\n\n`;
const formattedResults = results.map((session, i) => this.formatter.formatSessionIndex(session, i));
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults = results.map((session) => this.formatter.formatSessionResult(session));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} session(s) matching "${query}"\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((session, i) => this.formatter.formatSessionIndex(session, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -1047,7 +1040,7 @@ export class SearchManager {
async searchUserPrompts(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { query, format = 'index', ...options } = normalized;
const { query, ...options } = normalized;
let results: UserPromptSearchResult[] = [];
// Vector-first search via ChromaDB
@@ -1061,7 +1054,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) {
// Step 2: Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -1090,21 +1083,14 @@ export class SearchManager {
};
}
// Format based on requested format
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} user prompt(s) matching "${query}":\n\n`;
const formattedResults = results.map((prompt, i) => this.formatter.formatUserPromptIndex(prompt, i));
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults = results.map((prompt) => this.formatter.formatUserPromptResult(prompt));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} user prompt(s) matching "${query}"\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((prompt, i) => this.formatter.formatUserPromptIndex(prompt, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -1124,7 +1110,7 @@ export class SearchManager {
async findByConcept(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { concepts: concept, format = 'index', ...filters } = normalized;
const { concepts: concept, ...filters } = normalized;
let results: ObservationSearchResult[] = [];
// Metadata-first, semantic-enhanced search
@@ -1179,21 +1165,14 @@ export class SearchManager {
};
}
// Format based on requested format
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} observation(s) with concept "${concept}":\n\n`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} observation(s) with concept "${concept}"\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -1213,7 +1192,7 @@ export class SearchManager {
async findByFile(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { files: filePath, format = 'index', ...filters } = normalized;
const { files: filePath, ...filters } = normalized;
let observations: ObservationSearchResult[] = [];
let sessions: SessionSummarySearchResult[] = [];
@@ -1277,42 +1256,24 @@ export class SearchManager {
};
}
let combinedText: string;
if (format === 'index') {
const header = `Found ${totalResults} result(s) for file "${filePath}":\n\n`;
const formattedResults: string[] = [];
// Format as table
const header = `Found ${totalResults} result(s) for file "${filePath}"\n\n${this.formatter.formatTableHeader()}`;
const formattedResults: string[] = [];
// Add observations
observations.forEach((obs, i) => {
formattedResults.push(this.formatter.formatObservationIndex(obs, i));
});
// Add observations
observations.forEach((obs, i) => {
formattedResults.push(this.formatter.formatObservationIndex(obs, i));
});
// Add sessions
sessions.forEach((session, i) => {
formattedResults.push(this.formatter.formatSessionIndex(session, i + observations.length));
});
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults: string[] = [];
// Add observations
observations.forEach((obs) => {
formattedResults.push(this.formatter.formatObservationResult(obs));
});
// Add sessions
sessions.forEach((session) => {
formattedResults.push(this.formatter.formatSessionResult(session));
});
combinedText = formattedResults.join('\n\n---\n\n');
}
// Add sessions
sessions.forEach((session, i) => {
formattedResults.push(this.formatter.formatSessionIndex(session, i + observations.length));
});
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -1332,7 +1293,7 @@ export class SearchManager {
async findByType(args: any): Promise<any> {
try {
const normalized = this.normalizeParams(args);
const { type, format = 'index', ...filters } = normalized;
const { type, ...filters } = normalized;
const typeStr = Array.isArray(type) ? type.join(', ') : type;
let results: ObservationSearchResult[] = [];
@@ -1388,21 +1349,14 @@ export class SearchManager {
};
}
// Format based on requested format
let combinedText: string;
if (format === 'index') {
const header = `Found ${results.length} observation(s) with type "${typeStr}":\n\n`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
} else {
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
combinedText = formattedResults.join('\n\n---\n\n');
}
// Format as table
const header = `Found ${results.length} observation(s) with type "${typeStr}"\n\n${this.formatter.formatTableHeader()}`;
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
return {
content: [{
type: 'text' as const,
text: combinedText
text: header + '\n' + formattedResults.join('\n')
}]
};
} catch (error: any) {
@@ -1556,6 +1510,7 @@ export class SearchManager {
async getContextTimeline(args: any): Promise<any> {
try {
const { anchor, depth_before = 10, depth_after = 10, project } = args;
const cwd = process.cwd();
let anchorEpoch: number;
let anchorId: string | number = anchor;
@@ -1680,9 +1635,6 @@ export class SearchManager {
lines.push(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`);
lines.push('');
// Legend
lines.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | 🧠 decision`);
lines.push('');
// Group by day
const dayMap = new Map<string, TimelineItem[]>();
@@ -1728,10 +1680,9 @@ export class SearchManager {
// Render session
const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary';
const link = `claude-mem://session-summary/${sess.id}`;
const marker = isAnchor ? ' ← **ANCHOR**' : '';
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)}) [→](${link})${marker}`);
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)})${marker}`);
lines.push('');
} else if (item.type === 'prompt') {
// Close any open table
@@ -1752,7 +1703,7 @@ export class SearchManager {
} else if (item.type === 'observation') {
// Render observation in table
const obs = item.data as ObservationSearchResult;
const file = 'General'; // Simplified for timeline view
const file = extractFirstFile(obs.files_modified, cwd);
// Check if we need a new file section
if (file !== currentFile) {
@@ -1824,6 +1775,7 @@ export class SearchManager {
async getTimelineByQuery(args: any): Promise<any> {
try {
const { query, mode = 'auto', depth_before = 10, depth_after = 10, limit = 5, project } = args;
const cwd = process.cwd();
// Step 1: Search for observations
let results: ObservationSearchResult[] = [];
@@ -1837,7 +1789,7 @@ export class SearchManager {
if (chromaResults.ids.length > 0) {
// Filter by recency (90 days)
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
const recentIds = chromaResults.ids.filter((_id, idx) => {
const meta = chromaResults.metadatas[idx];
return meta && meta.created_at_epoch > ninetyDaysAgo;
@@ -1889,7 +1841,6 @@ export class SearchManager {
if (obs.subtitle) {
lines.push(` - ${obs.subtitle}`);
}
lines.push(` - Source: claude-mem://observation/${obs.id}`);
lines.push('');
}
@@ -1975,9 +1926,6 @@ export class SearchManager {
lines.push(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`);
lines.push('');
// Legend
lines.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | 🧠 decision`);
lines.push('');
// Group by day
const dayMap = new Map<string, TimelineItem[]>();
@@ -2020,9 +1968,8 @@ export class SearchManager {
// Render session
const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary';
const link = `claude-mem://session-summary/${sess.id}`;
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)}) [→](${link})`);
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)})`);
lines.push('');
} else if (item.type === 'prompt') {
// Close any open table
@@ -2043,7 +1990,7 @@ export class SearchManager {
} else if (item.type === 'observation') {
// Render observation in table
const obs = item.data as ObservationSearchResult;
const file = 'General'; // Simplified for timeline view
const file = extractFirstFile(obs.files_modified, cwd);
// Check if we need a new file section
if (file !== currentFile) {
+1 -2
View File
@@ -148,10 +148,9 @@ export class TimelineService {
const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary';
const link = `claude-mem://session-summary/${sess.id}`;
const marker = isAnchor ? ' ← **ANCHOR**' : '';
lines.push(`**🎯 #S${sess.id}** ${title} (${this.formatDateTime(item.epoch)}) [→](${link})${marker}`);
lines.push(`**🎯 #S${sess.id}** ${title} (${this.formatDateTime(item.epoch)})${marker}`);
lines.push('');
} else if (item.type === 'prompt') {
if (tableOpen) {
@@ -38,6 +38,7 @@ export class DataRoutes extends BaseRouteHandler {
// Fetch by ID endpoints
app.get('/api/observation/:id', this.handleGetObservationById.bind(this));
app.post('/api/observations/batch', this.handleGetObservationsByIds.bind(this));
app.get('/api/session/:id', this.handleGetSessionById.bind(this));
app.get('/api/prompt/:id', this.handleGetPromptById.bind(this));
@@ -96,6 +97,36 @@ export class DataRoutes extends BaseRouteHandler {
res.json(observation);
});
/**
* Get observations by array of IDs
* POST /api/observations/batch
* Body: { ids: number[], orderBy?: 'date_desc' | 'date_asc', limit?: number, project?: string }
*/
private handleGetObservationsByIds = this.wrapHandler((req: Request, res: Response): void => {
const { ids, orderBy, limit, project } = req.body;
if (!ids || !Array.isArray(ids)) {
this.badRequest(res, 'ids must be an array of numbers');
return;
}
if (ids.length === 0) {
res.json([]);
return;
}
// Validate all IDs are numbers
if (!ids.every(id => typeof id === 'number' && Number.isInteger(id))) {
this.badRequest(res, 'All ids must be integers');
return;
}
const store = this.dbManager.getSessionStore();
const observations = store.getObservationsByIds(ids, { orderBy, limit, project });
res.json(observations);
});
/**
* Get session by ID
* GET /api/session/:id
+11 -17
View File
@@ -45,7 +45,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Unified search (observations + sessions + prompts)
* GET /api/search?query=...&type=observations&format=index&limit=20
* GET /api/search?query=...&type=observations&limit=20
*/
private handleUnifiedSearch = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.search(req.query);
@@ -63,7 +63,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Semantic shortcut for finding decision observations
* GET /api/decisions?format=index&limit=20
* GET /api/decisions?limit=20
*/
private handleDecisions = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.decisions(req.query);
@@ -72,7 +72,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Semantic shortcut for finding change-related observations
* GET /api/changes?format=index&limit=20
* GET /api/changes?limit=20
*/
private handleChanges = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.changes(req.query);
@@ -81,7 +81,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Semantic shortcut for finding "how it works" explanations
* GET /api/how-it-works?format=index&limit=20
* GET /api/how-it-works?limit=20
*/
private handleHowItWorks = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.howItWorks(req.query);
@@ -90,7 +90,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Search observations (use /api/search?type=observations instead)
* GET /api/search/observations?query=...&format=index&limit=20&project=...
* GET /api/search/observations?query=...&limit=20&project=...
*/
private handleSearchObservations = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.searchObservations(req.query);
@@ -99,7 +99,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Search session summaries
* GET /api/search/sessions?query=...&format=index&limit=20
* GET /api/search/sessions?query=...&limit=20
*/
private handleSearchSessions = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.searchSessions(req.query);
@@ -108,7 +108,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Search user prompts
* GET /api/search/prompts?query=...&format=index&limit=20
* GET /api/search/prompts?query=...&limit=20
*/
private handleSearchPrompts = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.searchUserPrompts(req.query);
@@ -117,7 +117,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Search observations by concept
* GET /api/search/by-concept?concept=discovery&format=index&limit=5
* GET /api/search/by-concept?concept=discovery&limit=5
*/
private handleSearchByConcept = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.findByConcept(req.query);
@@ -126,7 +126,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Search by file path
* GET /api/search/by-file?filePath=...&format=index&limit=10
* GET /api/search/by-file?filePath=...&limit=10
*/
private handleSearchByFile = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.findByFile(req.query);
@@ -135,7 +135,7 @@ export class SearchRoutes extends BaseRouteHandler {
/**
* Search observations by type
* GET /api/search/by-type?type=bugfix&format=index&limit=10
* GET /api/search/by-type?type=bugfix&limit=10
*/
private handleSearchByType = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
const result = await this.searchManager.findByType(req.query);
@@ -252,7 +252,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Search observations using full-text search',
parameters: {
query: 'Search query (required)',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 20)',
project: 'Filter by project name (optional)'
}
@@ -263,7 +262,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Search session summaries using full-text search',
parameters: {
query: 'Search query (required)',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 20)'
}
},
@@ -273,7 +271,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Search user prompts using full-text search',
parameters: {
query: 'Search query (required)',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 20)',
project: 'Filter by project name (optional)'
}
@@ -284,7 +281,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Find observations by concept tag',
parameters: {
concept: 'Concept tag (required): discovery, decision, bugfix, feature, refactor',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 10)',
project: 'Filter by project name (optional)'
}
@@ -295,7 +291,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Find observations and sessions by file path',
parameters: {
filePath: 'File path or partial path (required)',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results per type (default: 10)',
project: 'Filter by project name (optional)'
}
@@ -306,7 +301,6 @@ export class SearchRoutes extends BaseRouteHandler {
description: 'Find observations by type',
parameters: {
type: 'Observation type (required): discovery, decision, bugfix, feature, refactor',
format: 'Response format: "index" or "full" (default: "full")',
limit: 'Number of results (default: 10)',
project: 'Filter by project name (optional)'
}
@@ -350,7 +344,7 @@ export class SearchRoutes extends BaseRouteHandler {
}
],
examples: [
'curl "http://localhost:37777/api/search/observations?query=authentication&format=index&limit=5"',
'curl "http://localhost:37777/api/search/observations?query=authentication&limit=5"',
'curl "http://localhost:37777/api/search/by-type?type=bugfix&limit=10"',
'curl "http://localhost:37777/api/context/recent?project=claude-mem&limit=3"',
'curl "http://localhost:37777/api/context/timeline?anchor=123&depth_before=5&depth_after=5"'
@@ -9,7 +9,6 @@ import express, { Request, Response } from 'express';
import { getWorkerPort } from '../../../../shared/worker-utils.js';
import { logger } from '../../../../utils/logger.js';
import { stripMemoryTagsFromJson, stripMemoryTagsFromPrompt } from '../../../../utils/tag-stripping.js';
import { happy_path_error__with_fallback } from '../../../../utils/silent-debug.js';
import { SessionManager } from '../../SessionManager.js';
import { DatabaseManager } from '../../DatabaseManager.js';
import { SDKAgent } from '../../SDKAgent.js';
@@ -342,9 +341,11 @@ export class SessionRoutes extends BaseRouteHandler {
tool_input: cleanedToolInput,
tool_response: cleanedToolResponse,
prompt_number: promptNumber,
cwd: cwd || happy_path_error__with_fallback(
cwd: cwd || logger.happyPathError(
'SESSION',
'Missing cwd when queueing observation in SessionRoutes',
{ sessionDbId, tool_name },
{ sessionId: sessionDbId },
{ tool_name },
''
)
});
@@ -394,9 +395,11 @@ export class SessionRoutes extends BaseRouteHandler {
// Queue summarize
this.sessionManager.queueSummarize(
sessionDbId,
last_user_message || happy_path_error__with_fallback(
last_user_message || logger.happyPathError(
'SESSION',
'Missing last_user_message when queueing summary in SessionRoutes',
{ sessionDbId },
{ sessionId: sessionDbId },
undefined,
''
),
last_assistant_message
+1 -1
View File
@@ -44,7 +44,7 @@ export class SettingsDefaultsManager {
* Default values for all settings
*/
private static readonly DEFAULTS: SettingsDefaults = {
CLAUDE_MEM_MODEL: 'claude-haiku-4-5',
CLAUDE_MEM_MODEL: 'claude-sonnet-4-5',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: '37777',
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
+1
View File
@@ -3,6 +3,7 @@ export const HOOK_TIMEOUTS = {
HEALTH_CHECK: 1000, // Worker health check (up from 500ms)
WORKER_STARTUP_WAIT: 1000,
WORKER_STARTUP_RETRIES: 15,
PRE_RESTART_SETTLE_DELAY: 2000, // Give files time to sync before restart
WINDOWS_MULTIPLIER: 1.5 // Platform-specific adjustment
} as const;
+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);
}
+61 -18
View File
@@ -13,42 +13,85 @@ export function extractLastMessage(
stripSystemReminders: boolean = false
): string {
if (!transcriptPath || !existsSync(transcriptPath)) {
logger.happyPathError(
'PARSER',
'Transcript path missing or file does not exist',
undefined,
{ transcriptPath, role },
''
);
return '';
}
try {
const content = readFileSync(transcriptPath, 'utf-8').trim();
if (!content) return '';
if (!content) {
logger.happyPathError(
'PARSER',
'Transcript file exists but is empty',
undefined,
{ transcriptPath, role },
''
);
return '';
}
const lines = content.split('\n');
let foundMatchingRole = false;
for (let i = lines.length - 1; i >= 0; i--) {
try {
const line = JSON.parse(lines[i]);
if (line.type === role && line.message?.content) {
let text = '';
const msgContent = line.message.content;
if (line.type === role) {
foundMatchingRole = true;
if (typeof msgContent === 'string') {
text = msgContent;
} else if (Array.isArray(msgContent)) {
text = msgContent
.filter((c: any) => c.type === 'text')
.map((c: any) => c.text)
.join('\n');
if (line.message?.content) {
let text = '';
const msgContent = line.message.content;
if (typeof msgContent === 'string') {
text = msgContent;
} else if (Array.isArray(msgContent)) {
text = msgContent
.filter((c: any) => c.type === 'text')
.map((c: any) => c.text)
.join('\n');
}
if (stripSystemReminders) {
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
text = text.replace(/\n{3,}/g, '\n\n').trim();
}
// Log if we found the role but the text is empty after processing
if (!text || text.trim() === '') {
logger.happyPathError(
'PARSER',
'Found message but content is empty after processing',
undefined,
{ role, transcriptPath, msgContentType: typeof msgContent, stripSystemReminders },
''
);
}
return text;
}
if (stripSystemReminders) {
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
text = text.replace(/\n{3,}/g, '\n\n').trim();
}
return text;
}
} catch {
continue;
}
}
// If we searched the whole transcript and didn't find any message of this role
if (!foundMatchingRole) {
logger.happyPathError(
'PARSER',
'No message found for role in transcript',
undefined,
{ role, transcriptPath, totalLines: lines.length },
''
);
}
} catch (error) {
logger.error('HOOK', 'Failed to read transcript', { transcriptPath }, error as Error);
}
+4 -1
View File
@@ -131,6 +131,9 @@ async function ensureWorkerVersionMatches(): Promise<void> {
workerVersion
});
// Give files time to sync before restart
await new Promise(resolve => setTimeout(resolve, getTimeout(HOOK_TIMEOUTS.PRE_RESTART_SETTLE_DELAY)));
// Restart the worker
await ProcessManager.restart(getWorkerPort());
@@ -142,7 +145,7 @@ async function ensureWorkerVersionMatches(): Promise<void> {
logger.error('SYSTEM', 'Worker failed to restart after version mismatch', {
expectedVersion: pluginVersion,
runningVersion: workerVersion,
port
port: getWorkerPort()
});
}
}
@@ -195,11 +195,6 @@ export function ContextSettingsModal({
}: ContextSettingsModalProps) {
const [formState, setFormState] = useState<Settings>(settings);
// MCP toggle state
const [mcpEnabled, setMcpEnabled] = useState(true);
const [mcpToggling, setMcpToggling] = useState(false);
const [mcpStatus, setMcpStatus] = useState('');
// Create debounced save function
const debouncedSave = useCallback(
debounce((newSettings: Settings) => {
@@ -213,14 +208,6 @@ export function ContextSettingsModal({
setFormState(settings);
}, [settings]);
// Fetch MCP status on mount
useEffect(() => {
fetch('/api/mcp/status')
.then(res => res.json())
.then(data => setMcpEnabled(data.enabled))
.catch(error => console.error('Failed to load MCP status:', error));
}, []);
// Get context preview based on current form state
const { preview, isLoading, error, projects, selectedProject, setSelectedProject } = useContextPreview(formState);
@@ -254,36 +241,6 @@ export function ContextSettingsModal({
updateSetting(key, values.join(','));
}, [updateSetting]);
// Handle MCP toggle
const handleMcpToggle = async (enabled: boolean) => {
setMcpToggling(true);
setMcpStatus('Toggling...');
try {
const response = await fetch('/api/mcp/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
const result = await response.json();
if (result.success) {
setMcpEnabled(result.enabled);
setMcpStatus('Updated (restart to apply)');
setTimeout(() => setMcpStatus(''), 3000);
} else {
setMcpStatus(`Error: ${result.error}`);
setTimeout(() => setMcpStatus(''), 3000);
}
} catch (err) {
setMcpStatus(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`);
setTimeout(() => setMcpStatus(''), 3000);
} finally {
setMcpToggling(false);
}
};
// Handle ESC key
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
@@ -521,14 +478,6 @@ export function ContextSettingsModal({
</FormField>
<div className="toggle-group" style={{ marginTop: '12px' }}>
<ToggleSwitch
id="mcp-enabled"
label="MCP search server"
description={mcpStatus || "Enable Model Context Protocol search"}
checked={mcpEnabled}
onChange={handleMcpToggle}
disabled={mcpToggling}
/>
<ToggleSwitch
id="show-last-summary"
label="Include last summary"
-428
View File
@@ -1,428 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Settings, Stats } from '../types';
import { DEFAULT_SETTINGS } from '../constants/settings';
import { formatUptime, formatBytes } from '../utils/formatters';
interface SidebarProps {
isOpen: boolean;
settings: Settings;
stats: Stats;
isSaving: boolean;
saveStatus: string;
isConnected: boolean;
projects: string[];
currentFilter: string;
onFilterChange: (filter: string) => void;
onSave: (settings: Settings) => void;
onClose: () => void;
onRefreshStats: () => void;
}
export function Sidebar({ isOpen, settings, stats, isSaving, saveStatus, isConnected, projects, currentFilter, onFilterChange, onSave, onClose, onRefreshStats }: SidebarProps) {
// Consolidated settings form state
const [formState, setFormState] = useState<Settings>({
CLAUDE_MEM_MODEL: settings.CLAUDE_MEM_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_MODEL,
CLAUDE_MEM_CONTEXT_OBSERVATIONS: settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATIONS,
CLAUDE_MEM_WORKER_PORT: settings.CLAUDE_MEM_WORKER_PORT || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_PORT,
CLAUDE_MEM_WORKER_HOST: settings.CLAUDE_MEM_WORKER_HOST || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_HOST,
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: settings.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS,
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: settings.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS,
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: settings.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT,
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: settings.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT,
CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: settings.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES,
CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: settings.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS,
CLAUDE_MEM_CONTEXT_FULL_COUNT: settings.CLAUDE_MEM_CONTEXT_FULL_COUNT || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_FULL_COUNT,
CLAUDE_MEM_CONTEXT_FULL_FIELD: settings.CLAUDE_MEM_CONTEXT_FULL_FIELD || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_FULL_FIELD,
CLAUDE_MEM_CONTEXT_SESSION_COUNT: settings.CLAUDE_MEM_CONTEXT_SESSION_COUNT || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SESSION_COUNT,
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: settings.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY,
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: settings.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE,
});
// MCP toggle state (separate from settings)
const [mcpEnabled, setMcpEnabled] = useState(true);
const [mcpToggling, setMcpToggling] = useState(false);
const [mcpStatus, setMcpStatus] = useState('');
// Helper to update form state
const updateFormState = (field: keyof Settings, value: string) => {
setFormState(prev => ({ ...prev, [field]: value }));
};
// Update settings form state when settings prop changes
useEffect(() => {
setFormState({
CLAUDE_MEM_MODEL: settings.CLAUDE_MEM_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_MODEL,
CLAUDE_MEM_CONTEXT_OBSERVATIONS: settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATIONS,
CLAUDE_MEM_WORKER_PORT: settings.CLAUDE_MEM_WORKER_PORT || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_PORT,
CLAUDE_MEM_WORKER_HOST: settings.CLAUDE_MEM_WORKER_HOST || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_HOST,
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: settings.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS,
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: settings.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS,
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT: settings.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT,
CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT: settings.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT,
CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES: settings.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES,
CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS: settings.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS,
CLAUDE_MEM_CONTEXT_FULL_COUNT: settings.CLAUDE_MEM_CONTEXT_FULL_COUNT || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_FULL_COUNT,
CLAUDE_MEM_CONTEXT_FULL_FIELD: settings.CLAUDE_MEM_CONTEXT_FULL_FIELD || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_FULL_FIELD,
CLAUDE_MEM_CONTEXT_SESSION_COUNT: settings.CLAUDE_MEM_CONTEXT_SESSION_COUNT || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SESSION_COUNT,
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: settings.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY,
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: settings.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE,
});
}, [settings]);
// Fetch MCP status on mount
useEffect(() => {
fetch('/api/mcp/status')
.then(res => res.json())
.then(data => setMcpEnabled(data.enabled))
.catch(error => console.error('Failed to load MCP status:', error));
}, []);
// Refresh stats when sidebar opens
useEffect(() => {
if (isOpen) {
onRefreshStats();
}
}, [isOpen, onRefreshStats]);
const handleSave = () => {
onSave(formState);
};
const handleMcpToggle = async (enabled: boolean) => {
setMcpToggling(true);
setMcpStatus('Toggling...');
try {
const response = await fetch('/api/mcp/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
const result = await response.json();
if (result.success) {
setMcpEnabled(result.enabled);
setMcpStatus('✓ Updated (restart Claude Code to apply)');
setTimeout(() => setMcpStatus(''), 3000);
} else {
setMcpStatus(`✗ Error: ${result.error}`);
setTimeout(() => setMcpStatus(''), 3000);
}
} catch (error) {
setMcpStatus(`✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
setTimeout(() => setMcpStatus(''), 3000);
} finally {
setMcpToggling(false);
}
};
return (
<div className={`sidebar ${isOpen ? 'open' : ''}`}>
<div className="sidebar-header">
<h1>Settings</h1>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span className={`status-dot ${isConnected ? 'connected' : ''}`} />
<span style={{ fontSize: '11px', opacity: 0.5, fontWeight: 300 }}>{isConnected ? 'Connected' : 'Disconnected'}</span>
</div>
<button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
onClick={onClose}
title="Close settings"
style={{
background: 'transparent',
border: '1px solid #404040',
padding: '8px',
width: '36px',
height: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<a
href="https://discord.gg/J4wttp9vDu"
target="_blank"
rel="noopener noreferrer"
className="sidebar-community-btn"
title="Join our Discord community"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style={{ marginRight: '6px' }}>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
</svg>
<span>Community</span>
</a>
<div className="sidebar-social-links">
<a
href="https://docs.claude-mem.ai"
target="_blank"
rel="noopener noreferrer"
title="Documentation"
className="icon-link"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
</svg>
</a>
<a
href="https://github.com/thedotmack/claude-mem/"
target="_blank"
rel="noopener noreferrer"
title="GitHub"
className="icon-link"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
<a
href="https://x.com/Claude_Memory"
target="_blank"
rel="noopener noreferrer"
title="X (Twitter)"
className="icon-link"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
</div>
<div className="sidebar-project-filter">
<label htmlFor="sidebar-project-select">Filter by Project</label>
<select
id="sidebar-project-select"
value={currentFilter}
onChange={e => onFilterChange(e.target.value)}
>
<option value="">All Projects</option>
{projects.map(project => (
<option key={project} value={project}>{project}</option>
))}
</select>
</div>
<div className="stats-scroll">
<div className="settings-section">
<h3>Environment Variables</h3>
<div className="form-group">
<label htmlFor="model">CLAUDE_MEM_MODEL</label>
<div className="setting-description">
Model used for AI compression of tool observations. Haiku is fast and cheap, Sonnet offers better quality, Opus is most capable but expensive.
</div>
<select
id="model"
value={formState.CLAUDE_MEM_MODEL}
onChange={e => updateFormState('CLAUDE_MEM_MODEL', e.target.value)}
>
{/* Shorthand names forward to latest model version */}
<option value="haiku">haiku</option>
<option value="sonnet">sonnet</option>
<option value="opus">opus</option>
</select>
</div>
<div className="form-group">
<label htmlFor="contextObs">CLAUDE_MEM_CONTEXT_OBSERVATIONS</label>
<div className="setting-description">
Number of recent observations to inject at session start. Higher values provide more context but increase token usage. Default: 50
</div>
<input
type="number"
id="contextObs"
min="1"
max="200"
value={formState.CLAUDE_MEM_CONTEXT_OBSERVATIONS}
onChange={e => updateFormState('CLAUDE_MEM_CONTEXT_OBSERVATIONS', e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="workerPort">CLAUDE_MEM_WORKER_PORT</label>
<div className="setting-description">
Port number for the background worker service. Change only if port 37777 conflicts with another service.
</div>
<input
type="number"
id="workerPort"
min="1024"
max="65535"
value={formState.CLAUDE_MEM_WORKER_PORT}
onChange={e => updateFormState('CLAUDE_MEM_WORKER_PORT', e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="workerHost">CLAUDE_MEM_WORKER_HOST</label>
<div className="setting-description">
IP address to bind the worker service. Use 127.0.0.1 (default) for local-only access, or 0.0.0.0 for remote access on servers.
</div>
<input
type="text"
id="workerHost"
value={formState.CLAUDE_MEM_WORKER_HOST}
onChange={e => updateFormState('CLAUDE_MEM_WORKER_HOST', e.target.value)}
placeholder="127.0.0.1"
/>
</div>
{/* Token Economics Display */}
<div className="form-group">
<label>Token Economics Display</label>
<div className="setting-description">
Choose which token metrics to show in session start context.
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={formState.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS === 'true'} onChange={e => updateFormState('CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS', e.target.checked ? 'true' : 'false')} />
Show read tokens
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={formState.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS === 'true'} onChange={e => updateFormState('CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS', e.target.checked ? 'true' : 'false')} />
Show work tokens
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={formState.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT === 'true'} onChange={e => updateFormState('CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT', e.target.checked ? 'true' : 'false')} />
Show savings amount
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={formState.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT === 'true'} onChange={e => updateFormState('CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT', e.target.checked ? 'true' : 'false')} />
Show savings percentage
</label>
</div>
</div>
{/* Display Configuration */}
<div className="form-group">
<label>Display Configuration</label>
<div className="setting-description">
Control how observations are displayed in the timeline.
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginTop: '8px' }}>
<div>
<label htmlFor="fullCount" style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>
Full observation count (0-20)
</label>
<input type="number" id="fullCount" min="0" max="20" value={formState.CLAUDE_MEM_CONTEXT_FULL_COUNT} onChange={e => updateFormState('CLAUDE_MEM_CONTEXT_FULL_COUNT', e.target.value)} style={{ width: '100%' }} />
<div style={{ fontSize: '12px', color: '#999', marginTop: '4px' }}>
Number of most recent observations to show with full details
</div>
</div>
<div>
<label htmlFor="fullField" style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>
Full observation field
</label>
<select id="fullField" value={formState.CLAUDE_MEM_CONTEXT_FULL_FIELD} onChange={e => updateFormState('CLAUDE_MEM_CONTEXT_FULL_FIELD', e.target.value)} style={{ width: '100%' }}>
<option value="narrative">Narrative</option>
<option value="facts">Facts</option>
</select>
</div>
<div>
<label htmlFor="sessionCount" style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>
Session summary count (1-50)
</label>
<input type="number" id="sessionCount" min="1" max="50" value={formState.CLAUDE_MEM_CONTEXT_SESSION_COUNT} onChange={e => updateFormState('CLAUDE_MEM_CONTEXT_SESSION_COUNT', e.target.value)} style={{ width: '100%' }} />
</div>
</div>
</div>
{/* Feature Toggles */}
<div className="form-group">
<label>Context Features</label>
<div className="setting-description">
Toggle additional features in session start context.
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={formState.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY === 'true'} onChange={e => updateFormState('CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY', e.target.checked ? 'true' : 'false')} />
Show last session summary
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={formState.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE === 'true'} onChange={e => updateFormState('CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE', e.target.checked ? 'true' : 'false')} />
Include last session message
</label>
</div>
</div>
{saveStatus && (
<div className="save-status">{saveStatus}</div>
)}
</div>
<div className="settings-section">
<h3>MCP Search Server</h3>
<div className="form-group">
<label htmlFor="mcpEnabled" style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
id="mcpEnabled"
checked={mcpEnabled}
onChange={e => handleMcpToggle(e.target.checked)}
disabled={mcpToggling}
style={{ cursor: mcpToggling ? 'not-allowed' : 'pointer' }}
/>
Enable MCP Search Server
</label>
<div className="setting-description">
claude-mem suggests using skill-based search (saves ~2,500 tokens at session start), but some users prefer MCP. Disable to only use skill-based search. Requires Claude Code restart to apply changes.
</div>
{mcpStatus && (
<div className="save-status">{mcpStatus}</div>
)}
</div>
</div>
<div className="settings-section">
<h3>Worker Stats</h3>
<div className="stats-grid">
<div className="stat">
<div className="stat-label">Version</div>
<div className="stat-value">{stats.worker?.version || '-'}</div>
</div>
<div className="stat">
<div className="stat-label">Uptime</div>
<div className="stat-value">{formatUptime(stats.worker?.uptime)}</div>
</div>
<div className="stat">
<div className="stat-label">Active Sessions</div>
<div className="stat-value">{stats.worker?.activeSessions || '0'}</div>
</div>
<div className="stat">
<div className="stat-label">SSE Clients</div>
<div className="stat-value">{stats.worker?.sseClients || '0'}</div>
</div>
</div>
</div>
<div className="settings-section">
<h3>Database Stats</h3>
<div className="stats-grid">
<div className="stat">
<div className="stat-label">DB Size</div>
<div className="stat-value">{formatBytes(stats.database?.size)}</div>
</div>
<div className="stat">
<div className="stat-label">Observations</div>
<div className="stat-value">{stats.database?.observations || '0'}</div>
</div>
<div className="stat">
<div className="stat-label">Sessions</div>
<div className="stat-value">{stats.database?.sessions || '0'}</div>
</div>
<div className="stat">
<div className="stat-label">Summaries</div>
<div className="stat-value">{stats.database?.summaries || '0'}</div>
</div>
</div>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@
* Shared across UI components and hooks
*/
export const DEFAULT_SETTINGS = {
CLAUDE_MEM_MODEL: 'claude-haiku-4-5',
CLAUDE_MEM_MODEL: 'claude-sonnet-4-5',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: '37777',
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
+52
View File
@@ -251,6 +251,58 @@ class Logger {
timing(component: Component, message: string, durationMs: number, context?: LogContext): void {
this.info(component, `${message}`, context, { duration: `${durationMs}ms` });
}
/**
* Happy Path Error - logs when the expected "happy path" fails but we have a fallback
*
* Semantic meaning: "When the happy path fails, this is an error, but we have a fallback."
*
* Use for:
* Unexpected null/undefined values that should theoretically never happen
* Defensive coding where silent fallback is acceptable
* Situations where you want to track unexpected nulls without breaking execution
*
* DO NOT use for:
* Nullable fields with valid default behavior (use direct || defaults)
* Critical validation failures (use logger.warn or throw Error)
* Try-catch blocks where error is already logged (redundant)
*
* @param component - Component where error occurred
* @param message - Error message describing what went wrong
* @param context - Optional context (sessionId, correlationId, etc)
* @param data - Optional data to include
* @param fallback - Value to return (defaults to empty string)
* @returns The fallback value
*/
happyPathError<T = string>(
component: Component,
message: string,
context?: LogContext,
data?: any,
fallback: T = '' as T
): T {
// Capture stack trace to get caller location
const stack = new Error().stack || '';
const stackLines = stack.split('\n');
// Line 0: "Error"
// Line 1: "at happyPathError ..."
// Line 2: "at <CALLER> ..." <- We want this one
const callerLine = stackLines[2] || '';
const callerMatch = callerLine.match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/);
const location = callerMatch
? `${callerMatch[1].split('/').pop()}:${callerMatch[2]}`
: 'unknown';
// Log as a warning with location info
const enhancedContext = {
...context,
location
};
this.warn(component, `[HAPPY-PATH] ${message}`, enhancedContext, data);
return fallback;
}
}
// Export singleton instance
+8 -25
View File
@@ -1,33 +1,16 @@
/**
* Happy Path Error With Fallback
*
* Semantic meaning: "When the happy path fails, this is an error, but we have a fallback."
* @deprecated This function is deprecated. Use logger.happyPathError() instead.
* All usages have been migrated to the new logger system which consolidates logs
* into the regular worker logs instead of separate silent.log files.
*
* Logs to ~/.claude-mem/silent.log and returns a fallback value.
* Check logs with `npm run logs:silent`
* Migration example:
* OLD: happy_path_error__with_fallback('Missing value', { data }, 'default')
* NEW: logger.happyPathError('COMPONENT', 'Missing value', undefined, { data }, 'default')
*
* Use happy_path_error__with_fallback for:
* Unexpected null/undefined values that should theoretically never happen
* Defensive coding where silent fallback is acceptable
* Situations where you want to track unexpected nulls without breaking execution
*
* DO NOT use for:
* Nullable fields with valid default behavior (use direct || defaults)
* Critical validation failures (use logger.warn or throw Error)
* Try-catch blocks where error is already logged (redundant)
*
* Good examples:
* // Truly unexpected null (should never happen in theory)
* const id = session.id || happy_path_error__with_fallback('session.id missing', { session });
*
* Bad examples (use direct defaults instead):
* // Nullable field with valid empty default
* const title = obs.title || happy_path_error__with_fallback('obs.title missing', { obs }, '(untitled)');
* // BETTER: const title = obs.title || '(untitled)';
*
* // Array that can validly be undefined/null
* const count = obs.files?.length ?? (happy_path_error__with_fallback('obs.files missing', { obs }), 0);
* // BETTER: const count = obs.files?.length ?? 0;
* See: src/utils/logger.ts for the new happyPathError method
* Issue: #312 - Consolidate silent logs into regular worker logs
*/
import { appendFileSync } from 'fs';
+5 -5
View File
@@ -11,7 +11,7 @@
* This keeps the worker service simple and follows one-way data stream.
*/
import { happy_path_error__with_fallback } from './silent-debug.js';
import { logger } from './logger.js';
/**
* Maximum number of tags allowed in a single content block
@@ -41,14 +41,14 @@ function countTags(content: string): number {
*/
export function stripMemoryTagsFromJson(content: string): string {
if (typeof content !== 'string') {
happy_path_error__with_fallback('[tag-stripping] received non-string for JSON context:', { type: typeof content });
logger.happyPathError('SYSTEM', 'received non-string for JSON context', undefined, { type: typeof content }, '{}');
return '{}'; // Safe default for JSON context
}
// ReDoS protection: limit tag count before regex processing
const tagCount = countTags(content);
if (tagCount > MAX_TAG_COUNT) {
happy_path_error__with_fallback('[tag-stripping] tag count exceeds limit, truncating:', {
logger.warn('SYSTEM', 'tag count exceeds limit', undefined, {
tagCount,
maxAllowed: MAX_TAG_COUNT,
contentLength: content.length
@@ -73,14 +73,14 @@ export function stripMemoryTagsFromJson(content: string): string {
*/
export function stripMemoryTagsFromPrompt(content: string): string {
if (typeof content !== 'string') {
happy_path_error__with_fallback('[tag-stripping] received non-string for prompt context:', { type: typeof content });
logger.happyPathError('SYSTEM', 'received non-string for prompt context', undefined, { type: typeof content }, '');
return ''; // Safe default for prompt content
}
// ReDoS protection: limit tag count before regex processing
const tagCount = countTags(content);
if (tagCount > MAX_TAG_COUNT) {
happy_path_error__with_fallback('[tag-stripping] tag count exceeds limit, truncating:', {
logger.warn('SYSTEM', 'tag count exceeds limit', undefined, {
tagCount,
maxAllowed: MAX_TAG_COUNT,
contentLength: content.length