claude-memory-layer 1.0.22 → 1.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.claude/settings.local.json +11 -0
  2. package/README.md +2 -0
  3. package/dist/cli/index.js +87 -17
  4. package/dist/cli/index.js.map +2 -2
  5. package/dist/core/index.js +30 -5
  6. package/dist/core/index.js.map +2 -2
  7. package/dist/hooks/post-tool-use.js +117 -18
  8. package/dist/hooks/post-tool-use.js.map +2 -2
  9. package/dist/hooks/semantic-daemon.js +7337 -0
  10. package/dist/hooks/semantic-daemon.js.map +7 -0
  11. package/dist/hooks/session-end.js +71 -16
  12. package/dist/hooks/session-end.js.map +2 -2
  13. package/dist/hooks/session-start.js +156 -24
  14. package/dist/hooks/session-start.js.map +4 -4
  15. package/dist/hooks/stop.js +101 -18
  16. package/dist/hooks/stop.js.map +2 -2
  17. package/dist/hooks/user-prompt-submit.js +291 -102
  18. package/dist/hooks/user-prompt-submit.js.map +4 -4
  19. package/dist/server/api/index.js +71 -16
  20. package/dist/server/api/index.js.map +2 -2
  21. package/dist/server/index.js +71 -16
  22. package/dist/server/index.js.map +2 -2
  23. package/dist/services/memory-service.js +71 -16
  24. package/dist/services/memory-service.js.map +2 -2
  25. package/dist/ui/app.js +48 -1
  26. package/dist/ui/index.html +11 -3
  27. package/memory/_index.md +1 -0
  28. package/memory/agent_response/uncategorized/2026-03-04.md +1138 -1
  29. package/memory/session_summary/uncategorized/2026-03-04.md +31 -0
  30. package/memory/tool_observation/uncategorized/2026-03-04.md +785 -1
  31. package/memory/user_prompt/uncategorized/2026-03-04.md +438 -1
  32. package/package.json +1 -1
  33. package/scripts/build.ts +2 -1
  34. package/specs/selective-tool-observation/context.md +100 -0
  35. package/specs/selective-tool-observation/plan.md +158 -0
  36. package/specs/selective-tool-observation/spec.md +127 -0
  37. package/src/cli/index.ts +1 -0
  38. package/src/core/embedder.ts +15 -4
  39. package/src/core/sqlite-event-store.ts +16 -0
  40. package/src/core/turn-state.ts +48 -0
  41. package/src/core/types.ts +1 -0
  42. package/src/hooks/post-tool-use.ts +47 -2
  43. package/src/hooks/semantic-daemon-client.ts +208 -0
  44. package/src/hooks/semantic-daemon.ts +276 -0
  45. package/src/hooks/session-start.ts +7 -0
  46. package/src/hooks/stop.ts +19 -4
  47. package/src/hooks/user-prompt-submit.ts +48 -40
  48. package/src/services/memory-service.ts +59 -16
  49. package/src/services/session-history-importer.ts +18 -0
  50. package/src/ui/app.js +48 -1
  51. package/src/ui/index.html +11 -3
