memorix 1.2.7 → 1.2.8
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.
- package/CHANGELOG.md +8 -0
- package/dist/cli/index.js +320 -35
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/memcode.js +0 -0
- package/dist/dashboard/static/app.js +7 -2
- package/dist/index.js +178 -26
- package/dist/index.js.map +1 -1
- package/dist/maintenance-runner.d.ts +6 -0
- package/dist/maintenance-runner.js +165 -24
- package/dist/maintenance-runner.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +8 -0
- package/dist/sdk.js +178 -26
- package/dist/sdk.js.map +1 -1
- package/docs/dev-log/progress.txt +25 -0
- package/package.json +3 -3
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/src/cli/commands/agent-integrations.ts +102 -5
- package/src/cli/commands/serve-http.ts +14 -3
- package/src/cli/commands/setup.ts +44 -0
- package/src/dashboard/server.ts +11 -0
- package/src/memory/observations.ts +67 -20
- package/src/runtime/maintenance-jobs.ts +84 -4
- package/src/runtime/project-maintenance.ts +63 -6
|
@@ -339,3 +339,28 @@
|
|
|
339
339
|
isolated Claude Code handoff/continuation smoke. `npm publish --dry-run
|
|
340
340
|
--ignore-scripts` accepted the public 1.2.7 tarball; the full prepublish
|
|
341
341
|
build/test path is covered by the root release gates above.
|
|
342
|
+
|
|
343
|
+
## 1.2.8 Vector Recovery and Codex Installation Clarity
|
|
344
|
+
- Reworked vector backfill failure handling so transient provider or local
|
|
345
|
+
index failures leave one durable, diagnosable retry with bounded backoff
|
|
346
|
+
instead of consuming the maintenance retry budget in each new MCP process.
|
|
347
|
+
Healthy work clears stale diagnostics, revives an old failed vector job when
|
|
348
|
+
needed, and resolves superseded historical failure rows once recovery is
|
|
349
|
+
complete. Provider calls are batched while process-local Orama writes remain
|
|
350
|
+
serialized.
|
|
351
|
+
- Corrected dashboard and HTTP control-plane vector reporting. A process that
|
|
352
|
+
does not own a hydrated MCP search index now states that vector status belongs
|
|
353
|
+
to the active MCP session rather than showing a misleading green `0 / 0`.
|
|
354
|
+
- Corrected Codex install diagnostics: an enabled official Codex plugin owns
|
|
355
|
+
its `.mcp.json` `memorix serve` endpoint, while first-use hook approval is an
|
|
356
|
+
explicit host consent step rather than a broken installation. Plugin setup
|
|
357
|
+
removes only the legacy source-path `memorix` entry after successful plugin
|
|
358
|
+
installation; custom user-managed MCP entries are preserved.
|
|
359
|
+
- Release verification: focused regression tests (96 assertions), `npm run
|
|
360
|
+
lint`, production build, and the full root test suite (all passed, 280s).
|
|
361
|
+
A live OpenRouter `qwen/qwen3-embedding-8b` batch returned two 4096d vectors.
|
|
362
|
+
An isolated tarball install reported CLI version `1.2.8`; its stdio MCP
|
|
363
|
+
handshake exposed 44 tools and completed `memorix_project_context`, and its
|
|
364
|
+
CLI `context --json` path returned a structured Workset. `npm pack --dry-run`
|
|
365
|
+
and `npm publish --dry-run --ignore-scripts` both accepted the final tarball
|
|
366
|
+
without manifest normalization warnings.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memorix",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.8",
|
|
4
4
|
"description": "Local-first shared memory layer for AI coding agents across MCP clients, Git history, reasoning context, and project sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
"packages/memcode"
|
|
12
12
|
],
|
|
13
13
|
"bin": {
|
|
14
|
-
"memorix": "
|
|
15
|
-
"memcode": "
|
|
14
|
+
"memorix": "dist/cli/index.js",
|
|
15
|
+
"memcode": "dist/cli/memcode.js"
|
|
16
16
|
},
|
|
17
17
|
"main": "./dist/index.js",
|
|
18
18
|
"types": "./dist/index.d.ts",
|
|
@@ -26,6 +26,8 @@ export interface AgentMcpCheck {
|
|
|
26
26
|
exists: boolean;
|
|
27
27
|
status: AgentIntegrationStatus;
|
|
28
28
|
issues: string[];
|
|
29
|
+
/** This endpoint is supplied by an enabled Codex plugin, not config.toml. */
|
|
30
|
+
managedByPlugin?: boolean;
|
|
29
31
|
server?: {
|
|
30
32
|
transport: 'stdio' | 'http';
|
|
31
33
|
command?: string;
|
|
@@ -229,6 +231,10 @@ function codexPluginPath(): string {
|
|
|
229
231
|
return `${homedir()}/.codex/plugins/${CODEX_PLUGIN_NAME}`;
|
|
230
232
|
}
|
|
231
233
|
|
|
234
|
+
function codexPluginMcpPath(): string {
|
|
235
|
+
return `${codexPluginPath()}/.mcp.json`;
|
|
236
|
+
}
|
|
237
|
+
|
|
232
238
|
function codexMarketplacePath(): string {
|
|
233
239
|
return `${homedir()}/.agents/plugins/marketplace.json`;
|
|
234
240
|
}
|
|
@@ -267,6 +273,7 @@ async function inspectCodexPluginBundle(): Promise<AgentPluginCheck> {
|
|
|
267
273
|
const pluginPath = codexPluginPath();
|
|
268
274
|
const manifestPath = `${pluginPath}/.codex-plugin/plugin.json`;
|
|
269
275
|
const hooksPath = `${pluginPath}/hooks/hooks.json`;
|
|
276
|
+
const mcpPath = codexPluginMcpPath();
|
|
270
277
|
if (!existsSync(pluginPath)) {
|
|
271
278
|
return {
|
|
272
279
|
scope: 'global',
|
|
@@ -317,6 +324,20 @@ async function inspectCodexPluginBundle(): Promise<AgentPluginCheck> {
|
|
|
317
324
|
if (version !== getCliVersion()) issues.push('codex-plugin-version-mismatch');
|
|
318
325
|
if (manifest.hooks !== './hooks/hooks.json') issues.push('codex-hook-manifest-missing');
|
|
319
326
|
|
|
327
|
+
if (!existsSync(mcpPath)) {
|
|
328
|
+
issues.push('codex-plugin-mcp-manifest-missing');
|
|
329
|
+
} else {
|
|
330
|
+
try {
|
|
331
|
+
const mcpConfig = asRecord(JSON.parse(await readFile(mcpPath, 'utf-8')));
|
|
332
|
+
const server = coerceMcpServer('memorix', asRecord(mcpConfig?.mcpServers)?.memorix);
|
|
333
|
+
if (!server || !isRecommendedStdioServer(server)) {
|
|
334
|
+
issues.push('codex-plugin-mcp-manifest-invalid');
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
issues.push('codex-plugin-mcp-manifest-unreadable');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
320
341
|
let declared: string[] = [];
|
|
321
342
|
if (!existsSync(hooksPath)) {
|
|
322
343
|
issues.push('codex-hook-manifest-missing');
|
|
@@ -479,7 +500,9 @@ async function inspectCodexHookTrust(): Promise<AgentPluginCheck> {
|
|
|
479
500
|
kind: 'hook-trust',
|
|
480
501
|
path: configPath,
|
|
481
502
|
exists: true,
|
|
482
|
-
|
|
503
|
+
// Codex records hook approval only after the user has reviewed it. Memorix
|
|
504
|
+
// must not treat an intentional, host-owned consent step as a broken setup.
|
|
505
|
+
status: issues.length > 0 ? 'skipped' : 'ok',
|
|
483
506
|
issues,
|
|
484
507
|
hookTrust: { trusted, expected: [...CODEX_HOOK_STATE_NAMES] },
|
|
485
508
|
};
|
|
@@ -562,7 +585,7 @@ function getClaudeLocalConfigPath(): string {
|
|
|
562
585
|
return `${homedir()}/.claude.json`;
|
|
563
586
|
}
|
|
564
587
|
|
|
565
|
-
function
|
|
588
|
+
function coerceMcpServer(name: string, value: unknown): MCPServerEntry | null {
|
|
566
589
|
const entry = asRecord(value);
|
|
567
590
|
if (!entry) return null;
|
|
568
591
|
const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
|
|
@@ -626,7 +649,7 @@ async function inspectClaudeLocalMcp(projectRoot: string): Promise<AgentMcpCheck
|
|
|
626
649
|
}
|
|
627
650
|
|
|
628
651
|
const servers = asRecord(localProject.project.mcpServers);
|
|
629
|
-
const server =
|
|
652
|
+
const server = coerceMcpServer('memorix', servers?.memorix);
|
|
630
653
|
if (!server) {
|
|
631
654
|
return {
|
|
632
655
|
scope: 'local',
|
|
@@ -683,6 +706,59 @@ async function installClaudeLocalMcpConfig(projectRoot: string): Promise<void> {
|
|
|
683
706
|
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
|
|
684
707
|
}
|
|
685
708
|
|
|
709
|
+
async function inspectCodexPluginMcp(): Promise<AgentMcpCheck | null> {
|
|
710
|
+
const runtime = inspectCodexPluginRuntime();
|
|
711
|
+
if (!runtime.runtime?.installed || !runtime.runtime.enabled) return null;
|
|
712
|
+
|
|
713
|
+
const mcpPath = codexPluginMcpPath();
|
|
714
|
+
if (!existsSync(mcpPath)) {
|
|
715
|
+
return {
|
|
716
|
+
scope: 'global',
|
|
717
|
+
path: mcpPath,
|
|
718
|
+
exists: false,
|
|
719
|
+
status: 'repairable',
|
|
720
|
+
issues: ['codex-plugin-mcp-manifest-missing'],
|
|
721
|
+
managedByPlugin: true,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
try {
|
|
726
|
+
const config = asRecord(JSON.parse(await readFile(mcpPath, 'utf-8')));
|
|
727
|
+
const server = coerceMcpServer('memorix', asRecord(config?.mcpServers)?.memorix);
|
|
728
|
+
if (!server) {
|
|
729
|
+
return {
|
|
730
|
+
scope: 'global',
|
|
731
|
+
path: mcpPath,
|
|
732
|
+
exists: true,
|
|
733
|
+
status: 'repairable',
|
|
734
|
+
issues: ['codex-plugin-mcp-manifest-invalid'],
|
|
735
|
+
managedByPlugin: true,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
const issues: string[] = [];
|
|
739
|
+
if (looksLikeStaleMemorixCommand(server)) issues.push('stale-command-path');
|
|
740
|
+
if (!isRecommendedStdioServer(server)) issues.push('nonstandard-mcp-command');
|
|
741
|
+
return {
|
|
742
|
+
scope: 'global',
|
|
743
|
+
path: mcpPath,
|
|
744
|
+
exists: true,
|
|
745
|
+
status: issues.length > 0 ? 'repairable' : 'ok',
|
|
746
|
+
issues,
|
|
747
|
+
managedByPlugin: true,
|
|
748
|
+
server: sanitizeServer(server),
|
|
749
|
+
};
|
|
750
|
+
} catch {
|
|
751
|
+
return {
|
|
752
|
+
scope: 'global',
|
|
753
|
+
path: mcpPath,
|
|
754
|
+
exists: true,
|
|
755
|
+
status: 'repairable',
|
|
756
|
+
issues: ['codex-plugin-mcp-manifest-unreadable'],
|
|
757
|
+
managedByPlugin: true,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
686
762
|
async function inspectMcp(agent: AgentName, projectRoot: string, scope: AgentIntegrationScope): Promise<AgentIntegrationEntry['mcp']> {
|
|
687
763
|
if (!isMcpConfigAgent(agent)) {
|
|
688
764
|
return { status: 'skipped', issues: ['mcp-managed-by-package'], checks: [] };
|
|
@@ -697,6 +773,14 @@ async function inspectMcp(agent: AgentName, projectRoot: string, scope: AgentInt
|
|
|
697
773
|
continue;
|
|
698
774
|
}
|
|
699
775
|
|
|
776
|
+
if (agent === 'codex' && targetScope === 'global') {
|
|
777
|
+
const pluginCheck = await inspectCodexPluginMcp();
|
|
778
|
+
if (pluginCheck) {
|
|
779
|
+
checks.push(pluginCheck);
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
700
784
|
const configPath = adapter.getConfigPath(targetScope === 'project' ? projectRoot : undefined);
|
|
701
785
|
if (targetScope === 'global' && configPath === adapter.getConfigPath(projectRoot)) continue;
|
|
702
786
|
|
|
@@ -872,11 +956,20 @@ export function formatAgentIntegrationReport(report: AgentIntegrationReport): st
|
|
|
872
956
|
lines.push(`${entry.agent}: ${status}`);
|
|
873
957
|
if (entry.mcp.issues.length > 0) lines.push(` MCP: ${entry.mcp.issues.join(', ')}`);
|
|
874
958
|
if (entry.guidance.issues.length > 0) lines.push(` Guidance: ${entry.guidance.issues.join(', ')}`);
|
|
875
|
-
|
|
959
|
+
const hookTrustPending = entry.plugin.issues.includes('codex-hook-trust-pending');
|
|
960
|
+
const pluginIssues = entry.plugin.issues.filter((issue) => issue !== 'codex-hook-trust-pending');
|
|
961
|
+
if (pluginIssues.length > 0) lines.push(` Plugin: ${pluginIssues.join(', ')}`);
|
|
962
|
+
if (hookTrustPending) {
|
|
963
|
+
lines.push(' Hooks: waiting for Codex approval on first use; MCP remains available.');
|
|
964
|
+
}
|
|
876
965
|
}
|
|
877
966
|
|
|
878
967
|
lines.push('');
|
|
879
|
-
|
|
968
|
+
if (report.summary.missing > 0 || report.summary.repairable > 0) {
|
|
969
|
+
lines.push(`Repair: ${report.repairCommand}`);
|
|
970
|
+
} else {
|
|
971
|
+
lines.push('No file repair needed.');
|
|
972
|
+
}
|
|
880
973
|
return lines.join('\n');
|
|
881
974
|
}
|
|
882
975
|
|
|
@@ -899,6 +992,10 @@ export async function repairAgentIntegrations(options: {
|
|
|
899
992
|
if (isMcpConfigAgent(entry.agent)) {
|
|
900
993
|
for (const check of entry.mcp.checks) {
|
|
901
994
|
if (check.status === 'ok' || check.status === 'skipped') continue;
|
|
995
|
+
if (check.managedByPlugin) {
|
|
996
|
+
skipped.push(`${entry.agent}:mcp:${check.scope}:plugin-managed`);
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
902
999
|
if (check.status === 'missing' && !canInstallMissing) {
|
|
903
1000
|
skipped.push(`${entry.agent}:mcp:${check.scope}:missing`);
|
|
904
1001
|
continue;
|
|
@@ -839,10 +839,21 @@ export default defineCommand({
|
|
|
839
839
|
};
|
|
840
840
|
} catch { /* best effort */ }
|
|
841
841
|
|
|
842
|
-
let vectorStatus = {
|
|
842
|
+
let vectorStatus = {
|
|
843
|
+
available: false,
|
|
844
|
+
total: 0,
|
|
845
|
+
missing: 0,
|
|
846
|
+
missingIds: [] as number[],
|
|
847
|
+
backfillRunning: false,
|
|
848
|
+
};
|
|
843
849
|
try {
|
|
844
|
-
const { getVectorStatus } = await import('../../memory/observations.js');
|
|
845
|
-
|
|
850
|
+
const { getSearchIndexStatus, getVectorStatus } = await import('../../memory/observations.js');
|
|
851
|
+
// The HTTP control plane has a different module graph from stdio MCP.
|
|
852
|
+
// Only report vector counts when this process actually owns a hydrated
|
|
853
|
+
// index; an empty singleton must not look like a healthy 0/0 state.
|
|
854
|
+
if (getSearchIndexStatus(statsProjectId).prepared) {
|
|
855
|
+
vectorStatus = { available: true, ...getVectorStatus(statsProjectId) };
|
|
856
|
+
}
|
|
846
857
|
} catch { /* best effort */ }
|
|
847
858
|
|
|
848
859
|
// Real search mode from the last actual search execution
|
|
@@ -484,6 +484,40 @@ function mergeTomlMcpConfig(existingContent: string | null, generatedContent: st
|
|
|
484
484
|
return `${parts.join('\n\n')}\n`;
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
function isLegacyCodexMemorixNodeServer(server: MCPServerEntry): boolean {
|
|
488
|
+
if (server.name !== 'memorix' || server.command.trim().toLowerCase() !== 'node') return false;
|
|
489
|
+
const startsMemorixStdio = server.args.some((arg) => arg.trim().toLowerCase() === 'serve');
|
|
490
|
+
const sourceEntry = server.args.some((arg) => {
|
|
491
|
+
const normalized = arg.replace(/\\/g, '/').toLowerCase();
|
|
492
|
+
return /\/memorix\/dist\/cli\/index\.(?:[cm]?js)$/.test(normalized);
|
|
493
|
+
});
|
|
494
|
+
return startsMemorixStdio && sourceEntry;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Codex plugins now own Memorix stdio MCP registration. Remove only the old
|
|
499
|
+
* source-path form emitted by pre-plugin setup, never a user's custom server.
|
|
500
|
+
*/
|
|
501
|
+
export async function removeLegacyCodexMemorixMcpConfig(
|
|
502
|
+
configPath = path.join(homedir(), '.codex', 'config.toml'),
|
|
503
|
+
): Promise<{ configPath: string; removed: boolean }> {
|
|
504
|
+
let existingContent: string;
|
|
505
|
+
try {
|
|
506
|
+
existingContent = await readFile(configPath, 'utf-8');
|
|
507
|
+
} catch {
|
|
508
|
+
return { configPath, removed: false };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const adapter = new CodexMCPAdapter();
|
|
512
|
+
const legacyServer = adapter.parse(existingContent).find(isLegacyCodexMemorixNodeServer);
|
|
513
|
+
if (!legacyServer) return { configPath, removed: false };
|
|
514
|
+
|
|
515
|
+
let nextContent = removeTomlSection(existingContent, 'mcp_servers.memorix.env');
|
|
516
|
+
nextContent = removeTomlSection(nextContent, 'mcp_servers.memorix');
|
|
517
|
+
await writeFile(configPath, nextContent ? `${nextContent}\n` : '', 'utf-8');
|
|
518
|
+
return { configPath, removed: true };
|
|
519
|
+
}
|
|
520
|
+
|
|
487
521
|
function mergeYamlMcpConfig(existingContent: string | null, generatedContent: string): string {
|
|
488
522
|
let existing: Record<string, unknown> = {};
|
|
489
523
|
try {
|
|
@@ -1091,6 +1125,16 @@ export async function installAgentSetup(agent: AgentName, plan: SetupPlan, globa
|
|
|
1091
1125
|
const install = tryInstallCodexPlugin();
|
|
1092
1126
|
if (install.ok) p.log.success(install.message);
|
|
1093
1127
|
else p.log.warn(install.message);
|
|
1128
|
+
if (install.ok) {
|
|
1129
|
+
try {
|
|
1130
|
+
const migration = await removeLegacyCodexMemorixMcpConfig();
|
|
1131
|
+
if (migration.removed) {
|
|
1132
|
+
p.log.info(`codex: removed legacy source-path MCP config from ${migration.configPath}`);
|
|
1133
|
+
}
|
|
1134
|
+
} catch {
|
|
1135
|
+
p.log.warn('codex: plugin installed, but the legacy MCP config could not be checked');
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1094
1138
|
} else if (agent === 'claude' && result.marketplaceRoot) {
|
|
1095
1139
|
const install = tryInstallClaudePlugin(result.marketplaceRoot);
|
|
1096
1140
|
if (install.ok) p.log.success(install.message);
|
package/src/dashboard/server.ts
CHANGED
|
@@ -351,6 +351,17 @@ async function handleApi(
|
|
|
351
351
|
sourceCounts,
|
|
352
352
|
recentObservations: sorted,
|
|
353
353
|
embedding: embeddingStatus,
|
|
354
|
+
// A standalone dashboard has no access to an active MCP
|
|
355
|
+
// process's in-memory Orama index. Keep this explicit so the
|
|
356
|
+
// UI never presents an empty local singleton as a healthy
|
|
357
|
+
// all-vectors-indexed result.
|
|
358
|
+
vectorStatus: {
|
|
359
|
+
available: false,
|
|
360
|
+
total: 0,
|
|
361
|
+
missing: 0,
|
|
362
|
+
missingIds: [],
|
|
363
|
+
backfillRunning: false,
|
|
364
|
+
},
|
|
354
365
|
storage: storageInfo,
|
|
355
366
|
maintenance,
|
|
356
367
|
...(lifecycle ? { lifecycle } : {}),
|
|
@@ -102,6 +102,10 @@ function normalizeEmbeddingFailure(error: unknown): { key: string; message: stri
|
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
function vectorBackfillError(error: unknown): string {
|
|
106
|
+
return sanitizeCredentials(normalizeEmbeddingFailure(error).message).slice(0, 1_000);
|
|
107
|
+
}
|
|
108
|
+
|
|
105
109
|
function queueVectorBackfill(projectId: string): void {
|
|
106
110
|
const dataDir = projectDir;
|
|
107
111
|
if (!dataDir) return;
|
|
@@ -1202,6 +1206,7 @@ export async function backfillVectorEmbeddings(options: {
|
|
|
1202
1206
|
attempted: number;
|
|
1203
1207
|
succeeded: number;
|
|
1204
1208
|
failed: number;
|
|
1209
|
+
lastError?: string;
|
|
1205
1210
|
}> {
|
|
1206
1211
|
if (vectorBackfillRunning) {
|
|
1207
1212
|
return { attempted: 0, succeeded: 0, failed: 0 };
|
|
@@ -1218,19 +1223,63 @@ export async function backfillVectorEmbeddings(options: {
|
|
|
1218
1223
|
let succeeded = 0;
|
|
1219
1224
|
let failed = 0;
|
|
1220
1225
|
let lastFailure: string | undefined;
|
|
1226
|
+
const recordFailure = (message: string): void => {
|
|
1227
|
+
// A single batch may have several symptoms after one root cause. Preserve
|
|
1228
|
+
// the first one so an informative provider/index error is not overwritten
|
|
1229
|
+
// by later missing-result fallout.
|
|
1230
|
+
if (!lastFailure) lastFailure = message;
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
const candidates: Array<{ id: number; observation: Observation; text: string }> = [];
|
|
1234
|
+
for (const id of ids) {
|
|
1235
|
+
const observation = observationById.get(id);
|
|
1236
|
+
if (!observation) {
|
|
1237
|
+
vectorMissingIds.delete(id);
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
candidates.push({
|
|
1241
|
+
id,
|
|
1242
|
+
observation,
|
|
1243
|
+
text: [observation.title, observation.narrative, ...observation.facts].join(' '),
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1221
1246
|
|
|
1222
1247
|
try {
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1248
|
+
if (candidates.length === 0) {
|
|
1249
|
+
return { attempted: ids.length, succeeded, failed };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
let embeddings: number[][] | null = null;
|
|
1253
|
+
try {
|
|
1254
|
+
const provider = await getEmbeddingProvider();
|
|
1255
|
+
if (!provider) {
|
|
1256
|
+
if (isEmbeddingExplicitlyDisabled()) {
|
|
1257
|
+
for (const candidate of candidates) vectorMissingIds.delete(candidate.id);
|
|
1258
|
+
} else {
|
|
1259
|
+
recordFailure('embedding provider unavailable');
|
|
1260
|
+
failed += candidates.length;
|
|
1261
|
+
}
|
|
1262
|
+
} else {
|
|
1263
|
+
// API providers batch and cache this efficiently; local providers can use
|
|
1264
|
+
// their native batch path. Index writes stay sequential because Orama is
|
|
1265
|
+
// process-local mutable state.
|
|
1266
|
+
embeddings = await provider.embedBatch(candidates.map((candidate) => candidate.text));
|
|
1228
1267
|
}
|
|
1268
|
+
} catch (error) {
|
|
1269
|
+
recordFailure(vectorBackfillError(error));
|
|
1270
|
+
failed += candidates.length;
|
|
1271
|
+
}
|
|
1229
1272
|
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1273
|
+
if (embeddings) {
|
|
1274
|
+
for (let index = 0; index < candidates.length; index++) {
|
|
1275
|
+
const { id, observation: obs } = candidates[index];
|
|
1276
|
+
const embedding = embeddings[index];
|
|
1277
|
+
if (!embedding || embedding.length === 0) {
|
|
1278
|
+
recordFailure('embedding provider returned no vector');
|
|
1279
|
+
failed++;
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
try {
|
|
1234
1283
|
if (!isVectorCompatibleWithCurrentIndex(embedding)) {
|
|
1235
1284
|
await upgradeVectorSchemaAfterFirstEmbedding(embedding);
|
|
1236
1285
|
}
|
|
@@ -1239,7 +1288,7 @@ export async function backfillVectorEmbeddings(options: {
|
|
|
1239
1288
|
console.error(
|
|
1240
1289
|
`[memorix] Backfill embedding mismatch for obs-${id}: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d (kept in queue)`,
|
|
1241
1290
|
);
|
|
1242
|
-
|
|
1291
|
+
recordFailure(`dimension mismatch: provider returned ${embedding.length}d, index expects ${vectorDimensions ?? 'unknown'}d`);
|
|
1243
1292
|
failed++;
|
|
1244
1293
|
continue;
|
|
1245
1294
|
}
|
|
@@ -1277,17 +1326,10 @@ export async function backfillVectorEmbeddings(options: {
|
|
|
1277
1326
|
await insertObservation(doc);
|
|
1278
1327
|
vectorMissingIds.delete(id);
|
|
1279
1328
|
succeeded++;
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
vectorMissingIds.delete(id);
|
|
1283
|
-
} else {
|
|
1284
|
-
// Provider temporarily unavailable — keep in queue for next backfill cycle
|
|
1285
|
-
lastFailure = 'embedding provider unavailable';
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
recordFailure(vectorBackfillError(error));
|
|
1286
1331
|
failed++;
|
|
1287
1332
|
}
|
|
1288
|
-
} catch (err) {
|
|
1289
|
-
lastFailure = err instanceof Error ? err.message : String(err);
|
|
1290
|
-
failed++;
|
|
1291
1333
|
}
|
|
1292
1334
|
}
|
|
1293
1335
|
} finally {
|
|
@@ -1301,5 +1343,10 @@ export async function backfillVectorEmbeddings(options: {
|
|
|
1301
1343
|
};
|
|
1302
1344
|
}
|
|
1303
1345
|
|
|
1304
|
-
return {
|
|
1346
|
+
return {
|
|
1347
|
+
attempted: ids.length,
|
|
1348
|
+
succeeded,
|
|
1349
|
+
failed,
|
|
1350
|
+
...(lastFailure ? { lastError: lastFailure } : {}),
|
|
1351
|
+
};
|
|
1305
1352
|
}
|
|
@@ -67,7 +67,18 @@ export interface MaintenanceJobSummary {
|
|
|
67
67
|
|
|
68
68
|
export type MaintenanceJobRunResult =
|
|
69
69
|
| { action: 'complete' }
|
|
70
|
-
| {
|
|
70
|
+
| {
|
|
71
|
+
action: 'reschedule';
|
|
72
|
+
delayMs: number;
|
|
73
|
+
resetAttempts?: boolean;
|
|
74
|
+
payload?: Record<string, unknown>;
|
|
75
|
+
/** Keep recoverable work out of the immediate pending queue during a cooldown. */
|
|
76
|
+
status?: 'pending' | 'retry';
|
|
77
|
+
/** Persist a sanitized diagnostic without consuming the job's retry budget. */
|
|
78
|
+
lastError?: string;
|
|
79
|
+
/** Clear a stale transient diagnostic after the job makes progress. */
|
|
80
|
+
clearLastError?: boolean;
|
|
81
|
+
};
|
|
71
82
|
|
|
72
83
|
export type MaintenanceJobHandler = (
|
|
73
84
|
job: MaintenanceJob,
|
|
@@ -160,6 +171,12 @@ export class MaintenanceJobStore {
|
|
|
160
171
|
`).get(input.projectId, input.kind, dedupeKey);
|
|
161
172
|
|
|
162
173
|
if (existing) {
|
|
174
|
+
// A vector backfill in cooldown is a circuit-breaker state. A new MCP
|
|
175
|
+
// process must not pull it forward and recreate the original retry storm.
|
|
176
|
+
if (input.kind === 'vector-backfill' && existing.status === 'retry') {
|
|
177
|
+
this.commit();
|
|
178
|
+
return rowToJob(existing);
|
|
179
|
+
}
|
|
163
180
|
const nextRunAfter = Math.min(Number(existing.run_after), runAfter);
|
|
164
181
|
this.db.prepare(`
|
|
165
182
|
UPDATE maintenance_jobs
|
|
@@ -171,6 +188,30 @@ export class MaintenanceJobStore {
|
|
|
171
188
|
return rowToJob(updated);
|
|
172
189
|
}
|
|
173
190
|
|
|
191
|
+
// Older releases exhausted vector jobs on transient provider/index errors.
|
|
192
|
+
// Reuse one failed record so the next healthy session recovers work instead
|
|
193
|
+
// of adding another permanent failure row for the same project.
|
|
194
|
+
if (input.kind === 'vector-backfill') {
|
|
195
|
+
const failed = this.db.prepare(`
|
|
196
|
+
SELECT * FROM maintenance_jobs
|
|
197
|
+
WHERE project_id = ? AND kind = ? AND dedupe_key = ? AND status = 'failed'
|
|
198
|
+
ORDER BY updated_at DESC
|
|
199
|
+
LIMIT 1
|
|
200
|
+
`).get(input.projectId, input.kind, dedupeKey);
|
|
201
|
+
if (failed) {
|
|
202
|
+
this.db.prepare(`
|
|
203
|
+
UPDATE maintenance_jobs
|
|
204
|
+
SET status = 'pending', attempts = 0, max_attempts = ?, run_after = ?,
|
|
205
|
+
payload_json = ?, lease_owner = NULL, lease_expires_at = NULL,
|
|
206
|
+
last_error = NULL, completed_at = NULL, updated_at = ?
|
|
207
|
+
WHERE id = ?
|
|
208
|
+
`).run(maxAttempts, runAfter, payloadJson, now, failed.id);
|
|
209
|
+
const revived = this.getRow(failed.id)!;
|
|
210
|
+
this.commit();
|
|
211
|
+
return rowToJob(revived);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
174
215
|
const id = randomUUID();
|
|
175
216
|
this.db.prepare(`
|
|
176
217
|
INSERT INTO maintenance_jobs (
|
|
@@ -361,21 +402,57 @@ export class MaintenanceJobStore {
|
|
|
361
402
|
reschedule(
|
|
362
403
|
id: string,
|
|
363
404
|
workerId: string,
|
|
364
|
-
options: {
|
|
405
|
+
options: {
|
|
406
|
+
delayMs: number;
|
|
407
|
+
resetAttempts?: boolean;
|
|
408
|
+
payload?: Record<string, unknown>;
|
|
409
|
+
status?: 'pending' | 'retry';
|
|
410
|
+
lastError?: string;
|
|
411
|
+
clearLastError?: boolean;
|
|
412
|
+
now?: number;
|
|
413
|
+
},
|
|
365
414
|
): MaintenanceJob | undefined {
|
|
366
415
|
const now = options.now ?? Date.now();
|
|
367
416
|
const delayMs = Number.isFinite(options.delayMs) ? Math.max(0, Math.floor(options.delayMs)) : 0;
|
|
368
417
|
const payloadJson = options.payload === undefined ? null : JSON.stringify(options.payload);
|
|
418
|
+
const status = options.status === 'retry' ? 'retry' : 'pending';
|
|
419
|
+
const hasLastError = options.lastError !== undefined;
|
|
420
|
+
const lastError = hasLastError ? errorText(options.lastError) : null;
|
|
369
421
|
this.db.prepare(`
|
|
370
422
|
UPDATE maintenance_jobs
|
|
371
|
-
SET status =
|
|
423
|
+
SET status = ?, run_after = ?, attempts = CASE WHEN ? THEN 0 ELSE attempts END,
|
|
372
424
|
payload_json = COALESCE(?, payload_json),
|
|
425
|
+
last_error = CASE
|
|
426
|
+
WHEN ? THEN NULL
|
|
427
|
+
WHEN ? THEN ?
|
|
428
|
+
ELSE last_error
|
|
429
|
+
END,
|
|
373
430
|
lease_owner = NULL, lease_expires_at = NULL, updated_at = ?
|
|
374
431
|
WHERE id = ? AND status = 'running' AND lease_owner = ?
|
|
375
|
-
`).run(
|
|
432
|
+
`).run(
|
|
433
|
+
status,
|
|
434
|
+
now + delayMs,
|
|
435
|
+
options.resetAttempts ? 1 : 0,
|
|
436
|
+
payloadJson,
|
|
437
|
+
options.clearLastError ? 1 : 0,
|
|
438
|
+
hasLastError ? 1 : 0,
|
|
439
|
+
lastError,
|
|
440
|
+
now,
|
|
441
|
+
id,
|
|
442
|
+
workerId,
|
|
443
|
+
);
|
|
376
444
|
return this.get(id);
|
|
377
445
|
}
|
|
378
446
|
|
|
447
|
+
resolveFailedVectorBackfills(projectId: string, dedupeKey = 'vector-backfill', now = Date.now()): number {
|
|
448
|
+
const result = this.db.prepare(`
|
|
449
|
+
UPDATE maintenance_jobs
|
|
450
|
+
SET status = 'completed', completed_at = ?, updated_at = ?
|
|
451
|
+
WHERE project_id = ? AND kind = 'vector-backfill' AND dedupe_key = ? AND status = 'failed'
|
|
452
|
+
`).run(now, now, projectId, dedupeKey);
|
|
453
|
+
return Number(result.changes ?? 0);
|
|
454
|
+
}
|
|
455
|
+
|
|
379
456
|
fail(id: string, workerId: string, error: unknown, now = Date.now()): MaintenanceJob | undefined {
|
|
380
457
|
this.begin();
|
|
381
458
|
try {
|
|
@@ -486,6 +563,9 @@ export class MaintenanceJobWorker {
|
|
|
486
563
|
delayMs: result.delayMs,
|
|
487
564
|
resetAttempts: result.resetAttempts,
|
|
488
565
|
payload: result.payload,
|
|
566
|
+
status: result.status,
|
|
567
|
+
lastError: result.lastError,
|
|
568
|
+
clearLastError: result.clearLastError,
|
|
489
569
|
now,
|
|
490
570
|
});
|
|
491
571
|
return { state: 'rescheduled', job: updated };
|