claude-memory-layer 1.0.23 → 1.0.25

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 (58) hide show
  1. package/.claude/settings.local.json +25 -0
  2. package/README.md +2 -0
  3. package/dist/cli/index.js +229 -978
  4. package/dist/cli/index.js.map +4 -4
  5. package/dist/core/index.js +59 -71
  6. package/dist/core/index.js.map +3 -3
  7. package/dist/hooks/post-tool-use.js +287 -976
  8. package/dist/hooks/post-tool-use.js.map +4 -4
  9. package/dist/hooks/semantic-daemon.js +6520 -0
  10. package/dist/hooks/semantic-daemon.js.map +7 -0
  11. package/dist/hooks/session-end.js +209 -973
  12. package/dist/hooks/session-end.js.map +4 -4
  13. package/dist/hooks/session-start.js +293 -978
  14. package/dist/hooks/session-start.js.map +4 -4
  15. package/dist/hooks/stop.js +247 -975
  16. package/dist/hooks/stop.js.map +4 -4
  17. package/dist/hooks/user-prompt-submit.js +406 -1036
  18. package/dist/hooks/user-prompt-submit.js.map +4 -4
  19. package/dist/server/api/index.js +209 -973
  20. package/dist/server/api/index.js.map +4 -4
  21. package/dist/server/index.js +209 -973
  22. package/dist/server/index.js.map +4 -4
  23. package/dist/services/memory-service.js +209 -973
  24. package/dist/services/memory-service.js.map +4 -4
  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 +1314 -1
  29. package/memory/session_summary/uncategorized/2026-03-04.md +50 -0
  30. package/memory/tool_observation/uncategorized/2026-03-04.md +969 -1
  31. package/memory/user_prompt/uncategorized/2026-03-04.md +555 -1
  32. package/package.json +1 -2
  33. package/scripts/build.ts +2 -1
  34. package/specs/memory-utilization-improvements/context.md +145 -0
  35. package/specs/memory-utilization-improvements/plan.md +361 -0
  36. package/specs/memory-utilization-improvements/spec.md +308 -0
  37. package/specs/optional-duckdb/context.md +77 -0
  38. package/specs/optional-duckdb/plan.md +142 -0
  39. package/specs/optional-duckdb/spec.md +35 -0
  40. package/specs/selective-tool-observation/context.md +100 -0
  41. package/specs/selective-tool-observation/plan.md +158 -0
  42. package/specs/selective-tool-observation/spec.md +127 -0
  43. package/src/cli/index.ts +1 -0
  44. package/src/core/db-wrapper.ts +18 -73
  45. package/src/core/embedder.ts +13 -4
  46. package/src/core/sqlite-event-store.ts +40 -0
  47. package/src/core/turn-state.ts +48 -0
  48. package/src/core/types.ts +1 -0
  49. package/src/hooks/post-tool-use.ts +72 -2
  50. package/src/hooks/semantic-daemon-client.ts +208 -0
  51. package/src/hooks/semantic-daemon.ts +276 -0
  52. package/src/hooks/session-start.ts +11 -0
  53. package/src/hooks/stop.ts +33 -4
  54. package/src/hooks/user-prompt-submit.ts +48 -40
  55. package/src/services/memory-service.ts +112 -65
  56. package/src/services/session-history-importer.ts +18 -0
  57. package/src/ui/app.js +48 -1
  58. 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`);
@@ -1,34 +1,14 @@
1
1
  /**
2
- * DuckDB Promise Wrapper
3
- * Wraps the callback-based DuckDB API with Promise-based async/await interface
2
+ * SQLite Database Wrapper
3
+ * Provides Promise-based interface over better-sqlite3 synchronous API
4
4
  */
5
5
 
6
- import duckdb from 'duckdb';
6
+ import BetterSqlite3 from 'better-sqlite3';
7
7
 
8
- export type Database = duckdb.Database;
9
-
10
- /**
11
- * Converts BigInt values to Number in an object
12
- * DuckDB returns BigInt for COUNT(*) and other aggregate functions
13
- */
14
- function convertBigInts<T>(obj: T): T {
15
- if (obj === null || obj === undefined) return obj;
16
- if (typeof obj === 'bigint') return Number(obj) as unknown as T;
17
- if (obj instanceof Date) return obj; // Preserve Date objects
18
- if (Array.isArray(obj)) return obj.map(convertBigInts) as unknown as T;
19
- if (typeof obj === 'object') {
20
- const result: Record<string, unknown> = {};
21
- for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
22
- result[key] = convertBigInts(value);
23
- }
24
- return result as T;
25
- }
26
- return obj;
27
- }
8
+ export type Database = BetterSqlite3.Database;
28
9
 
29
10
  /**
30
11
  * Safely converts a value to a Date object
31
- * Handles both Date objects and string timestamps from DuckDB
32
12
  */
33
13
  export function toDate(value: unknown): Date {
34
14
  if (value instanceof Date) return value;
@@ -42,78 +22,43 @@ export interface DatabaseOptions {
42
22
  }
43
23
 
44
24
  /**
45
- * Creates a new DuckDB database with Promise-based API
25
+ * Creates a new SQLite database connection
46
26
  */
47
- export function createDatabase(path: string, options?: DatabaseOptions): Database {
48
- if (options?.readOnly) {
49
- return new duckdb.Database(path, { access_mode: 'READ_ONLY' });
50
- }
51
- return new duckdb.Database(path);
27
+ export function createDatabase(dbPath: string, options?: DatabaseOptions): Database {
28
+ return new BetterSqlite3(dbPath, { readonly: options?.readOnly });
52
29
  }
53
30
 
54
31
  /**
55
- * Promisified db.run() - executes a statement that doesn't return rows
32
+ * Executes a statement that doesn't return rows
56
33
  */
57
34
  export function dbRun(db: Database, sql: string, params: unknown[] = []): Promise<void> {
58
- return new Promise((resolve, reject) => {
59
- if (params.length === 0) {
60
- db.run(sql, (err: Error | null) => {
61
- if (err) reject(err);
62
- else resolve();
63
- });
64
- } else {
65
- db.run(sql, ...params, (err: Error | null) => {
66
- if (err) reject(err);
67
- else resolve();
68
- });
69
- }
70
- });
35
+ db.prepare(sql).run(...(params as never[]));
36
+ return Promise.resolve();
71
37
  }
72
38
 
73
39
  /**
74
- * Promisified db.all() - executes a query and returns all rows
75
- * Automatically converts BigInt values to Number
40
+ * Executes a query and returns all rows
76
41
  */
77
42
  export function dbAll<T = Record<string, unknown>>(
78
43
  db: Database,
79
44
  sql: string,
80
45
  params: unknown[] = []
81
46
  ): Promise<T[]> {
82
- return new Promise((resolve, reject) => {
83
- if (params.length === 0) {
84
- db.all(sql, (err: Error | null, rows: T[]) => {
85
- if (err) reject(err);
86
- else resolve(convertBigInts(rows || []));
87
- });
88
- } else {
89
- db.all(sql, ...params, (err: Error | null, rows: T[]) => {
90
- if (err) reject(err);
91
- else resolve(convertBigInts(rows || []));
92
- });
93
- }
94
- });
47
+ return Promise.resolve(db.prepare(sql).all(...(params as never[])) as T[]);
95
48
  }
96
49
 
97
50
  /**
98
- * Promisified db.close() - closes the database connection
51
+ * Closes the database connection
99
52
  */
100
53
  export function dbClose(db: Database): Promise<void> {
101
- return new Promise((resolve, reject) => {
102
- db.close((err: Error | null) => {
103
- if (err) reject(err);
104
- else resolve();
105
- });
106
- });
54
+ db.close();
55
+ return Promise.resolve();
107
56
  }
108
57
 
109
58
  /**
110
- * Promisified db.exec() - executes multiple statements
59
+ * Executes multiple statements
111
60
  */
112
61
  export function dbExec(db: Database, sql: string): Promise<void> {
113
- return new Promise((resolve, reject) => {
114
- db.exec(sql, (err: Error | null) => {
115
- if (err) reject(err);
116
- else resolve();
117
- });
118
- });
62
+ db.exec(sql);
63
+ return Promise.resolve();
119
64
  }
@@ -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);
@@ -531,6 +531,30 @@ export class SQLiteEventStore {
531
531
  }
532
532
  }
533
533
 
534
+ /**
535
+ * Get session IDs that have events but no session_summary event.
536
+ * Used to backfill summaries for sessions that ended without Stop hook.
537
+ */
538
+ async getSessionsWithoutSummary(currentSessionId: string, limit = 5): Promise<string[]> {
539
+ await this.initialize();
540
+ const rows = sqliteAll<{ session_id: string }>(
541
+ this.db,
542
+ `SELECT DISTINCT e.session_id
543
+ FROM events e
544
+ WHERE e.session_id != ?
545
+ AND e.event_type != 'session_summary'
546
+ AND e.session_id NOT IN (
547
+ SELECT DISTINCT session_id FROM events WHERE event_type = 'session_summary'
548
+ )
549
+ GROUP BY e.session_id
550
+ HAVING COUNT(*) >= 3
551
+ ORDER BY MAX(e.timestamp) DESC
552
+ LIMIT ?`,
553
+ [currentSessionId, limit]
554
+ );
555
+ return rows.map((r) => r.session_id);
556
+ }
557
+
534
558
  /**
535
559
  * Get events by session ID
536
560
  */
@@ -1145,6 +1169,22 @@ export class SQLiteEventStore {
1145
1169
  );
1146
1170
  }
1147
1171
 
1172
+ /**
1173
+ * Get session IDs that have unevaluated retrievals (measured_at IS NULL).
1174
+ * Excludes the current session. Used to backfill sessions that ended without Stop hook.
1175
+ */
1176
+ async getUnevaluatedSessions(currentSessionId: string, limit = 5): Promise<string[]> {
1177
+ await this.initialize();
1178
+ const rows = sqliteAll<{ session_id: string }>(
1179
+ this.db,
1180
+ `SELECT DISTINCT session_id FROM memory_helpfulness
1181
+ WHERE measured_at IS NULL AND session_id != ?
1182
+ ORDER BY created_at DESC LIMIT ?`,
1183
+ [currentSessionId, limit]
1184
+ );
1185
+ return rows.map((r) => r.session_id);
1186
+ }
1187
+
1148
1188
  /**
1149
1189
  * Evaluate helpfulness for all retrievals in a session
1150
1190
  * 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,67 @@ 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
+ // Keywords that indicate a Bash output is worth storing
44
+ const IMPORTANT_BASH_KEYWORDS = [
45
+ 'error', 'failed', 'exception', 'traceback', 'panic',
46
+ 'warning', 'deprecated',
47
+ 'test passed', 'test failed', 'tests passed', 'tests failed',
48
+ 'coverage', 'assert',
49
+ 'published', 'deployed', 'built successfully', 'build complete',
50
+ 'successfully installed', 'successfully created',
51
+ ];
52
+
53
+ /**
54
+ * For Bash commands, only store output that is significant:
55
+ * - Has stderr content
56
+ * - Contains important keywords (errors, test results, deploy events)
57
+ * - Output is very long (> 800 chars), indicating meaningful work
58
+ */
59
+ function isBashSignificant(output: string, response: PostToolUseInput['tool_response']): boolean {
60
+ if (response?.stderr && response.stderr.trim().length > 20) return true;
61
+ const lower = output.toLowerCase();
62
+ if (IMPORTANT_BASH_KEYWORDS.some((kw) => lower.includes(kw))) return true;
63
+ return output.trim().length > 800;
64
+ }
65
+
66
+ /**
67
+ * Determine if a tool output is significant enough to store.
68
+ * Always-store tools bypass the length check.
69
+ * Bash uses keyword-based significance detection.
70
+ * Other tools require non-empty stderr or output length >= minLen.
71
+ */
72
+ function hasSignificantOutput(
73
+ toolName: string,
74
+ output: string,
75
+ response: PostToolUseInput['tool_response'],
76
+ minLen: number
77
+ ): boolean {
78
+ if (ALWAYS_STORE_TOOLS.has(toolName)) return true;
79
+ if (toolName === 'Bash') return isBashSignificant(output, response);
80
+ if (response?.stderr && response.stderr.trim().length > 0) return true;
81
+ return output.trim().length >= minLen;
82
+ }
83
+
29
84
  const DEFAULT_PRIVACY_CONFIG: Config['privacy'] = {
30
85
  excludePatterns: ['password', 'secret', 'api_key', 'token', 'bearer'],
31
86
  anonymize: false,
@@ -77,9 +132,15 @@ async function main(): Promise<void> {
77
132
  const inputData = await readStdin();
78
133
  const input: PostToolUseInput = JSON.parse(inputData);
79
134
 
80
- const config = DEFAULT_CONFIG;
135
+ const config = { ...DEFAULT_CONFIG };
81
136
  const privacyConfig = DEFAULT_PRIVACY_CONFIG;
82
137
 
138
+ // Allow env-based blocklist override
139
+ const envBlocklist = process.env.CLAUDE_MEMORY_TOOL_BLOCKLIST;
140
+ if (envBlocklist !== undefined) {
141
+ config.excludedTools = envBlocklist.split(',').map((s) => s.trim()).filter(Boolean);
142
+ }
143
+
83
144
  // 1. Check if tool observation is enabled
84
145
  if (!config.enabled) {
85
146
  console.log(JSON.stringify({}));
@@ -102,6 +163,15 @@ async function main(): Promise<void> {
102
163
  return;
103
164
  }
104
165
 
166
+ // 4.5. Output-level filter: skip low-signal outputs
167
+ if (!hasSignificantOutput(
168
+ input.tool_name, toolOutput, input.tool_response,
169
+ config.minOutputLength ?? 100
170
+ )) {
171
+ console.log(JSON.stringify({}));
172
+ return;
173
+ }
174
+
105
175
  try {
106
176
  const memoryService = getLightweightMemoryService(input.session_id);
107
177