memorix 1.1.13 → 1.2.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 (88) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +3 -2
  3. package/README.zh-CN.md +3 -2
  4. package/dist/cli/index.js +36313 -31189
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/dashboard/static/app.js +50 -30
  7. package/dist/index.js +5348 -674
  8. package/dist/index.js.map +1 -1
  9. package/dist/maintenance-runner.d.ts +1 -1
  10. package/dist/maintenance-runner.js +3661 -293
  11. package/dist/maintenance-runner.js.map +1 -1
  12. package/dist/memcode-runtime/CHANGELOG.md +19 -0
  13. package/dist/sdk.d.ts +1 -1
  14. package/dist/sdk.js +5346 -672
  15. package/dist/sdk.js.map +1 -1
  16. package/docs/1.2.0-CLAIM-LEDGER.md +72 -0
  17. package/docs/1.2.0-CODE-STATE.md +61 -0
  18. package/docs/1.2.0-DEVELOPMENT-CHARTER.md +255 -0
  19. package/docs/1.2.0-DYNAMIC-LIFECYCLE.md +71 -0
  20. package/docs/1.2.0-EVALUATION-HARNESS.md +51 -0
  21. package/docs/1.2.0-IMPLEMENTATION-PLAN.md +554 -0
  22. package/docs/1.2.0-KNOWLEDGE-WORKFLOW-RESEARCH.md +205 -0
  23. package/docs/1.2.0-KNOWLEDGE-WORKSPACE.md +68 -0
  24. package/docs/1.2.0-PRODUCT-STORY.md +234 -0
  25. package/docs/1.2.0-PROVIDER-QUALITY.md +189 -0
  26. package/docs/1.2.0-WORKFLOW-INHERITANCE.md +101 -0
  27. package/docs/1.2.0-WORKSET-RETRIEVAL.md +80 -0
  28. package/docs/AGENT_OPERATOR_PLAYBOOK.md +6 -3
  29. package/docs/API_REFERENCE.md +25 -6
  30. package/docs/CONFIGURATION.md +21 -3
  31. package/docs/README.md +17 -2
  32. package/docs/dev-log/progress.txt +120 -40
  33. package/llms-full.txt +16 -2
  34. package/llms.txt +9 -4
  35. package/package.json +3 -2
  36. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  37. package/src/cli/capability-map.ts +1 -0
  38. package/src/cli/commands/codegraph.ts +112 -9
  39. package/src/cli/commands/context.ts +2 -0
  40. package/src/cli/commands/doctor.ts +73 -4
  41. package/src/cli/commands/knowledge.ts +282 -0
  42. package/src/cli/commands/serve-http.ts +12 -1
  43. package/src/cli/index.ts +3 -1
  44. package/src/cli/tui/App.tsx +1 -1
  45. package/src/cli/tui/Panels.tsx +8 -8
  46. package/src/cli/tui/theme.ts +1 -1
  47. package/src/cli/tui/views/GraphView.tsx +8 -7
  48. package/src/cli/tui/views/KnowledgeView.tsx +9 -9
  49. package/src/codegraph/auto-context.ts +171 -9
  50. package/src/codegraph/code-state.ts +95 -0
  51. package/src/codegraph/context-pack.ts +82 -1
  52. package/src/codegraph/external-provider.ts +581 -0
  53. package/src/codegraph/lite-provider.ts +64 -19
  54. package/src/codegraph/project-context.ts +9 -1
  55. package/src/codegraph/store.ts +154 -6
  56. package/src/codegraph/types.ts +117 -0
  57. package/src/config/resolved-config.ts +28 -0
  58. package/src/config/toml-loader.ts +3 -0
  59. package/src/config/yaml-loader.ts +6 -0
  60. package/src/dashboard/server.ts +15 -1
  61. package/src/evaluation/workset-evaluation.ts +120 -0
  62. package/src/hooks/handler.ts +48 -6
  63. package/src/knowledge/claim-store.ts +267 -0
  64. package/src/knowledge/claims.ts +537 -0
  65. package/src/knowledge/markdown.ts +129 -0
  66. package/src/knowledge/types.ts +157 -0
  67. package/src/knowledge/wiki.ts +524 -0
  68. package/src/knowledge/workflow-store.ts +168 -0
  69. package/src/knowledge/workflow-types.ts +95 -0
  70. package/src/knowledge/workflows.ts +743 -0
  71. package/src/knowledge/workset.ts +515 -0
  72. package/src/knowledge/workspace-store.ts +220 -0
  73. package/src/knowledge/workspace-types.ts +106 -0
  74. package/src/knowledge/workspace.ts +220 -0
  75. package/src/memory/observations.ts +19 -0
  76. package/src/runtime/control-plane-maintenance.ts +5 -0
  77. package/src/runtime/isolated-maintenance.ts +5 -0
  78. package/src/runtime/lifecycle-status.ts +102 -0
  79. package/src/runtime/lifecycle.ts +107 -0
  80. package/src/runtime/maintenance-jobs.ts +5 -0
  81. package/src/runtime/project-maintenance.ts +190 -0
  82. package/src/server/tool-profile.ts +3 -2
  83. package/src/server.ts +354 -14
  84. package/src/store/file-lock.ts +24 -4
  85. package/src/store/sqlite-db.ts +307 -0
  86. package/src/wiki/generator.ts +4 -2
  87. package/src/wiki/knowledge-graph.ts +7 -4
  88. package/src/wiki/types.ts +16 -4