@@ -0,0 +1,158 @@
1
+ # Plan: Selective Storage Filtering
2
+
3
+ ## 구현 범위
4
+
5
+ 3개 파일 수정, 스키마 변경 없음.
6
+
7
+ ---
8
+
9
+ ## Step 1. post-tool-use.ts — blocklist 확장 + output 필터
10
+
11
+ ### 1-1. DEFAULT_CONFIG 업데이트
12
+
13
+ ```ts
14
+ const DEFAULT_CONFIG: Config['toolObservation'] = {
15
+ enabled: true,
16
+ excludedTools: [
17
+ // 기존
18
+ 'TodoWrite', 'TodoRead',
19
+ // 추가: 재현 가능한 조회 도구
20
+ 'Read', 'Grep', 'Glob',
21
+ 'ToolSearch', 'WebFetch', 'WebSearch', 'NotebookRead',
22
+ // 추가: 저가치 시스템 도구
23
+ 'Skill', 'EnterPlanMode',
24
+ ],
25
+ minOutputLength: parseInt(process.env.CLAUDE_MEMORY_TOOL_MIN_OUTPUT_LEN || '100'),
26
+ maxOutputLength: 10000,
27
+ maxOutputLines: 100,
28
+ storeOnlyOnSuccess: false
29
+ };
30
+ ```
31
+
32
+ ### 1-2. 환경변수 오버라이드
33
+
34
+ ```ts
35
+ const envBlocklist = process.env.CLAUDE_MEMORY_TOOL_BLOCKLIST;
36
+ if (envBlocklist) {
37
+ config.excludedTools = envBlocklist.split(',').map(s => s.trim());
38
+ }
39
+ ```
40
+
41
+ ### 1-3. ALWAYS_STORE 집합 + hasSignificantOutput 함수
42
+
43
+ ```ts
44
+ const ALWAYS_STORE_TOOLS = new Set([
45
+ 'Write', 'Edit', 'MultiEdit', 'Agent', 'Task', 'ExitPlanMode'
46
+ ]);
47
+
48
+ function hasSignificantOutput(
49
+ toolName: string,
50
+ output: string,
51
+ response: PostToolUseInput['tool_response'],
52
+ minLen: number
53
+ ): boolean {
54
+ if (ALWAYS_STORE_TOOLS.has(toolName)) return true;
55
+ if (response?.stderr && response.stderr.trim().length > 0) return true;
56
+ return output.trim().length >= minLen;
57
+ }
58
+ ```
59
+
60
+ ### 1-4. main() — step 4.5 위치에 output 필터 삽입
61
+
62
+ ```ts
63
+ // 기존 step 4 (success filter) 다음에 추가
64
+ // 4.5. output-level 필터
65
+ if (!hasSignificantOutput(
66
+ input.tool_name, toolOutput, input.tool_response,
67
+ config.minOutputLength ?? 100
68
+ )) {
69
+ console.log(JSON.stringify({}));
70
+ return;
71
+ }
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Step 2. stop.ts — agent_response min-length 필터
77
+
78
+ ### 변경 위치: storeAgentResponse 루프 내
79
+
80
+ ```ts
81
+ const MIN_AGENT_RESPONSE_LEN = parseInt(
82
+ process.env.CLAUDE_MEMORY_AGENT_RESPONSE_MIN_LEN || '150'
83
+ );
84
+
85
+ // Store each assistant response
86
+ const lastIdx = assistantMessages.length - 1;
87
+ for (let i = 0; i < assistantMessages.length; i++) {
88
+ const text = assistantMessages[i];
89
+ const isLast = i === lastIdx;
90
+
91
+ // 마지막 메시지는 최종 답변일 수 있으므로 길이 무관 저장
92
+ if (!isLast && text.trim().length < MIN_AGENT_RESPONSE_LEN) continue;
93
+
94
+ // ... 기존 privacy filter, truncate, store 로직
95
+ }
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Step 3. session-history-importer.ts — shouldStorePrompt 적용
101
+
102
+ ### 변경 위치: user_prompt 저장 전
103
+
104
+ ```ts
105
+ // shouldStorePrompt와 동일한 로직 인라인 적용
106
+ function isWorthStoringPrompt(content: string): boolean {
107
+ const trimmed = content.trim();
108
+ if (trimmed.startsWith('/')) return false;
109
+ if (trimmed.length < 15) return false;
110
+ if (!/[a-zA-Z가-힣]{2,}/.test(trimmed)) return false;
111
+ return true;
112
+ }
113
+
114
+ // importer 루프 내 user role 메시지 처리 시:
115
+ if (message.role === 'user') {
116
+ const textContent = extractTextContent(message);
117
+ if (!isWorthStoringPrompt(textContent)) continue; // 추가
118
+ await service.storeUserPrompt(sessionId, textContent, ...);
119
+ }
120
+ ```
121
+
122
+ > 참고: `shouldStorePrompt`를 `user-prompt-submit.ts`에서 공유 유틸로 추출하면
123
+ > 중복 없이 재사용 가능. 단, 임포터만 수정하는 경우엔 인라인도 무방.
124
+
125
+ ---
126
+
127
+ ## 구현 순서
128
+
129
+ 1. `src/hooks/post-tool-use.ts` 수정 (Step 1)
130
+ 2. `src/hooks/stop.ts` 수정 (Step 2)
131
+ 3. `src/services/session-history-importer.ts` 수정 (Step 3)
132
+ 4. `npm run build`
133
+ 5. 검증
134
+
135
+ ---
136
+
137
+ ## 리스크 및 대응
138
+
139
+ | 리스크 | 대응 |
140
+ |--------|------|
141
+ | Read 결과가 필요한 경우 | agent_response에 내용이 반영됨. Read 자체보다 해석이 더 가치 있음 |
142
+ | Grep 결과 패턴 필요 | user_prompt + agent_response에 충분한 맥락 있음 |
143
+ | 짧은 agent_response가 중요한 경우 | 마지막 메시지 예외 처리로 커버 |
144
+ | importer 소급 필터 없음 | 신규 import부터 적용, 기존 데이터 유지 |
145
+ | 환경변수로 비활성화 가능 | `CLAUDE_MEMORY_TOOL_BLOCKLIST=""` 로 전체 허용 가능 |
146
+
147
+ ---
148
+
149
+ ## 검증 기준
150
+
151
+ - `npm run build` 성공
152
+ - Read/Grep/Glob 도구 사용 후 tool_observation 미생성 확인
153
+ - Bash 에러 발생 시 tool_observation 생성 확인
154
+ - Write/Edit 실행 시 tool_observation 생성 확인
155
+ - 짧은 agent_response (< 150자) 저장 안 됨 확인
156
+ - 마지막 agent_response는 길이 무관 저장 확인
157
+ - import 시 '1', 'go', Ctrl+C 저장 안 됨 확인
158
+ - dashboard stats tool_observation 비율 감소 추세 확인
@@ -0,0 +1,127 @@
1
+ # Spec: Selective Storage Filtering
2
+
3
+ ## 개요
4
+
5
+ 모든 이벤트 타입에 걸쳐 메모리 가치가 낮은 데이터를 선별적으로 필터링하여
6
+ 저장량 55% 감소, 임베딩 backlog 해소, retrieval 품질 향상을 목표로 한다.
7
+
8
+ ## 목표
9
+
10
+ - 전체 이벤트 저장량 **-55%** (10,536 → ~4,693)
11
+ - 임베딩 pending 증가 속도 감소
12
+ - retrieval signal-to-noise 향상
13
+ - Ctrl+C, 메뉴번호 같은 쓰레기 데이터 제거
14
+
15
+ ## 비목표
16
+
17
+ - 저장 스키마 변경 없음
18
+ - 기존 저장된 이벤트 소급 삭제 없음
19
+ - session_summary 로직 변경 없음
20
+
21
+ ---
22
+
23
+ ## 필터 규칙 1: tool_observation (post-tool-use.ts)
24
+
25
+ ### Blocklist 확장
26
+
27
+ **추가 제외 도구** (현재: TodoWrite, TodoRead만 제외):
28
+
29
+ ```
30
+ Read, Grep, Glob, ToolSearch,
31
+ WebFetch, WebSearch, NotebookRead,
32
+ Skill, EnterPlanMode,
33
+ mcp__* (MCP 도구 전체, 조건부 예외 적용)
34
+ ```
35
+
36
+ **항상 저장 (allowlist)**:
37
+ - `Write`, `Edit`, `MultiEdit` — 파일 변경 기록
38
+ - `Agent`, `Task` — 서브태스크 결과
39
+ - `Bash` — 조건부 (output 필터 적용)
40
+ - `ExitPlanMode` — 계획 완료 기록 (조건부)
41
+
42
+ ### Output-level 필터 (Bash 등 조건부 도구)
43
+
44
+ | 조건 | 동작 |
45
+ |------|------|
46
+ | `stderr` 존재 | 저장 (에러 컨텍스트) |
47
+ | `stdout` 길이 ≥ 100 chars | 저장 |
48
+ | Write/Edit/Agent/Task | 길이 무관 저장 |
49
+ | 그 외 | 스킵 |
50
+
51
+ ### 환경변수
52
+
53
+ ```bash
54
+ CLAUDE_MEMORY_TOOL_BLOCKLIST="Read,Grep,Glob,..." # 커스텀 blocklist
55
+ CLAUDE_MEMORY_TOOL_MIN_OUTPUT_LEN=100 # Bash 최소 출력 길이
56
+ ```
57
+
58
+ ---
59
+
60
+ ## 필터 규칙 2: agent_response (stop.ts)
61
+
62
+ ### Min-length 필터
63
+
64
+ **150자 미만 agent_response는 저장 안 함**
65
+
66
+ 근거: 50자 미만 608개 (27%), 50~200자 587개 (26%) 가 도구 체인 전환 메시지.
67
+ 독립적 retrieval 가치 없음.
68
+
69
+ ```bash
70
+ CLAUDE_MEMORY_AGENT_RESPONSE_MIN_LEN=150 # 기본값
71
+ ```
72
+
73
+ **예외 (짧아도 저장):**
74
+ - 세션의 마지막 agent_response (최종 답변일 가능성)
75
+
76
+ ---
77
+
78
+ ## 필터 규칙 3: user_prompt (importer + hook)
79
+
80
+ ### 임포터에 shouldStorePrompt() 적용
81
+
82
+ 현재 import 시 transcript의 모든 user 메시지를 무조건 저장.
83
+ Ctrl+C(`\x03`), 숫자 `'1'`, `'go'` 등이 저장되는 원인.
84
+
85
+ **변경:** `session-history-importer.ts`에서 각 user_prompt 저장 전
86
+ `shouldStorePrompt()` 동일 조건 적용:
87
+ - 길이 < 15자 → 스킵
88
+ - `/`로 시작 → 스킵
89
+ - 제어문자 포함 → 스킵
90
+ - 한글/영문 2글자 이상 포함 여부 확인
91
+
92
+ ---
93
+
94
+ ## 적용 파일
95
+
96
+ | 파일 | 변경 |
97
+ |------|------|
98
+ | `src/hooks/post-tool-use.ts` | blocklist 확장 + output-level 필터 |
99
+ | `src/hooks/stop.ts` | agent_response min-length 필터 |
100
+ | `src/services/session-history-importer.ts` | shouldStorePrompt() 임포트 적용 |
101
+
102
+ ---
103
+
104
+ ## 판단 흐름
105
+
106
+ ```
107
+ [PostToolUse]
108
+ tool_name이 blocklist? → 스킵
109
+ tool_name이 allowlist(Write/Edit/Agent/Task)? → 저장
110
+ Bash/기타: output length ≥ 100 OR stderr 있음? → 저장 else 스킵
111
+
112
+ [Stop - agent_response]
113
+ 마지막 메시지? → 저장
114
+ length ≥ 150? → 저장 else 스킵
115
+
116
+ [Importer - user_prompt]
117
+ shouldStorePrompt() 통과? → 저장 else 스킵
118
+ ```
119
+
120
+ ---
121
+
122
+ ## 성공 지표
123
+
124
+ - 신규 세션 tool_observation 비율 < 40% (현재 68.5%)
125
+ - agent_response 저장 비율 < 50% (현재 전량 저장)
126
+ - user_prompt 쓰레기 입력 0건
127
+ - 임베딩 pending 증가 속도 현재 대비 -50%
package/src/cli/index.ts CHANGED
@@ -442,6 +442,7 @@ program
442
442
  const service = getMemoryServiceForProject(projectPath);
