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.
- package/.claude/settings.local.json +25 -0
- package/README.md +2 -0
- package/dist/cli/index.js +229 -978
- package/dist/cli/index.js.map +4 -4
- package/dist/core/index.js +59 -71
- package/dist/core/index.js.map +3 -3
- package/dist/hooks/post-tool-use.js +287 -976
- package/dist/hooks/post-tool-use.js.map +4 -4
- package/dist/hooks/semantic-daemon.js +6520 -0
- package/dist/hooks/semantic-daemon.js.map +7 -0
- package/dist/hooks/session-end.js +209 -973
- package/dist/hooks/session-end.js.map +4 -4
- package/dist/hooks/session-start.js +293 -978
- package/dist/hooks/session-start.js.map +4 -4
- package/dist/hooks/stop.js +247 -975
- package/dist/hooks/stop.js.map +4 -4
- package/dist/hooks/user-prompt-submit.js +406 -1036
- package/dist/hooks/user-prompt-submit.js.map +4 -4
- package/dist/server/api/index.js +209 -973
- package/dist/server/api/index.js.map +4 -4
- package/dist/server/index.js +209 -973
- package/dist/server/index.js.map +4 -4
- package/dist/services/memory-service.js +209 -973
- package/dist/services/memory-service.js.map +4 -4
- 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 +1314 -1
- package/memory/session_summary/uncategorized/2026-03-04.md +50 -0
- package/memory/tool_observation/uncategorized/2026-03-04.md +969 -1
- package/memory/user_prompt/uncategorized/2026-03-04.md +555 -1
- package/package.json +1 -2
- package/scripts/build.ts +2 -1
- package/specs/memory-utilization-improvements/context.md +145 -0
- package/specs/memory-utilization-improvements/plan.md +361 -0
- package/specs/memory-utilization-improvements/spec.md +308 -0
- package/specs/optional-duckdb/context.md +77 -0
- package/specs/optional-duckdb/plan.md +142 -0
- package/specs/optional-duckdb/spec.md +35 -0
- 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/db-wrapper.ts +18 -73
- package/src/core/embedder.ts +13 -4
- package/src/core/sqlite-event-store.ts +40 -0
- package/src/core/turn-state.ts +48 -0
- package/src/core/types.ts +1 -0
- package/src/hooks/post-tool-use.ts +72 -2
- package/src/hooks/semantic-daemon-client.ts +208 -0
- package/src/hooks/semantic-daemon.ts +276 -0
- package/src/hooks/session-start.ts +11 -0
- package/src/hooks/stop.ts +33 -4
- package/src/hooks/user-prompt-submit.ts +48 -40
- package/src/services/memory-service.ts +112 -65
- 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/db-wrapper.ts
CHANGED
|
@@ -1,34 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* SQLite Database Wrapper
|
|
3
|
+
* Provides Promise-based interface over better-sqlite3 synchronous API
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import BetterSqlite3 from 'better-sqlite3';
|
|
7
7
|
|
|
8
|
-
export type 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
|
|
25
|
+
* Creates a new SQLite database connection
|
|
46
26
|
*/
|
|
47
|
-
export function createDatabase(
|
|
48
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
51
|
+
* Closes the database connection
|
|
99
52
|
*/
|
|
100
53
|
export function dbClose(db: Database): Promise<void> {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (err) reject(err);
|
|
104
|
-
else resolve();
|
|
105
|
-
});
|
|
106
|
-
});
|
|
54
|
+
db.close();
|
|
55
|
+
return Promise.resolve();
|
|
107
56
|
}
|
|
108
57
|
|
|
109
58
|
/**
|
|
110
|
-
*
|
|
59
|
+
* Executes multiple statements
|
|
111
60
|
*/
|
|
112
61
|
export function dbExec(db: Database, sql: string): Promise<void> {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (err) reject(err);
|
|
116
|
-
else resolve();
|
|
117
|
-
});
|
|
118
|
-
});
|
|
62
|
+
db.exec(sql);
|
|
63
|
+
return Promise.resolve();
|
|
119
64
|
}
|
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);
|
|
@@ -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
|
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,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: [
|
|
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
|
|