claude-memory-layer 1.0.0
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-plugin/commands/memory-forget.md +42 -0
- package/.claude-plugin/commands/memory-history.md +34 -0
- package/.claude-plugin/commands/memory-import.md +56 -0
- package/.claude-plugin/commands/memory-list.md +37 -0
- package/.claude-plugin/commands/memory-search.md +36 -0
- package/.claude-plugin/commands/memory-stats.md +34 -0
- package/.claude-plugin/hooks.json +59 -0
- package/.claude-plugin/plugin.json +24 -0
- package/.history/package_20260201112328.json +45 -0
- package/.history/package_20260201113602.json +45 -0
- package/.history/package_20260201113713.json +45 -0
- package/.history/package_20260201114110.json +45 -0
- package/Memo.txt +558 -0
- package/README.md +520 -0
- package/context.md +636 -0
- package/dist/.claude-plugin/commands/memory-forget.md +42 -0
- package/dist/.claude-plugin/commands/memory-history.md +34 -0
- package/dist/.claude-plugin/commands/memory-import.md +56 -0
- package/dist/.claude-plugin/commands/memory-list.md +37 -0
- package/dist/.claude-plugin/commands/memory-search.md +36 -0
- package/dist/.claude-plugin/commands/memory-stats.md +34 -0
- package/dist/.claude-plugin/hooks.json +59 -0
- package/dist/.claude-plugin/plugin.json +24 -0
- package/dist/cli/index.js +3539 -0
- package/dist/cli/index.js.map +7 -0
- package/dist/core/index.js +4408 -0
- package/dist/core/index.js.map +7 -0
- package/dist/hooks/session-end.js +2971 -0
- package/dist/hooks/session-end.js.map +7 -0
- package/dist/hooks/session-start.js +2969 -0
- package/dist/hooks/session-start.js.map +7 -0
- package/dist/hooks/stop.js +3123 -0
- package/dist/hooks/stop.js.map +7 -0
- package/dist/hooks/user-prompt-submit.js +2960 -0
- package/dist/hooks/user-prompt-submit.js.map +7 -0
- package/dist/services/memory-service.js +2931 -0
- package/dist/services/memory-service.js.map +7 -0
- package/package.json +45 -0
- package/plan.md +1642 -0
- package/scripts/build.ts +102 -0
- package/spec.md +624 -0
- package/specs/citations-system/context.md +243 -0
- package/specs/citations-system/plan.md +495 -0
- package/specs/citations-system/spec.md +371 -0
- package/specs/endless-mode/context.md +305 -0
- package/specs/endless-mode/plan.md +620 -0
- package/specs/endless-mode/spec.md +455 -0
- package/specs/entity-edge-model/context.md +401 -0
- package/specs/entity-edge-model/plan.md +459 -0
- package/specs/entity-edge-model/spec.md +391 -0
- package/specs/evidence-aligner-v2/context.md +401 -0
- package/specs/evidence-aligner-v2/plan.md +303 -0
- package/specs/evidence-aligner-v2/spec.md +312 -0
- package/specs/mcp-desktop-integration/context.md +278 -0
- package/specs/mcp-desktop-integration/plan.md +550 -0
- package/specs/mcp-desktop-integration/spec.md +494 -0
- package/specs/post-tool-use-hook/context.md +319 -0
- package/specs/post-tool-use-hook/plan.md +469 -0
- package/specs/post-tool-use-hook/spec.md +364 -0
- package/specs/private-tags/context.md +288 -0
- package/specs/private-tags/plan.md +412 -0
- package/specs/private-tags/spec.md +345 -0
- package/specs/progressive-disclosure/context.md +346 -0
- package/specs/progressive-disclosure/plan.md +663 -0
- package/specs/progressive-disclosure/spec.md +415 -0
- package/specs/task-entity-system/context.md +297 -0
- package/specs/task-entity-system/plan.md +301 -0
- package/specs/task-entity-system/spec.md +314 -0
- package/specs/vector-outbox-v2/context.md +470 -0
- package/specs/vector-outbox-v2/plan.md +562 -0
- package/specs/vector-outbox-v2/spec.md +466 -0
- package/specs/web-viewer-ui/context.md +384 -0
- package/specs/web-viewer-ui/plan.md +797 -0
- package/specs/web-viewer-ui/spec.md +516 -0
- package/src/cli/index.ts +570 -0
- package/src/core/canonical-key.ts +186 -0
- package/src/core/citation-generator.ts +63 -0
- package/src/core/consolidated-store.ts +279 -0
- package/src/core/consolidation-worker.ts +384 -0
- package/src/core/context-formatter.ts +276 -0
- package/src/core/continuity-manager.ts +336 -0
- package/src/core/edge-repo.ts +324 -0
- package/src/core/embedder.ts +124 -0
- package/src/core/entity-repo.ts +342 -0
- package/src/core/event-store.ts +672 -0
- package/src/core/evidence-aligner.ts +635 -0
- package/src/core/graduation.ts +365 -0
- package/src/core/index.ts +32 -0
- package/src/core/matcher.ts +210 -0
- package/src/core/metadata-extractor.ts +203 -0
- package/src/core/privacy/filter.ts +179 -0
- package/src/core/privacy/index.ts +20 -0
- package/src/core/privacy/tag-parser.ts +145 -0
- package/src/core/progressive-retriever.ts +415 -0
- package/src/core/retriever.ts +235 -0
- package/src/core/task/blocker-resolver.ts +325 -0
- package/src/core/task/index.ts +9 -0
- package/src/core/task/task-matcher.ts +238 -0
- package/src/core/task/task-projector.ts +345 -0
- package/src/core/task/task-resolver.ts +414 -0
- package/src/core/types.ts +841 -0
- package/src/core/vector-outbox.ts +295 -0
- package/src/core/vector-store.ts +182 -0
- package/src/core/vector-worker.ts +488 -0
- package/src/core/working-set-store.ts +244 -0
- package/src/hooks/post-tool-use.ts +127 -0
- package/src/hooks/session-end.ts +78 -0
- package/src/hooks/session-start.ts +57 -0
- package/src/hooks/stop.ts +78 -0
- package/src/hooks/user-prompt-submit.ts +54 -0
- package/src/mcp/handlers.ts +212 -0
- package/src/mcp/index.ts +47 -0
- package/src/mcp/tools.ts +78 -0
- package/src/server/api/citations.ts +101 -0
- package/src/server/api/events.ts +101 -0
- package/src/server/api/index.ts +18 -0
- package/src/server/api/search.ts +98 -0
- package/src/server/api/sessions.ts +111 -0
- package/src/server/api/stats.ts +97 -0
- package/src/server/index.ts +91 -0
- package/src/services/memory-service.ts +626 -0
- package/src/services/session-history-importer.ts +367 -0
- package/tests/canonical-key.test.ts +101 -0
- package/tests/evidence-aligner.test.ts +152 -0
- package/tests/matcher.test.ts +112 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session History Importer
|
|
3
|
+
* Imports existing Claude Code conversation history into memory
|
|
4
|
+
*
|
|
5
|
+
* Claude Code stores session history in:
|
|
6
|
+
* ~/.claude/projects/<project-hash>/<session-id>.jsonl
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import * as os from 'os';
|
|
12
|
+
import * as readline from 'readline';
|
|
13
|
+
import { MemoryService } from './memory-service.js';
|
|
14
|
+
|
|
15
|
+
export interface ImportOptions {
|
|
16
|
+
projectPath?: string;
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
limit?: number;
|
|
19
|
+
skipExisting?: boolean;
|
|
20
|
+
verbose?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ImportResult {
|
|
24
|
+
totalSessions: number;
|
|
25
|
+
totalMessages: number;
|
|
26
|
+
importedPrompts: number;
|
|
27
|
+
importedResponses: number;
|
|
28
|
+
skippedDuplicates: number;
|
|
29
|
+
errors: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ClaudeMessage {
|
|
33
|
+
type: string;
|
|
34
|
+
message?: {
|
|
35
|
+
role: string;
|
|
36
|
+
content: string | Array<{ type: string; text?: string }>;
|
|
37
|
+
};
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
timestamp?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class SessionHistoryImporter {
|
|
43
|
+
private readonly memoryService: MemoryService;
|
|
44
|
+
private readonly claudeDir: string;
|
|
45
|
+
|
|
46
|
+
constructor(memoryService: MemoryService) {
|
|
47
|
+
this.memoryService = memoryService;
|
|
48
|
+
this.claudeDir = path.join(os.homedir(), '.claude');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Import all sessions from a project
|
|
53
|
+
*/
|
|
54
|
+
async importProject(projectPath: string, options: ImportOptions = {}): Promise<ImportResult> {
|
|
55
|
+
const result: ImportResult = {
|
|
56
|
+
totalSessions: 0,
|
|
57
|
+
totalMessages: 0,
|
|
58
|
+
importedPrompts: 0,
|
|
59
|
+
importedResponses: 0,
|
|
60
|
+
skippedDuplicates: 0,
|
|
61
|
+
errors: []
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Find project directory
|
|
65
|
+
const projectDir = await this.findProjectDir(projectPath);
|
|
66
|
+
if (!projectDir) {
|
|
67
|
+
result.errors.push(`Project directory not found for: ${projectPath}`);
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Find all session files
|
|
72
|
+
const sessionFiles = await this.findSessionFiles(projectDir);
|
|
73
|
+
result.totalSessions = sessionFiles.length;
|
|
74
|
+
|
|
75
|
+
if (options.verbose) {
|
|
76
|
+
console.log(`Found ${sessionFiles.length} session files in ${projectDir}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Import each session
|
|
80
|
+
for (const sessionFile of sessionFiles) {
|
|
81
|
+
try {
|
|
82
|
+
const sessionResult = await this.importSessionFile(sessionFile, options);
|
|
83
|
+
result.totalMessages += sessionResult.totalMessages;
|
|
84
|
+
result.importedPrompts += sessionResult.importedPrompts;
|
|
85
|
+
result.importedResponses += sessionResult.importedResponses;
|
|
86
|
+
result.skippedDuplicates += sessionResult.skippedDuplicates;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
result.errors.push(`Failed to import ${sessionFile}: ${error}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Import a specific session file
|
|
97
|
+
*/
|
|
98
|
+
async importSessionFile(filePath: string, options: ImportOptions = {}): Promise<ImportResult> {
|
|
99
|
+
const result: ImportResult = {
|
|
100
|
+
totalSessions: 1,
|
|
101
|
+
totalMessages: 0,
|
|
102
|
+
importedPrompts: 0,
|
|
103
|
+
importedResponses: 0,
|
|
104
|
+
skippedDuplicates: 0,
|
|
105
|
+
errors: []
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (!fs.existsSync(filePath)) {
|
|
109
|
+
result.errors.push(`File not found: ${filePath}`);
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Extract session ID from filename
|
|
114
|
+
const sessionId = path.basename(filePath, '.jsonl');
|
|
115
|
+
|
|
116
|
+
// Start session in memory
|
|
117
|
+
await this.memoryService.startSession(sessionId, options.projectPath);
|
|
118
|
+
|
|
119
|
+
// Read and parse JSONL file
|
|
120
|
+
const fileStream = fs.createReadStream(filePath);
|
|
121
|
+
const rl = readline.createInterface({
|
|
122
|
+
input: fileStream,
|
|
123
|
+
crlfDelay: Infinity
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
let lineCount = 0;
|
|
127
|
+
const limit = options.limit || Infinity;
|
|
128
|
+
|
|
129
|
+
for await (const line of rl) {
|
|
130
|
+
if (lineCount >= limit) break;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const entry = JSON.parse(line) as ClaudeMessage;
|
|
134
|
+
result.totalMessages++;
|
|
135
|
+
|
|
136
|
+
// Process message entries
|
|
137
|
+
if (entry.type === 'user' || entry.type === 'assistant') {
|
|
138
|
+
const content = this.extractContent(entry);
|
|
139
|
+
if (!content) continue;
|
|
140
|
+
|
|
141
|
+
if (entry.type === 'user') {
|
|
142
|
+
const appendResult = await this.memoryService.storeUserPrompt(
|
|
143
|
+
sessionId,
|
|
144
|
+
content,
|
|
145
|
+
{ importedFrom: filePath, originalTimestamp: entry.timestamp }
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (appendResult.isDuplicate) {
|
|
149
|
+
result.skippedDuplicates++;
|
|
150
|
+
} else {
|
|
151
|
+
result.importedPrompts++;
|
|
152
|
+
}
|
|
153
|
+
} else if (entry.type === 'assistant') {
|
|
154
|
+
// Truncate very long responses
|
|
155
|
+
const truncatedContent = content.length > 5000
|
|
156
|
+
? content.slice(0, 5000) + '...[truncated]'
|
|
157
|
+
: content;
|
|
158
|
+
|
|
159
|
+
const appendResult = await this.memoryService.storeAgentResponse(
|
|
160
|
+
sessionId,
|
|
161
|
+
truncatedContent,
|
|
162
|
+
{ importedFrom: filePath, originalTimestamp: entry.timestamp }
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (appendResult.isDuplicate) {
|
|
166
|
+
result.skippedDuplicates++;
|
|
167
|
+
} else {
|
|
168
|
+
result.importedResponses++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
lineCount++;
|
|
173
|
+
}
|
|
174
|
+
} catch (parseError) {
|
|
175
|
+
// Skip malformed lines
|
|
176
|
+
result.errors.push(`Parse error on line: ${parseError}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// End session
|
|
181
|
+
await this.memoryService.endSession(sessionId);
|
|
182
|
+
|
|
183
|
+
if (options.verbose) {
|
|
184
|
+
console.log(`Imported ${result.importedPrompts} prompts, ${result.importedResponses} responses from ${filePath}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Import all sessions from all projects
|
|
192
|
+
*/
|
|
193
|
+
async importAll(options: ImportOptions = {}): Promise<ImportResult> {
|
|
194
|
+
const result: ImportResult = {
|
|
195
|
+
totalSessions: 0,
|
|
196
|
+
totalMessages: 0,
|
|
197
|
+
importedPrompts: 0,
|
|
198
|
+
importedResponses: 0,
|
|
199
|
+
skippedDuplicates: 0,
|
|
200
|
+
errors: []
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const projectsDir = path.join(this.claudeDir, 'projects');
|
|
204
|
+
if (!fs.existsSync(projectsDir)) {
|
|
205
|
+
result.errors.push(`Projects directory not found: ${projectsDir}`);
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Find all project directories
|
|
210
|
+
const projectDirs = fs.readdirSync(projectsDir)
|
|
211
|
+
.map(name => path.join(projectsDir, name))
|
|
212
|
+
.filter(p => fs.statSync(p).isDirectory());
|
|
213
|
+
|
|
214
|
+
if (options.verbose) {
|
|
215
|
+
console.log(`Found ${projectDirs.length} project directories`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const projectDir of projectDirs) {
|
|
219
|
+
try {
|
|
220
|
+
const sessionFiles = await this.findSessionFiles(projectDir);
|
|
221
|
+
|
|
222
|
+
for (const sessionFile of sessionFiles) {
|
|
223
|
+
const sessionResult = await this.importSessionFile(sessionFile, options);
|
|
224
|
+
result.totalSessions++;
|
|
225
|
+
result.totalMessages += sessionResult.totalMessages;
|
|
226
|
+
result.importedPrompts += sessionResult.importedPrompts;
|
|
227
|
+
result.importedResponses += sessionResult.importedResponses;
|
|
228
|
+
result.skippedDuplicates += sessionResult.skippedDuplicates;
|
|
229
|
+
result.errors.push(...sessionResult.errors);
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
result.errors.push(`Failed to process ${projectDir}: ${error}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Find project directory from project path
|
|
241
|
+
*/
|
|
242
|
+
private async findProjectDir(projectPath: string): Promise<string | null> {
|
|
243
|
+
const projectsDir = path.join(this.claudeDir, 'projects');
|
|
244
|
+
if (!fs.existsSync(projectsDir)) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Claude uses a hash of the project path as directory name
|
|
249
|
+
// Try to find matching directory by checking all projects
|
|
250
|
+
const projectDirs = fs.readdirSync(projectsDir)
|
|
251
|
+
.map(name => path.join(projectsDir, name))
|
|
252
|
+
.filter(p => fs.statSync(p).isDirectory());
|
|
253
|
+
|
|
254
|
+
// Look for directory that matches the project path pattern
|
|
255
|
+
// The directory name format is: -home-user-project-name
|
|
256
|
+
const normalizedPath = projectPath.replace(/\//g, '-').replace(/^-/, '');
|
|
257
|
+
|
|
258
|
+
for (const dir of projectDirs) {
|
|
259
|
+
const dirName = path.basename(dir);
|
|
260
|
+
if (dirName.includes(normalizedPath) || normalizedPath.includes(dirName)) {
|
|
261
|
+
return dir;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// If exact match not found, return first match or null
|
|
266
|
+
return projectDirs.length > 0 ? projectDirs[0] : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Find all JSONL session files in a directory
|
|
271
|
+
*/
|
|
272
|
+
private async findSessionFiles(dir: string): Promise<string[]> {
|
|
273
|
+
if (!fs.existsSync(dir)) {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return fs.readdirSync(dir)
|
|
278
|
+
.filter(name => name.endsWith('.jsonl'))
|
|
279
|
+
.map(name => path.join(dir, name))
|
|
280
|
+
.filter(p => fs.statSync(p).isFile());
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Extract text content from Claude message
|
|
285
|
+
*/
|
|
286
|
+
private extractContent(entry: ClaudeMessage): string | null {
|
|
287
|
+
if (!entry.message?.content) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const content = entry.message.content;
|
|
292
|
+
|
|
293
|
+
if (typeof content === 'string') {
|
|
294
|
+
return content;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (Array.isArray(content)) {
|
|
298
|
+
// Extract text from content blocks
|
|
299
|
+
const texts = content
|
|
300
|
+
.filter(block => block.type === 'text' && block.text)
|
|
301
|
+
.map(block => block.text as string);
|
|
302
|
+
|
|
303
|
+
return texts.join('\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* List available sessions for import
|
|
311
|
+
*/
|
|
312
|
+
async listAvailableSessions(projectPath?: string): Promise<Array<{
|
|
313
|
+
sessionId: string;
|
|
314
|
+
filePath: string;
|
|
315
|
+
size: number;
|
|
316
|
+
modifiedAt: Date;
|
|
317
|
+
}>> {
|
|
318
|
+
const sessions: Array<{
|
|
319
|
+
sessionId: string;
|
|
320
|
+
filePath: string;
|
|
321
|
+
size: number;
|
|
322
|
+
modifiedAt: Date;
|
|
323
|
+
}> = [];
|
|
324
|
+
|
|
325
|
+
let projectDirs: string[] = [];
|
|
326
|
+
|
|
327
|
+
if (projectPath) {
|
|
328
|
+
const projectDir = await this.findProjectDir(projectPath);
|
|
329
|
+
if (projectDir) {
|
|
330
|
+
projectDirs = [projectDir];
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
const projectsDir = path.join(this.claudeDir, 'projects');
|
|
334
|
+
if (fs.existsSync(projectsDir)) {
|
|
335
|
+
projectDirs = fs.readdirSync(projectsDir)
|
|
336
|
+
.map(name => path.join(projectsDir, name))
|
|
337
|
+
.filter(p => fs.statSync(p).isDirectory());
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const projectDir of projectDirs) {
|
|
342
|
+
const sessionFiles = await this.findSessionFiles(projectDir);
|
|
343
|
+
|
|
344
|
+
for (const filePath of sessionFiles) {
|
|
345
|
+
const stats = fs.statSync(filePath);
|
|
346
|
+
sessions.push({
|
|
347
|
+
sessionId: path.basename(filePath, '.jsonl'),
|
|
348
|
+
filePath,
|
|
349
|
+
size: stats.size,
|
|
350
|
+
modifiedAt: stats.mtime
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Sort by modified date (newest first)
|
|
356
|
+
sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
|
|
357
|
+
|
|
358
|
+
return sessions;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Create importer with default memory service
|
|
364
|
+
*/
|
|
365
|
+
export function createSessionHistoryImporter(memoryService: MemoryService): SessionHistoryImporter {
|
|
366
|
+
return new SessionHistoryImporter(memoryService);
|
|
367
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for canonical key functions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
makeCanonicalKey,
|
|
8
|
+
isSameCanonicalKey,
|
|
9
|
+
makeDedupeKey,
|
|
10
|
+
hashContent
|
|
11
|
+
} from '../src/core/canonical-key.js';
|
|
12
|
+
|
|
13
|
+
describe('makeCanonicalKey', () => {
|
|
14
|
+
it('should normalize to lowercase', () => {
|
|
15
|
+
expect(makeCanonicalKey('Hello World')).toBe('hello world');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should remove punctuation', () => {
|
|
19
|
+
expect(makeCanonicalKey('Hello, World!')).toBe('hello world');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should normalize unicode (NFKC)', () => {
|
|
23
|
+
// Full-width characters should be normalized
|
|
24
|
+
expect(makeCanonicalKey('Hello')).toBe('hello');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should collapse whitespace', () => {
|
|
28
|
+
expect(makeCanonicalKey('hello world')).toBe('hello world');
|
|
29
|
+
expect(makeCanonicalKey('hello\n\nworld')).toBe('hello world');
|
|
30
|
+
expect(makeCanonicalKey(' hello world ')).toBe('hello world');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should add project context when provided', () => {
|
|
34
|
+
const key = makeCanonicalKey('test', { project: 'myproject' });
|
|
35
|
+
expect(key).toBe('myproject::test');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should truncate long keys with MD5 suffix', () => {
|
|
39
|
+
const longTitle = 'a'.repeat(300);
|
|
40
|
+
const key = makeCanonicalKey(longTitle);
|
|
41
|
+
expect(key.length).toBeLessThanOrEqual(200);
|
|
42
|
+
expect(key).toMatch(/_[a-f0-9]{8}$/);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('isSameCanonicalKey', () => {
|
|
47
|
+
it('should return true for equivalent strings', () => {
|
|
48
|
+
expect(isSameCanonicalKey('Hello World', 'hello world')).toBe(true);
|
|
49
|
+
expect(isSameCanonicalKey('Hello, World!', 'hello world')).toBe(true);
|
|
50
|
+
expect(isSameCanonicalKey(' hello world ', 'hello world')).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should return false for different strings', () => {
|
|
54
|
+
expect(isSameCanonicalKey('hello', 'world')).toBe(false);
|
|
55
|
+
expect(isSameCanonicalKey('hello world', 'world hello')).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('makeDedupeKey', () => {
|
|
60
|
+
it('should create unique keys for different content', () => {
|
|
61
|
+
const key1 = makeDedupeKey('content1', 'session1');
|
|
62
|
+
const key2 = makeDedupeKey('content2', 'session1');
|
|
63
|
+
expect(key1).not.toBe(key2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should create unique keys for different sessions', () => {
|
|
67
|
+
const key1 = makeDedupeKey('content', 'session1');
|
|
68
|
+
const key2 = makeDedupeKey('content', 'session2');
|
|
69
|
+
expect(key1).not.toBe(key2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should create same key for same content and session', () => {
|
|
73
|
+
const key1 = makeDedupeKey('content', 'session');
|
|
74
|
+
const key2 = makeDedupeKey('content', 'session');
|
|
75
|
+
expect(key1).toBe(key2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should include session ID prefix', () => {
|
|
79
|
+
const key = makeDedupeKey('content', 'session123');
|
|
80
|
+
expect(key.startsWith('session123:')).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('hashContent', () => {
|
|
85
|
+
it('should return consistent hash', () => {
|
|
86
|
+
const hash1 = hashContent('test content');
|
|
87
|
+
const hash2 = hashContent('test content');
|
|
88
|
+
expect(hash1).toBe(hash2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should return different hash for different content', () => {
|
|
92
|
+
const hash1 = hashContent('content1');
|
|
93
|
+
const hash2 = hashContent('content2');
|
|
94
|
+
expect(hash1).not.toBe(hash2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should return 64-character hex string (SHA-256)', () => {
|
|
98
|
+
const hash = hashContent('test');
|
|
99
|
+
expect(hash).toMatch(/^[a-f0-9]{64}$/);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Evidence Aligner
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { EvidenceAligner } from '../src/core/evidence-aligner.js';
|
|
7
|
+
|
|
8
|
+
describe('EvidenceAligner', () => {
|
|
9
|
+
const aligner = new EvidenceAligner();
|
|
10
|
+
|
|
11
|
+
describe('align', () => {
|
|
12
|
+
it('should find exact matches', () => {
|
|
13
|
+
const claims = ['the quick brown fox'];
|
|
14
|
+
const source = 'The quick brown fox jumps over the lazy dog.';
|
|
15
|
+
|
|
16
|
+
const result = aligner.align(claims, source);
|
|
17
|
+
|
|
18
|
+
expect(result.isAligned).toBe(true);
|
|
19
|
+
expect(result.spans.length).toBe(1);
|
|
20
|
+
expect(result.spans[0].matchType).toBe('exact');
|
|
21
|
+
expect(result.spans[0].confidence).toBe(1.0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should find fuzzy matches', () => {
|
|
25
|
+
const claims = ['quick brown fox jumping'];
|
|
26
|
+
const source = 'The quick brown fox jumps over the lazy dog.';
|
|
27
|
+
|
|
28
|
+
const result = aligner.align(claims, source);
|
|
29
|
+
|
|
30
|
+
// May or may not find fuzzy match depending on threshold
|
|
31
|
+
expect(result.confidence).toBeGreaterThanOrEqual(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should report missing claims', () => {
|
|
35
|
+
const claims = ['completely unrelated content'];
|
|
36
|
+
const source = 'The quick brown fox jumps over the lazy dog.';
|
|
37
|
+
|
|
38
|
+
const result = aligner.align(claims, source);
|
|
39
|
+
|
|
40
|
+
expect(result.missingClaims.length).toBe(1);
|
|
41
|
+
expect(result.missingClaims[0]).toBe('completely unrelated content');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should skip short claims', () => {
|
|
45
|
+
const claims = ['short'];
|
|
46
|
+
const source = 'This is a short test.';
|
|
47
|
+
|
|
48
|
+
const result = aligner.align(claims, source);
|
|
49
|
+
|
|
50
|
+
// Short claims are skipped
|
|
51
|
+
expect(result.spans.length).toBe(0);
|
|
52
|
+
expect(result.missingClaims.length).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should calculate correct confidence', () => {
|
|
56
|
+
const claims = [
|
|
57
|
+
'the quick brown fox',
|
|
58
|
+
'jumps over the lazy dog'
|
|
59
|
+
];
|
|
60
|
+
const source = 'The quick brown fox jumps over the lazy dog.';
|
|
61
|
+
|
|
62
|
+
const result = aligner.align(claims, source);
|
|
63
|
+
|
|
64
|
+
expect(result.confidence).toBeGreaterThan(0.5);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('extractClaims', () => {
|
|
69
|
+
it('should split text into sentences', () => {
|
|
70
|
+
const text = 'First sentence. Second sentence. Third sentence.';
|
|
71
|
+
const claims = aligner.extractClaims(text);
|
|
72
|
+
|
|
73
|
+
expect(claims.length).toBe(3);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should filter out questions', () => {
|
|
77
|
+
const text = 'This is a statement. Is this a question?';
|
|
78
|
+
const claims = aligner.extractClaims(text);
|
|
79
|
+
|
|
80
|
+
expect(claims.length).toBe(1);
|
|
81
|
+
expect(claims[0]).not.toContain('?');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should filter out short sentences', () => {
|
|
85
|
+
const text = 'Hi. This is a longer sentence that should be included.';
|
|
86
|
+
const claims = aligner.extractClaims(text);
|
|
87
|
+
|
|
88
|
+
// "Hi" is too short
|
|
89
|
+
expect(claims.some(c => c === 'Hi')).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('verifyGrounding', () => {
|
|
94
|
+
it('should verify response is grounded in context', () => {
|
|
95
|
+
const response = 'The fox is quick and brown.';
|
|
96
|
+
const context = [
|
|
97
|
+
'The quick brown fox jumps over the lazy dog.',
|
|
98
|
+
'Foxes are known for their speed.'
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const result = aligner.verifyGrounding(response, context);
|
|
102
|
+
|
|
103
|
+
expect(result.isAligned).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should detect ungrounded responses', () => {
|
|
107
|
+
const response = 'Elephants are the largest land animals.';
|
|
108
|
+
const context = [
|
|
109
|
+
'The quick brown fox jumps over the lazy dog.'
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const result = aligner.verifyGrounding(response, context);
|
|
113
|
+
|
|
114
|
+
expect(result.missingClaims.length).toBeGreaterThan(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('custom options', () => {
|
|
119
|
+
it('should use custom fuzzy threshold', () => {
|
|
120
|
+
const strictAligner = new EvidenceAligner({
|
|
121
|
+
fuzzyThreshold: 0.95
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const claims = ['quick brown foxes'];
|
|
125
|
+
const source = 'The quick brown fox jumps.';
|
|
126
|
+
|
|
127
|
+
const result = strictAligner.align(claims, source);
|
|
128
|
+
|
|
129
|
+
// Strict threshold should result in no match
|
|
130
|
+
expect(result.spans.length).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should use custom max missing claims', () => {
|
|
134
|
+
const tolerantAligner = new EvidenceAligner({
|
|
135
|
+
maxMissingClaims: 5
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const claims = [
|
|
139
|
+
'claim one that exists',
|
|
140
|
+
'claim two missing',
|
|
141
|
+
'claim three missing',
|
|
142
|
+
'claim four missing'
|
|
143
|
+
];
|
|
144
|
+
const source = 'This source contains claim one that exists.';
|
|
145
|
+
|
|
146
|
+
const result = tolerantAligner.align(claims, source);
|
|
147
|
+
|
|
148
|
+
// Should still be aligned with 3 missing claims (< 5)
|
|
149
|
+
expect(result.isAligned).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|