memorix 1.1.9 → 1.1.11

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 (188) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/TEAM.md +86 -86
  3. package/dist/cli/index.js +2452 -845
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/dashboard/static/app.js +29 -3
  6. package/dist/dashboard/static/index.html +201 -201
  7. package/dist/dashboard/static/style.css +3762 -3584
  8. package/dist/index.js +2790 -1310
  9. package/dist/index.js.map +1 -1
  10. package/dist/maintenance-runner.d.ts +40 -0
  11. package/dist/maintenance-runner.js +8193 -0
  12. package/dist/maintenance-runner.js.map +1 -0
  13. package/dist/memcode-runtime/CHANGELOG.md +28 -0
  14. package/dist/memcode-runtime/package.json +4 -4
  15. package/dist/sdk.js +2787 -1307
  16. package/dist/sdk.js.map +1 -1
  17. package/docs/CONFIGURATION.md +15 -4
  18. package/docs/DESIGN_DECISIONS.md +357 -357
  19. package/docs/GIT_MEMORY.md +2 -2
  20. package/docs/PERFORMANCE.md +23 -0
  21. package/docs/dev-log/progress.txt +54 -24
  22. package/package.json +1 -1
  23. package/src/audit/index.ts +156 -156
  24. package/src/cli/commands/audit-list.ts +89 -89
  25. package/src/cli/commands/background.ts +659 -659
  26. package/src/cli/commands/cleanup.ts +279 -255
  27. package/src/cli/commands/codegraph.ts +7 -5
  28. package/src/cli/commands/formation.ts +48 -48
  29. package/src/cli/commands/git-hook-install.ts +111 -111
  30. package/src/cli/commands/handoff.ts +66 -66
  31. package/src/cli/commands/hooks-status.ts +63 -63
  32. package/src/cli/commands/ingest-commit.ts +153 -153
  33. package/src/cli/commands/ingest-image.ts +73 -73
  34. package/src/cli/commands/ingest-log.ts +180 -180
  35. package/src/cli/commands/ingest.ts +44 -44
  36. package/src/cli/commands/integrate-shared.ts +15 -15
  37. package/src/cli/commands/lock.ts +96 -96
  38. package/src/cli/commands/message.ts +121 -121
  39. package/src/cli/commands/poll.ts +70 -70
  40. package/src/cli/commands/purge-all-memory.ts +85 -85
  41. package/src/cli/commands/purge-project-memory.ts +83 -83
  42. package/src/cli/commands/reasoning.ts +132 -132
  43. package/src/cli/commands/retention.ts +107 -108
  44. package/src/cli/commands/serve-http.ts +49 -22
  45. package/src/cli/commands/serve-shared.ts +118 -118
  46. package/src/cli/commands/skills.ts +123 -123
  47. package/src/cli/commands/task.ts +192 -192
  48. package/src/cli/commands/transfer.ts +73 -73
  49. package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
  50. package/src/cli/tui/ChatView.tsx +234 -234
  51. package/src/cli/tui/CommandBar.tsx +312 -312
  52. package/src/cli/tui/ContextRail.tsx +118 -118
  53. package/src/cli/tui/HeaderBar.tsx +72 -72
  54. package/src/cli/tui/LogoBanner.tsx +51 -51
  55. package/src/cli/tui/Panels.tsx +632 -632
  56. package/src/cli/tui/Sidebar.tsx +179 -179
  57. package/src/cli/tui/chat-service.ts +742 -742
  58. package/src/cli/tui/data.ts +547 -547
  59. package/src/cli/tui/index.ts +41 -41
  60. package/src/cli/tui/markdown-render.tsx +371 -371
  61. package/src/cli/tui/theme.ts +178 -178
  62. package/src/cli/tui/use-mouse.ts +157 -157
  63. package/src/cli/tui/useNavigation.ts +56 -56
  64. package/src/cli/update-checker.ts +211 -211
  65. package/src/cli/version.ts +7 -7
  66. package/src/cli/workbench.ts +1 -1
  67. package/src/codegraph/auto-context.ts +50 -29
  68. package/src/codegraph/binder.ts +109 -16
  69. package/src/codegraph/exclude.ts +10 -0
  70. package/src/codegraph/lite-provider.ts +148 -18
  71. package/src/codegraph/project-context.ts +58 -12
  72. package/src/codegraph/store.ts +182 -0
  73. package/src/compact/token-budget.ts +74 -74
  74. package/src/config/behavior.ts +97 -59
  75. package/src/config/resolved-config.ts +4 -0
  76. package/src/config/toml-loader.ts +2 -0
  77. package/src/config/yaml-loader.ts +3 -1
  78. package/src/dashboard/project-classification.ts +64 -64
  79. package/src/dashboard/server.ts +31 -30
  80. package/src/dashboard/static/index.html +201 -201
  81. package/src/dashboard/static/style.css +3762 -3584
  82. package/src/embedding/fastembed-provider.ts +142 -142
  83. package/src/embedding/transformers-provider.ts +111 -111
  84. package/src/git/extractor.ts +209 -209
  85. package/src/git/hooks-path.ts +85 -85
  86. package/src/git/noise-filter.ts +226 -210
  87. package/src/hooks/handler.ts +10 -2
  88. package/src/hooks/pattern-detector.ts +173 -173
  89. package/src/hooks/significance-filter.ts +250 -250
  90. package/src/llm/memory-manager.ts +328 -328
  91. package/src/llm/provider.ts +885 -885
  92. package/src/llm/quality.ts +248 -248
  93. package/src/memory/attribution-guard.ts +249 -249
  94. package/src/memory/auto-relations.ts +107 -107
  95. package/src/memory/consolidation.ts +328 -302
  96. package/src/memory/disclosure-policy.ts +141 -141
  97. package/src/memory/entity-extractor.ts +197 -197
  98. package/src/memory/export-import.ts +3 -1
  99. package/src/memory/formation/evaluate.ts +217 -217
  100. package/src/memory/formation/extract.ts +361 -361
  101. package/src/memory/formation/index.ts +417 -417
  102. package/src/memory/formation/resolve.ts +344 -344
  103. package/src/memory/formation/types.ts +315 -315
  104. package/src/memory/freshness.ts +122 -122
  105. package/src/memory/graph.ts +197 -197
  106. package/src/memory/observations.ts +91 -27
  107. package/src/memory/refs.ts +94 -94
  108. package/src/memory/retention.ts +511 -433
  109. package/src/memory/secret-filter.ts +79 -79
  110. package/src/memory/session.ts +542 -523
  111. package/src/multimodal/image-loader.ts +143 -143
  112. package/src/orchestrate/adapters/claude-stream.ts +192 -192
  113. package/src/orchestrate/adapters/claude.ts +111 -111
  114. package/src/orchestrate/adapters/codex-stream.ts +134 -134
  115. package/src/orchestrate/adapters/codex.ts +41 -41
  116. package/src/orchestrate/adapters/gemini-stream.ts +166 -166
  117. package/src/orchestrate/adapters/gemini.ts +42 -42
  118. package/src/orchestrate/adapters/index.ts +73 -73
  119. package/src/orchestrate/adapters/opencode-stream.ts +143 -143
  120. package/src/orchestrate/adapters/opencode.ts +47 -47
  121. package/src/orchestrate/adapters/spawn-helper.ts +286 -286
  122. package/src/orchestrate/adapters/types.ts +77 -77
  123. package/src/orchestrate/capability-router.ts +284 -284
  124. package/src/orchestrate/context-compact.ts +188 -188
  125. package/src/orchestrate/cost-tracker.ts +219 -219
  126. package/src/orchestrate/error-recovery.ts +191 -191
  127. package/src/orchestrate/evidence.ts +140 -140
  128. package/src/orchestrate/ledger.ts +110 -110
  129. package/src/orchestrate/memorix-bridge.ts +380 -380
  130. package/src/orchestrate/output-budget.ts +80 -80
  131. package/src/orchestrate/permission.ts +152 -152
  132. package/src/orchestrate/pipeline-trace.ts +131 -131
  133. package/src/orchestrate/prompt-builder.ts +155 -155
  134. package/src/orchestrate/ring-buffer.ts +37 -37
  135. package/src/orchestrate/task-graph.ts +389 -389
  136. package/src/orchestrate/verify-gate.ts +219 -219
  137. package/src/orchestrate/worktree.ts +232 -232
  138. package/src/project/aliases.ts +374 -374
  139. package/src/project/detector.ts +268 -268
  140. package/src/rules/adapters/claude-code.ts +99 -99
  141. package/src/rules/adapters/codex.ts +97 -97
  142. package/src/rules/adapters/copilot.ts +124 -124
  143. package/src/rules/adapters/cursor.ts +114 -114
  144. package/src/rules/adapters/kiro.ts +126 -126
  145. package/src/rules/adapters/trae.ts +56 -56
  146. package/src/rules/adapters/windsurf.ts +83 -83
  147. package/src/rules/syncer.ts +235 -235
  148. package/src/runtime/control-plane-maintenance.ts +62 -0
  149. package/src/runtime/isolated-maintenance.ts +187 -0
  150. package/src/runtime/maintenance-jobs.ts +512 -0
  151. package/src/runtime/maintenance-runner.ts +96 -0
  152. package/src/runtime/maintenance-targets.ts +59 -0
  153. package/src/runtime/project-maintenance.ts +274 -0
  154. package/src/sdk.ts +327 -327
  155. package/src/search/intent-detector.ts +289 -289
  156. package/src/search/query-expansion.ts +52 -52
  157. package/src/server/formation-timeout.ts +27 -27
  158. package/src/server.ts +212 -224
  159. package/src/skills/mini-skills.ts +386 -386
  160. package/src/store/chat-store.ts +119 -119
  161. package/src/store/file-lock.ts +100 -100
  162. package/src/store/graph-store.ts +249 -249
  163. package/src/store/mini-skill-store.ts +349 -349
  164. package/src/store/obs-store.ts +293 -255
  165. package/src/store/persistence-json.ts +212 -212
  166. package/src/store/persistence.ts +291 -291
  167. package/src/store/project-affinity.ts +195 -195
  168. package/src/store/session-store.ts +268 -259
  169. package/src/store/sqlite-db.ts +40 -0
  170. package/src/store/sqlite-store.ts +429 -339
  171. package/src/team/event-bus.ts +76 -76
  172. package/src/team/file-locks.ts +173 -173
  173. package/src/team/handoff.ts +167 -167
  174. package/src/team/messages.ts +203 -203
  175. package/src/team/poll.ts +132 -132
  176. package/src/team/tasks.ts +211 -211
  177. package/src/wiki/generator.ts +237 -237
  178. package/src/wiki/knowledge-graph.ts +334 -334
  179. package/src/wiki/types.ts +85 -85
  180. package/src/workspace/mcp-adapters/codex.ts +191 -191
  181. package/src/workspace/mcp-adapters/copilot.ts +105 -105
  182. package/src/workspace/mcp-adapters/cursor.ts +53 -53
  183. package/src/workspace/mcp-adapters/kiro.ts +64 -64
  184. package/src/workspace/mcp-adapters/opencode.ts +123 -123
  185. package/src/workspace/mcp-adapters/trae.ts +134 -134
  186. package/src/workspace/mcp-adapters/windsurf.ts +91 -91
  187. package/src/workspace/sanitizer.ts +60 -60
  188. package/src/workspace/workflow-sync.ts +131 -131
