memorix 1.7.3 → 1.8.1

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 (73) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/dist/cli/index.js +1488 -270
  5. package/dist/dashboard/static/app.js +125 -27
  6. package/dist/dashboard/static/index.html +11 -1
  7. package/dist/dashboard/static/style.css +86 -0
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +927 -240
  10. package/dist/maintenance-runner.js +607 -68
  11. package/dist/media-audio-runner.js +602 -63
  12. package/dist/media-video-runner.js +607 -68
  13. package/dist/memcode-runtime/CHANGELOG.md +41 -0
  14. package/dist/sdk.js +926 -239
  15. package/dist/types.d.ts +8 -0
  16. package/dist/vector-backfill-runner.js +607 -68
  17. package/docs/1.2.4-PERSISTENT-MEMORY-DELIVERY.md +1 -1
  18. package/docs/1.8.0-RELEASE-CANDIDATE-SPEC.md +75 -0
  19. package/docs/API_REFERENCE.md +39 -1
  20. package/docs/ARCHITECTURE.md +3 -1
  21. package/docs/DASHBOARD.md +16 -0
  22. package/docs/DEVELOPMENT.md +3 -2
  23. package/docs/INTEGRATIONS.md +1 -1
  24. package/docs/README.md +15 -2
  25. package/package.json +1 -1
  26. package/plugins/antigravity/memorix/rules/memorix.md +1 -1
  27. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  28. package/plugins/claude/memorix/SKILL.md +1 -1
  29. package/plugins/claude/memorix/skills/memorix-memory/SKILL.md +2 -2
  30. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  31. package/plugins/codebuddy/memorix/skills/memorix-memory/SKILL.md +2 -2
  32. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  33. package/plugins/codex/memorix/skills/memorix-memory/SKILL.md +2 -2
  34. package/plugins/copilot/memorix/plugin.json +1 -1
  35. package/plugins/copilot/memorix/skills/memorix-memory/SKILL.md +2 -2
  36. package/plugins/cursor/memorix/skills/memorix-memory/SKILL.md +2 -2
  37. package/plugins/gemini/memorix/GEMINI.md +1 -1
  38. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  39. package/plugins/omp/memorix/package.json +1 -1
  40. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  41. package/plugins/pi/memorix/package.json +1 -1
  42. package/plugins/pi/memorix/skills/memorix-memory/SKILL.md +2 -2
  43. package/server.json +2 -2
  44. package/src/cli/capability-map.ts +2 -0
  45. package/src/cli/commands/context.ts +11 -0
  46. package/src/cli/commands/dashboard.ts +1 -1
  47. package/src/cli/commands/evidence.ts +56 -0
  48. package/src/cli/commands/feedback.ts +58 -0
  49. package/src/cli/commands/resume.ts +2 -0
  50. package/src/cli/commands/serve-http.ts +229 -13
  51. package/src/cli/fallback-budget.ts +59 -0
  52. package/src/cli/index.ts +5 -1
  53. package/src/compact/engine.ts +26 -1
  54. package/src/config/dotenv-loader.ts +17 -0
  55. package/src/dashboard/config-provenance.ts +17 -0
  56. package/src/dashboard/evidence.ts +63 -0
  57. package/src/dashboard/server.ts +178 -154
  58. package/src/dashboard/static/index.html +11 -1
  59. package/src/dashboard/static/style.css +86 -0
  60. package/src/hooks/installers/index.ts +1 -1
  61. package/src/hooks/official-skills.ts +3 -3
  62. package/src/hooks/rules/memorix-agent-rules.md +1 -1
  63. package/src/memory/feedback-model.ts +4 -0
  64. package/src/memory/feedback.ts +220 -0
  65. package/src/memory/observations.ts +19 -0
  66. package/src/memory/retention.ts +38 -5
  67. package/src/server/mcp-binding-store.ts +79 -0
  68. package/src/server/mcp-diagnostics.ts +40 -0
  69. package/src/server/tool-profile.ts +4 -0
  70. package/src/server.ts +125 -5
  71. package/src/store/evidence-store.ts +261 -0
  72. package/src/store/sqlite-db.ts +104 -0
  73. package/src/types.ts +8 -0
