memorix 1.9.4 → 1.9.6

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 (90) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/README.md +12 -6
  3. package/README.zh-CN.md +12 -6
  4. package/dist/cli/index.js +2533 -585
  5. package/dist/index.d.ts +15 -8
  6. package/dist/index.js +1201 -284
  7. package/dist/maintenance-runner.js +387 -216
  8. package/dist/media-audio-runner.js +300 -129
  9. package/dist/media-video-runner.js +300 -129
  10. package/dist/memcode-runtime/CHANGELOG.md +75 -1
  11. package/dist/sdk.d.ts +1 -0
  12. package/dist/sdk.js +1882 -998
  13. package/dist/types.d.ts +1 -1
  14. package/dist/vector-backfill-runner.js +300 -129
  15. package/docs/AGENT_OPERATOR_PLAYBOOK.md +47 -3
  16. package/docs/API_REFERENCE.md +39 -1
  17. package/docs/CONFIGURATION.md +58 -1
  18. package/docs/DEVELOPMENT.md +1 -1
  19. package/docs/DOCKER.md +23 -3
  20. package/docs/INTEGRATIONS.md +1 -1
  21. package/docs/KNOWN_ISSUES_AND_ROADMAP.md +61 -1
  22. package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +4 -0
  23. package/docs/PERFORMANCE.md +46 -2
  24. package/docs/README.md +2 -1
  25. package/docs/SETUP.md +56 -1
  26. package/package.json +3 -2
  27. package/plugins/antigravity/memorix/plugin.json +1 -1
  28. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  29. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  30. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  31. package/plugins/copilot/memorix/plugin.json +1 -1
  32. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  33. package/plugins/hermes/memorix/plugin.yaml +1 -1
  34. package/plugins/omp/memorix/package.json +1 -1
  35. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  36. package/plugins/pi/memorix/package.json +1 -1
  37. package/server.json +2 -2
  38. package/src/cli/capability-map.ts +1 -0
  39. package/src/cli/command-guide.ts +15 -3
  40. package/src/cli/commands/agent-integrations.ts +57 -0
  41. package/src/cli/commands/background.ts +11 -2
  42. package/src/cli/commands/context.ts +2 -0
  43. package/src/cli/commands/continuity.ts +155 -0
  44. package/src/cli/commands/init.ts +5 -2
  45. package/src/cli/commands/serve-http.ts +435 -84
  46. package/src/cli/commands/setup.ts +77 -10
  47. package/src/cli/commands/sync-store.ts +47 -12
  48. package/src/cli/commands/sync.ts +26 -0
  49. package/src/cli/index.ts +3 -1
  50. package/src/cli/tui/ConfigureView.tsx +2 -0
  51. package/src/codegraph/auto-context.ts +17 -0
  52. package/src/config/legacy-loader.ts +2 -2
  53. package/src/config/resolved-config.ts +8 -5
  54. package/src/config/yaml-loader.ts +20 -10
  55. package/src/dashboard/server.ts +10 -12
  56. package/src/embedding/api-provider.ts +4 -2
  57. package/src/embedding/fastembed-provider.ts +47 -14
  58. package/src/embedding/transformers-provider.ts +29 -7
  59. package/src/evaluation/memory-quality-gate.ts +90 -0
  60. package/src/hooks/official-skills.ts +2 -0
  61. package/src/integrations/registry.ts +1 -1
  62. package/src/knowledge/context-assembly.ts +1 -0
  63. package/src/knowledge/context-receipt.ts +15 -0
  64. package/src/knowledge/task-continuity.ts +413 -0
  65. package/src/knowledge/workset.ts +77 -0
  66. package/src/llm/provider.ts +2 -1
  67. package/src/memory/graph.ts +10 -8
  68. package/src/memory/session.ts +1 -1
  69. package/src/orchestrate/coordinator.ts +60 -0
  70. package/src/orchestrate/effect-ledger.ts +160 -0
  71. package/src/orchestrate/pipeline-trace.ts +40 -6
  72. package/src/runtime/maintenance-jobs.ts +12 -6
  73. package/src/sdk.ts +24 -0
  74. package/src/server/mcp-binding-store.ts +14 -3
  75. package/src/server/modern-mcp-bridge.ts +153 -27
  76. package/src/server/tool-profile.ts +3 -2
  77. package/src/server.ts +131 -7
  78. package/src/store/file-lock.ts +28 -3
  79. package/src/store/graph-store.ts +178 -46
  80. package/src/store/persistence.ts +14 -9
  81. package/src/store/sqlite-db.ts +150 -7
  82. package/src/store/sqlite-store.ts +32 -27
  83. package/src/sync/engine.ts +9 -7
  84. package/src/sync/namespace.ts +23 -0
  85. package/src/sync/policy.ts +19 -4
  86. package/src/sync/store-port.ts +43 -13
  87. package/src/sync/types.ts +2 -0
  88. package/src/team/team-store.ts +62 -27
  89. package/src/types.ts +1 -1
  90. package/src/workspace/mcp-adapters/grok.ts +32 -0
