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.
Files changed (127) hide show
  1. package/.claude-plugin/commands/memory-forget.md +42 -0
  2. package/.claude-plugin/commands/memory-history.md +34 -0
  3. package/.claude-plugin/commands/memory-import.md +56 -0
  4. package/.claude-plugin/commands/memory-list.md +37 -0
  5. package/.claude-plugin/commands/memory-search.md +36 -0
  6. package/.claude-plugin/commands/memory-stats.md +34 -0
  7. package/.claude-plugin/hooks.json +59 -0
  8. package/.claude-plugin/plugin.json +24 -0
  9. package/.history/package_20260201112328.json +45 -0
  10. package/.history/package_20260201113602.json +45 -0
  11. package/.history/package_20260201113713.json +45 -0
  12. package/.history/package_20260201114110.json +45 -0
  13. package/Memo.txt +558 -0
  14. package/README.md +520 -0
  15. package/context.md +636 -0
  16. package/dist/.claude-plugin/commands/memory-forget.md +42 -0
  17. package/dist/.claude-plugin/commands/memory-history.md +34 -0
  18. package/dist/.claude-plugin/commands/memory-import.md +56 -0
  19. package/dist/.claude-plugin/commands/memory-list.md +37 -0
  20. package/dist/.claude-plugin/commands/memory-search.md +36 -0
  21. package/dist/.claude-plugin/commands/memory-stats.md +34 -0
  22. package/dist/.claude-plugin/hooks.json +59 -0
  23. package/dist/.claude-plugin/plugin.json +24 -0
  24. package/dist/cli/index.js +3539 -0
  25. package/dist/cli/index.js.map +7 -0
  26. package/dist/core/index.js +4408 -0
  27. package/dist/core/index.js.map +7 -0
  28. package/dist/hooks/session-end.js +2971 -0
  29. package/dist/hooks/session-end.js.map +7 -0
  30. package/dist/hooks/session-start.js +2969 -0
  31. package/dist/hooks/session-start.js.map +7 -0
  32. package/dist/hooks/stop.js +3123 -0
  33. package/dist/hooks/stop.js.map +7 -0
  34. package/dist/hooks/user-prompt-submit.js +2960 -0
  35. package/dist/hooks/user-prompt-submit.js.map +7 -0
  36. package/dist/services/memory-service.js +2931 -0
  37. package/dist/services/memory-service.js.map +7 -0
  38. package/package.json +45 -0
  39. package/plan.md +1642 -0
  40. package/scripts/build.ts +102 -0
  41. package/spec.md +624 -0
  42. package/specs/citations-system/context.md +243 -0
  43. package/specs/citations-system/plan.md +495 -0
  44. package/specs/citations-system/spec.md +371 -0
  45. package/specs/endless-mode/context.md +305 -0
  46. package/specs/endless-mode/plan.md +620 -0
  47. package/specs/endless-mode/spec.md +455 -0
  48. package/specs/entity-edge-model/context.md +401 -0
  49. package/specs/entity-edge-model/plan.md +459 -0
  50. package/specs/entity-edge-model/spec.md +391 -0
  51. package/specs/evidence-aligner-v2/context.md +401 -0
  52. package/specs/evidence-aligner-v2/plan.md +303 -0
  53. package/specs/evidence-aligner-v2/spec.md +312 -0
  54. package/specs/mcp-desktop-integration/context.md +278 -0
  55. package/specs/mcp-desktop-integration/plan.md +550 -0
  56. package/specs/mcp-desktop-integration/spec.md +494 -0
  57. package/specs/post-tool-use-hook/context.md +319 -0
  58. package/specs/post-tool-use-hook/plan.md +469 -0
  59. package/specs/post-tool-use-hook/spec.md +364 -0
  60. package/specs/private-tags/context.md +288 -0
  61. package/specs/private-tags/plan.md +412 -0
  62. package/specs/private-tags/spec.md +345 -0
  63. package/specs/progressive-disclosure/context.md +346 -0
  64. package/specs/progressive-disclosure/plan.md +663 -0
  65. package/specs/progressive-disclosure/spec.md +415 -0
  66. package/specs/task-entity-system/context.md +297 -0
  67. package/specs/task-entity-system/plan.md +301 -0
  68. package/specs/task-entity-system/spec.md +314 -0
  69. package/specs/vector-outbox-v2/context.md +470 -0
  70. package/specs/vector-outbox-v2/plan.md +562 -0
  71. package/specs/vector-outbox-v2/spec.md +466 -0
  72. package/specs/web-viewer-ui/context.md +384 -0
  73. package/specs/web-viewer-ui/plan.md +797 -0
  74. package/specs/web-viewer-ui/spec.md +516 -0
  75. package/src/cli/index.ts +570 -0
  76. package/src/core/canonical-key.ts +186 -0
  77. package/src/core/citation-generator.ts +63 -0
  78. package/src/core/consolidated-store.ts +279 -0
  79. package/src/core/consolidation-worker.ts +384 -0
  80. package/src/core/context-formatter.ts +276 -0
  81. package/src/core/continuity-manager.ts +336 -0
  82. package/src/core/edge-repo.ts +324 -0
  83. package/src/core/embedder.ts +124 -0
  84. package/src/core/entity-repo.ts +342 -0
  85. package/src/core/event-store.ts +672 -0
  86. package/src/core/evidence-aligner.ts +635 -0
  87. package/src/core/graduation.ts +365 -0
  88. package/src/core/index.ts +32 -0
  89. package/src/core/matcher.ts +210 -0
  90. package/src/core/metadata-extractor.ts +203 -0
  91. package/src/core/privacy/filter.ts +179 -0
  92. package/src/core/privacy/index.ts +20 -0
  93. package/src/core/privacy/tag-parser.ts +145 -0
  94. package/src/core/progressive-retriever.ts +415 -0
  95. package/src/core/retriever.ts +235 -0
  96. package/src/core/task/blocker-resolver.ts +325 -0
  97. package/src/core/task/index.ts +9 -0
  98. package/src/core/task/task-matcher.ts +238 -0
  99. package/src/core/task/task-projector.ts +345 -0
  100. package/src/core/task/task-resolver.ts +414 -0
  101. package/src/core/types.ts +841 -0
  102. package/src/core/vector-outbox.ts +295 -0
  103. package/src/core/vector-store.ts +182 -0
  104. package/src/core/vector-worker.ts +488 -0
  105. package/src/core/working-set-store.ts +244 -0
  106. package/src/hooks/post-tool-use.ts +127 -0
  107. package/src/hooks/session-end.ts +78 -0
  108. package/src/hooks/session-start.ts +57 -0
  109. package/src/hooks/stop.ts +78 -0
  110. package/src/hooks/user-prompt-submit.ts +54 -0
  111. package/src/mcp/handlers.ts +212 -0
  112. package/src/mcp/index.ts +47 -0
  113. package/src/mcp/tools.ts +78 -0
  114. package/src/server/api/citations.ts +101 -0
  115. package/src/server/api/events.ts +101 -0
  116. package/src/server/api/index.ts +18 -0
  117. package/src/server/api/search.ts +98 -0
  118. package/src/server/api/sessions.ts +111 -0
  119. package/src/server/api/stats.ts +97 -0
  120. package/src/server/index.ts +91 -0
  121. package/src/services/memory-service.ts +626 -0
  122. package/src/services/session-history-importer.ts +367 -0
  123. package/tests/canonical-key.test.ts +101 -0
  124. package/tests/evidence-aligner.test.ts +152 -0
  125. package/tests/matcher.test.ts +112 -0
  126. package/tsconfig.json +24 -0
  127. 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
+ });