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.
- package/.claude/settings.local.json +11 -0
- package/README.md +2 -0
- package/dist/cli/index.js +87 -17
- package/dist/cli/index.js.map +2 -2
- package/dist/core/index.js +30 -5
- package/dist/core/index.js.map +2 -2
- package/dist/hooks/post-tool-use.js +117 -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 +71 -16
- package/dist/hooks/session-end.js.map +2 -2
- package/dist/hooks/session-start.js +156 -24
- package/dist/hooks/session-start.js.map +4 -4
- package/dist/hooks/stop.js +101 -18
- package/dist/hooks/stop.js.map +2 -2
- package/dist/hooks/user-prompt-submit.js +291 -102
- package/dist/hooks/user-prompt-submit.js.map +4 -4
- package/dist/server/api/index.js +71 -16
- package/dist/server/api/index.js.map +2 -2
- package/dist/server/index.js +71 -16
- package/dist/server/index.js.map +2 -2
- package/dist/services/memory-service.js +71 -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 +1138 -1
- package/memory/session_summary/uncategorized/2026-03-04.md +31 -0
- package/memory/tool_observation/uncategorized/2026-03-04.md +785 -1
- package/memory/user_prompt/uncategorized/2026-03-04.md +438 -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 +15 -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,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`);
|
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,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
|
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
|
+
}
|