@@ -322,9 +322,15 @@ async function handleApi(
322
322
  };
323
323
 
324
324
  let maintenance = { total: 0, pending: 0, running: 0, retrying: 0, completed: 0, failed: 0 };
325
+ let lifecycle: unknown;
325
326
  try {
326
327
  const { MaintenanceJobStore } = await import('../runtime/maintenance-jobs.js');
327
328
  maintenance = new MaintenanceJobStore(effectiveDataDir).summary(effectiveProjectId);
329
+ const { collectLifecycleDiagnostics } = await import('../runtime/lifecycle-status.js');
330
+ lifecycle = await collectLifecycleDiagnostics({
331
+ dataDir: effectiveDataDir,
332
+ projectId: effectiveProjectId,
333
+ });
328
334
  } catch { /* optional maintenance diagnostics */ }
329
335
 
330
336
  sendJson(res, {
@@ -338,6 +344,7 @@ async function handleApi(
338
344
  embedding: embeddingStatus,
339
345
  storage: storageInfo,
340
346
  maintenance,
347
+ ...(lifecycle ? { lifecycle } : {}),
341
348
  gitSummary: {
342
349
  total: gitMemories.length,
343
350
  recentWeek: recentGitCount,
@@ -349,7 +356,10 @@ async function handleApi(
349
356
  }
350
357
 
351
358
  case '/maintenance': {
352
- const { MaintenanceJobStore } = await import('../runtime/maintenance-jobs.js');
359
+ const [{ MaintenanceJobStore }, { collectLifecycleDiagnostics }] = await Promise.all([
360
+ import('../runtime/maintenance-jobs.js'),
361
+ import('../runtime/lifecycle-status.js'),
362
+ ]);
353
363
  const jobs = new MaintenanceJobStore(effectiveDataDir).list({
354
364
  projectId: effectiveProjectId,
355
365
  limit: 50,
@@ -357,6 +367,10 @@ async function handleApi(
357
367
  sendJson(res, {
358
368
  summary: new MaintenanceJobStore(effectiveDataDir).summary(effectiveProjectId),
359
369
  jobs,
370
+ lifecycle: await collectLifecycleDiagnostics({
371
+ dataDir: effectiveDataDir,
372
+ projectId: effectiveProjectId,
373
+ }),
360
374
  });
361
375
  break;
362
376
  }
@@ -0,0 +1,120 @@
1
+ import { countTextTokens } from '../compact/token-budget.js';
2
+
3
+ export const WORKSET_VARIANTS = [
4
+ 'memory-only',
5
+ 'current-context',
6
+ 'candidate-workset',
7
+ ] as const;
8
+
9
+ export type WorksetVariant = typeof WORKSET_VARIANTS[number];
10
+
11
+ /**
12
+ * A normalized result shape for evaluating a task context. The production
13
+ * Workset builder will populate this shape in Phase 5; Phase 0 uses it to
14
+ * establish deterministic baseline fixtures without an LLM dependency.
15
+ */
16
+ export interface EvaluatedWorkset {
17
+ variant: WorksetVariant;
18
+ prompt: string;
19
+ startHere: string[];
20
+ evidenceIds: string[];
21
+ cautions: string[];
22
+ }
23
+
24
+ export interface WorksetExpectation {
25
+ requiredStartHere?: string[];
26
+ requiredEvidenceIds?: string[];
27
+ requiredCautions?: string[];
28
+ maxTokens: number;
29
+ maxTokensByVariant?: Partial<Record<WorksetVariant, number>>;
30
+ }
31
+
32
+ export interface WorksetEvaluationFixture {
33
+ id: string;
34
+ title: string;
35
+ task: string;
36
+ expectation: WorksetExpectation;
37
+ }
38
+
39
+ export interface WorksetEvaluationResult {
40
+ fixtureId: string;
41
+ variant: WorksetVariant;
42
+ tokenCount: number;
43
+ tokenBudget: number;
44
+ withinBudget: boolean;
45
+ missingStartHere: string[];
46
+ missingEvidenceIds: string[];
47
+ missingCautions: string[];
48
+ passed: boolean;
49
+ }
50
+
51
+ export interface WorksetComparison {
52
+ fixtureId: string;
53
+ results: WorksetEvaluationResult[];
54
+ byVariant: Partial<Record<WorksetVariant, WorksetEvaluationResult>>;
55
+ }
56
+
57
+ function unique(values: string[]): string[] {
58
+ return [...new Set(values)];
59
+ }
60
+
61
+ function missing(required: string[] | undefined, actual: string[]): string[] {
62
+ const available = new Set(actual);
63
+ return unique(required ?? []).filter(value => !available.has(value));
64
+ }
65
+
66
+ function tokenBudgetFor(
67
+ expectation: WorksetExpectation,
68
+ variant: WorksetVariant,
69
+ ): number {
70
+ return expectation.maxTokensByVariant?.[variant] ?? expectation.maxTokens;
71
+ }
72
+
73
+ /**
74
+ * Evaluate one context against source-backed fixture requirements. This is
75
+ * deliberately mechanical: it reports evidence, safety, and token gaps
76
+ * instead of asking a model to grade another model's answer.
77
+ */
78
+ export function evaluateWorkset(
79
+ fixture: WorksetEvaluationFixture,
80
+ workset: EvaluatedWorkset,
81
+ ): WorksetEvaluationResult {
82
+ const tokenCount = countTextTokens(workset.prompt);
83
+ const missingStartHere = missing(fixture.expectation.requiredStartHere, workset.startHere);
84
+ const missingEvidenceIds = missing(fixture.expectation.requiredEvidenceIds, workset.evidenceIds);
85
+ const missingCautions = missing(fixture.expectation.requiredCautions, workset.cautions);
86
+ const tokenBudget = tokenBudgetFor(fixture.expectation, workset.variant);
87
+ const withinBudget = tokenCount <= tokenBudget;
88
+
89
+ return {
90
+ fixtureId: fixture.id,
91
+ variant: workset.variant,
92
+ tokenCount,
93
+ tokenBudget,
94
+ withinBudget,
95
+ missingStartHere,
96
+ missingEvidenceIds,
97
+ missingCautions,
98
+ passed: withinBudget
99
+ && missingStartHere.length === 0
100
+ && missingEvidenceIds.length === 0
101
+ && missingCautions.length === 0,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Compare memory-only, current-context, and candidate Worksets against the
107
+ * same task requirements. Missing variants are intentionally allowed so a
108
+ * new retrieval implementation can be introduced incrementally.
109
+ */
110
+ export function compareWorksets(
111
+ fixture: WorksetEvaluationFixture,
112
+ worksets: EvaluatedWorkset[],
113
+ ): WorksetComparison {
114
+ const results = worksets.map(workset => evaluateWorkset(fixture, workset));
115
+ const byVariant: Partial<Record<WorksetVariant, WorksetEvaluationResult>> = {};
116
+ for (const result of results) {
117
+ byVariant[result.variant] = result;
118
+ }
119
+ return { fixtureId: fixture.id, results, byVariant };
120
+ }
@@ -298,12 +298,12 @@ async function handleSessionStart(input: NormalizedHookInput): Promise<{
298
298
  dataDir,
299
299
  observations: activeObservations,
300
300
  refresh: 'auto',
301
- enqueueRefresh: () => import('../runtime/maintenance-jobs.js').then(({ MaintenanceJobStore }) => {
302
- new MaintenanceJobStore(dataDir).enqueue({
301
+ enqueueRefresh: () => import('../runtime/lifecycle.js').then(({ enqueueCodegraphRefresh }) => {
302
+ enqueueCodegraphRefresh({
303
+ dataDir,
303
304
  projectId: canonicalId,
304
- kind: 'codegraph-refresh',
305
- dedupeKey: 'hook-codegraph-refresh',
306
- payload: { maxFiles: 5_000 },
305
+ source: 'hook-session-start',
306
+ maxFiles: 5_000,
307
307
  });
308
308
  }),
309
309
  });
@@ -337,7 +337,7 @@ async function handleSessionStart(input: NormalizedHookInput): Promise<{
337
337
  * Pipeline: Classify → Policy check → Store → Respond
338
338
  * Pattern detection is used for classification only, not storage gating.
339
339
  */
340
- export async function handleHookEvent(input: NormalizedHookInput): Promise<{
340
+ async function handleHookEventCore(input: NormalizedHookInput): Promise<{
341
341
  observation: ReturnType<typeof buildObservation> | null;
342
342
  output: HookOutput;
343
343
  }> {
@@ -431,6 +431,48 @@ export async function handleHookEvent(input: NormalizedHookInput): Promise<{
431
431
  };
432
432
  }
433
433
 
434
+ /** Queue a later scan after an actual file mutation without scanning in the hook. */
435
+ async function queueCodegraphRefreshForMutation(input: NormalizedHookInput): Promise<void> {
436
+ if (classifyTool(input) !== 'file_modify') return;
437
+ try {
438
+ const [
439
+ { detectProject },
440
+ { getProjectDataDir },
441
+ { initAliasRegistry, registerAlias },
442
+ { enqueueCodegraphRefresh },
443
+ ] = await Promise.all([
444
+ import('../project/detector.js'),
445
+ import('../store/persistence.js'),
446
+ import('../project/aliases.js'),
447
+ import('../runtime/lifecycle.js'),
448
+ ]);
449
+ const project = detectProject(input.cwd || process.cwd());
450
+ if (!project) return;
451
+ const dataDir = await getProjectDataDir(project.id);
452
+ initAliasRegistry(dataDir);
453
+ const projectId = await registerAlias(project);
454
+ enqueueCodegraphRefresh({
455
+ dataDir,
456
+ projectId,
457
+ source: 'hook-file-mutation',
458
+ maxFiles: 5_000,
459
+ });
460
+ } catch {
461
+ // Hooks remain capture-first even if optional Code Memory scheduling fails.
462
+ }
463
+ }
464
+
465
+ export async function handleHookEvent(input: NormalizedHookInput): Promise<{
466
+ observation: ReturnType<typeof buildObservation> | null;
467
+ output: HookOutput;
468
+ }> {
469
+ try {
470
+ return await handleHookEventCore(input);
471
+ } finally {
472
+ await queueCodegraphRefreshForMutation(input);
473
+ }
474
+ }
475
+
434
476
  /**
435
477
  * Convert Memorix's neutral hook response into the host-specific response
436
478
  * schema expected by each supported agent.
@@ -0,0 +1,267 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { sanitizeCredentials } from '../memory/secret-filter.js';
3
+ import { getDatabase } from '../store/sqlite-db.js';
4
+ import type {
5
+ ClaimConflict,
6
+ ClaimEvent,
7
+ ClaimEvidenceRef,
8
+ KnowledgeClaim,
9
+ KnowledgeClaimStatus,
10
+ } from './types.js';
11
+
12
+ function optionalText(value: unknown): string | undefined {
13
+ return typeof value === 'string' && value ? value : undefined;
14
+ }
15
+
16
+ function rowToClaim(row: any): KnowledgeClaim {
17
+ return {
18
+ id: row.id,
19
+ projectId: row.projectId,
20
+ subject: row.subject,
21
+ predicate: row.predicate,
22
+ objectValue: row.objectValue,
23
+ scope: row.scope,
24
+ claimKey: row.claimKey,
25
+ conflictKey: row.conflictKey,
26
+ status: row.status,
27
+ confidence: Number(row.confidence),
28
+ observedAt: row.observedAt,
29
+ ...(optionalText(row.validFrom) ? { validFrom: row.validFrom } : {}),
30
+ ...(optionalText(row.validTo) ? { validTo: row.validTo } : {}),
31
+ ...(optionalText(row.supersededBy) ? { supersededBy: row.supersededBy } : {}),
32
+ reviewState: row.reviewState,
33
+ origin: row.origin,
34
+ createdAt: row.createdAt,
35
+ updatedAt: row.updatedAt,
36
+ };
37
+ }
38
+
39
+ function rowToEvidence(row: any): ClaimEvidenceRef {
40
+ return {
41
+ id: row.id,
42
+ claimId: row.claimId,
43
+ evidenceKind: row.evidenceKind,
44
+ evidenceId: row.evidenceId,
45
+ relation: row.relation,
46
+ ...(optionalText(row.snapshotId) ? { snapshotId: row.snapshotId } : {}),
47
+ ...(optionalText(row.locator) ? { locator: row.locator } : {}),
48
+ ...(optionalText(row.capturedHash) ? { capturedHash: row.capturedHash } : {}),
49
+ createdAt: row.createdAt,
50
+ };
51
+ }
52
+
53
+ function rowToEvent(row: any): ClaimEvent {
54
+ return {
55
+ id: row.id,
56
+ projectId: row.projectId,
57
+ claimId: row.claimId,
58
+ kind: row.kind,
59
+ ...(optionalText(row.fromStatus) ? { fromStatus: row.fromStatus } : {}),
60
+ ...(optionalText(row.toStatus) ? { toStatus: row.toStatus } : {}),
61
+ ...(optionalText(row.relatedClaimId) ? { relatedClaimId: row.relatedClaimId } : {}),
62
+ ...(optionalText(row.detail) ? { detail: row.detail } : {}),
63
+ createdAt: row.createdAt,
64
+ };
65
+ }
66
+
67
+ function evidenceKey(input: Omit<ClaimEvidenceRef, 'id' | 'claimId' | 'createdAt'>): string {
68
+ return createHash('sha256').update(JSON.stringify([
69
+ input.evidenceKind,
70
+ input.evidenceId,
71
+ input.relation,
72
+ input.snapshotId ?? '',
73
+ input.locator ?? '',
74
+ input.capturedHash ?? '',
75
+ ])).digest('hex');
76
+ }
77
+
78
+ /**
79
+ * SQLite access for the claim ledger. Domain policy belongs in claims.ts; this
80
+ * class only preserves records and their audit trail atomically.
81
+ */
82
+ export class ClaimStore {
83
+ private db: any = null;
84
+
85
+ async init(dataDir: string): Promise<void> {
86
+ this.db = getDatabase(dataDir);
87
+ }
88
+
89
+ transaction<T>(fn: () => T): T {
90
+ return this.db.transaction(fn)();
91
+ }
92
+
93
+ getClaim(id: string): KnowledgeClaim | undefined {
94
+ const row = this.db.prepare('SELECT * FROM knowledge_claims WHERE id = ?').get(id);
95
+ return row ? rowToClaim(row) : undefined;
96
+ }
97
+
98
+ listClaims(
99
+ projectId: string,
100
+ options: { statuses?: readonly KnowledgeClaimStatus[]; limit?: number } = {},
101
+ ): KnowledgeClaim[] {
102
+ const limit = Math.max(1, Math.min(1_000, Math.floor(options.limit ?? 500)));
103
+ if (options.statuses && options.statuses.length > 0) {
104
+ return this.db.prepare(`
105
+ SELECT * FROM knowledge_claims
106
+ WHERE projectId = ? AND status IN (SELECT value FROM json_each(?))
107
+ ORDER BY updatedAt DESC, id
108
+ LIMIT ?
109
+ `).all(projectId, JSON.stringify([...new Set(options.statuses)]), limit).map(rowToClaim);
110
+ }
111
+ return this.db.prepare(`
112
+ SELECT * FROM knowledge_claims
113
+ WHERE projectId = ?
114
+ ORDER BY updatedAt DESC, id
115
+ LIMIT ?
116
+ `).all(projectId, limit).map(rowToClaim);
117
+ }
118
+
119
+ findReusableClaim(projectId: string, claimKey: string): KnowledgeClaim | undefined {
120
+ const row = this.db.prepare(`
121
+ SELECT * FROM knowledge_claims
122
+ WHERE projectId = ? AND claimKey = ? AND status IN ('active', 'disputed')
123
+ ORDER BY updatedAt DESC
124
+ LIMIT 1
125
+ `).get(projectId, claimKey);
126
+ return row ? rowToClaim(row) : undefined;
127
+ }
128
+
129
+ listClaimsByConflictKey(projectId: string, conflictKey: string): KnowledgeClaim[] {
130
+ return this.db.prepare(`
131
+ SELECT * FROM knowledge_claims
132
+ WHERE projectId = ? AND conflictKey = ?
133
+ ORDER BY createdAt ASC, id
134
+ `).all(projectId, conflictKey).map(rowToClaim);
135
+ }
136
+
137
+ listConflicts(projectId: string): ClaimConflict[] {
138
+ const rows = this.db.prepare(`
139
+ SELECT conflictKey
140
+ FROM knowledge_claims
141
+ WHERE projectId = ? AND status IN ('active', 'disputed') AND reviewState = 'approved'
142
+ GROUP BY conflictKey
143
+ HAVING COUNT(DISTINCT claimKey) > 1
144
+ ORDER BY conflictKey
145
+ `).all(projectId) as Array<{ conflictKey: string }>;
146
+ return rows.map(({ conflictKey }) => ({
147
+ conflictKey,
148
+ claims: this.listClaimsByConflictKey(projectId, conflictKey)
149
+ .filter(claim => claim.status === 'active' || claim.status === 'disputed'),
150
+ }));
151
+ }
152
+
153
+ insertClaim(claim: KnowledgeClaim): void {
154
+ this.db.prepare(`
155
+ INSERT INTO knowledge_claims (
156
+ id, projectId, subject, predicate, objectValue, scope, claimKey, conflictKey,
157
+ status, confidence, observedAt, validFrom, validTo, supersededBy,
158
+ reviewState, origin, createdAt, updatedAt
159
+ ) VALUES (
160
+ @id, @projectId, @subject, @predicate, @objectValue, @scope, @claimKey, @conflictKey,
161
+ @status, @confidence, @observedAt, @validFrom, @validTo, @supersededBy,
162
+ @reviewState, @origin, @createdAt, @updatedAt
163
+ )
164
+ `).run({
165
+ ...claim,
166
+ validFrom: claim.validFrom ?? null,
167
+ validTo: claim.validTo ?? null,
168
+ supersededBy: claim.supersededBy ?? null,
169
+ });
170
+ }
171
+
172
+ updateClaim(claim: KnowledgeClaim): void {
173
+ this.db.prepare(`
174
+ UPDATE knowledge_claims SET
175
+ subject = @subject,
176
+ predicate = @predicate,
177
+ objectValue = @objectValue,
178
+ scope = @scope,
179
+ claimKey = @claimKey,
180
+ conflictKey = @conflictKey,
181
+ status = @status,
182
+ confidence = @confidence,
183
+ observedAt = @observedAt,
184
+ validFrom = @validFrom,
185
+ validTo = @validTo,
186
+ supersededBy = @supersededBy,
187
+ reviewState = @reviewState,
188
+ origin = @origin,
189
+ updatedAt = @updatedAt
190
+ WHERE id = @id
191
+ `).run({
192
+ ...claim,
193
+ validFrom: claim.validFrom ?? null,
194
+ validTo: claim.validTo ?? null,
195
+ supersededBy: claim.supersededBy ?? null,
196
+ });
197
+ }
198
+
199
+ touchClaim(id: string, updatedAt: string): void {
200
+ this.db.prepare('UPDATE knowledge_claims SET updatedAt = ? WHERE id = ?').run(updatedAt, id);
201
+ }
202
+
203
+ insertEvidence(input: Omit<ClaimEvidenceRef, 'id' | 'createdAt'> & { createdAt?: string }): boolean {
204
+ const createdAt = input.createdAt ?? new Date().toISOString();
205
+ const key = evidenceKey(input);
206
+ const result = this.db.prepare(`
207
+ INSERT OR IGNORE INTO knowledge_claim_evidence (
208
+ id, claimId, evidenceKind, evidenceId, relation, snapshotId,
209
+ locator, capturedHash, evidenceKey, createdAt
210
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
211
+ `).run(
212
+ randomUUID(),
213
+ input.claimId,
214
+ input.evidenceKind,
215
+ input.evidenceId,
216
+ input.relation,
217
+ input.snapshotId ?? null,
218
+ input.locator ? sanitizeCredentials(input.locator) : null,
219
+ input.capturedHash ?? null,
220
+ key,
221
+ createdAt,
222
+ );
223
+ return Number(result.changes ?? 0) > 0;
224
+ }
225
+
226
+ listEvidence(claimId: string): ClaimEvidenceRef[] {
227
+ return this.db.prepare(`
228
+ SELECT * FROM knowledge_claim_evidence
229
+ WHERE claimId = ?
230
+ ORDER BY createdAt ASC, id
231
+ `).all(claimId).map(rowToEvidence);
232
+ }
233
+
234
+ recordEvent(input: Omit<ClaimEvent, 'id' | 'createdAt'> & { createdAt?: string }): ClaimEvent {
235
+ const event: ClaimEvent = {
236
+ id: randomUUID(),
237
+ ...input,
238
+ ...(input.detail ? { detail: sanitizeCredentials(input.detail) } : {}),
239
+ createdAt: input.createdAt ?? new Date().toISOString(),
240
+ };
241
+ this.db.prepare(`
242
+ INSERT INTO knowledge_claim_events (
243
+ id, projectId, claimId, kind, fromStatus, toStatus,
244
+ relatedClaimId, detail, createdAt
245
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
246
+ `).run(
247
+ event.id,
248
+ event.projectId,
249
+ event.claimId,
250
+ event.kind,
251
+ event.fromStatus ?? null,
252
+ event.toStatus ?? null,
253
+ event.relatedClaimId ?? null,
254
+ event.detail ?? null,
255
+ event.createdAt,
256
+ );
257
+ return event;
258
+ }
259
+
260
+ listEvents(claimId: string): ClaimEvent[] {
261
+ return this.db.prepare(`
262
+ SELECT * FROM knowledge_claim_events
263
+ WHERE claimId = ?
264
+ ORDER BY createdAt ASC, id
265
+ `).all(claimId).map(rowToEvent);
266
+ }
267
+ }