@@ -0,0 +1,79 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { getDatabase } from '../store/sqlite-db.js';
3
+
4
+ export interface McpProjectBinding {
5
+ handleId: string;
6
+ projectId: string;
7
+ projectRoot: string;
8
+ dataDir: string;
9
+ createdAt: string;
10
+ lastUsedAt: string;
11
+ expiresAt?: string;
12
+ }
13
+
14
+ function rowToBinding(row: any): McpProjectBinding {
15
+ return {
16
+ handleId: String(row.handle_id),
17
+ projectId: String(row.project_id),
18
+ projectRoot: String(row.project_root),
19
+ dataDir: String(row.data_dir),
20
+ createdAt: String(row.created_at),
21
+ lastUsedAt: String(row.last_used_at),
22
+ ...(row.expires_at ? { expiresAt: String(row.expires_at) } : {}),
23
+ };
24
+ }
25
+
26
+ /** Durable project binding handles for stateless MCP requests. */
27
+ export class McpBindingStore {
28
+ private db: any = null;
29
+
30
+ async init(dataDir: string): Promise<void> {
31
+ this.db = getDatabase(dataDir);
32
+ }
33
+
34
+ private requireDb(): any {
35
+ if (!this.db) throw new Error('McpBindingStore is not initialized.');
36
+ return this.db;
37
+ }
38
+
39
+ create(input: { projectId: string; projectRoot: string; dataDir: string; ttlMs?: number }): McpProjectBinding {
40
+ const now = new Date();
41
+ const binding: McpProjectBinding = {
42
+ handleId: `mxh_${randomUUID().replace(/-/g, '')}`,
43
+ projectId: input.projectId,
44
+ projectRoot: input.projectRoot,
45
+ dataDir: input.dataDir,
46
+ createdAt: now.toISOString(),
47
+ lastUsedAt: now.toISOString(),
48
+ ...(input.ttlMs && input.ttlMs > 0 ? { expiresAt: new Date(now.getTime() + input.ttlMs).toISOString() } : {}),
49
+ };
50
+ this.requireDb().prepare(`
51
+ INSERT INTO mcp_bindings (handle_id, project_id, project_root, data_dir, created_at, last_used_at, expires_at)
52
+ VALUES (?, ?, ?, ?, ?, ?, ?)
53
+ `).run(binding.handleId, binding.projectId, binding.projectRoot, binding.dataDir, binding.createdAt, binding.lastUsedAt, binding.expiresAt ?? null);
54
+ return binding;
55
+ }
56
+
57
+ get(handleId: string): McpProjectBinding | undefined {
58
+ const row = this.requireDb().prepare(`SELECT * FROM mcp_bindings WHERE handle_id = ?`).get(handleId);
59
+ if (!row) return undefined;
60
+ const binding = rowToBinding(row);
61
+ if (binding.expiresAt && Date.parse(binding.expiresAt) <= Date.now()) {
62
+ this.delete(handleId);
63
+ return undefined;
64
+ }
65
+ return binding;
66
+ }
67
+
68
+ touch(handleId: string): McpProjectBinding | undefined {
69
+ const binding = this.get(handleId);
70
+ if (!binding) return undefined;
71
+ const lastUsedAt = new Date().toISOString();
72
+ this.requireDb().prepare(`UPDATE mcp_bindings SET last_used_at = ? WHERE handle_id = ?`).run(lastUsedAt, handleId);
73
+ return { ...binding, lastUsedAt };
74
+ }
75
+
76
+ delete(handleId: string): void {
77
+ this.requireDb().prepare(`DELETE FROM mcp_bindings WHERE handle_id = ?`).run(handleId);
78
+ }
79
+ }
@@ -0,0 +1,40 @@
1
+ /** MCP compatibility diagnostics shared by HTTP and CLI-facing checks. */
2
+
3
+ export const CURRENT_MCP_PROTOCOL_VERSION = '2026-07-28';
4
+ export const LEGACY_MCP_PROTOCOL_VERSION = '2024-11-05';
5
+ export const MCP_SERVER_NAME = 'io.github.AVIDS2/memorix';
6
+
7
+ export interface McpDiagnostics {
8
+ serverName: string;
9
+ protocol: {
10
+ current: string;
11
+ legacy: string;
12
+ statelessRequests: 'supported' | 'compatibility-only' | 'unsupported';
13
+ statefulStreamableHttp: 'supported';
14
+ };
15
+ capabilities: {
16
+ tasks: 'supported' | 'unsupported';
17
+ polling: 'supported' | 'unsupported';
18
+ listCache: 'supported';
19
+ explicitProjectHandle: 'supported';
20
+ };
21
+ }
22
+
23
+ /** The pinned SDK is stateful; do not present a fake durable task loop. */
24
+ export function getMcpDiagnostics(): McpDiagnostics {
25
+ return {
26
+ serverName: MCP_SERVER_NAME,
27
+ protocol: {
28
+ current: CURRENT_MCP_PROTOCOL_VERSION,
29
+ legacy: LEGACY_MCP_PROTOCOL_VERSION,
30
+ statelessRequests: 'supported',
31
+ statefulStreamableHttp: 'supported',
32
+ },
33
+ capabilities: {
34
+ tasks: 'unsupported',
35
+ polling: 'unsupported',
36
+ listCache: 'supported',
37
+ explicitProjectHandle: 'supported',
38
+ },
39
+ };
40
+ }
@@ -40,6 +40,10 @@ export const TOOL_PROFILES: Record<string, ReadonlyArray<ToolProfile>> = Object.
40
40
  // multimodal support to one additional schema rather than enabling lite.
