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
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* User Prompt Submit Hook
|
|
4
|
-
* Called when user submits a prompt - retrieves relevant memories
|
|
4
|
+
* Called when user submits a prompt - retrieves relevant memories.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Retrieval mode (CLAUDE_MEMORY_RETRIEVAL_MODE):
|
|
7
|
+
* - keyword (default-fast): SQLite FTS5 only, no ML model (~10ms)
|
|
8
|
+
* - semantic: vector search via long-running semantic daemon (~15-20ms warm)
|
|
9
|
+
* - hybrid: semantic first, keyword fallback (default)
|
|
10
|
+
*
|
|
11
|
+
* The semantic daemon keeps the embedding model in memory across hook invocations,
|
|
12
|
+
* avoiding per-request model initialization (~730ms cold start).
|
|
8
13
|
*
|
|
9
14
|
* Turn Grouping: Generates a turn_id and persists it to a state file
|
|
10
15
|
* so PostToolUse and Stop hooks can associate their events with this turn.
|
|
@@ -14,8 +19,9 @@ import { randomUUID } from 'crypto';
|
|
|
14
19
|
import * as fs from 'fs';
|
|
15
20
|
import * as path from 'path';
|
|
16
21
|
import * as os from 'os';
|
|
17
|
-
import { getLightweightMemoryService
|
|
18
|
-
import { writeTurnState } from '../core/turn-state.js';
|
|
22
|
+
import { getLightweightMemoryService } from '../services/memory-service.js';
|
|
23
|
+
import { writeTurnState, readLastAssistantSnippet } from '../core/turn-state.js';
|
|
24
|
+
import { retrieveSemanticMemories } from './semantic-daemon-client.js';
|
|
19
25
|
import type { UserPromptSubmitInput, UserPromptSubmitOutput } from '../core/types.js';
|
|
20
26
|
|
|
21
27
|
// Configuration
|
|
@@ -25,7 +31,7 @@ const BASE_MIN_SCORE = parseFloat(process.env.CLAUDE_MEMORY_MIN_SCORE || '0.4');
|
|
|
25
31
|
const FALLBACK_MIN_SCORE = parseFloat(process.env.CLAUDE_MEMORY_FALLBACK_MIN_SCORE || '0.3');
|
|
26
32
|
const ENABLE_SEARCH = process.env.CLAUDE_MEMORY_SEARCH !== 'false';
|
|
27
33
|
const RETRIEVAL_MODE = (process.env.CLAUDE_MEMORY_RETRIEVAL_MODE || 'hybrid') as 'keyword' | 'semantic' | 'hybrid';
|
|
28
|
-
const SEMANTIC_TIMEOUT_MS = parseInt(process.env.CLAUDE_MEMORY_SEMANTIC_TIMEOUT_MS || '
|
|
34
|
+
const SEMANTIC_TIMEOUT_MS = parseInt(process.env.CLAUDE_MEMORY_SEMANTIC_TIMEOUT_MS || '2000');
|
|
29
35
|
const ADHERENCE_INTERVAL_TURNS = parseInt(process.env.CLAUDE_MEMORY_ADHERENCE_INTERVAL_TURNS || '3');
|
|
30
36
|
|
|
31
37
|
const ADHERENCE_STATE_DIR = path.join(os.homedir(), '.claude-code', 'memory');
|
|
@@ -59,21 +65,6 @@ function getDynamicMinScore(prompt: string): number {
|
|
|
59
65
|
return BASE_MIN_SCORE;
|
|
60
66
|
}
|
|
61
67
|
|
|
62
|
-
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
63
|
-
return new Promise((resolve, reject) => {
|
|
64
|
-
const timer = setTimeout(() => reject(new Error(`semantic retrieval timeout (${timeoutMs}ms)`)), timeoutMs);
|
|
65
|
-
promise
|
|
66
|
-
.then((result) => {
|
|
67
|
-
clearTimeout(timer);
|
|
68
|
-
resolve(result);
|
|
69
|
-
})
|
|
70
|
-
.catch((error) => {
|
|
71
|
-
clearTimeout(timer);
|
|
72
|
-
reject(error);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
68
|
function formatMemoryContext(items: Array<{ type: string; content: string }>): string {
|
|
78
69
|
if (items.length === 0) return '';
|
|
79
70
|
const lines = items.map((m) => {
|
|
@@ -196,6 +187,12 @@ async function main(): Promise<void> {
|
|
|
196
187
|
const adherenceDecision = shouldRunAdherenceCheck(currentTurn, input.prompt, adherenceState);
|
|
197
188
|
logAdherenceDecision(input.session_id, currentTurn, adherenceDecision.run, adherenceDecision.reason);
|
|
198
189
|
|
|
190
|
+
// On first turn of a new session, backfill helpfulness for sessions
|
|
191
|
+
// that ended without Stop hook (crash, force-close, etc.)
|
|
192
|
+
if (currentTurn === 1) {
|
|
193
|
+
memoryService.evaluatePendingSessions(input.session_id).catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
|
|
199
196
|
// Store only non-trivial prompts (skip /commands, short inputs)
|
|
200
197
|
if (shouldStorePrompt(input.prompt)) {
|
|
201
198
|
await memoryService.storeUserPrompt(
|
|
@@ -214,32 +211,30 @@ async function main(): Promise<void> {
|
|
|
214
211
|
|
|
215
212
|
// Search strategy: turn-1 always enforce adherence check,
|
|
216
213
|
// then adaptively enforce on write-intent/topic-shift/interval
|
|
217
|
-
|
|
214
|
+
const isSlashCommand = input.prompt.trimStart().startsWith('/');
|
|
215
|
+
if (ENABLE_SEARCH && !isSlashCommand && input.prompt.length > 10 && adherenceDecision.run) {
|
|
218
216
|
const minScore = getDynamicMinScore(input.prompt);
|
|
219
217
|
let mergedMemories: Array<{ type: string; content: string; id?: string; score?: number }> = [];
|
|
220
218
|
|
|
219
|
+
// On turn 2+, enrich the retrieval query with the previous assistant response
|
|
220
|
+
// so short/ambiguous follow-ups ("그거 고쳐줘") resolve correctly.
|
|
221
|
+
const lastSnippet = currentTurn > 1 ? readLastAssistantSnippet(input.session_id) : null;
|
|
222
|
+
const retrievalQuery = lastSnippet
|
|
223
|
+
? `${lastSnippet}\n\n${input.prompt}`
|
|
224
|
+
: input.prompt;
|
|
225
|
+
|
|
221
226
|
const canUseSemantic = RETRIEVAL_MODE === 'semantic' || RETRIEVAL_MODE === 'hybrid';
|
|
222
227
|
if (canUseSemantic) {
|
|
223
228
|
try {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
semanticService.retrieveMemories(input.prompt, {
|
|
227
|
-
topK: MAX_MEMORIES,
|
|
228
|
-
minScore,
|
|
229
|
+
mergedMemories = await retrieveSemanticMemories(
|
|
230
|
+
{
|
|
229
231
|
sessionId: input.session_id,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
232
|
+
prompt: retrievalQuery,
|
|
233
|
+
topK: MAX_MEMORIES,
|
|
234
|
+
minScore
|
|
235
|
+
},
|
|
234
236
|
SEMANTIC_TIMEOUT_MS
|
|
235
237
|
);
|
|
236
|
-
|
|
237
|
-
mergedMemories = semantic.memories.map((m) => ({
|
|
238
|
-
type: m.event.eventType,
|
|
239
|
-
content: m.event.content,
|
|
240
|
-
id: m.event.id,
|
|
241
|
-
score: m.score
|
|
242
|
-
}));
|
|
243
238
|
} catch {
|
|
244
239
|
// Semantic retrieval is best-effort; fallback below handles the rest
|
|
245
240
|
}
|
|
@@ -251,14 +246,14 @@ async function main(): Promise<void> {
|
|
|
251
246
|
mergedMemories.length === 0;
|
|
252
247
|
|
|
253
248
|
if (shouldUseKeywordFallback && mergedMemories.length < MAX_MEMORIES) {
|
|
254
|
-
let results = await memoryService.keywordSearch(
|
|
249
|
+
let results = await memoryService.keywordSearch(retrievalQuery, {
|
|
255
250
|
topK: MAX_MEMORIES,
|
|
256
251
|
minScore
|
|
257
252
|
});
|
|
258
253
|
|
|
259
254
|
// recall rescue: if nothing found at tuned threshold, retry with fallback floor
|
|
260
255
|
if (results.length === 0 && FALLBACK_MIN_SCORE < minScore) {
|
|
261
|
-
results = await memoryService.keywordSearch(
|
|
256
|
+
results = await memoryService.keywordSearch(retrievalQuery, {
|
|
262
257
|
topK: MAX_MEMORIES,
|
|
263
258
|
minScore: FALLBACK_MIN_SCORE
|
|
264
259
|
});
|
|
@@ -299,6 +294,19 @@ async function main(): Promise<void> {
|
|
|
299
294
|
|
|
300
295
|
context = formatMemoryContext(mergedMemories);
|
|
301
296
|
}
|
|
297
|
+
|
|
298
|
+
// Record query-level trace for dashboard stats (retrieval_traces table)
|
|
299
|
+
const allCandidateIds = mergedMemories.map((m) => m.id).filter((v): v is string => Boolean(v));
|
|
300
|
+
try {
|
|
301
|
+
await memoryService.recordQueryTrace({
|
|
302
|
+
sessionId: input.session_id,
|
|
303
|
+
queryText: retrievalQuery,
|
|
304
|
+
strategy: RETRIEVAL_MODE,
|
|
305
|
+
candidateEventIds: allCandidateIds,
|
|
306
|
+
selectedEventIds: allCandidateIds,
|
|
307
|
+
confidence: mergedMemories.length > 0 ? 'medium' : 'none'
|
|
308
|
+
});
|
|
309
|
+
} catch { /* non-critical */ }
|
|
302
310
|
}
|
|
303
311
|
|
|
304
312
|
writeAdherenceState({
|
|
@@ -10,7 +10,6 @@ import * as crypto from 'crypto';
|
|
|
10
10
|
|
|
11
11
|
import { EventStore } from '../core/event-store.js';
|
|
12
12
|
import { SQLiteEventStore } from '../core/sqlite-event-store.js';
|
|
13
|
-
import { SyncWorker } from '../core/sync-worker.js';
|
|
14
13
|
import { VectorStore } from '../core/vector-store.js';
|
|
15
14
|
import { Embedder, getDefaultEmbedder } from '../core/embedder.js';
|
|
16
15
|
import { VectorWorker, createVectorWorker } from '../core/vector-worker.js';
|
|
@@ -61,6 +60,8 @@ export interface MemoryServiceConfig {
|
|
|
61
60
|
analyticsEnabled?: boolean;
|
|
62
61
|
/** Lightweight mode for hooks - skip heavy initialization (default: false) */
|
|
63
62
|
lightweightMode?: boolean;
|
|
63
|
+
/** Start only VectorWorker, skip GraduationWorker and SyncWorker (default: false) */
|
|
64
|
+
embeddingOnly?: boolean;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
// ============================================================
|
|
@@ -180,9 +181,6 @@ export function getSessionProject(sessionId: string): SessionRegistryEntry | nul
|
|
|
180
181
|
export class MemoryService {
|
|
181
182
|
// Primary store: SQLite (WAL mode) - for hooks, always available
|
|
182
183
|
private readonly sqliteStore: SQLiteEventStore;
|
|
183
|
-
// Analytics store: DuckDB - for server reads (optional, synced from SQLite)
|
|
184
|
-
private readonly analyticsStore: EventStore | null;
|
|
185
|
-
private syncWorker: SyncWorker | null = null;
|
|
186
184
|
|
|
187
185
|
private readonly vectorStore: VectorStore;
|
|
188
186
|
private readonly embedder: Embedder;
|
|
@@ -212,6 +210,7 @@ export class MemoryService {
|
|
|
212
210
|
|
|
213
211
|
private readonly readOnly: boolean;
|
|
214
212
|
private readonly lightweightMode: boolean;
|
|
213
|
+
private readonly embeddingOnly: boolean;
|
|
215
214
|
private readonly mdMirror: MarkdownMirror;
|
|
216
215
|
private readonly storagePath: string;
|
|
217
216
|
|
|
@@ -220,6 +219,7 @@ export class MemoryService {
|
|
|
220
219
|
this.storagePath = storagePath;
|
|
221
220
|
this.readOnly = config.readOnly ?? false;
|
|
222
221
|
this.lightweightMode = config.lightweightMode ?? false;
|
|
222
|
+
this.embeddingOnly = config.embeddingOnly ?? false;
|
|
223
223
|
this.mdMirror = new MarkdownMirror(process.cwd());
|
|
224
224
|
|
|
225
225
|
// Ensure storage directory exists (only if not read-only)
|
|
@@ -243,32 +243,6 @@ export class MemoryService {
|
|
|
243
243
|
}
|
|
244
244
|
);
|
|
245
245
|
|
|
246
|
-
// Initialize ANALYTICS store: DuckDB (optional, for server reads)
|
|
247
|
-
// Hooks set analyticsEnabled=false to avoid DuckDB lock conflicts
|
|
248
|
-
const analyticsEnabled = config.analyticsEnabled ?? this.readOnly; // Default: enabled only for read-only (server)
|
|
249
|
-
|
|
250
|
-
if (!analyticsEnabled) {
|
|
251
|
-
// Hook mode: skip DuckDB entirely to avoid lock conflicts
|
|
252
|
-
this.analyticsStore = null;
|
|
253
|
-
} else if (this.readOnly) {
|
|
254
|
-
// Server mode: try to use DuckDB for analytics, will fallback to SQLite
|
|
255
|
-
try {
|
|
256
|
-
this.analyticsStore = new EventStore(
|
|
257
|
-
path.join(storagePath, 'analytics.duckdb'),
|
|
258
|
-
{ readOnly: true }
|
|
259
|
-
);
|
|
260
|
-
} catch {
|
|
261
|
-
// DuckDB not available, will use SQLite for reads
|
|
262
|
-
this.analyticsStore = null;
|
|
263
|
-
}
|
|
264
|
-
} else {
|
|
265
|
-
// Writer mode with analytics: create DuckDB for sync target
|
|
266
|
-
this.analyticsStore = new EventStore(
|
|
267
|
-
path.join(storagePath, 'analytics.duckdb'),
|
|
268
|
-
{ readOnly: false }
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
246
|
this.vectorStore = new VectorStore(path.join(storagePath, 'vectors'));
|
|
273
247
|
const embeddingModel = config.embeddingModel || process.env.CLAUDE_MEMORY_EMBEDDING_MODEL;
|
|
274
248
|
this.embedder = embeddingModel
|
|
@@ -302,16 +276,6 @@ export class MemoryService {
|
|
|
302
276
|
return;
|
|
303
277
|
}
|
|
304
278
|
|
|
305
|
-
// Initialize analytics store if available (DuckDB)
|
|
306
|
-
if (this.analyticsStore) {
|
|
307
|
-
try {
|
|
308
|
-
await this.analyticsStore.initialize();
|
|
309
|
-
} catch (error) {
|
|
310
|
-
console.warn('[MemoryService] Analytics store (DuckDB) initialization failed, using SQLite for reads:', error);
|
|
311
|
-
// Continue without analytics - SQLite will be used for reads
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
279
|
await this.vectorStore.initialize();
|
|
316
280
|
await this.embedder.initialize();
|
|
317
281
|
|
|
@@ -325,24 +289,17 @@ export class MemoryService {
|
|
|
325
289
|
);
|
|
326
290
|
this.vectorWorker.start();
|
|
327
291
|
|
|
328
|
-
|
|
329
|
-
|
|
292
|
+
if (!this.embeddingOnly) {
|
|
293
|
+
// Connect graduation pipeline to retriever for access tracking
|
|
294
|
+
this.retriever.setGraduationPipeline(this.graduation);
|
|
330
295
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
);
|
|
336
|
-
this.graduationWorker.start();
|
|
337
|
-
|
|
338
|
-
// Start sync worker (SQLite -> DuckDB) if analytics store is available
|
|
339
|
-
if (this.analyticsStore) {
|
|
340
|
-
this.syncWorker = new SyncWorker(
|
|
341
|
-
this.sqliteStore,
|
|
342
|
-
this.analyticsStore,
|
|
343
|
-
{ intervalMs: 30000, batchSize: 500 }
|
|
296
|
+
// Start graduation worker for automatic level promotion
|
|
297
|
+
this.graduationWorker = createGraduationWorker(
|
|
298
|
+
this.sqliteStore as unknown as EventStore,
|
|
299
|
+
this.graduation
|
|
344
300
|
);
|
|
345
|
-
this.
|
|
301
|
+
this.graduationWorker.start();
|
|
302
|
+
|
|
346
303
|
}
|
|
347
304
|
|
|
348
305
|
// Load endless mode setting
|
|
@@ -596,6 +553,67 @@ export class MemoryService {
|
|
|
596
553
|
);
|
|
597
554
|
}
|
|
598
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Backfill session summaries for recent sessions that are missing them.
|
|
558
|
+
* Called from session-start hook to catch sessions that ended without Stop hook.
|
|
559
|
+
*/
|
|
560
|
+
async backfillMissingSummaries(currentSessionId: string, limit = 5): Promise<void> {
|
|
561
|
+
await this.initialize();
|
|
562
|
+
|
|
563
|
+
// Get recent sessions that don't have a summary event
|
|
564
|
+
const recentSessionIds = await this.sqliteStore.getSessionsWithoutSummary(currentSessionId, limit);
|
|
565
|
+
for (const sid of recentSessionIds) {
|
|
566
|
+
try {
|
|
567
|
+
await this.generateSessionSummary(sid);
|
|
568
|
+
} catch {
|
|
569
|
+
// non-critical
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Generate a rule-based session summary from stored events.
|
|
576
|
+
* Called at session end (Stop hook) when no LLM-generated summary exists.
|
|
577
|
+
* Skips if a summary already exists for this session.
|
|
578
|
+
*/
|
|
579
|
+
async generateSessionSummary(sessionId: string): Promise<void> {
|
|
580
|
+
await this.initialize();
|
|
581
|
+
|
|
582
|
+
const events = await this.sqliteStore.getSessionEvents(sessionId);
|
|
583
|
+
if (events.length < 3) return; // Too short to summarize
|
|
584
|
+
|
|
585
|
+
// Skip if summary already exists
|
|
586
|
+
const hasSummary = events.some((e) => e.eventType === 'session_summary');
|
|
587
|
+
if (hasSummary) return;
|
|
588
|
+
|
|
589
|
+
const prompts = events.filter((e) => e.eventType === 'user_prompt');
|
|
590
|
+
const toolObs = events.filter((e) => e.eventType === 'tool_observation');
|
|
591
|
+
const toolNames = [...new Set(
|
|
592
|
+
toolObs.map((e) => (e.metadata as Record<string, unknown>)?.toolName as string).filter(Boolean)
|
|
593
|
+
)];
|
|
594
|
+
const errorObs = toolObs.filter((e) => {
|
|
595
|
+
const meta = e.metadata as Record<string, unknown>;
|
|
596
|
+
return meta?.exitCode !== undefined && meta.exitCode !== 0;
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const datePart = events[0].timestamp.toISOString().split('T')[0];
|
|
600
|
+
const parts: string[] = [`[${datePart}] ${prompts.length}턴 세션.`];
|
|
601
|
+
|
|
602
|
+
if (prompts.length > 0) {
|
|
603
|
+
const firstPrompt = prompts[0].content.slice(0, 120).replace(/\n/g, ' ');
|
|
604
|
+
parts.push(`주요 작업: ${firstPrompt}`);
|
|
605
|
+
}
|
|
606
|
+
if (toolNames.length > 0) {
|
|
607
|
+
parts.push(`사용 툴: ${toolNames.slice(0, 6).join(', ')}`);
|
|
608
|
+
}
|
|
609
|
+
if (errorObs.length > 0) {
|
|
610
|
+
parts.push(`오류 ${errorObs.length}건 발생`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const summary = parts.join('. ');
|
|
614
|
+
await this.storeSessionSummary(sessionId, summary, { generated: 'rule-based', eventCount: events.length });
|
|
615
|
+
}
|
|
616
|
+
|
|
599
617
|
/**
|
|
600
618
|
* Store a tool observation
|
|
601
619
|
*/
|
|
@@ -1254,6 +1272,28 @@ export class MemoryService {
|
|
|
1254
1272
|
await this.sqliteStore.recordRetrieval(eventId, sessionId, score, query);
|
|
1255
1273
|
}
|
|
1256
1274
|
|
|
1275
|
+
/**
|
|
1276
|
+
* Record a query-level retrieval trace (used by user-prompt-submit hook).
|
|
1277
|
+
* Feeds the retrieval_traces table that powers dashboard stats.
|
|
1278
|
+
*/
|
|
1279
|
+
async recordQueryTrace(input: {
|
|
1280
|
+
sessionId: string;
|
|
1281
|
+
queryText: string;
|
|
1282
|
+
strategy: string;
|
|
1283
|
+
candidateEventIds: string[];
|
|
1284
|
+
selectedEventIds: string[];
|
|
1285
|
+
confidence: string;
|
|
1286
|
+
}): Promise<void> {
|
|
1287
|
+
await this.initialize();
|
|
1288
|
+
await this.sqliteStore.recordRetrievalTrace({
|
|
1289
|
+
...input,
|
|
1290
|
+
projectHash: this.projectHash || undefined,
|
|
1291
|
+
candidateDetails: [],
|
|
1292
|
+
selectedDetails: [],
|
|
1293
|
+
fallbackTrace: [],
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1257
1297
|
/**
|
|
1258
1298
|
* Evaluate helpfulness of retrievals in a session (called at session end)
|
|
1259
1299
|
*/
|
|
@@ -1262,6 +1302,22 @@ export class MemoryService {
|
|
|
1262
1302
|
await this.sqliteStore.evaluateSessionHelpfulness(sessionId);
|
|
1263
1303
|
}
|
|
1264
1304
|
|
|
1305
|
+
/**
|
|
1306
|
+
* Backfill helpfulness evaluation for sessions that ended without Stop hook.
|
|
1307
|
+
* Call on first turn of a new session to catch missed evaluations.
|
|
1308
|
+
*/
|
|
1309
|
+
async evaluatePendingSessions(currentSessionId: string): Promise<void> {
|
|
1310
|
+
await this.initialize();
|
|
1311
|
+
const sessions = await this.sqliteStore.getUnevaluatedSessions(currentSessionId, 5);
|
|
1312
|
+
for (const sid of sessions) {
|
|
1313
|
+
try {
|
|
1314
|
+
await this.sqliteStore.evaluateSessionHelpfulness(sid);
|
|
1315
|
+
} catch {
|
|
1316
|
+
// non-critical, skip failed
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1265
1321
|
/**
|
|
1266
1322
|
* Get most helpful memories ranked by helpfulness score
|
|
1267
1323
|
*/
|
|
@@ -1609,11 +1665,6 @@ export class MemoryService {
|
|
|
1609
1665
|
this.vectorWorker.stop();
|
|
1610
1666
|
}
|
|
1611
1667
|
|
|
1612
|
-
// Stop sync worker
|
|
1613
|
-
if (this.syncWorker) {
|
|
1614
|
-
this.syncWorker.stop();
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
1668
|
// Close shared store
|
|
1618
1669
|
if (this.sharedEventStore) {
|
|
1619
1670
|
await this.sharedEventStore.close();
|
|
@@ -1622,10 +1673,6 @@ export class MemoryService {
|
|
|
1622
1673
|
// Close primary store (SQLite)
|
|
1623
1674
|
await this.sqliteStore.close();
|
|
1624
1675
|
|
|
1625
|
-
// Close analytics store (DuckDB)
|
|
1626
|
-
if (this.analyticsStore) {
|
|
1627
|
-
await this.analyticsStore.close();
|
|
1628
|
-
}
|
|
1629
1676
|
}
|
|
1630
1677
|
|
|
1631
1678
|
/**
|
|
@@ -59,6 +59,18 @@ export interface ClaudeMessage {
|
|
|
59
59
|
* - 'thinking': Assistant thinking (thinking blocks)
|
|
60
60
|
* - 'skip': Everything else (progress, system, summary, etc.)
|
|
61
61
|
*/
|
|
62
|
+
/**
|
|
63
|
+
* Filter trivial user inputs that aren't worth storing.
|
|
64
|
+
* Mirrors the shouldStorePrompt() logic from user-prompt-submit.ts.
|
|
65
|
+
*/
|
|
66
|
+
function isWorthStoringPrompt(content: string): boolean {
|
|
67
|
+
const trimmed = content.trim();
|
|
68
|
+
if (trimmed.startsWith('/')) return false;
|
|
69
|
+
if (trimmed.length < 15) return false;
|
|
70
|
+
if (!/[a-zA-Z가-힣]{2,}/.test(trimmed)) return false;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
function classifyEntry(entry: ClaudeMessage): 'user_prompt' | 'tool_result' | 'agent_text' | 'tool_use' | 'thinking' | 'skip' {
|
|
63
75
|
if (entry.type !== 'user' && entry.type !== 'assistant') {
|
|
64
76
|
return 'skip';
|
|
@@ -283,6 +295,12 @@ export class SessionHistoryImporter {
|
|
|
283
295
|
const content = this.extractContent(entry);
|
|
284
296
|
if (!content) continue;
|
|
285
297
|
|
|
298
|
+
// Skip trivial inputs: slash commands, very short, no real words
|
|
299
|
+
if (!isWorthStoringPrompt(content)) {
|
|
300
|
+
result.skippedDuplicates++;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
286
304
|
// New turn starts with each real user prompt
|
|
287
305
|
currentTurnId = randomUUID();
|
|
288
306
|
|
package/src/ui/app.js
CHANGED
|
@@ -388,9 +388,20 @@ function updateStatsUI() {
|
|
|
388
388
|
const sharedCount = state.sharedStats ?
|
|
389
389
|
((state.sharedStats.troubleshooting || 0) + (state.sharedStats.bestPractices || 0) + (state.sharedStats.commonErrors || 0)) : 0;
|
|
390
390
|
|
|
391
|
-
document.getElementById('stat-shared').textContent = formatNumber(sharedCount);
|
|
392
391
|
document.getElementById('stat-vectors').textContent = formatNumber(vectorCount);
|
|
393
392
|
|
|
393
|
+
// Retrieval quality stat card
|
|
394
|
+
const rtStats = state.retrievalTraces?.stats;
|
|
395
|
+
const totalQueries = rtStats?.totalQueries || 0;
|
|
396
|
+
const selRate = rtStats ? ((rtStats.selectionRate || 0) * 100).toFixed(0) : null;
|
|
397
|
+
document.getElementById('stat-retrieval-queries').textContent = formatNumber(totalQueries);
|
|
398
|
+
const rateEl = document.getElementById('stat-retrieval-rate');
|
|
399
|
+
if (rateEl) {
|
|
400
|
+
rateEl.textContent = totalQueries > 0 && selRate !== null
|
|
401
|
+
? `${selRate}% selection rate`
|
|
402
|
+
: totalQueries > 0 ? '' : 'no queries yet';
|
|
403
|
+
}
|
|
404
|
+
|
|
394
405
|
const levelCounts = {};
|
|
395
406
|
if (state.stats.levelStats) {
|
|
396
407
|
state.stats.levelStats.forEach(item => { levelCounts[item.level] = item.count; });
|
|
@@ -596,10 +607,46 @@ function updateEventsListUI() {
|
|
|
596
607
|
|
|
597
608
|
// --- Memory Usage ---
|
|
598
609
|
|
|
610
|
+
function updateTopAccessedEventsUI() {
|
|
611
|
+
const container = document.getElementById('top-accessed-events-list');
|
|
612
|
+
if (!container) return;
|
|
613
|
+
|
|
614
|
+
const events = (state.mostAccessed?.events || state.mostAccessed?.memories || []);
|
|
615
|
+
const filtered = events.filter(e => (e.accessCount || 0) > 0).slice(0, 5);
|
|
616
|
+
|
|
617
|
+
if (filtered.length === 0) {
|
|
618
|
+
container.innerHTML = '<div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">No accessed memories yet</div>';
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
container.innerHTML = filtered.map((m, i) => {
|
|
623
|
+
const type = m.eventType || m.type || 'memory';
|
|
624
|
+
const preview = (m.summary || m.preview || m.content || '').replace(/<[^>]*>/g, '').slice(0, 80);
|
|
625
|
+
const lastAccessed = m.lastAccessedAt ? new Date(m.lastAccessedAt).toLocaleDateString() : (m.lastAccessed ? new Date(m.lastAccessed).toLocaleDateString() : '-');
|
|
626
|
+
const id = m.id || m.memoryId || '';
|
|
627
|
+
return `
|
|
628
|
+
<div class="shared-item" style="cursor:pointer;" ${id ? `onclick="openDetailModal('${id}')"` : ''}>
|
|
629
|
+
<div class="shared-info" style="flex-direction:column; align-items:flex-start; gap:2px;">
|
|
630
|
+
<div style="display:flex; gap:6px; align-items:center;">
|
|
631
|
+
<span class="event-type-badge type-${type.replace('_','-')}">${type}</span>
|
|
632
|
+
<span style="font-size:10px; color:var(--text-muted);">last: ${lastAccessed}</span>
|
|
633
|
+
</div>
|
|
634
|
+
<span style="font-size:12px; color:var(--text-secondary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:200px;" title="${escapeHtml(preview)}">${escapeHtml(preview) || '(no preview)'}</span>
|
|
635
|
+
</div>
|
|
636
|
+
<div style="display:flex; flex-direction:column; align-items:flex-end; gap:2px; min-width:40px;">
|
|
637
|
+
<span style="font-size:15px; font-weight:700; color:var(--accent-primary);">${m.accessCount}</span>
|
|
638
|
+
<span style="font-size:10px; color:var(--text-muted);">hits</span>
|
|
639
|
+
</div>
|
|
640
|
+
</div>
|
|
641
|
+
`;
|
|
642
|
+
}).join('');
|
|
643
|
+
}
|
|
644
|
+
|
|
599
645
|
function updateMemoryUsageUI() {
|
|
600
646
|
updateGraduationBars();
|
|
601
647
|
updateHelpfulnessUI();
|
|
602
648
|
updateMostHelpfulList();
|
|
649
|
+
updateTopAccessedEventsUI();
|
|
603
650
|
updateAdherenceSummaryUI();
|
|
604
651
|
updateRetrievalTraceUI();
|
|
605
652
|
}
|
package/src/ui/index.html
CHANGED
|
@@ -110,11 +110,12 @@
|
|
|
110
110
|
<i class="ri-discuss-line"></i> Active Sessions
|
|
111
111
|
</div>
|
|
112
112
|
</div>
|
|
113
|
-
<div class="stat-card" data-stat="
|
|
114
|
-
<div class="stat-value" id="stat-
|
|
113
|
+
<div class="stat-card" data-stat="retrieval">
|
|
114
|
+
<div class="stat-value" id="stat-retrieval-queries">0</div>
|
|
115
115
|
<div class="stat-label">
|
|
116
|
-
<i class="ri-
|
|
116
|
+
<i class="ri-search-eye-line"></i> Retrieval Queries
|
|
117
117
|
</div>
|
|
118
|
+
<div id="stat-retrieval-rate" style="font-size:11px; color:var(--text-muted); margin-top:2px;">-</div>
|
|
118
119
|
</div>
|
|
119
120
|
<div class="stat-card" data-stat="vectors">
|
|
120
121
|
<div class="stat-value" id="stat-vectors">0</div>
|
|
@@ -327,6 +328,13 @@
|
|
|
327
328
|
<div id="adherence-summary" style="padding:8px 0; font-size:13px; color:var(--text-muted);">Loading...</div>
|
|
328
329
|
</div>
|
|
329
330
|
|
|
331
|
+
<div style="margin-top:20px;">
|
|
332
|
+
<div class="section-label">Top Accessed Events</div>
|
|
333
|
+
<div id="top-accessed-events-list" class="shared-list">
|
|
334
|
+
<div style="padding:12px; text-align:center; color:var(--text-muted); font-size:13px;">Loading...</div>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
330
338
|
<div style="margin-top:20px;">
|
|
331
339
|
<div class="section-label">Retrieval Trace (1:1)</div>
|
|
332
340
|
<div id="retrieval-trace-summary" style="padding:8px 0; font-size:13px; color:var(--text-muted);">Loading...</div>
|