443
443
 
444
444
  try {
445
+ await service.initialize();
445
446
  console.log('⏳ Processing pending embeddings...');
446
447
  const count = await service.processPendingEmbeddings();
447
448
  console.log(`✅ Processed ${count} embeddings`);
@@ -46,6 +46,13 @@ export class Embedder {
46
46
  }
47
47
  }
48
48
 
49
+ // ~4 chars per token; 512 tokens * 4 = 2048, use 2000 to be safe
50
+ private static readonly MAX_CHARS = 2000;
51
+
52
+ private truncate(text: string): string {
53
+ return text.length > Embedder.MAX_CHARS ? text.slice(0, Embedder.MAX_CHARS) : text;
54
+ }
55
+
49
56
  /**
50
57
  * Generate embedding for a single text
51
58
  */
@@ -56,9 +63,11 @@ export class Embedder {
56
63
  throw new Error('Embedding pipeline not initialized');
57
64
  }
58
65
 
59
- const output = await this.pipeline(text, {
66
+ const output = await this.pipeline(this.truncate(text), {
60
67
  pooling: 'mean',
61
- normalize: true
68
+ normalize: true,
69
+ truncation: true,
70
+ max_length: 512
62
71
  });
63
72
 
64
73
  const vector = Array.from(output.data as Float32Array);
@@ -88,9 +97,11 @@ export class Embedder {
88
97
  const batch = texts.slice(i, i + batchSize);
89
98
 
90
99
  for (const text of batch) {
91
- const output = await this.pipeline(text, {
100
+ const output = await this.pipeline(this.truncate(text), {
92
101
  pooling: 'mean',
93
- normalize: true
102
+ normalize: true,
103
+ truncation: true,
104
+ max_length: 512
94
105
  });
95
106
 
96
107
  const vector = Array.from(output.data as Float32Array);
@@ -1145,6 +1145,22 @@ export class SQLiteEventStore {
1145
1145
  );
1146
1146
  }
1147
1147
 
1148
+ /**
1149
+ * Get session IDs that have unevaluated retrievals (measured_at IS NULL).
1150
+ * Excludes the current session. Used to backfill sessions that ended without Stop hook.
1151
+ */
1152
+ async getUnevaluatedSessions(currentSessionId: string, limit = 5): Promise<string[]> {
1153
+ await this.initialize();
1154
+ const rows = sqliteAll<{ session_id: string }>(
1155
+ this.db,
1156
+ `SELECT DISTINCT session_id FROM memory_helpfulness
1157
+ WHERE measured_at IS NULL AND session_id != ?
1158
+ ORDER BY created_at DESC LIMIT ?`,
1159
+ [currentSessionId, limit]
1160
+ );
1161
+ return rows.map((r) => r.session_id);
1162
+ }
1163
+
1148
1164
  /**
1149
1165
  * Evaluate helpfulness for all retrievals in a session
1150
1166
  * Called at session end - uses behavioral signals to compute score
@@ -122,6 +122,54 @@ export function clearTurnState(sessionId: string): void {
122
122
  }
123
123
  }
124
124
 
125
+ // ---------------------------------------------------------------------------
126
+ // Last Assistant Snippet State
127
+ // Persists the last ~500 chars of the assistant's response so the next
128
+ // UserPromptSubmit can enrich the retrieval query with conversation context.
129
+ // ---------------------------------------------------------------------------
130
+
131
+ const LAST_RESPONSE_SNIPPET_CHARS = 500;
132
+
133
+ interface LastResponseState {
134
+ sessionId: string;
135
+ snippet: string;
136
+ createdAt: string;
137
+ }
138
+
139
+ function getLastResponsePath(sessionId: string): string {
140
+ return path.join(TURN_STATE_DIR, `.last-response-${sessionId}.json`);
141
+ }
142
+
143
+ export function writeLastAssistantSnippet(sessionId: string, text: string): void {
144
+ try {
145
+ if (!fs.existsSync(TURN_STATE_DIR)) {
146
+ fs.mkdirSync(TURN_STATE_DIR, { recursive: true });
147
+ }
148
+ const snippet = text.slice(0, LAST_RESPONSE_SNIPPET_CHARS);
149
+ const state: LastResponseState = { sessionId, snippet, createdAt: new Date().toISOString() };
150
+ const filePath = getLastResponsePath(sessionId);
151
+ const tempPath = filePath + '.tmp';
152
+ fs.writeFileSync(tempPath, JSON.stringify(state));
153
+ fs.renameSync(tempPath, filePath);
154
+ } catch {
155
+ // non-critical
156
+ }
157
+ }
158
+
159
+ export function readLastAssistantSnippet(sessionId: string): string | null {
160
+ try {
161
+ const filePath = getLastResponsePath(sessionId);
162
+ if (!fs.existsSync(filePath)) return null;
163
+ const state: LastResponseState = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
164
+ if (state.sessionId !== sessionId) return null;
165
+ // Ignore if older than 2 hours (stale session)
166
+ if (Date.now() - new Date(state.createdAt).getTime() > 2 * 60 * 60 * 1000) return null;
167
+ return state.snippet || null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
125
173
  /**
126
174
  * Clean up stale turn state files (older than 1 hour).
127
175
  * Can be called periodically to prevent file accumulation.
package/src/core/types.ts CHANGED
@@ -185,6 +185,7 @@ export const ConfigSchema = z.object({
185
185
  toolObservation: z.object({
186
186
  enabled: z.boolean().default(true),
187
187
  excludedTools: z.array(z.string()).default(['TodoWrite', 'TodoRead']),
188
+ minOutputLength: z.number().default(100),
188
189
  maxOutputLength: z.number().default(10000),
189
190
  maxOutputLines: z.number().default(100),
190
191
  storeOnlyOnSuccess: z.boolean().default(false)
@@ -20,12 +20,42 @@ import type { PostToolUseInput, ToolObservationPayload, Config } from '../core/t
20
20
  // Default config
21
21
  const DEFAULT_CONFIG: Config['toolObservation'] = {
22
22
  enabled: true,
23
- excludedTools: ['TodoWrite', 'TodoRead'],
23
+ excludedTools: [
24
+ // Trivial meta tools
25
+ 'TodoWrite', 'TodoRead',
26
+ // Reproducible query tools (no storage value)
27
+ 'Read', 'Grep', 'Glob',
28
+ 'ToolSearch', 'WebFetch', 'WebSearch', 'NotebookRead',
29
+ // Low-value system tools
30
+ 'Skill', 'EnterPlanMode',
31
+ ],
32
+ minOutputLength: parseInt(process.env.CLAUDE_MEMORY_TOOL_MIN_OUTPUT_LEN || '100'),
24
33
  maxOutputLength: 10000,
25
34
  maxOutputLines: 100,
26
35
  storeOnlyOnSuccess: false
27
36
  };
28
37
 
38
+ // Tools that are always stored regardless of output length
39
+ const ALWAYS_STORE_TOOLS = new Set([
40
+ 'Write', 'Edit', 'MultiEdit', 'Agent', 'Task', 'ExitPlanMode'
41
+ ]);
42
+
43
+ /**
44
+ * Determine if a tool output is significant enough to store.
45
+ * Always-store tools bypass the length check.
46
+ * Other tools require non-empty stderr or output length >= minLen.
47
+ */
48
+ function hasSignificantOutput(
49
+ toolName: string,
50
+ output: string,
51
+ response: PostToolUseInput['tool_response'],
52
+ minLen: number
53
+ ): boolean {
54
+ if (ALWAYS_STORE_TOOLS.has(toolName)) return true;
55
+ if (response?.stderr && response.stderr.trim().length > 0) return true;
56
+ return output.trim().length >= minLen;
57
+ }
58
+
29
59
  const DEFAULT_PRIVACY_CONFIG: Config['privacy'] = {
30
60
  excludePatterns: ['password', 'secret', 'api_key', 'token', 'bearer'],
31
61
  anonymize: false,
@@ -77,9 +107,15 @@ async function main(): Promise<void> {
77
107
  const inputData = await readStdin();
78
108
  const input: PostToolUseInput = JSON.parse(inputData);
79
109
 
80
- const config = DEFAULT_CONFIG;
110
+ const config = { ...DEFAULT_CONFIG };
81
111
  const privacyConfig = DEFAULT_PRIVACY_CONFIG;
82
112
 
113
+ // Allow env-based blocklist override
114
+ const envBlocklist = process.env.CLAUDE_MEMORY_TOOL_BLOCKLIST;
115
+ if (envBlocklist !== undefined) {
116
+ config.excludedTools = envBlocklist.split(',').map((s) => s.trim()).filter(Boolean);
117
+ }
118
+
83
119
  // 1. Check if tool observation is enabled
84
120
  if (!config.enabled) {
85
121
  console.log(JSON.stringify({}));
@@ -102,6 +138,15 @@ async function main(): Promise<void> {
102
138
  return;
103
139
  }
104
140
 
141
+ // 4.5. Output-level filter: skip low-signal outputs
142
+ if (!hasSignificantOutput(
143
+ input.tool_name, toolOutput, input.tool_response,
144
+ config.minOutputLength ?? 100
145
+ )) {
146
+ console.log(JSON.stringify({}));
147
+ return;
148
+ }
149
+
105
150
  try {
106
151
  const memoryService = getLightweightMemoryService(input.session_id);
107
152
 
@@ -0,0 +1,208 @@
1
+ import { spawn } from 'child_process';
2
+ import * as fs from 'fs';
3
+ import * as net from 'net';
4
+ import * as os from 'os';
5
+ import * as path from 'path';
6
+
7
+ interface SemanticRequest {
8
+ sessionId: string;
9
+ prompt: string;
10
+ topK: number;
11
+ minScore: number;
12
+ }
13
+
14
+ interface SemanticMemory {
15
+ type: string;
16
+ content: string;
17
+ id?: string;
18
+ score?: number;
19
+ }
20
+
21
+ interface SemanticDaemonRequest {
22
+ type: 'retrieve';
23
+ sessionId: string;
24
+ prompt: string;
25
+ topK: number;
26
+ minScore: number;
27
+ }
28
+
29
+ interface SemanticDaemonResponse {
30
+ ok: boolean;
31
+ memories?: SemanticMemory[];
32
+ error?: string;
33
+ }
34
+
35
+ const DEFAULT_SOCKET_PATH = path.join(
36
+ os.homedir(),
37
+ '.claude-code',
38
+ 'memory',
39
+ 'semantic-daemon.sock'
40
+ );
41
+
42
+ const DAEMON_SOCKET_PATH = process.env.CLAUDE_MEMORY_SEMANTIC_SOCKET || DEFAULT_SOCKET_PATH;
43
+ const DAEMON_START_TIMEOUT_MS = parseInt(process.env.CLAUDE_MEMORY_SEMANTIC_DAEMON_START_MS || '1500');
44
+
45
+ let daemonStartPromise: Promise<void> | null = null;
46
+
47
+ export async function retrieveSemanticMemories(
48
+ request: SemanticRequest,
49
+ timeoutMs: number
50
+ ): Promise<SemanticMemory[]> {
51
+ const payload: SemanticDaemonRequest = {
52
+ type: 'retrieve',
53
+ sessionId: request.sessionId,
54
+ prompt: request.prompt,
55
+ topK: request.topK,
56
+ minScore: request.minScore
57
+ };
58
+
59
+ try {
60
+ return await requestFromDaemon(payload, timeoutMs);
61
+ } catch (error) {
62
+ if (!isConnectionError(error)) {
63
+ throw error;
64
+ }
65
+
66
+ await ensureDaemonRunning();
67
+ return requestFromDaemon(payload, timeoutMs).catch((retryError) => {
68
+ if (process.env.CLAUDE_MEMORY_DEBUG) {
69
+ console.error('[semantic-client] retry failed after daemon start:', retryError);
70
+ }
71
+ throw retryError;
72
+ });
73
+ }
74
+ }
75
+
76
+ function requestFromDaemon(
77
+ payload: SemanticDaemonRequest,
78
+ timeoutMs: number
79
+ ): Promise<SemanticMemory[]> {
80
+ return new Promise((resolve, reject) => {
81
+ const client = net.createConnection(DAEMON_SOCKET_PATH);
82
+ client.setEncoding('utf8');
83
+
84
+ let settled = false;
85
+ let responseRaw = '';
86
+ const timer = setTimeout(() => {
87
+ const timeoutError = new Error(`semantic daemon timeout (${timeoutMs}ms)`);
88
+ (timeoutError as NodeJS.ErrnoException).code = 'ETIMEDOUT';
89
+ settle(timeoutError);
90
+ client.destroy();
91
+ }, timeoutMs);
92
+
93
+ const settle = (error?: Error, memories?: SemanticMemory[]) => {
94
+ if (settled) return;
95
+ settled = true;
96
+ clearTimeout(timer);
97
+ if (error) {
98
+ reject(error);
99
+ } else {
100
+ resolve(memories || []);
101
+ }
102
+ };
103
+
104
+ client.on('connect', () => {
105
+ client.end(JSON.stringify(payload));
106
+ });
107
+
108
+ client.on('data', (chunk) => {
109
+ responseRaw += chunk;
110
+ if (responseRaw.length > 4 * 1024 * 1024) {
111
+ settle(new Error('semantic daemon response too large'));
112
+ client.destroy();
113
+ }
114
+ });
115
+
116
+ client.on('end', () => {
117
+ try {
118
+ const parsed = JSON.parse(responseRaw || '{}') as SemanticDaemonResponse;
119
+ if (!parsed.ok) {
120
+ settle(new Error(parsed.error || 'semantic daemon error'));
121
+ return;
122
+ }
123
+ settle(undefined, parsed.memories || []);
124
+ } catch (error) {
125
+ settle(error as Error);
126
+ }
127
+ });
128
+
129
+ client.on('error', (error) => {
130
+ settle(error as Error);
131
+ });
132
+ });
133
+ }
134
+
135
+ export async function ensureDaemonRunning(): Promise<void> {
136
+ if (daemonStartPromise) {
137
+ return daemonStartPromise;
138
+ }
139
+
140
+ daemonStartPromise = (async () => {
141
+ if (await canConnect()) {
142
+ return;
143
+ }
144
+
145
+ const daemonScriptPath = getDaemonScriptPath();
146
+ if (!fs.existsSync(daemonScriptPath)) {
147
+ throw new Error(`semantic daemon script not found: ${daemonScriptPath}`);
148
+ }
149
+
150
+ const daemonDir = path.dirname(DAEMON_SOCKET_PATH);
151
+ if (!fs.existsSync(daemonDir)) {
152
+ fs.mkdirSync(daemonDir, { recursive: true });
153
+ }
154
+
155
+ const child = spawn(process.execPath, [daemonScriptPath], {
156
+ detached: true,
157
+ stdio: 'ignore',
158
+ env: process.env
159
+ });
160
+ child.unref();
161
+
162
+ const startDeadline = Date.now() + DAEMON_START_TIMEOUT_MS;
163
+ while (Date.now() < startDeadline) {
164
+ if (await canConnect()) {
165
+ return;
166
+ }
167
+ await sleep(60);
168
+ }
169
+
170
+ throw new Error(`semantic daemon start timeout (${DAEMON_START_TIMEOUT_MS}ms)`);
171
+ })();
172
+
173
+ try {
174
+ await daemonStartPromise;
175
+ } finally {
176
+ daemonStartPromise = null;
177
+ }
178
+ }
179
+
180
+ function getDaemonScriptPath(): string {
181
+ return path.join(path.dirname(new URL(import.meta.url).pathname), 'semantic-daemon.js');
182
+ }
183
+
184
+ function canConnect(): Promise<boolean> {
185
+ return new Promise((resolve) => {
186
+ let settled = false;
187
+ const client = net.createConnection(DAEMON_SOCKET_PATH);
188
+ const finalize = (ok: boolean) => {
189
+ if (settled) return;
190
+ settled = true;
191
+ client.destroy();
192
+ resolve(ok);
193
+ };
194
+
195
+ client.on('connect', () => finalize(true));
196
+ client.on('error', () => finalize(false));
197
+ setTimeout(() => finalize(false), 120).unref();
198
+ });
199
+ }
200
+
201
+ function isConnectionError(error: unknown): boolean {
202
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
203
+ return code === 'ENOENT' || code === 'ECONNREFUSED' || code === 'EPIPE' || code === 'ECONNRESET';
204
+ }
205
+
206
+ function sleep(ms: number): Promise<void> {
207
+ return new Promise((resolve) => setTimeout(resolve, ms));
208
+ }