41
41
  memorix_session_start: ['micro', 'lite', 'team', 'full'],
42
42
  memorix_media: ['micro', 'lite', 'team', 'full'],
43
+ // Evidence and feedback are deliberate operator/context-control surfaces;
44
+ // keep the default micro profile at its nine-tool token budget.
45
+ memorix_evidence: ['lite', 'team', 'full'],
46
+ memorix_feedback: ['lite', 'team', 'full'],
43
47
 
44
48
  // ── lite: extended solo memory surface ────────────────────────────
45
49
  memorix_graph_context: ['lite', 'team', 'full'],
package/src/server.ts CHANGED
@@ -36,6 +36,8 @@ import { detectProject } from './project/detector.js';
36
36
  import { homeProjectRootError, isHomeDirectory, writeLastProjectRoot } from './project/launch-root.js';
37
37
  import { registerAlias, initAliasRegistry, resolveAliases, autoMergeByBaseName } from './project/aliases.js';
38
38
  import { getProjectDataDir } from './store/persistence.js';
39
+ import { EvidenceCardStore } from './store/evidence-store.js';
40
+ import { MemoryFeedbackStore } from './memory/feedback.js';
39
41
  import type { ObservationType, RuleSource, AgentTarget, MCPServerEntry, ObservationReader } from './types.js';
40
42
  import { RulesSyncer } from './rules/syncer.js';
41
43
  import { WorkspaceSyncEngine } from './workspace/engine.js';