@@ -24,6 +24,7 @@ import { classifyError, resetBackoff, type RecoveryAction } from './error-recove
24
24
  import { calculatePipelineCost, isBudgetExceeded, formatCostSummary } from './cost-tracker.js';
25
25
  import { writeTaskEvidence, writePipelineSummary, type TaskEvidence } from './evidence.js';
26
26
  import { TaskToolTracker } from './permission.js';
27
+ import { AgentEffectLedger } from './effect-ledger.js';
27
28
  import { storeVerifiedFix, storeFixExhausted, searchKnownFixes, searchLessons, storeTaskCompletion, storePipelineSummary as memStorePipelineSummary, type BridgeConfig, DEFAULT_BRIDGE_CONFIG } from './memorix-bridge.js';
28
29
 
29
30
  // ── Types ──────────────────────────────────────────────────────────
@@ -232,10 +233,12 @@ export async function runCoordinationLoop(config: CoordinatorConfig): Promise<Co
232
233
 
233
234
  // Phase 6g: Pipeline tracing
234
235
  let traceDb: ReturnType<typeof teamStore.getDb> | null = null;
236
+ let effectLedger: AgentEffectLedger | null = null;
235
237
  try {
236
238
  traceDb = teamStore.getDb();
237
239
  resetTraceCache();
238
240
  initTraceTable(traceDb);
241
+ if (pipelineId) effectLedger = new AgentEffectLedger(traceDb);
239
242
  } catch { /* best-effort: tracing is non-critical */ }
240
243
 
241
244
  // Phase 6i: Cleanup orphan worktrees from previous crashed runs