@@ -0,0 +1,512 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { getDatabase } from '../store/sqlite-db.js';
4
+ import { sanitizeCredentials } from '../memory/secret-filter.js';
5
+
6
+ export const MAINTENANCE_JOB_KINDS = [
7
+ 'vector-backfill',
8
+ 'retention-archive',
9
+ 'consolidation',
10
+ 'codegraph-refresh',
11
+ ] as const;
12
+
13
+ export type MaintenanceJobKind = typeof MAINTENANCE_JOB_KINDS[number];
14
+ export type MaintenanceJobStatus = 'pending' | 'running' | 'retry' | 'completed' | 'failed';
15
+
16
+ export interface MaintenanceJob {
17
+ id: string;
18
+ projectId: string;
19
+ kind: MaintenanceJobKind;
20
+ dedupeKey: string;
21
+ payload: Record<string, unknown>;
22
+ status: MaintenanceJobStatus;
23
+ attempts: number;
24
+ maxAttempts: number;
25
+ runAfter: number;
26
+ leaseOwner?: string;
27
+ leaseExpiresAt?: number;
28
+ lastError?: string;
29
+ createdAt: number;
30
+ updatedAt: number;
31
+ completedAt?: number;
32
+ }
33
+
34
+ export interface EnqueueMaintenanceJobInput {
35
+ projectId: string;
36
+ kind: MaintenanceJobKind;
37
+ dedupeKey?: string;
38
+ payload?: Record<string, unknown>;
39
+ maxAttempts?: number;
40
+ runAfter?: number;
41
+ now?: number;
42
+ }
43
+
44
+ export interface ClaimMaintenanceJobOptions {
45
+ workerId: string;
46
+ projectId?: string;
47
+ /** Limit this worker to compatible job kinds without claiming other work. */
48
+ kinds?: readonly MaintenanceJobKind[];
49
+ leaseMs?: number;
50
+ now?: number;
51
+ }
52
+
53
+ export interface MaintenanceJobSummary {
54
+ total: number;
55
+ pending: number;
56
+ running: number;
57
+ retrying: number;
58
+ completed: number;
59
+ failed: number;
60
+ }
61
+
62
+ export type MaintenanceJobRunResult =
63
+ | { action: 'complete' }
64
+ | { action: 'reschedule'; delayMs: number; resetAttempts?: boolean; payload?: Record<string, unknown> };
65
+
66
+ export type MaintenanceJobHandler = (
67
+ job: MaintenanceJob,
68
+ ) => Promise<MaintenanceJobRunResult | void>;
69
+
70
+ const DEFAULT_MAX_ATTEMPTS = 8;
71
+ const DEFAULT_LEASE_MS = 30_000;
72
+ const DEFAULT_POLL_INTERVAL_MS = 2_000;
73
+ const MAX_ERROR_LENGTH = 1_000;
74
+ const DEFAULT_COMPLETED_HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
75
+
76
+ function parsePayload(raw: unknown): Record<string, unknown> {
77
+ if (typeof raw !== 'string' || !raw) return {};
78
+ try {
79
+ const parsed = JSON.parse(raw);
80
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
81
+ ? parsed as Record<string, unknown>
82
+ : {};
83
+ } catch {
84
+ return {};
85
+ }
86
+ }
87
+
88
+ function rowToJob(row: any): MaintenanceJob {
89
+ return {
90
+ id: row.id,
91
+ projectId: row.project_id,
92
+ kind: row.kind as MaintenanceJobKind,
93
+ dedupeKey: row.dedupe_key,
94
+ payload: parsePayload(row.payload_json),
95
+ status: row.status as MaintenanceJobStatus,
96
+ attempts: Number(row.attempts),
97
+ maxAttempts: Number(row.max_attempts),
98
+ runAfter: Number(row.run_after),
99
+ ...(row.lease_owner ? { leaseOwner: row.lease_owner } : {}),
100
+ ...(row.lease_expires_at != null ? { leaseExpiresAt: Number(row.lease_expires_at) } : {}),
101
+ ...(row.last_error ? { lastError: row.last_error } : {}),
102
+ createdAt: Number(row.created_at),
103
+ updatedAt: Number(row.updated_at),
104
+ ...(row.completed_at != null ? { completedAt: Number(row.completed_at) } : {}),
105
+ };
106
+ }
107
+
108
+ function clampPositiveInteger(value: number | undefined, fallback: number): number {
109
+ if (!Number.isFinite(value) || value == null) return fallback;
110
+ return Math.max(1, Math.floor(value));
111
+ }
112
+
113
+ function errorText(error: unknown): string {
114
+ const text = error instanceof Error ? error.message : String(error);
115
+ return sanitizeCredentials(text).slice(0, MAX_ERROR_LENGTH);
116
+ }
117
+
118
+ function normalizedKinds(kinds: readonly MaintenanceJobKind[] | undefined): MaintenanceJobKind[] | undefined {
119
+ if (!kinds) return undefined;
120
+ return [...new Set(kinds.filter((kind) => MAINTENANCE_JOB_KINDS.includes(kind)))];
121
+ }
122
+
123
+ /** Retry delay grows slowly enough for a local control plane but never spins. */
124
+ export function getMaintenanceRetryDelayMs(attempts: number): number {
125
+ return Math.min(60_000, 1_000 * (2 ** Math.max(0, attempts - 1)));
126
+ }
127
+
128
+ /**
129
+ * Persistent queue for work that must never run inside an MCP tool handler.
130
+ * SQLite leases make recovery deterministic when a process exits mid-job.
131
+ */
132
+ export class MaintenanceJobStore {
133
+ private readonly db: any;
134
+
135
+ constructor(dataDir: string) {
136
+ this.db = getDatabase(dataDir);
137
+ }
138
+
139
+ enqueue(input: EnqueueMaintenanceJobInput): MaintenanceJob {
140
+ const now = input.now ?? Date.now();
141
+ const dedupeKey = input.dedupeKey ?? input.kind;
142
+ const runAfter = input.runAfter ?? now;
143
+ const maxAttempts = clampPositiveInteger(input.maxAttempts, DEFAULT_MAX_ATTEMPTS);
144
+ const payloadJson = JSON.stringify(input.payload ?? {});
145
+
146
+ this.begin();
147
+ try {
148
+ this.pruneCompletedHistory({ now });
149
+ const existing = this.db.prepare(`
150
+ SELECT * FROM maintenance_jobs
151
+ WHERE project_id = ? AND kind = ? AND dedupe_key = ?
152
+ AND status IN ('pending', 'running', 'retry')
153
+ LIMIT 1
154
+ `).get(input.projectId, input.kind, dedupeKey);
155
+
156
+ if (existing) {
157
+ const nextRunAfter = Math.min(Number(existing.run_after), runAfter);
158
+ this.db.prepare(`
159
+ UPDATE maintenance_jobs
160
+ SET payload_json = ?, run_after = ?, updated_at = ?
161
+ WHERE id = ?
162
+ `).run(payloadJson, nextRunAfter, now, existing.id);
163
+ const updated = this.getRow(existing.id)!;
164
+ this.commit();
165
+ return rowToJob(updated);
166
+ }
167
+
168
+ const id = randomUUID();
169
+ this.db.prepare(`
170
+ INSERT INTO maintenance_jobs (
171
+ id, project_id, kind, dedupe_key, payload_json, status, attempts,
172
+ max_attempts, run_after, created_at, updated_at
173
+ ) VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)
174
+ `).run(id, input.projectId, input.kind, dedupeKey, payloadJson, maxAttempts, runAfter, now, now);
175
+ const created = this.getRow(id)!;
176
+ this.commit();
177
+ return rowToJob(created);
178
+ } catch (error) {
179
+ this.rollback();
180
+ throw error;
181
+ }
182
+ }
183
+
184
+ get(id: string): MaintenanceJob | undefined {
185
+ const row = this.getRow(id);
186
+ return row ? rowToJob(row) : undefined;
187
+ }
188
+
189
+ list(options: { projectId?: string; status?: MaintenanceJobStatus; limit?: number } = {}): MaintenanceJob[] {
190
+ const limit = Math.min(500, clampPositiveInteger(options.limit, 100));
191
+ const clauses: string[] = [];
192
+ const values: unknown[] = [];
193
+ if (options.projectId) {
194
+ clauses.push('project_id = ?');
195
+ values.push(options.projectId);
196
+ }
197
+ if (options.status) {
198
+ clauses.push('status = ?');
199
+ values.push(options.status);
200
+ }
201
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
202
+ const rows = this.db.prepare(`
203
+ SELECT * FROM maintenance_jobs
204
+ ${where}
205
+ ORDER BY run_after ASC, created_at ASC
206
+ LIMIT ?
207
+ `).all(...values, limit);
208
+ return rows.map(rowToJob);
209
+ }
210
+
211
+ summary(projectId?: string): MaintenanceJobSummary {
212
+ const row = projectId
213
+ ? this.db.prepare(`
214
+ SELECT
215
+ COUNT(*) AS total,
216
+ SUM(status = 'pending') AS pending,
217
+ SUM(status = 'running') AS running,
218
+ SUM(status = 'retry') AS retrying,
219
+ SUM(status = 'completed') AS completed,
220
+ SUM(status = 'failed') AS failed
221
+ FROM maintenance_jobs WHERE project_id = ?
222
+ `).get(projectId)
223
+ : this.db.prepare(`
224
+ SELECT
225
+ COUNT(*) AS total,
226
+ SUM(status = 'pending') AS pending,
227
+ SUM(status = 'running') AS running,
228
+ SUM(status = 'retry') AS retrying,
229
+ SUM(status = 'completed') AS completed,
230
+ SUM(status = 'failed') AS failed
231
+ FROM maintenance_jobs
232
+ `).get();
233
+ return {
234
+ total: Number(row?.total ?? 0),
235
+ pending: Number(row?.pending ?? 0),
236
+ running: Number(row?.running ?? 0),
237
+ retrying: Number(row?.retrying ?? 0),
238
+ completed: Number(row?.completed ?? 0),
239
+ failed: Number(row?.failed ?? 0),
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Completed jobs are useful recent operator history, not permanent data.
245
+ * Failed jobs stay available for diagnosis until an operator explicitly
246
+ * resolves them or clears the database.
247
+ */
248
+ pruneCompletedHistory(options: { now?: number; maxAgeMs?: number } = {}): number {
249
+ const now = options.now ?? Date.now();
250
+ const maxAgeMs = Number.isFinite(options.maxAgeMs)
251
+ ? Math.max(0, Math.floor(options.maxAgeMs!))
252
+ : DEFAULT_COMPLETED_HISTORY_RETENTION_MS;
253
+ const result = this.db.prepare(`
254
+ DELETE FROM maintenance_jobs
255
+ WHERE status = 'completed'
256
+ AND completed_at IS NOT NULL
257
+ AND completed_at < ?
258
+ `).run(now - maxAgeMs);
259
+ return Number(result.changes ?? 0);
260
+ }
261
+
262
+ claimNext(options: ClaimMaintenanceJobOptions): MaintenanceJob | undefined {
263
+ const now = options.now ?? Date.now();
264
+ const leaseMs = clampPositiveInteger(options.leaseMs, DEFAULT_LEASE_MS);
265
+ const kinds = normalizedKinds(options.kinds);
266
+
267
+ this.begin();
268
+ try {
269
+ // A dead worker leaves a leased row behind. Make it retryable before
270
+ // selecting the next candidate, while respecting its retry budget.
271
+ this.db.prepare(`
272
+ UPDATE maintenance_jobs
273
+ SET
274
+ status = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'retry' END,
275
+ run_after = CASE WHEN attempts >= max_attempts THEN run_after ELSE ? END,
276
+ lease_owner = NULL,
277
+ lease_expires_at = NULL,
278
+ last_error = COALESCE(last_error, 'worker lease expired'),
279
+ updated_at = ?
280
+ WHERE status = 'running' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?
281
+ `).run(now, now, now);
282
+ this.db.prepare(`
283
+ UPDATE maintenance_jobs
284
+ SET status = 'failed', updated_at = ?
285
+ WHERE status IN ('pending', 'retry') AND attempts >= max_attempts
286
+ `).run(now);
287
+
288
+ if (options.kinds && kinds?.length === 0) {
289
+ this.commit();
290
+ return undefined;
291
+ }
292
+ const projectClause = options.projectId ? 'AND project_id = ?' : '';
293
+ const kindClause = kinds?.length
294
+ ? `AND kind IN (${kinds.map(() => '?').join(', ')})`
295
+ : '';
296
+ const values = [
297
+ now,
298
+ ...(kinds ?? []),
299
+ ...(options.projectId ? [options.projectId] : []),
300
+ ];
301
+ const candidate = this.db.prepare(`
302
+ SELECT * FROM maintenance_jobs
303
+ WHERE status IN ('pending', 'retry') AND run_after <= ?
304
+ ${kindClause}
305
+ ${projectClause}
306
+ ORDER BY run_after ASC, created_at ASC
307
+ LIMIT 1
308
+ `).get(...values);
309
+
310
+ if (!candidate) {
311
+ this.commit();
312
+ return undefined;
313
+ }
314
+
315
+ const leaseExpiresAt = now + leaseMs;
316
+ this.db.prepare(`
317
+ UPDATE maintenance_jobs
318
+ SET status = 'running', attempts = attempts + 1, lease_owner = ?,
319
+ lease_expires_at = ?, updated_at = ?
320
+ WHERE id = ?
321
+ `).run(options.workerId, leaseExpiresAt, now, candidate.id);
322
+ const claimed = this.getRow(candidate.id)!;
323
+ this.commit();
324
+ return rowToJob(claimed);
325
+ } catch (error) {
326
+ this.rollback();
327
+ throw error;
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Extend a running job's lease. A worker may only renew its own lease, so a
333
+ * stale worker cannot reclaim work that was already recovered elsewhere.
334
+ */
335
+ renewLease(id: string, workerId: string, leaseMs = DEFAULT_LEASE_MS, now = Date.now()): boolean {
336
+ const expiresAt = now + clampPositiveInteger(leaseMs, DEFAULT_LEASE_MS);
337
+ const result = this.db.prepare(`
338
+ UPDATE maintenance_jobs
339
+ SET lease_expires_at = ?, updated_at = ?
340
+ WHERE id = ? AND status = 'running' AND lease_owner = ?
341
+ `).run(expiresAt, now, id, workerId);
342
+ return Number(result.changes) > 0;
343
+ }
344
+
345
+ complete(id: string, workerId: string, now = Date.now()): MaintenanceJob | undefined {
346
+ this.db.prepare(`
347
+ UPDATE maintenance_jobs
348
+ SET status = 'completed', lease_owner = NULL, lease_expires_at = NULL,
349
+ completed_at = ?, updated_at = ?
350
+ WHERE id = ? AND status = 'running' AND lease_owner = ?
351
+ `).run(now, now, id, workerId);
352
+ return this.get(id);
353
+ }
354
+
355
+ reschedule(
356
+ id: string,
357
+ workerId: string,
358
+ options: { delayMs: number; resetAttempts?: boolean; payload?: Record<string, unknown>; now?: number },
359
+ ): MaintenanceJob | undefined {
360
+ const now = options.now ?? Date.now();
361
+ const delayMs = Number.isFinite(options.delayMs) ? Math.max(0, Math.floor(options.delayMs)) : 0;
362
+ const payloadJson = options.payload === undefined ? null : JSON.stringify(options.payload);
363
+ this.db.prepare(`
364
+ UPDATE maintenance_jobs
365
+ SET status = 'pending', run_after = ?, attempts = CASE WHEN ? THEN 0 ELSE attempts END,
366
+ payload_json = COALESCE(?, payload_json),
367
+ lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
368
+ WHERE id = ? AND status = 'running' AND lease_owner = ?
369
+ `).run(now + delayMs, options.resetAttempts ? 1 : 0, payloadJson, now, id, workerId);
370
+ return this.get(id);
371
+ }
372
+
373
+ fail(id: string, workerId: string, error: unknown, now = Date.now()): MaintenanceJob | undefined {
374
+ this.begin();
375
+ try {
376
+ const current = this.db.prepare(`
377
+ SELECT * FROM maintenance_jobs
378
+ WHERE id = ? AND status = 'running' AND lease_owner = ?
379
+ `).get(id, workerId);
380
+ if (!current) {
381
+ this.commit();
382
+ return undefined;
383
+ }
384
+
385
+ const attempts = Number(current.attempts);
386
+ const exhausted = attempts >= Number(current.max_attempts);
387
+ const status: MaintenanceJobStatus = exhausted ? 'failed' : 'retry';
388
+ const runAfter = exhausted ? Number(current.run_after) : now + getMaintenanceRetryDelayMs(attempts);
389
+ this.db.prepare(`
390
+ UPDATE maintenance_jobs
391
+ SET status = ?, run_after = ?, lease_owner = NULL, lease_expires_at = NULL,
392
+ last_error = ?, updated_at = ?
393
+ WHERE id = ?
394
+ `).run(status, runAfter, errorText(error), now, id);
395
+ const updated = this.getRow(id)!;
396
+ this.commit();
397
+ return rowToJob(updated);
398
+ } catch (caught) {
399
+ this.rollback();
400
+ throw caught;
401
+ }
402
+ }
403
+
404
+ private getRow(id: string): any {
405
+ return this.db.prepare(`SELECT * FROM maintenance_jobs WHERE id = ?`).get(id);
406
+ }
407
+
408
+ private begin(): void {
409
+ this.db.prepare('BEGIN IMMEDIATE').run();
410
+ }
411
+
412
+ private commit(): void {
413
+ this.db.prepare('COMMIT').run();
414
+ }
415
+
416
+ private rollback(): void {
417
+ try { this.db.prepare('ROLLBACK').run(); } catch { /* transaction already closed */ }
418
+ }
419
+ }
420
+
421
+ export class MaintenanceJobWorker {
422
+ private readonly workerId: string;
423
+ private readonly leaseMs: number;
424
+ private readonly pollIntervalMs: number;
425
+ private readonly projectId?: string;
426
+ private readonly kinds?: readonly MaintenanceJobKind[];
427
+ private timer: ReturnType<typeof setInterval> | null = null;
428
+ private running = false;
429
+
430
+ constructor(
431
+ private readonly store: MaintenanceJobStore,
432
+ private readonly handler: MaintenanceJobHandler,
433
+ options: {
434
+ workerId?: string;
435
+ leaseMs?: number;
436
+ pollIntervalMs?: number;
437
+ projectId?: string;
438
+ kinds?: readonly MaintenanceJobKind[];
439
+ } = {},
440
+ ) {
441
+ this.workerId = options.workerId ?? `memorix-${process.pid}-${randomUUID().slice(0, 8)}`;
442
+ this.leaseMs = clampPositiveInteger(options.leaseMs, DEFAULT_LEASE_MS);
443
+ this.pollIntervalMs = clampPositiveInteger(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS);
444
+ this.projectId = options.projectId;
445
+ this.kinds = options.kinds;
446
+ }
447
+
448
+ async runOnce(now = Date.now()): Promise<{ state: 'idle' | 'busy' | 'completed' | 'rescheduled' | 'failed'; job?: MaintenanceJob }> {
449
+ if (this.running) return { state: 'busy' };
450
+ this.running = true;
451
+ try {
452
+ const job = this.store.claimNext({
453
+ workerId: this.workerId,
454
+ projectId: this.projectId,
455
+ kinds: this.kinds,
456
+ now,
457
+ leaseMs: this.leaseMs,
458
+ });
459
+ if (!job) return { state: 'idle' };
460
+
461
+ const heartbeatMs = Math.max(10, Math.floor(this.leaseMs / 3));
462
+ let leaseLost = false;
463
+ const heartbeat = setInterval(() => {
464
+ try {
465
+ leaseLost = !this.store.renewLease(job.id, this.workerId, this.leaseMs);
466
+ } catch {
467
+ // The next heartbeat can recover from a transient SQLite error. The
468
+ // job itself still has its prior lease until it expires.
469
+ }
470
+ }, heartbeatMs);
471
+ heartbeat.unref?.();
472
+
473
+ try {
474
+ const result = await this.handler(job);
475
+ if (leaseLost) {
476
+ return { state: 'failed', job: this.store.get(job.id) };
477
+ }
478
+ if (result?.action === 'reschedule') {
479
+ const updated = this.store.reschedule(job.id, this.workerId, {
480
+ delayMs: result.delayMs,
481
+ resetAttempts: result.resetAttempts,
482
+ payload: result.payload,
483
+ now,
484
+ });
485
+ return { state: 'rescheduled', job: updated };
486
+ }
487
+ const updated = this.store.complete(job.id, this.workerId, now);
488
+ return { state: 'completed', job: updated };
489
+ } catch (error) {
490
+ const updated = this.store.fail(job.id, this.workerId, error, now);
491
+ return { state: 'failed', job: updated };
492
+ } finally {
493
+ clearInterval(heartbeat);
494
+ }
495
+ } finally {
496
+ this.running = false;
497
+ }
498
+ }
499
+
500
+ start(): void {
501
+ if (this.timer) return;
502
+ void this.runOnce().catch(() => {});
503
+ this.timer = setInterval(() => { void this.runOnce().catch(() => {}); }, this.pollIntervalMs);
504
+ this.timer.unref?.();
505
+ }
506
+
507
+ stop(): void {
508
+ if (!this.timer) return;
509
+ clearInterval(this.timer);
510
+ this.timer = null;
511
+ }
512
+ }
@@ -0,0 +1,96 @@
1
+ import { closeAllDatabases } from '../store/sqlite-db.js';
2
+ import { loadDotenv } from '../config/dotenv-loader.js';
3
+ import { initProjectRoot } from '../config/yaml-loader.js';
4
+ import { initLLM } from '../llm/provider.js';
5
+ import { initObservationStore } from '../store/obs-store.js';
6
+ import {
7
+ MAINTENANCE_JOB_KINDS,
8
+ type MaintenanceJob,
9
+ type MaintenanceJobRunResult,
10
+ } from './maintenance-jobs.js';
11
+ import {
12
+ ISOLATED_MAINTENANCE_JOB_KINDS,
13
+ MAINTENANCE_RESULT_PREFIX,
14
+ type IsolatedMaintenanceRequest,
15
+ } from './isolated-maintenance.js';
16
+ import { createProjectMaintenanceHandler } from './project-maintenance.js';
17
+
18
+ function isRecord(value: unknown): value is Record<string, unknown> {
19
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
20
+ }
21
+
22
+ function isJob(value: unknown): value is MaintenanceJob {
23
+ if (!isRecord(value)) return false;
24
+ return typeof value.id === 'string'
25
+ && typeof value.projectId === 'string'
26
+ && typeof value.kind === 'string'
27
+ && MAINTENANCE_JOB_KINDS.includes(value.kind as MaintenanceJob['kind'])
28
+ && ISOLATED_MAINTENANCE_JOB_KINDS.includes(value.kind as MaintenanceJob['kind'])
29
+ && isRecord(value.payload);
30
+ }
31
+
32
+ export function parseMaintenanceRequest(raw: string): IsolatedMaintenanceRequest {
33
+ let value: unknown;
34
+ try {
35
+ value = JSON.parse(raw);
36
+ } catch {
37
+ throw new Error('Maintenance runner received invalid JSON input');
38
+ }
39
+ if (!isRecord(value) || !isJob(value.job)) {
40
+ throw new Error('Maintenance runner received an invalid job payload');
41
+ }
42
+ if (typeof value.projectRoot !== 'string' || !value.projectRoot) {
43
+ throw new Error('Maintenance runner requires projectRoot');
44
+ }
45
+ if (typeof value.dataDir !== 'string' || !value.dataDir) {
46
+ throw new Error('Maintenance runner requires dataDir');
47
+ }
48
+ return {
49
+ job: value.job,
50
+ projectRoot: value.projectRoot,
51
+ dataDir: value.dataDir,
52
+ };
53
+ }
54
+
55
+ export async function executeMaintenanceRequest(
56
+ request: IsolatedMaintenanceRequest,
57
+ ): Promise<MaintenanceJobRunResult> {
58
+ initProjectRoot(request.projectRoot);
59
+ loadDotenv(request.projectRoot);
60
+ await initObservationStore(request.dataDir);
61
+ if (request.job.kind === 'consolidation') initLLM();
62
+
63
+ const handler = createProjectMaintenanceHandler(
64
+ request.job.projectId,
65
+ request.dataDir,
66
+ request.projectRoot,
67
+ );
68
+ return (await handler(request.job)) ?? { action: 'complete' };
69
+ }
70
+
71
+ function readStdin(): Promise<string> {
72
+ return new Promise((resolve, reject) => {
73
+ let raw = '';
74
+ process.stdin.setEncoding('utf8');
75
+ process.stdin.on('data', (chunk) => { raw += chunk; });
76
+ process.stdin.once('error', reject);
77
+ process.stdin.once('end', () => resolve(raw));
78
+ });
79
+ }
80
+
81
+ export async function main(): Promise<void> {
82
+ try {
83
+ const request = parseMaintenanceRequest(await readStdin());
84
+ const result = await executeMaintenanceRequest(request);
85
+ process.stdout.write(`${MAINTENANCE_RESULT_PREFIX}${JSON.stringify(result)}\n`);
86
+ } catch (error) {
87
+ process.stderr.write(`[memorix] maintenance runner failed: ${error instanceof Error ? error.message : String(error)}\n`);
88
+ process.exitCode = 1;
89
+ } finally {
90
+ closeAllDatabases();
91
+ }
92
+ }
93
+
94
+ if (process.argv[1] && process.argv[1].endsWith('maintenance-runner.js')) {
95
+ void main();
96
+ }
@@ -0,0 +1,59 @@
1
+ import { getDatabase } from '../store/sqlite-db.js';
2
+
3
+ export interface MaintenanceTarget {
4
+ projectId: string;
5
+ projectRoot: string;
6
+ dataDir: string;
7
+ updatedAt: number;
8
+ }
9
+
10
+ export interface RegisterMaintenanceTargetInput {
11
+ projectId: string;
12
+ projectRoot: string;
13
+ dataDir: string;
14
+ now?: number;
15
+ }
16
+
17
+ function rowToTarget(row: any): MaintenanceTarget {
18
+ return {
19
+ projectId: row.project_id,
20
+ projectRoot: row.project_root,
21
+ dataDir: row.data_dir,
22
+ updatedAt: Number(row.updated_at),
23
+ };
24
+ }
25
+
26
+ /**
27
+ * A local registry for isolated maintenance jobs. It deliberately stores only
28
+ * project identity and local paths; it is never sent through MCP responses.
29
+ */
30
+ export class MaintenanceTargetStore {
31
+ private readonly db: any;
32
+
33
+ constructor(dataDir: string) {
34
+ this.db = getDatabase(dataDir);
35
+ }
36
+
37
+ register(input: RegisterMaintenanceTargetInput): MaintenanceTarget {
38
+ if (!input.projectId.trim()) throw new Error('Maintenance target requires a project ID');
39
+ if (!input.projectRoot.trim()) throw new Error('Maintenance target requires a project root');
40
+ if (!input.dataDir.trim()) throw new Error('Maintenance target requires a data directory');
41
+ const now = input.now ?? Date.now();
42
+ this.db.prepare(`
43
+ INSERT INTO maintenance_targets (project_id, project_root, data_dir, updated_at)
44
+ VALUES (?, ?, ?, ?)
45
+ ON CONFLICT(project_id) DO UPDATE SET
46
+ project_root = excluded.project_root,
47
+ data_dir = excluded.data_dir,
48
+ updated_at = excluded.updated_at
49
+ `).run(input.projectId, input.projectRoot, input.dataDir, now);
50
+ return this.get(input.projectId)!;
51
+ }
52
+
53
+ get(projectId: string): MaintenanceTarget | undefined {
54
+ const row = this.db.prepare(`
55
+ SELECT * FROM maintenance_targets WHERE project_id = ?
56
+ `).get(projectId);
57
+ return row ? rowToTarget(row) : undefined;
58
+ }
59
+ }