@@ -196,7 +198,7 @@ function createDeterministicInstanceId(projectId: string, agentType: string, age
196
198
  * Create and configure the Memorix MCP Server.
197
199
  */
198
200
  /** Optional shared TeamStore — passed by serve-http so all sessions share state */
199
- export interface SharedTeamInstances {
201
+ export interface SharedTeamStore {
200
202
  teamStore: import('./team/team-store.js').TeamStore;
201
203
  }
202
204
 
@@ -243,7 +245,7 @@ export function shouldAwaitProjectRuntime(toolName: string): boolean {
243
245
  export async function createMemorixServer(
244
246
  cwd?: string,
245
247
  existingServer?: McpServer,
246
- sharedTeam?: SharedTeamInstances,
248
+ sharedTeam?: SharedTeamStore,
247
249
  options: CreateMemorixServerOptions = {},
248
250
  ): Promise<{
249
251
  server: McpServer;
@@ -4418,7 +4420,7 @@ export async function createMemorixServer(
4418
4420
  console.error(`[memorix] Dashboard staticDir: ${staticDir}`);
4419
4421
 
4420
4422
  // Start in background (non-blocking), disable auto-open (we'll open it ourselves)
4421
- startDashboard(projectDir, portNum, staticDir, project.id, project.name, false, undefined, project.rootPath, projectResolved)
4423
+ startDashboard(projectDir, portNum, staticDir, project.id, project.name, false, project.rootPath, projectResolved)
4422
4424
  .then(() => { dashboardRunning = true; })
4423
4425
  .catch((err) => { console.error('[memorix] Dashboard error:', err); dashboardRunning = false; });
4424
4426
 
@@ -4445,7 +4447,7 @@ export async function createMemorixServer(
4445
4447
  process.platform === 'win32' ? `start "" "${url}"` :
4446
4448
  process.platform === 'darwin' ? `open "${url}"` :
4447
4449
  `xdg-open "${url}"`;
4448
- execCmd(openCmd, () => { });
4450
+ execCmd(openCmd, { windowsHide: true }, () => { });
4449
4451
 
4450
4452
  return {
4451
4453
  content: [{
@@ -4474,6 +4476,124 @@ export async function createMemorixServer(
4474
4476
  // Orchestration Coordination Tools (Multi-Agent) - SQLite-backed
4475
4477
  // ================================================================
4476
4478
 
4479
+ server.registerTool(
4480
+ 'memorix_evidence',
4481
+ {
4482
+ title: 'Evidence Cards',
4483
+ description:
4484
+ 'Inspect the bounded, project-scoped provenance cards behind Memorix context. ' +
4485
+ 'Use list for a compact index, get/events for one source, sync to repair the projection, ' +
4486
+ 'and stale when known source files changed. Cards expose sourceRef, locator, capturedHash, and freshness.',
4487
+ inputSchema: {
4488
+ action: z.enum(['list', 'get', 'sync', 'stale', 'events']).describe('Evidence operation'),
4489
+ candidateKind: z.enum(['observation', 'code', 'git', 'test', 'document', 'workflow', 'graph']).optional().default('observation'),
4490
+ candidateId: z.string().optional().describe('Candidate id, usually an observation id'),
4491
+ query: z.string().optional().describe('Optional title/source query for list'),
4492
+ paths: z.array(z.string()).max(200).optional().describe('Changed source paths for stale'),
4493
+ reason: z.string().max(500).optional().describe('Why evidence became stale'),
4494
+ limit: z.number().int().positive().max(100).optional().default(20),
4495
+ },
4496
+ },
4497
+ async ({ action, candidateKind, candidateId, query, paths, reason, limit }) => {
4498
+ const unresolved = requireResolvedProject('inspect evidence cards for the current project');
4499
+ if (unresolved) return unresolved;
4500
+ const cards = new EvidenceCardStore();
4501
+ await cards.init(projectDir);
4502
+ if (action === 'sync') {
4503
+ const observations = await getObservationStore().loadByProject(project.id);
4504
+ const synced = cards.syncObservations(observations);
4505
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ projectId: project.id, synced: synced.length, cards: synced.slice(0, limit ?? 20) }, null, 2) }] };
4506
+ }
4507
+ if (action === 'stale') {
4508
+ const changed = cards.markStaleForPaths(project.id, paths ?? [], reason ?? 'source file changed');
4509
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ projectId: project.id, staleMarked: changed, paths: paths ?? [] }, null, 2) }] };
4510
+ }
4511
+ if (action === 'get') {
4512
+ if (!candidateId?.trim()) return { content: [{ type: 'text' as const, text: 'candidateId is required for evidence get.' }], isError: true };
4513
+ const card = cards.get(project.id, candidateKind ?? 'observation', candidateId.trim());
4514
+ if (!card) return { content: [{ type: 'text' as const, text: 'Evidence card not found.' }], isError: true };
4515
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ card, events: cards.listEvents(card.id), projectId: project.id }, null, 2) }] };
4516
+ }
4517
+ if (action === 'events') {
4518
+ if (!candidateId?.trim()) return { content: [{ type: 'text' as const, text: 'candidateId is required for evidence events.' }], isError: true };
4519
+ const card = cards.get(project.id, candidateKind ?? 'observation', candidateId.trim());
4520
+ if (!card) return { content: [{ type: 'text' as const, text: 'Evidence card not found.' }], isError: true };
4521
+ return { content: [{ type: 'text' as const, text: JSON.stringify(cards.listEvents(card.id, limit), null, 2) }] };
4522
+ }
4523
+ const needle = query?.trim().toLowerCase();
4524
+ const listed = cards.list(project.id, { limit: Math.max(limit ?? 20, 100) })
4525
+ .filter((card) => !needle || [card.title, card.summary, card.sourceRef, card.locator ?? ''].join(' ').toLowerCase().includes(needle))
4526
+ .slice(0, limit ?? 20);
4527
+ return { content: [{ type: 'text' as const, text: JSON.stringify({ projectId: project.id, cards: listed }, null, 2) }] };
4528
+ },
4529
+ );
4530
+
4531
+ server.registerTool(
4532
+ 'memorix_feedback',
4533
+ {
4534
+ title: 'Memory Feedback',
4535
+ description:
4536
+ 'Record or inspect auditable memory outcomes. Positive verification/use strengthens recall; correction, conflict, source change, or weaken lowers it. ' +
4537
+ 'Use revoke with targetEventId to undo one prior signal without deleting audit history.',
4538
+ inputSchema: {
4539
+ action: z.enum(['record', 'show', 'audit']).describe('Feedback operation'),
4540
+ candidateKind: z.enum(['observation', 'claim', 'durable-memory', 'workflow']).optional().default('observation'),
4541
+ candidateId: z.string().describe('Memory or knowledge candidate id'),
4542
+ signal: z.enum(['user-correction', 'verification-success', 'verification-failure', 'code-change', 'used', 'not-used', 'code-conflict', 'strengthen', 'weaken', 'revoke']).optional(),
4543
+ sourceRef: z.string().max(500).optional().describe('Source of this feedback, such as test:run-42 or user:chat'),
4544
+ actor: z.string().max(200).optional(),
4545
+ note: z.string().max(2_000).optional(),
4546
+ targetEventId: z.string().optional().describe('Existing event id when action=record and signal=revoke'),
4547
+ limit: z.number().int().positive().max(200).optional().default(100),
4548
+ },
4549
+ },
4550
+ async ({ action, candidateKind, candidateId, signal, sourceRef, actor, note, targetEventId, limit }) => {
4551
+ const unresolved = requireResolvedProject('record memory feedback for the current project');
4552
+ if (unresolved) return unresolved;
4553
+ const feedback = new MemoryFeedbackStore();
4554
+ await feedback.init(projectDir);
4555
+ const kind = candidateKind ?? 'observation';
4556
+ if (action === 'show') {
4557
+ return { content: [{ type: 'text' as const, text: JSON.stringify(feedback.getState(project.id, kind, candidateId), null, 2) }] };
4558
+ }
4559
+ if (action === 'audit') {
4560
+ return { content: [{ type: 'text' as const, text: JSON.stringify(feedback.audit(project.id, kind, candidateId, limit), null, 2) }] };
4561
+ }
4562
+ if (!signal || !sourceRef?.trim()) {
4563
+ return { content: [{ type: 'text' as const, text: 'record requires signal and sourceRef.' }], isError: true };
4564
+ }
4565
+ try {
4566
+ const result = feedback.record({
4567
+ projectId: project.id,
4568
+ candidateKind: kind,
4569
+ candidateId,
4570
+ signal,
4571
+ sourceRef,
4572
+ ...(actor || currentAgentId ? { actor: actor ?? currentAgentId } : {}),
4573
+ ...(note ? { note } : {}),
4574
+ ...(targetEventId ? { targetEventId } : {}),
4575
+ });
4576
+ if (kind === 'observation') {
4577
+ const cards = new EvidenceCardStore();
4578
+ await cards.init(projectDir);
4579
+ const card = cards.get(project.id, 'observation', candidateId);
4580
+ if (card) {
4581
+ const verification = signal === 'verification-success' || signal === 'strengthen' ? 'verified' : signal === 'user-correction' || signal === 'code-conflict' || signal === 'weaken' ? 'conflicted' : card.verification;
4582
+ cards.recordEvent(project.id, card.id, `feedback:${signal}`, note);
4583
+ if (verification !== card.verification) {
4584
+ // Keep the card projection in sync with the feedback state while
4585
+ // retaining the append-only feedback audit as the source of truth.
4586
+ cards.setVerification(project.id, card.id, verification);
4587
+ }
4588
+ }
4589
+ }
4590
+ return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
4591
+ } catch (error) {
4592
+ return { content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }], isError: true };
4593
+ }
4594
+ },
4595
+ );
4596
+
4477
4597
  // Use shared TeamStore (from HTTP server) or create new one (stdio mode).
