claude-memory-layer 1.0.23 → 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.
- package/.claude/settings.local.json +11 -0
- package/README.md +2 -0
- package/dist/cli/index.js +85 -17
- package/dist/cli/index.js.map +2 -2
- package/dist/core/index.js +28 -5
- package/dist/core/index.js.map +2 -2
- package/dist/hooks/post-tool-use.js +115 -18
- package/dist/hooks/post-tool-use.js.map +2 -2
- package/dist/hooks/semantic-daemon.js +7337 -0
- package/dist/hooks/semantic-daemon.js.map +7 -0
- package/dist/hooks/session-end.js +69 -16
- package/dist/hooks/session-end.js.map +2 -2
- package/dist/hooks/session-start.js +154 -24
- package/dist/hooks/session-start.js.map +4 -4
- package/dist/hooks/stop.js +99 -18
- package/dist/hooks/stop.js.map +2 -2
- package/dist/hooks/user-prompt-submit.js +289 -102
- package/dist/hooks/user-prompt-submit.js.map +4 -4
- package/dist/server/api/index.js +69 -16
- package/dist/server/api/index.js.map +2 -2
- package/dist/server/index.js +69 -16
- package/dist/server/index.js.map +2 -2
- package/dist/services/memory-service.js +69 -16
- package/dist/services/memory-service.js.map +2 -2
- package/dist/ui/app.js +48 -1
- package/dist/ui/index.html +11 -3
- package/memory/_index.md +1 -0
- package/memory/agent_response/uncategorized/2026-03-04.md +1098 -1
- package/memory/session_summary/uncategorized/2026-03-04.md +31 -0
- package/memory/tool_observation/uncategorized/2026-03-04.md +733 -1
- package/memory/user_prompt/uncategorized/2026-03-04.md +371 -1
- package/package.json +1 -1
- package/scripts/build.ts +2 -1
- package/specs/selective-tool-observation/context.md +100 -0
- package/specs/selective-tool-observation/plan.md +158 -0
- package/specs/selective-tool-observation/spec.md +127 -0
- package/src/cli/index.ts +1 -0
- package/src/core/embedder.ts +13 -4
- package/src/core/sqlite-event-store.ts +16 -0
- package/src/core/turn-state.ts +48 -0
- package/src/core/types.ts +1 -0
- package/src/hooks/post-tool-use.ts +47 -2
- package/src/hooks/semantic-daemon-client.ts +208 -0
- package/src/hooks/semantic-daemon.ts +276 -0
- package/src/hooks/session-start.ts +7 -0
- package/src/hooks/stop.ts +19 -4
- package/src/hooks/user-prompt-submit.ts +48 -40
- package/src/services/memory-service.ts +59 -16
- package/src/services/session-history-importer.ts +18 -0
- package/src/ui/app.js +48 -1
- package/src/ui/index.html +11 -3
|
@@ -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`);
|
package/src/core/embedder.ts
CHANGED
|
@@ -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,10 +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
68
|
normalize: true,
|
|
62
|
-
truncation: true
|
|
69
|
+
truncation: true,
|
|
70
|
+
max_length: 512
|
|
63
71
|
});
|
|
64
72
|
|
|
65
73
|
const vector = Array.from(output.data as Float32Array);
|
|
@@ -89,10 +97,11 @@ export class Embedder {
|
|
|
89
97
|
const batch = texts.slice(i, i + batchSize);
|
|
90
98
|
|
|
91
99
|
for (const text of batch) {
|
|
92
|
-
const output = await this.pipeline(text, {
|
|
100
|
+
const output = await this.pipeline(this.truncate(text), {
|
|
93
101
|
pooling: 'mean',
|
|
94
102
|
normalize: true,
|
|
95
|
-
truncation: true
|
|
103
|
+
truncation: true,
|
|
104
|
+
max_length: 512
|
|
96
105
|
});
|
|
97
106
|
|
|
98
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
|
package/src/core/turn-state.ts
CHANGED
|
@@ -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: [
|
|
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
|
+
}
|