@@ -567,6 +570,31 @@ export async function runCoordinationLoop(config: CoordinatorConfig): Promise<Co
567
570
  for await (const msg of agentProcess.messages!) {
568
571
  if (msg.type === 'tool_use') {
569
572
  dispatch.toolCount++;
573
+ const effectKey = msg.callId ?? `seq:${dispatch.toolCount}`;
574
+ const effect = effectLedger?.observe({
575
+ pipelineId: pipelineId!,
576
+ taskId: dispatch.taskId,
577
+ attempt: dispatch.attempt,
578
+ effectKey,
579
+ tool: msg.tool ?? 'unknown',
580
+ ...(msg.callId ? { callId: msg.callId } : {}),
581
+ ...(msg.input ? { input: msg.input } : {}),
582
+ });
583
+ if (effect && traceDb && pipelineId) {
584
+ try {
585
+ writeTrace(traceDb, {
586
+ pipelineId,
587
+ timestamp: Date.now(),
588
+ type: 'effect',
589
+ taskId: dispatch.taskId,
590
+ agent: dispatch.adapterName,
591
+ detail: `observed tool=${effect.tool} risk=${effect.riskTier} replay=${effect.replayPolicy} effect=${effect.effectKey}`,
592
+ attempt: dispatch.attempt,
593
+ riskTier: effect.riskTier,
594
+ effectKey: effect.effectKey,
595
+ });
596
+ } catch { /* effect trace is best-effort */ }
597
+ }
570
598
  // Phase 7: Record tool usage for risk profiling
571
599
  if (msg.tool) tracker.record(msg.tool);
572
600
  emit('agent:tool_use', `[${adapter.name}] tool #${dispatch.toolCount}: ${msg.tool ?? 'unknown'}`, {
@@ -587,6 +615,16 @@ export async function runCoordinationLoop(config: CoordinatorConfig): Promise<Co
587
615
  } catch { /* trace is best-effort */ }
588
616
  }
589
617
  }
618
+ if (msg.type === 'tool_result' && msg.callId && effectLedger && pipelineId) {
619
+ effectLedger.settle({
620
+ pipelineId,
621
+ taskId: dispatch.taskId,
622
+ attempt: dispatch.attempt,
623
+ effectKey: msg.callId,
624
+ status: 'succeeded',
625
+ detail: 'tool result observed',
626
+ });
627
+ }
590
628
  // Accumulate text for planner output extraction (avoids ring buffer truncation)
591
629
  if (msg.type === 'text' && msg.content) {
592
630
  dispatch.accumulatedText += msg.content;
@@ -671,6 +709,9 @@ export async function runCoordinationLoop(config: CoordinatorConfig): Promise<Co
671
709
  if (result.sessionId) {
672
710
  taskSessionIds.set(dispatch.taskId, result.sessionId);
673
711
  }
712
+ if (effectLedger && pipelineId) {
713
+ effectLedger.markAttemptUnknown(pipelineId, dispatch.taskId, dispatch.attempt);
714
+ }
674
715
 
675
716
  // ── 方案 A: Orchestrator owns task lifecycle ──
676
717
  // Agent does NOT call team_task. Orchestrator infers outcome from exit code.
@@ -755,6 +796,25 @@ export async function runCoordinationLoop(config: CoordinatorConfig): Promise<Co
755
796
  for await (const msg of fixProcess.messages!) {
756
797
  if (msg.type === 'tool_use') {
757
798
  fixDispatch.toolCount++;
799
+ effectLedger?.observe({
800
+ pipelineId: pipelineId!,
801
+ taskId: fixDispatch.taskId,
802
+ attempt: fixDispatch.attempt,
803
+ effectKey: msg.callId ?? `seq:${fixDispatch.toolCount}`,
804
+ tool: msg.tool ?? 'unknown',
805
+ ...(msg.callId ? { callId: msg.callId } : {}),
806
+ ...(msg.input ? { input: msg.input } : {}),
807
+ });
808
+ }
809
+ if (msg.type === 'tool_result' && msg.callId && effectLedger && pipelineId) {
810
+ effectLedger.settle({
811
+ pipelineId,
812
+ taskId: fixDispatch.taskId,
813
+ attempt: fixDispatch.attempt,
814
+ effectKey: msg.callId,
815
+ status: 'succeeded',
816
+ detail: 'tool result observed',
817
+ });
758
818
  }
759
819
  if (msg.type === 'text' && msg.content) {
760
820
  fixDispatch.accumulatedText += msg.content;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Durable, privacy-safe records for agent tool effects.
3
+ *
4
+ * This is deliberately not a claim of exactly-once tool delivery. It records
5
+ * what the coordinator observed, whether a result arrived, and what replay
6
+ * policy a future recovery path must apply before repeating the effect.
7
+ */
8
+
9
+ import { createHash, randomUUID } from 'node:crypto';
10
+ import { sanitizeCredentials } from '../memory/secret-filter.js';
11
+ import { classifyTool, type RiskTier } from './permission.js';
12
+
13
+ export type AgentEffectStatus = 'observed' | 'succeeded' | 'failed' | 'unknown';
14
+ export type AgentEffectReplayPolicy = 'safe' | 'requires-idempotency-key';
15
+
16
+ export interface AgentEffectRecord {
17
+ id: string;
18
+ pipelineId: string;
19
+ taskId: string;
20
+ attempt: number;
21
+ effectKey: string;
22
+ tool: string;
23
+ riskTier: RiskTier;
24
+ replayPolicy: AgentEffectReplayPolicy;
25
+ status: AgentEffectStatus;
26
+ callId?: string;
27
+ idempotencyKey: string;
28
+ inputFingerprint?: string;
29
+ detail?: string;
30
+ startedAt: number;
31
+ finishedAt?: number;
32
+ }
33
+
34
+ export function fingerprintEffectInput(input: unknown): string | undefined {
35
+ if (input === undefined) return undefined;
36
+ try {
37
+ const safe = sanitizeCredentials(JSON.stringify(input));
38
+ return createHash('sha256').update(safe).digest('hex').slice(0, 24);
39
+ } catch {
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ function rowToEffect(row: any): AgentEffectRecord {
45
+ return {
46
+ id: String(row.id),
47
+ pipelineId: String(row.pipeline_id),
48
+ taskId: String(row.task_id),
49
+ attempt: Number(row.attempt),
50
+ effectKey: String(row.effect_key),
51
+ tool: String(row.tool),
52
+ riskTier: row.risk_tier as RiskTier,
53
+ replayPolicy: row.replay_policy as AgentEffectReplayPolicy,
54
+ status: row.status as AgentEffectStatus,
55
+ ...(row.call_id ? { callId: String(row.call_id) } : {}),
56
+ idempotencyKey: String(row.idempotency_key),
57
+ ...(row.input_fingerprint ? { inputFingerprint: String(row.input_fingerprint) } : {}),
58
+ ...(row.detail ? { detail: String(row.detail) } : {}),
59
+ startedAt: Number(row.started_at),
60
+ ...(row.finished_at ? { finishedAt: Number(row.finished_at) } : {}),
61
+ };
62
+ }
63
+
64
+ export class AgentEffectLedger {
65
+ constructor(private readonly db: any) {}
66
+
67
+ observe(input: {
68
+ pipelineId: string;
69
+ taskId: string;
70
+ attempt: number;
71
+ effectKey: string;
72
+ tool: string;
73
+ callId?: string;
74
+ input?: unknown;
75
+ }): AgentEffectRecord {
76
+ const existing = this.db.prepare(`
77
+ SELECT * FROM agent_effects
78
+ WHERE pipeline_id = ? AND task_id = ? AND attempt = ? AND effect_key = ?
79
+ `).get(input.pipelineId, input.taskId, input.attempt, input.effectKey);
80
+ if (existing) return rowToEffect(existing);
81
+
82
+ const riskTier = classifyTool(input.tool);
83
+ const replayPolicy: AgentEffectReplayPolicy = riskTier === 'safe'
84
+ ? 'safe'
85
+ : 'requires-idempotency-key';
86
+ const idempotencyKey = `${input.pipelineId}:${input.taskId}:${input.attempt}:${input.effectKey}`;
87
+ const now = Date.now();
88
+ const record: AgentEffectRecord = {
89
+ id: randomUUID(),
90
+ pipelineId: input.pipelineId,
91
+ taskId: input.taskId,
92
+ attempt: input.attempt,
93
+ effectKey: input.effectKey,
94
+ tool: input.tool.slice(0, 200),
95
+ riskTier,
96
+ replayPolicy,
97
+ status: 'observed',
98
+ ...(input.callId ? { callId: input.callId.slice(0, 200) } : {}),
99
+ idempotencyKey,
100
+ ...(fingerprintEffectInput(input.input) ? { inputFingerprint: fingerprintEffectInput(input.input) } : {}),
101
+ startedAt: now,
102
+ };
103
+ this.db.prepare(`
104
+ INSERT OR IGNORE INTO agent_effects (
105
+ id, pipeline_id, task_id, attempt, effect_key, tool, risk_tier,
106
+ replay_policy, status, call_id, idempotency_key, input_fingerprint,
107
+ detail, started_at, finished_at
108
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ `).run(
110
+ record.id, record.pipelineId, record.taskId, record.attempt, record.effectKey,
111
+ record.tool, record.riskTier, record.replayPolicy, record.status,
112
+ record.callId ?? null, record.idempotencyKey, record.inputFingerprint ?? null,
113
+ null, record.startedAt, null,
114
+ );
115
+ return rowToEffect(this.db.prepare(`
116
+ SELECT * FROM agent_effects
117
+ WHERE pipeline_id = ? AND task_id = ? AND attempt = ? AND effect_key = ?
118
+ `).get(input.pipelineId, input.taskId, input.attempt, input.effectKey));
119
+ }
120
+
121
+ settle(input: {
122
+ pipelineId: string;
123
+ taskId: string;
124
+ attempt: number;
125
+ effectKey: string;
126
+ status: Exclude<AgentEffectStatus, 'observed'>;
127
+ detail?: string;
128
+ }): AgentEffectRecord | undefined {
129
+ const detail = input.detail ? sanitizeCredentials(input.detail).slice(0, 500) : undefined;
130
+ this.db.prepare(`
131
+ UPDATE agent_effects
132
+ SET status = ?, detail = ?, finished_at = ?
133
+ WHERE pipeline_id = ? AND task_id = ? AND attempt = ? AND effect_key = ?
134
+ `).run(
135
+ input.status, detail ?? null, Date.now(), input.pipelineId, input.taskId,
136
+ input.attempt, input.effectKey,
137
+ );
138
+ const row = this.db.prepare(`
139
+ SELECT * FROM agent_effects
140
+ WHERE pipeline_id = ? AND task_id = ? AND attempt = ? AND effect_key = ?
141
+ `).get(input.pipelineId, input.taskId, input.attempt, input.effectKey);
142
+ return row ? rowToEffect(row) : undefined;
143
+ }
144
+
145
+ markAttemptUnknown(pipelineId: string, taskId: string, attempt: number): number {
146
+ const result = this.db.prepare(`
147
+ UPDATE agent_effects
148
+ SET status = 'unknown', detail = COALESCE(detail, 'No matching tool result observed'), finished_at = ?
149
+ WHERE pipeline_id = ? AND task_id = ? AND attempt = ? AND status = 'observed'
150
+ `).run(Date.now(), pipelineId, taskId, attempt);
151
+ return Number(result.changes ?? 0);
152
+ }
153
+
154
+ list(pipelineId: string, taskId?: string): AgentEffectRecord[] {
155
+ const rows = taskId
156
+ ? this.db.prepare('SELECT * FROM agent_effects WHERE pipeline_id = ? AND task_id = ? ORDER BY started_at ASC, rowid ASC').all(pipelineId, taskId)
157
+ : this.db.prepare('SELECT * FROM agent_effects WHERE pipeline_id = ? ORDER BY started_at ASC, rowid ASC').all(pipelineId);
158
+ return rows.map(rowToEffect);
159
+ }
160
+ }
@@ -6,13 +6,15 @@
6
6
  */
7
7
 
8
8
  import type Database from 'better-sqlite3';
9
+ import { sanitizeCredentials } from '../memory/secret-filter.js';
9
10
 
10
11
  // ── Types ──────────────────────────────────────────────────────────
11
12
 
12
13
  export type TraceEventType =
13
14
  | 'plan' | 'materialize' | 'dispatch' | 'complete' | 'fail'
14
15
  | 'retry' | 'timeout' | 'stale' | 'replan' | 'worktree:create'
15
- | 'worktree:merge' | 'worktree:cleanup' | 'pipeline:start' | 'pipeline:end';
16
+ | 'worktree:merge' | 'worktree:cleanup' | 'pipeline:start' | 'pipeline:end'
17
+ | 'effect';
16
18
 
17
19
  export interface TraceEvent {
18
20
  pipelineId: string;
@@ -22,6 +24,10 @@ export interface TraceEvent {
22
24
  agent?: string;
23
25
  detail: string;
24
26
  durationMs?: number;
27
+ attempt?: number;
28
+ riskTier?: 'safe' | 'moderate' | 'dangerous';
29
+ effectKey?: string;
30
+ memoryRefs?: string[];
25
31
  }
26
32
 
27
33
  // ── Schema ─────────────────────────────────────────────────────────
@@ -35,7 +41,11 @@ const CREATE_TABLE_SQL = `
35
41
  task_id TEXT,
36
42
  agent TEXT,
37
43
  detail TEXT NOT NULL,
38
- duration_ms INTEGER
44
+ duration_ms INTEGER,
45
+ attempt INTEGER,
46
+ risk_tier TEXT,
47
+ effect_key TEXT,
48
+ memory_refs TEXT
39
49
  )
40
50
  `;
41
51
 
@@ -48,6 +58,9 @@ const CREATE_INDEX_SQL = `
48
58
 
49
59
  export function initTraceTable(db: Database.Database): void {
50
60
  db.exec(CREATE_TABLE_SQL);
61
+ for (const column of ['attempt INTEGER', 'risk_tier TEXT', 'effect_key TEXT', 'memory_refs TEXT']) {
62
+ try { db.exec(`ALTER TABLE pipeline_traces ADD COLUMN ${column}`); } catch { /* already exists */ }
63
+ }
51
64
  db.exec(CREATE_INDEX_SQL);
52
65
  }
53
66
 
@@ -58,8 +71,10 @@ let _insertStmt: Database.Statement | null = null;
58
71
  export function writeTrace(db: Database.Database, event: TraceEvent): void {
59
72
  if (!_insertStmt) {
60
73
  _insertStmt = db.prepare(`
61
- INSERT INTO pipeline_traces (pipeline_id, timestamp, type, task_id, agent, detail, duration_ms)
62
- VALUES (?, ?, ?, ?, ?, ?, ?)
74
+ INSERT INTO pipeline_traces (
75
+ pipeline_id, timestamp, type, task_id, agent, detail, duration_ms,
76
+ attempt, risk_tier, effect_key, memory_refs
77
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
63
78
  `);
64
79
  }
65
80
  _insertStmt.run(
@@ -68,8 +83,12 @@ export function writeTrace(db: Database.Database, event: TraceEvent): void {
68
83
  event.type,
69
84
  event.taskId ?? null,
70
85
  event.agent ?? null,
71
- event.detail,
86
+ sanitizeCredentials(event.detail).slice(0, 2_000),
72
87
  event.durationMs ?? null,
88
+ event.attempt ?? null,
89
+ event.riskTier ?? null,
90
+ event.effectKey ?? null,
91
+ event.memoryRefs ? JSON.stringify(event.memoryRefs.slice(0, 16)) : null,
73
92
  );
74
93
  }
75
94
 
@@ -81,7 +100,8 @@ export function getTraces(db: Database.Database, pipelineId: string): TraceEvent
81
100
  ).all(pipelineId) as Array<{
82
101
  pipeline_id: string; timestamp: number; type: string;
83
102
  task_id: string | null; agent: string | null; detail: string;
84
- duration_ms: number | null;
103
+ duration_ms: number | null; attempt: number | null; risk_tier: string | null;
104
+ effect_key: string | null; memory_refs: string | null;
85
105
  }>;
86
106
 
87
107
  return rows.map(r => ({
@@ -92,9 +112,23 @@ export function getTraces(db: Database.Database, pipelineId: string): TraceEvent
92
112
  agent: r.agent ?? undefined,
93
113
  detail: r.detail,
94
114
  durationMs: r.duration_ms ?? undefined,
115
+ attempt: r.attempt ?? undefined,
116
+ riskTier: r.risk_tier as TraceEvent['riskTier'] ?? undefined,
117
+ effectKey: r.effect_key ?? undefined,
118
+ memoryRefs: parseMemoryRefs(r.memory_refs),
95
119
  }));
96
120
  }
97
121
 
122
+ function parseMemoryRefs(value: string | null): string[] | undefined {
123
+ if (!value) return undefined;
124
+ try {
125
+ const parsed = JSON.parse(value);
126
+ return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : undefined;
127
+ } catch {
128
+ return undefined;
129
+ }
130
+ }
131
+
98
132
  // ── Prune ──────────────────────────────────────────────────────────
99
133
 
100
134
  /**
@@ -92,6 +92,7 @@ const DEFAULT_LEASE_MS = 30_000;
92
92
  const DEFAULT_POLL_INTERVAL_MS = 2_000;
93
93
  const MAX_ERROR_LENGTH = 1_000;
94
94
  const DEFAULT_COMPLETED_HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
95
+ export const DEFAULT_FAILED_HISTORY_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
95
96
 
96
97
  function parsePayload(raw: unknown): Record<string, unknown> {
97
98
  if (typeof raw !== 'string' || !raw) return {};
@@ -292,20 +293,25 @@ export class MaintenanceJobStore {
292
293
 
293
294
  /**
294
295
  * Completed jobs are useful recent operator history, not permanent data.
295
- * Failed jobs stay available for diagnosis until an operator explicitly
296
- * resolves them or clears the database.
296
+ * Failed diagnostics are retained longer, but are still bounded so a
297
+ * permanently failing job kind cannot grow the database forever.
297
298
  */
298
- pruneCompletedHistory(options: { now?: number; maxAgeMs?: number } = {}): number {
299
+ pruneCompletedHistory(options: { now?: number; maxAgeMs?: number; failedMaxAgeMs?: number } = {}): number {
299
300
  const now = options.now ?? Date.now();
300
301
  const maxAgeMs = Number.isFinite(options.maxAgeMs)
301
302
  ? Math.max(0, Math.floor(options.maxAgeMs!))
302
303
  : DEFAULT_COMPLETED_HISTORY_RETENTION_MS;
304
+ const failedMaxAgeMs = Number.isFinite(options.failedMaxAgeMs)
305
+ ? Math.max(0, Math.floor(options.failedMaxAgeMs!))
306
+ : DEFAULT_FAILED_HISTORY_RETENTION_MS;
303
307
  const result = this.db.prepare(`
304
308
  DELETE FROM maintenance_jobs
305
- WHERE status = 'completed'
309
+ WHERE (status = 'completed'
306
310
  AND completed_at IS NOT NULL
307
- AND completed_at < ?
308
- `).run(now - maxAgeMs);
311
+ AND completed_at < ?)
312
+ OR (status = 'failed'
313
+ AND updated_at < ?)
314
+ `).run(now - maxAgeMs, now - failedMaxAgeMs);
309
315
  return Number(result.changes ?? 0);
310
316
  }
311
317
 
package/src/sdk.ts CHANGED
@@ -28,6 +28,7 @@ import type {
28
28
  ObservationReader,
29
29
  RetrievalQuality,
30
30
  } from './types.js';
31
+ import path from 'node:path';
31
32
  import {
32
33
  canManageObservation,
33
34
  canReadObservation,
@@ -101,6 +102,9 @@ export interface ResolveResult {
101
102
  notFound: number[];
102
103
  }
103
104
 
105
+ const activeSdkClients = new Map<string, number>();
106
+ let activeSdkDataDir: string | null = null;
107
+
104
108
  /**
105
109
  * A lightweight, self-contained memory client for reading and writing
106
110
  * Memorix observations without MCP overhead.
@@ -124,6 +128,7 @@ export class MemoryClient {
124
128
  private _oramaStore!: typeof import('./store/orama-store.js');
125
129
  private _obsStore!: typeof import('./store/obs-store.js');
126
130
  private _freshness!: typeof import('./memory/freshness.js');
131
+ private _sdkRefRegistered = false;
127
132
 
128
133
  /** @internal — use createMemoryClient() instead */
129
134
  constructor(projectId: string, projectRoot: string, dataDir: string) {
@@ -147,6 +152,12 @@ export class MemoryClient {
147
152
  * Called by createMemoryClient(). Do not call directly.
148
153
  */
149
154
  async _init(silent: boolean): Promise<void> {
155
+ const normalizedDataDir = path.resolve(this._dataDir);
156
+ if (activeSdkDataDir && activeSdkDataDir !== normalizedDataDir) {
157
+ throw new Error(
158
+ '[memorix-sdk] Multiple active clients with different data directories are not supported in one process; use separate processes.',
159
+ );
160
+ }
150
161
  // Suppress logs if requested
151
162
  const originalError = console.error;
152
163
  if (silent) {
@@ -167,6 +178,9 @@ export class MemoryClient {
167
178
 
168
179
  // Prepare search index (hydrate Orama from SQLite)
169
180
  await this._observations.prepareSearchIndex();
181
+ activeSdkDataDir = normalizedDataDir;
182
+ activeSdkClients.set(normalizedDataDir, (activeSdkClients.get(normalizedDataDir) ?? 0) + 1);
183
+ this._sdkRefRegistered = true;
170
184
  } finally {
171
185
  if (silent) {
172
186
  console.error = originalError;
@@ -298,6 +312,16 @@ export class MemoryClient {
298
312
  async close(): Promise<void> {
299
313
  if (this._closed) return;
300
314
  this._closed = true;
315
+ const normalizedDataDir = path.resolve(this._dataDir);
316
+ const currentRefs = activeSdkClients.get(normalizedDataDir) ?? 0;
317
+ const remainingRefs = Math.max(0, currentRefs - (this._sdkRefRegistered ? 1 : 0));
318
+ if (remainingRefs > 0) {
319
+ activeSdkClients.set(normalizedDataDir, remainingRefs);
320
+ this._sdkRefRegistered = false;
321
+ return;
322
+ }
323
+ activeSdkClients.delete(normalizedDataDir);
324
+ if (activeSdkDataDir === normalizedDataDir) activeSdkDataDir = null;
301
325
  try {
302
326
  this._obsStore.resetObservationStore();
303
327
  await this._oramaStore.resetDb();
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { getDatabase } from '../store/sqlite-db.js';
3
3
 
4
+ export const DEFAULT_MCP_BINDING_TTL_MS = 30 * 24 * 60 * 60 * 1000;
5
+
4
6
  export interface McpProjectBinding {
5
7
  handleId: string;
6
8
  projectId: string;
@@ -38,6 +40,7 @@ export class McpBindingStore {
38
40
 
39
41
  create(input: { projectId: string; projectRoot: string; dataDir: string; ttlMs?: number }): McpProjectBinding {
40
42
  const now = new Date();
43
+ const ttlMs = input.ttlMs ?? DEFAULT_MCP_BINDING_TTL_MS;
41
44
  const binding: McpProjectBinding = {
42
45
  handleId: `mxh_${randomUUID().replace(/-/g, '')}`,
43
46
  projectId: input.projectId,
@@ -45,7 +48,7 @@ export class McpBindingStore {
45
48
  dataDir: input.dataDir,
46
49
  createdAt: now.toISOString(),
47
50
  lastUsedAt: now.toISOString(),
48
- ...(input.ttlMs && input.ttlMs > 0 ? { expiresAt: new Date(now.getTime() + input.ttlMs).toISOString() } : {}),
51
+ ...(ttlMs > 0 ? { expiresAt: new Date(now.getTime() + ttlMs).toISOString() } : {}),
49
52
  };
50
53
  this.requireDb().prepare(`
51
54
  INSERT INTO mcp_bindings (handle_id, project_id, project_root, data_dir, created_at, last_used_at, expires_at)
@@ -69,8 +72,16 @@ export class McpBindingStore {
69
72
  const binding = this.get(handleId);
70
73
  if (!binding) return undefined;
71
74
  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 };
75
+ const expiresAt = new Date(Date.now() + DEFAULT_MCP_BINDING_TTL_MS).toISOString();
76
+ this.requireDb().prepare(`UPDATE mcp_bindings SET last_used_at = ?, expires_at = ? WHERE handle_id = ?`).run(lastUsedAt, expiresAt, handleId);
77
+ return { ...binding, lastUsedAt, expiresAt };
78
+ }
79
+
80
+ cleanupExpired(now = Date.now()): number {
81
+ const result = this.requireDb().prepare(
82
+ `DELETE FROM mcp_bindings WHERE expires_at IS NOT NULL AND expires_at <= ?`,
83
+ ).run(new Date(now).toISOString());
84
+ return Number(result.changes ?? 0);
74
85
  }
75
86
 
76
87
  /** Reuse the most recent valid extension handle for a verified project root. */