4478
4598
  // All team state is canonical in SQLite — no JSON persistence, no sync/flush.
4479
4599
  if (teamFeaturesEnabled) {
@@ -4666,7 +4786,7 @@ export async function createMemorixServer(
4666
4786
  server.registerTool(
4667
4787
  'team_task',
4668
4788
  {
4669
- title: 'Task Board',
4789
+ title: 'Task Status',
4670
4790
  description:
4671
4791
  'Create, claim, complete, or list tasks in the team task board. Supports dependencies. ' +
4672
4792
  'Action "create": create a task. Action "claim": assign to yourself (atomic, race-safe). ' +
@@ -0,0 +1,261 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import type { Observation } from '../types.js';
3
+ import { getDatabase } from './sqlite-db.js';
4
+
5
+ export type EvidenceCandidateKind = 'observation' | 'code' | 'git' | 'test' | 'document' | 'workflow' | 'graph';
6
+ export type EvidenceVerification = 'unverified' | 'verified' | 'conflicted';
7
+ export type EvidenceFreshness = 'current' | 'stale' | 'unknown';
8
+
9
+ export interface EvidenceCardRecord {
10
+ id: string;
11
+ projectId: string;
12
+ candidateKind: EvidenceCandidateKind;
13
+ candidateId: string;
14
+ title: string;
15
+ summary: string;
16
+ entityName?: string;
17
+ sourceKind: string;
18
+ sourceRef: string;
19
+ locator?: string;
20
+ capturedHash?: string;
21
+ sessionId?: string;
22
+ status: string;
23
+ verification: EvidenceVerification;
24
+ freshness: EvidenceFreshness;
25
+ staleReason?: string;
26
+ files: string[];
27
+ relatedEntities: string[];
28
+ createdAt: string;
29
+ updatedAt: string;
30
+ }
31
+
32
+ export interface EvidenceCardEvent {
33
+ id: string;
34
+ projectId: string;
35
+ cardId: string;
36
+ kind: string;
37
+ detail?: string;
38
+ createdAt: string;
39
+ }
40
+
41
+ function safeJson<T>(value: unknown, fallback: T): T {
42
+ if (typeof value !== 'string' || value.length === 0) return fallback;
43
+ try {
44
+ return JSON.parse(value) as T;
45
+ } catch {
46
+ return fallback;
47
+ }
48
+ }
49
+
50
+ function rowToCard(row: any): EvidenceCardRecord {
51
+ return {
52
+ id: String(row.id),
53
+ projectId: String(row.project_id),
54
+ candidateKind: row.candidate_kind,
55
+ candidateId: String(row.candidate_id),
56
+ title: String(row.title),
57
+ summary: String(row.summary ?? ''),
58
+ ...(row.entity_name ? { entityName: String(row.entity_name) } : {}),
59
+ sourceKind: String(row.source_kind),
60
+ sourceRef: String(row.source_ref),
61
+ ...(row.locator ? { locator: String(row.locator) } : {}),
62
+ ...(row.captured_hash ? { capturedHash: String(row.captured_hash) } : {}),
63
+ ...(row.session_id ? { sessionId: String(row.session_id) } : {}),
64
+ status: String(row.status ?? 'active'),
65
+ verification: row.verification,
66
+ freshness: row.freshness,
67
+ ...(row.stale_reason ? { staleReason: String(row.stale_reason) } : {}),
68
+ files: safeJson(row.files_json, []),
69
+ relatedEntities: safeJson(row.related_entities_json, []),
70
+ createdAt: String(row.created_at),
71
+ updatedAt: String(row.updated_at),
72
+ };
73
+ }
74
+
75
+ function observationHash(observation: Observation): string {
76
+ return createHash('sha256')
77
+ .update(JSON.stringify({
78
+ projectId: observation.projectId,
79
+ id: observation.id,
80
+ title: observation.title,
81
+ narrative: observation.narrative,
82
+ facts: observation.facts,
83
+ files: observation.filesModified,
84
+ relatedEntities: observation.relatedEntities,
85
+ }))
86
+ .digest('hex');
87
+ }
88
+
89
+ function sourceKind(observation: Observation): string {
90
+ if (observation.source === 'git' || observation.sourceDetail === 'git-ingest') return 'git';
91
+ if (observation.sourceDetail === 'hook') return 'hook';
92
+ return observation.source ?? 'agent';
93
+ }
94
+
95
+ /** SQLite-backed provenance cards used by MCP, CLI, and Dashboard surfaces. */
96
+ export class EvidenceCardStore {
97
+ private db: any = null;
98
+
99
+ async init(dataDir: string): Promise<void> {
100
+ this.db = getDatabase(dataDir);
101
+ }
102
+
103
+ private requireDb(): any {
104
+ if (!this.db) throw new Error('EvidenceCardStore is not initialized.');
105
+ return this.db;
106
+ }
107
+
108
+ upsertObservation(observation: Observation): EvidenceCardRecord {
109
+ const db = this.requireDb();
110
+ const now = new Date().toISOString();
111
+ const card: EvidenceCardRecord = {
112
+ id: `evidence:observation:${observation.projectId}:${observation.id}`,
113
+ projectId: observation.projectId,
114
+ candidateKind: 'observation',
115
+ candidateId: String(observation.id),
116
+ title: observation.title,
117
+ summary: observation.narrative.slice(0, 500),
118
+ entityName: observation.entityName,
119
+ sourceKind: sourceKind(observation),
120
+ sourceRef: `observation:${observation.id}`,
121
+ locator: observation.filesModified?.join(', ') || `observation:${observation.id}`,
122
+ capturedHash: observationHash(observation),
123
+ ...(observation.sessionId ? { sessionId: observation.sessionId } : {}),
124
+ status: observation.status ?? 'active',
125
+ verification: observation.status === 'resolved' ? 'verified' : 'unverified',
126
+ freshness: 'current',
127
+ files: observation.filesModified ?? [],
128
+ relatedEntities: observation.relatedEntities ?? [],
129
+ createdAt: observation.createdAt,
130
+ updatedAt: now,
131
+ };
132
+ db.prepare(`
133
+ INSERT INTO evidence_cards (
134
+ id, project_id, candidate_kind, candidate_id, title, summary, entity_name,
135
+ source_kind, source_ref, locator, captured_hash, session_id, status,
136
+ verification, freshness, stale_reason, files_json, related_entities_json,
137
+ created_at, updated_at
138
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?)
139
+ ON CONFLICT(project_id, candidate_kind, candidate_id) DO UPDATE SET
140
+ title = excluded.title,
141
+ summary = excluded.summary,
142
+ entity_name = excluded.entity_name,
143
+ source_kind = excluded.source_kind,
144
+ source_ref = excluded.source_ref,
145
+ locator = excluded.locator,
146
+ captured_hash = excluded.captured_hash,
147
+ session_id = excluded.session_id,
148
+ status = excluded.status,
149
+ freshness = 'current',
150
+ stale_reason = NULL,
151
+ files_json = excluded.files_json,
152
+ related_entities_json = excluded.related_entities_json,
153
+ updated_at = excluded.updated_at
154
+ `).run(
155
+ card.id,
156
+ card.projectId,
157
+ card.candidateKind,
158
+ card.candidateId,
159
+ card.title,
160
+ card.summary,
161
+ card.entityName ?? null,
162
+ card.sourceKind,
163
+ card.sourceRef,
164
+ card.locator ?? null,
165
+ card.capturedHash ?? null,
166
+ card.sessionId ?? null,
167
+ card.status,
168
+ card.verification,
169
+ card.freshness,
170
+ JSON.stringify(card.files),
171
+ JSON.stringify(card.relatedEntities),
172
+ card.createdAt,
173
+ card.updatedAt,
174
+ );
175
+ return this.get(card.projectId, card.candidateKind, card.candidateId) ?? card;
176
+ }
177
+
178
+ get(projectId: string, candidateKind: EvidenceCandidateKind, candidateId: string): EvidenceCardRecord | undefined {
179
+ const row = this.requireDb().prepare(`
180
+ SELECT * FROM evidence_cards WHERE project_id = ? AND candidate_kind = ? AND candidate_id = ?
181
+ `).get(projectId, candidateKind, candidateId);
182
+ return row ? rowToCard(row) : undefined;
183
+ }
184
+
185
+ list(projectId: string, options: { limit?: number; freshness?: EvidenceFreshness; status?: string } = {}): EvidenceCardRecord[] {
186
+ const limit = Math.min(100, Math.max(1, Math.floor(options.limit ?? 20)));
187
+ const clauses = ['project_id = ?'];
188
+ const args: unknown[] = [projectId];
189
+ if (options.freshness) { clauses.push('freshness = ?'); args.push(options.freshness); }
190
+ if (options.status) { clauses.push('status = ?'); args.push(options.status); }
191
+ args.push(limit);
192
+ const rows = this.requireDb().prepare(`
193
+ SELECT * FROM evidence_cards WHERE ${clauses.join(' AND ')} ORDER BY updated_at DESC, id DESC LIMIT ?
194
+ `).all(...args);
195
+ return rows.map(rowToCard);
196
+ }
197
+
198
+ syncObservations(observations: Observation[]): EvidenceCardRecord[] {
199
+ return observations.map((observation) => this.upsertObservation(observation));
200
+ }
201
+
202
+ markStaleForPaths(projectId: string, paths: string[], reason = 'source file changed'): number {
203
+ const normalized = new Set(paths.map((value) => value.replace(/\\/g, '/').toLowerCase()));
204
+ if (normalized.size === 0) return 0;
205
+ const db = this.requireDb();
206
+ const rows = db.prepare(`SELECT * FROM evidence_cards WHERE project_id = ? AND freshness != 'stale'`).all(projectId);
207
+ const update = db.prepare(`
208
+ UPDATE evidence_cards SET freshness = 'stale', stale_reason = ?, updated_at = ?
209
+ WHERE id = ?
210
+ `);
211
+ let changed = 0;
212
+ const now = new Date().toISOString();
213
+ const run = db.transaction(() => {
214
+ for (const row of rows) {
215
+ const files = safeJson<string[]>(row.files_json, []);
216
+ if (!files.some((file) => normalized.has(file.replace(/\\/g, '/').toLowerCase()))) continue;
217
+ update.run(reason, now, row.id);
218
+ changed++;
219
+ }
220
+ });
221
+ run();
222
+ return changed;
223
+ }
224
+
225
+ setVerification(projectId: string, cardId: string, verification: EvidenceVerification): void {
226
+ this.requireDb().prepare(`
227
+ UPDATE evidence_cards SET verification = ?, updated_at = ? WHERE project_id = ? AND id = ?
228
+ `).run(verification, new Date().toISOString(), projectId, cardId);
229
+ }
230
+
231
+ recordEvent(projectId: string, cardId: string, kind: string, detail?: string): EvidenceCardEvent {
232
+ const event: EvidenceCardEvent = {
233
+ id: randomUUID(),
234
+ projectId,
235
+ cardId,
236
+ kind,
237
+ ...(detail ? { detail: detail.slice(0, 2_000) } : {}),
238
+ createdAt: new Date().toISOString(),
239
+ };
240
+ this.requireDb().prepare(`
241
+ INSERT INTO evidence_card_events (id, project_id, card_id, kind, detail, created_at)
242
+ VALUES (?, ?, ?, ?, ?, ?)
243
+ `).run(event.id, event.projectId, event.cardId, event.kind, event.detail ?? null, event.createdAt);
244
+ return event;
245
+ }
246
+
247
+ listEvents(cardId: string, limit = 50): EvidenceCardEvent[] {
248
+ const rows = this.requireDb().prepare(`
249
+ SELECT * FROM evidence_card_events WHERE card_id = ? ORDER BY created_at DESC LIMIT ?
250
+ `).all(cardId, Math.min(200, Math.max(1, Math.floor(limit))));
251
+ return rows.map((row: any) => ({
252
+ id: String(row.id), projectId: String(row.project_id), cardId: String(row.card_id),
253
+ kind: String(row.kind), ...(row.detail ? { detail: String(row.detail) } : {}),
254
+ createdAt: String(row.created_at),
255
+ }));
256
+ }
257
+ }
258
+
259
+ export function buildEvidenceCardId(projectId: string, candidateKind: EvidenceCandidateKind, candidateId: string): string {
260
+ return `evidence:${candidateKind}:${projectId}:${candidateId}`;
261
+ }