@aabadin/project-memory-context 0.2.3 → 0.2.5

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/cli/context.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { access } from 'node:fs/promises';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
@@ -22,7 +22,7 @@ export async function findProjectRoot(startDir = process.cwd()) {
22
22
 
23
23
  while (true) {
24
24
  const installPath = join(currentDir, '.planning', 'project-memory-context', 'install.json');
25
- if (await fileExists(installPath)) {
25
+ if (existsSync(installPath)) {
26
26
  return currentDir;
27
27
  }
28
28
 
@@ -35,6 +35,23 @@ export async function findProjectRoot(startDir = process.cwd()) {
35
35
  }
36
36
  }
37
37
 
38
+ async function markContext(projectRoot, nodeIds) {
39
+ if (!nodeIds || nodeIds.length === 0) return;
40
+ const trackerPath = join(projectRoot, '.planning', 'project-memory-context', 'context-tracker.json');
41
+ let tracker = { activeNodeIds: [] };
42
+ try {
43
+ if (existsSync(trackerPath)) {
44
+ tracker = JSON.parse(readFileSync(trackerPath, 'utf-8'));
45
+ }
46
+ } catch {}
47
+ const existing = new Set(tracker.activeNodeIds || []);
48
+ nodeIds.forEach((id) => existing.add(id));
49
+ tracker.activeNodeIds = [...existing];
50
+ const dir = dirname(trackerPath);
51
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
52
+ writeFileSync(trackerPath, JSON.stringify(tracker, null, 2));
53
+ }
54
+
38
55
  export function parseArgs(args) {
39
56
  const DEPTH_VALUES = ['compact', 'extended', 'deep', 'disk'];
40
57
  const FOCUS_VALUES = ['dependencies', 'callers', 'containment', 'impact', 'all'];
@@ -190,12 +207,12 @@ export function buildRenderInput(engine, resolved, { depth, focus }) {
190
207
  };
191
208
  }
192
209
 
193
- export async function runTargetContext({ projectRoot, target, explicitMode, depth, focus }) {
194
- const artifacts = await loadArtifacts(projectRoot);
210
+ export async function runTargetContext({ projectRoot, target, explicitMode, depth, focus, artifacts }) {
211
+ const artfs = artifacts ?? await loadArtifacts(projectRoot);
195
212
  const engine = createQueryEngine({
196
- graph: artifacts.graph,
197
- symbolIndex: artifacts.symbolIndex,
198
- worklist: artifacts.worklist,
213
+ graph: artfs.graph,
214
+ symbolIndex: artfs.symbolIndex,
215
+ worklist: artfs.worklist,
199
216
  enrichmentDir: join(projectRoot, '.planning', 'project-memory-context', 'enrichment'),
200
217
  projectSlug: 'project',
201
218
  });
@@ -205,7 +222,7 @@ export async function runTargetContext({ projectRoot, target, explicitMode, dept
205
222
  const input = buildRenderInput(engine, resolved, { depth, focus });
206
223
  const output = renderTargetContext(input);
207
224
 
208
- return { output, resolved, input };
225
+ return { output, resolved, input, artifacts: artfs };
209
226
  }
210
227
 
211
228
  export async function runProjectContext(projectRoot = process.cwd(), refresh = false) {
@@ -301,7 +318,8 @@ export async function main(args = process.argv.slice(2)) {
301
318
  return 1;
302
319
  }
303
320
 
304
- const { output } = await runTargetContext({
321
+ const artifacts = await loadArtifacts(projectRoot);
322
+ const { output, resolved } = await runTargetContext({
305
323
  projectRoot,
306
324
  target: parsed.target,
307
325
  explicitMode: parsed.explicitMode,
@@ -309,6 +327,17 @@ export async function main(args = process.argv.slice(2)) {
309
327
  focus: parsed.focus,
310
328
  });
311
329
 
330
+ const nodeIdsToMark = [];
331
+ if (resolved.symbolKey) {
332
+ const entry = artifacts.symbolIndex[resolved.symbolKey];
333
+ if (entry?.graphNodeId) nodeIdsToMark.push(entry.graphNodeId);
334
+ } else if (resolved.target && (resolved.mode === 'file' || resolved.mode === 'symbol-missing')) {
335
+ const fileNodeId = resolved.target.replace(/[/\\]/g, '_').replace(/[:.]/g, '_');
336
+ nodeIdsToMark.push(fileNodeId);
337
+ }
338
+
339
+ await markContext(projectRoot, nodeIdsToMark);
340
+
312
341
  console.log(output);
313
342
  return 0;
314
343
  }
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
+ import { open } from 'node:fs/promises';
3
4
  import { resolve, dirname, basename } from 'node:path';
4
5
  import { homedir } from 'node:os';
5
6
  import { fileURLToPath } from 'node:url';
7
+ import { spawn } from 'node:child_process';
6
8
 
7
9
  import { appendProviderEvent, withRecordedAttempt } from '../src/enrichment-attempts.mjs';
8
10
  import { PMC_ENRICHMENT_CONFIG_FILE, resolveEnrichmentConfig } from '../src/enrichment-config.mjs';
@@ -571,6 +573,19 @@ async function main() {
571
573
  summary,
572
574
  });
573
575
 
576
+ const retryLaunch = await maybeLaunchRetryErrors({
577
+ projectRoot: PROJECT_ROOT,
578
+ enrichmentDir,
579
+ summary,
580
+ loadRetryState: async () => loadOptionalJson(resolve(enrichmentDir, 'retry-state.json')),
581
+ });
582
+
583
+ if (retryLaunch.launched) {
584
+ console.error(`[queue] Auto-launched retry-errors in background -> ${retryLaunch.stdoutPath}`);
585
+ } else if (retryLaunch.reason === 'already-running') {
586
+ console.error('[queue] Retry-errors already running; skipping second launch');
587
+ }
588
+
574
589
  console.log(JSON.stringify({
575
590
  complete: summary.pending === 0,
576
591
  total: worklist.length,
@@ -584,6 +599,59 @@ async function main() {
584
599
  }, null, 2));
585
600
  }
586
601
 
602
+ export async function maybeLaunchRetryErrors({
603
+ projectRoot,
604
+ enrichmentDir,
605
+ summary,
606
+ loadRetryState = async () => null,
607
+ spawnRetryProcess = launchRetryProcess,
608
+ }) {
609
+ if ((summary?.errors ?? 0) === 0) {
610
+ return { launched: false, reason: 'no-errors' };
611
+ }
612
+
613
+ const retryState = await loadRetryState();
614
+ if (retryState?.status === 'running') {
615
+ return { launched: false, reason: 'already-running', retryState };
616
+ }
617
+
618
+ const scriptPath = resolve(PROJECT_ROOT, 'tools/project-memory-context/cli/retry-errors.mjs');
619
+ const stdoutPath = resolve(enrichmentDir, 'retry-stdout.log');
620
+ const stderrPath = resolve(enrichmentDir, 'retry-stderr.log');
621
+ const startedAt = new Date().toISOString();
622
+ const pid = process.pid;
623
+
624
+ await writeRetryState({
625
+ enrichmentDir,
626
+ status: 'running',
627
+ pid,
628
+ projectRoot,
629
+ startedAt,
630
+ heartbeatAt: startedAt,
631
+ });
632
+
633
+ await spawnRetryProcess({ projectRoot, scriptPath, stdoutPath, stderrPath });
634
+ return { launched: true, reason: 'spawned', stdoutPath, stderrPath };
635
+ }
636
+
637
+ async function launchRetryProcess({ projectRoot, scriptPath, stdoutPath, stderrPath }) {
638
+ const stdout = await open(stdoutPath, 'a');
639
+ const stderr = await open(stderrPath, 'a');
640
+ const child = spawn(process.execPath, [scriptPath, projectRoot, '--concurrency', '1', '--timeout', String(TIMEOUT_MS)], {
641
+ detached: true,
642
+ stdio: ['ignore', stdout.fd, stderr.fd],
643
+ cwd: projectRoot,
644
+ });
645
+ child.unref();
646
+ stdout.close();
647
+ stderr.close();
648
+ }
649
+
650
+ export async function writeRetryState({ enrichmentDir, status, pid, projectRoot, startedAt, heartbeatAt, finishedAt = null, lastError = null }) {
651
+ const retryStateFile = resolve(enrichmentDir, 'retry-state.json');
652
+ await saveJson(retryStateFile, { status, pid, projectRoot, startedAt, heartbeatAt, finishedAt, lastError });
653
+ }
654
+
587
655
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
588
656
  main().catch(async (err) => {
589
657
  try {
@@ -4,11 +4,13 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
4
4
  import { resolve, dirname, basename } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
7
- import { appendProviderEvent, withRecordedAttempt } from '../src/enrichment-attempts.mjs';
7
+ import { appendProviderEvent } from '../src/enrichment-attempts.mjs';
8
8
  import { resolveEnrichmentConfig, PMC_ENRICHMENT_CONFIG_FILE } from '../src/enrichment-config.mjs';
9
9
  import { runEnrichmentWithFallback } from '../src/enrichment-driver.mjs';
10
10
  import { createLocalModelProvider } from '../src/providers/local-model-provider.mjs';
11
+ import { createCloudApiProvider } from '../src/providers/cloud-api-provider.mjs';
11
12
  import { appendSyncEntry, createSyncEntry } from '../src/sync-manifest.mjs';
13
+ import { MAX_RETRY_ITERATIONS, runRetryLoop } from '../src/retry-errors-runner.mjs';
12
14
  import { homedir } from 'node:os';
13
15
 
14
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -52,11 +54,10 @@ async function buildSymbolPrompt(symbol, projectRoot, maxCodeLines = 80) {
52
54
  }
53
55
 
54
56
  function parseArgs(argv) {
55
- const args = { concurrency: 4, timeoutMs: 600000, model: null, baseUrl: null, reportFile: null, limit: null };
57
+ const args = { timeoutMs: 600000, model: null, baseUrl: null, reportFile: null, limit: null };
56
58
  for (let i = 2; i < argv.length; i++) {
57
59
  const arg = argv[i];
58
- if (arg === '--concurrency' && argv[i + 1]) { args.concurrency = parseInt(argv[++i], 10); }
59
- else if (arg === '--timeout' && argv[i + 1]) { args.timeoutMs = parseInt(argv[++i], 10); }
60
+ if (arg === '--timeout' && argv[i + 1]) { args.timeoutMs = parseInt(argv[++i], 10); }
60
61
  else if (arg === '--model' && argv[i + 1]) { args.model = argv[++i]; }
61
62
  else if (arg === '--base-url' && argv[i + 1]) { args.baseUrl = argv[++i]; }
62
63
  else if (arg === '--report' && argv[i + 1]) { args.reportFile = argv[++i]; }
@@ -68,18 +69,17 @@ function parseArgs(argv) {
68
69
 
69
70
  function printHelp() {
70
71
  console.log(`
71
- PMC Retry Errors — Re-enriches symbols that failed using Ollama directly
72
+ PMC Retry Errors — Re-enriches symbols that failed using full provider fallback chain
72
73
 
73
- Usage: node tools/project-memory-context/cli/retry-errors.mjs [options]
74
+ Usage: node tools/project-memory-context/cli/retry-errors.mjs [options] [project-root]
74
75
 
75
76
  Options:
76
- --concurrency N Parallel slots (default: 4)
77
- --timeout MS Timeout per symbol in ms (default: 600000 = 10min)
78
- --model NAME Ollama model name (default: from config)
79
- --base-url URL Ollama base URL (default: http://localhost:11434)
80
- --limit N Only retry first N error entries (default: all)
81
- --report PATH Save JSON report to file (default: .planning/.../retry-report.json)
82
- -h, --help Show this help
77
+ --timeout MS Timeout per symbol in ms (default: 600000 = 10min)
78
+ --model NAME Ollama model name (default: from config)
79
+ --base-url URL Ollama base URL (default: http://localhost:11434)
80
+ --limit N Only retry first N error entries (default: all)
81
+ --report PATH Save JSON report to file (default: .planning/.../retry-report.json)
82
+ -h, --help Show this help
83
83
  `);
84
84
  }
85
85
 
@@ -121,8 +121,6 @@ async function main() {
121
121
  const globalConfig = await loadOptionalJson(resolve(homedir(), '.config', 'opencode', PMC_ENRICHMENT_CONFIG_FILE));
122
122
  let config = resolveEnrichmentConfig({ projectConfig, globalConfig, env: process.env });
123
123
 
124
- config.preferredModes = ['local-model'];
125
-
126
124
  if (args.model) {
127
125
  config.localModel.model = args.model;
128
126
  }
@@ -132,219 +130,165 @@ async function main() {
132
130
 
133
131
  const projectSlug = process.env.PMC_PROJECT_SLUG || basename(projectRoot);
134
132
 
135
- const providers = [createLocalModelProvider()];
133
+ const providers = [
134
+ createLocalModelProvider(),
135
+ createCloudApiProvider(),
136
+ ];
136
137
 
137
138
  console.error(`\n[retry] ═════════════════════════════════════════════════════`);
138
- console.error(`[retry] Retrying ${errorEntries.length} error symbols`);
139
- console.error(`[retry] Provider: ${config.localModel.provider} | Model: ${config.localModel.model}`);
140
- console.error(`[retry] Base URL: ${config.localModel.baseUrl}`);
141
- console.error(`[retry] Concurrency: ${args.concurrency} | Timeout: ${args.timeoutMs / 1000}s`);
139
+ console.error(`[retry] Retrying ${errorEntries.length} error symbols using full provider chain`);
140
+ console.error(`[retry] Local Model: ${config.localModel.provider} | ${config.localModel.model} @ ${config.localModel.baseUrl}`);
141
+ console.error(`[retry] Cloud API: ${config.cloudApi.provider} @ ${config.cloudApi.baseUrl}`);
142
+ console.error(`[retry] Max iterations: ${MAX_RETRY_ITERATIONS} | Timeout: ${args.timeoutMs / 1000}s`);
142
143
  console.error(`[retry] ═════════════════════════════════════════════════════\n`);
143
144
 
144
- const report = {
145
- startedAt: new Date().toISOString(),
146
- config: { model: config.localModel.model, baseUrl: config.localModel.baseUrl, concurrency: args.concurrency, timeoutMs: args.timeoutMs },
147
- previousErrors: {},
148
- results: [],
149
- summary: { succeeded: 0, failed: 0, skipped: 0 },
150
- };
145
+ const retrySymbol = async (candidate, iteration) => {
146
+ const startTime = Date.now();
147
+ const entry = worklist.find(e => e.symbolKey === candidate.symbolKey);
148
+ if (!entry) return { status: 'failed', elapsedMs: 0, attempts: [], failureReason: 'not found in worklist' };
151
149
 
152
- for (const entry of errorEntries) {
153
- const lastLocalAttempt = (entry.attempts ?? []).find(a => a.mode === 'local-model');
154
- report.previousErrors[entry.symbolKey] = {
155
- name: entry.name,
156
- filePath: entry.filePath,
157
- kind: entry.kind,
158
- previousError: entry.error,
159
- localModelError: lastLocalAttempt?.errorMessage ?? null,
160
- localModelErrorType: lastLocalAttempt?.errorType ?? null,
161
- attemptCount: entry.attempts?.length ?? 0,
162
- };
163
- }
150
+ entry.status = 'pending';
151
+ delete entry.error;
152
+ delete entry.failedAt;
164
153
 
165
- const queue = [...errorEntries];
166
- const activeSlots = new Map();
167
- const results = [];
168
- let slotIdCounter = 0;
154
+ try {
155
+ const prompt = await buildSymbolPrompt(entry, projectRoot);
156
+ const result = await runEnrichmentWithFallback({
157
+ request: { prompt, timeoutMs: args.timeoutMs },
158
+ config,
159
+ providers,
160
+ env: process.env,
161
+ });
169
162
 
170
- async function dispatchSlot() {
171
- while (queue.length > 0) {
172
- const entry = queue.shift();
163
+ const elapsed = Date.now() - startTime;
173
164
 
174
- entry.status = 'pending';
175
- delete entry.error;
176
- delete entry.failedAt;
165
+ for (const attempt of result.attempts ?? []) {
166
+ await appendProviderEvent(enrichmentDir, { symbolKey: entry.symbolKey, name: entry.name, ...attempt });
167
+ }
177
168
 
178
- const slotId = slotIdCounter++;
179
- const startTime = Date.now();
169
+ if (result.status === 'succeeded') {
170
+ const memoryId = `queue-${safeKey(entry.symbolKey)}`;
171
+ const enrichedAt = new Date().toISOString();
172
+ const memoryFile = resolve(enrichmentDir, `${safeKey(entry.symbolKey)}.memory.json`);
180
173
 
181
- activeSlots.set(slotId, { entry, startTime });
174
+ await saveJson(memoryFile, {
175
+ content: result.content,
176
+ category: 'architecture',
177
+ tags: ['symbol', entry.language, entry.kind, `project:${projectSlug}`, `file:${entry.filePath}`],
178
+ });
182
179
 
183
- try {
184
- const prompt = await buildSymbolPrompt(entry, projectRoot);
185
- const result = await runEnrichmentWithFallback({
186
- request: { prompt, timeoutMs: args.timeoutMs },
187
- config,
188
- providers,
189
- env: process.env,
180
+ const newAttempts = [...(entry.attempts ?? []), ...(result.attempts ?? [])];
181
+ Object.assign(entry, {
182
+ status: 'enriched',
183
+ memoryId,
184
+ enrichedAt,
185
+ error: undefined,
186
+ failedAt: undefined,
187
+ attempts: newAttempts,
190
188
  });
191
189
 
192
- const elapsed = Date.now() - startTime;
193
- activeSlots.delete(slotId);
190
+ symbolIndex[entry.symbolKey] = {
191
+ memoryId,
192
+ graphNodeId: entry.graphNodeId ?? null,
193
+ codeHash: entry.codeHash,
194
+ status: 'enriched',
195
+ lastEnrichedAt: enrichedAt,
196
+ };
194
197
 
195
- for (const attempt of result.attempts ?? []) {
196
- await appendProviderEvent(enrichmentDir, { symbolKey: entry.symbolKey, name: entry.name, ...attempt });
198
+ try {
199
+ await appendSyncEntry(enrichmentDir, createSyncEntry({
200
+ action: 'upsert',
201
+ keyTag: `key:symbol:${safeKey(entry.symbolKey)}`,
202
+ content: `## ${entry.name}\n\n${result.content}`,
203
+ category: 'architecture',
204
+ tags: ['symbol', entry.language, entry.kind, `project:${projectSlug}`, `file:${entry.filePath}`, 'enriched-by-retry'],
205
+ source: 'retry-errors',
206
+ symbolKey: entry.symbolKey,
207
+ }));
208
+ } catch (syncErr) {
209
+ console.error(`[retry] WARN: sync append failed for ${entry.name}: ${syncErr.message}`);
197
210
  }
198
211
 
199
- const reportEntry = {
200
- symbolKey: entry.symbolKey,
201
- name: entry.name,
202
- filePath: entry.filePath,
203
- kind: entry.kind,
204
- language: entry.language,
205
- elapsedMs: elapsed,
206
- attempts: result.attempts ?? [],
207
- };
212
+ console.error(` [OK] iter ${iteration} | ${entry.name} (${entry.filePath}:${entry.range.startLine}) — ${Math.round(elapsed / 1000)}s`);
208
213
 
209
- if (result.status === 'succeeded') {
210
- const memoryId = `queue-${safeKey(entry.symbolKey)}`;
211
- const enrichedAt = new Date().toISOString();
212
- const memoryFile = resolve(enrichmentDir, `${safeKey(entry.symbolKey)}.memory.json`);
214
+ await saveJson(worklistFile, worklist);
215
+ await saveJson(symbolIndexFile, symbolIndex);
213
216
 
214
- await saveJson(memoryFile, {
215
- content: result.content,
216
- category: 'architecture',
217
- tags: ['symbol', entry.language, entry.kind, `project:${projectSlug}`, `file:${entry.filePath}`],
218
- });
219
-
220
- const newAttempts = [...(entry.attempts ?? []), ...(result.attempts ?? [])];
221
- Object.assign(entry, {
222
- status: 'enriched',
223
- memoryId,
224
- enrichedAt,
225
- error: undefined,
226
- failedAt: undefined,
227
- attempts: newAttempts,
228
- lastModeUsed: 'local-model',
229
- });
230
-
231
- symbolIndex[entry.symbolKey] = {
232
- memoryId,
233
- graphNodeId: entry.graphNodeId ?? null,
234
- codeHash: entry.codeHash,
235
- status: 'enriched',
236
- lastEnrichedAt: enrichedAt,
237
- };
238
-
239
- try {
240
- await appendSyncEntry(enrichmentDir, createSyncEntry({
241
- action: 'upsert',
242
- keyTag: `key:symbol:${safeKey(entry.symbolKey)}`,
243
- content: `## ${entry.name}\n\n${result.content}`,
244
- category: 'architecture',
245
- tags: ['symbol', entry.language, entry.kind, `project:${projectSlug}`, `file:${entry.filePath}`, 'enriched-by-retry'],
246
- source: 'retry-errors',
247
- symbolKey: entry.symbolKey,
248
- }));
249
- } catch (syncErr) {
250
- console.error(`[retry] WARN: sync append failed for ${entry.name}: ${syncErr.message}`);
251
- }
252
-
253
- reportEntry.status = 'succeeded';
254
- reportEntry.contentPreview = result.content?.substring(0, 200);
255
- reportEntry.memoryId = memoryId;
256
- report.summary.succeeded++;
257
-
258
- const done = report.summary.succeeded + report.summary.failed;
259
- console.error(` [OK] ${entry.name} (${entry.filePath}:${entry.range.startLine}) — ${Math.round(elapsed / 1000)}s [${done}/${errorEntries.length}]`);
260
- } else {
261
- const lastError = result.attempts?.find(a => a.errorMessage)?.errorMessage ?? 'all providers failed';
262
-
263
- const newAttempts = [...(entry.attempts ?? []), ...(result.attempts ?? [])];
264
- Object.assign(entry, {
265
- status: 'error',
266
- error: lastError,
267
- failedAt: new Date().toISOString(),
268
- attempts: newAttempts,
269
- });
270
-
271
- symbolIndex[entry.symbolKey] = {
272
- memoryId: null,
273
- graphNodeId: entry.graphNodeId ?? null,
274
- codeHash: entry.codeHash,
275
- status: 'error',
276
- lastEnrichedAt: new Date().toISOString(),
277
- };
278
-
279
- reportEntry.status = 'failed';
280
- reportEntry.failureReason = lastError;
281
- report.summary.failed++;
282
-
283
- const done = report.summary.succeeded + report.summary.failed;
284
- console.error(` [FAIL] ${entry.name} (${entry.filePath}:${entry.range.startLine}) — ${lastError} — ${Math.round(elapsed / 1000)}s [${done}/${errorEntries.length}]`);
285
- }
217
+ return { status: 'succeeded', elapsedMs: elapsed, attempts: result.attempts ?? [], memoryId, contentPreview: result.content?.substring(0, 200) };
218
+ } else {
219
+ const lastError = result.attempts?.find(a => a.errorMessage)?.errorMessage ?? 'all providers failed';
220
+ const newAttempts = [...(entry.attempts ?? []), ...(result.attempts ?? [])];
221
+ Object.assign(entry, {
222
+ status: 'error',
223
+ error: lastError,
224
+ failedAt: new Date().toISOString(),
225
+ attempts: newAttempts,
226
+ });
286
227
 
287
- results.push(reportEntry);
288
- } catch (err) {
289
- const elapsed = Date.now() - startTime;
290
- activeSlots.delete(slotId);
291
-
292
- entry.status = 'error';
293
- entry.error = err.message;
294
- entry.failedAt = new Date().toISOString();
295
-
296
- const reportEntry = {
297
- symbolKey: entry.symbolKey,
298
- name: entry.name,
299
- filePath: entry.filePath,
300
- kind: entry.kind,
301
- language: entry.language,
302
- elapsedMs: elapsed,
303
- status: 'failed',
304
- failureReason: err.message,
305
- attempts: [],
228
+ symbolIndex[entry.symbolKey] = {
229
+ memoryId: null,
230
+ graphNodeId: entry.graphNodeId ?? null,
231
+ codeHash: entry.codeHash,
232
+ status: 'error',
233
+ lastEnrichedAt: new Date().toISOString(),
306
234
  };
307
235
 
308
- results.push(reportEntry);
309
- report.summary.failed++;
236
+ console.error(` [FAIL] iter ${iteration} | ${entry.name} — ${lastError} — ${Math.round(elapsed / 1000)}s`);
237
+
238
+ await saveJson(worklistFile, worklist);
239
+ await saveJson(symbolIndexFile, symbolIndex);
310
240
 
311
- const done = report.summary.succeeded + report.summary.failed;
312
- console.error(` [ERR] ${entry.name} — ${err.message} — ${Math.round(elapsed / 1000)}s [${done}/${errorEntries.length}]`);
241
+ return { status: 'failed', elapsedMs: elapsed, attempts: result.attempts ?? [], failureReason: lastError };
313
242
  }
243
+ } catch (err) {
244
+ const elapsed = Date.now() - startTime;
245
+ entry.status = 'error';
246
+ entry.error = err.message;
247
+ entry.failedAt = new Date().toISOString();
248
+
249
+ console.error(` [ERR] iter ${iteration} | ${entry.name} — ${err.message} — ${Math.round(elapsed / 1000)}s`);
314
250
 
315
251
  await saveJson(worklistFile, worklist);
316
252
  await saveJson(symbolIndexFile, symbolIndex);
317
- }
318
- }
319
253
 
320
- const slots = [];
321
- for (let i = 0; i < args.concurrency; i++) {
322
- slots.push(dispatchSlot());
323
- }
324
- await Promise.all(slots);
254
+ return { status: 'failed', elapsedMs: elapsed, attempts: [], failureReason: err.message };
255
+ }
256
+ };
325
257
 
326
- report.results = results;
327
- report.finishedAt = new Date().toISOString();
328
- report.totalElapsedMs = results.reduce((a, r) => a + (r.elapsedMs ?? 0), 0);
258
+ const startedAt = new Date().toISOString();
259
+ const result = await runRetryLoop({
260
+ worklist,
261
+ maxIterations: MAX_RETRY_ITERATIONS,
262
+ retrySymbol,
263
+ });
329
264
 
330
265
  const reportPath = args.reportFile || resolve(enrichmentDir, 'retry-report.json');
331
- await saveJson(reportPath, report);
266
+ await saveJson(reportPath, {
267
+ startedAt,
268
+ finishedAt: new Date().toISOString(),
269
+ iterations: result.iterations,
270
+ config: { model: config.localModel.model, baseUrl: config.localModel.baseUrl, timeoutMs: args.timeoutMs },
271
+ symbols: result.symbols,
272
+ summary: result.summary,
273
+ });
332
274
 
333
275
  console.error(`\n[retry] ═════════════════════════════════════════════════════`);
334
- console.error(`[retry] DONE — ${report.summary.succeeded} succeeded, ${report.summary.failed} failed, ${report.summary.skipped} skipped`);
276
+ console.error(`[retry] DONE — ${result.summary.symbolsRecovered} recovered, ${result.summary.symbolsStillFailing} still failing in ${result.iterations} iterations`);
335
277
  console.error(`[retry] Report: ${reportPath}`);
336
278
  console.error(`[retry] ═════════════════════════════════════════════════════\n`);
337
279
 
338
280
  console.log(JSON.stringify({
339
281
  ok: true,
340
282
  command: 'retry-errors',
341
- total: errorEntries.length,
342
- succeeded: report.summary.succeeded,
343
- failed: report.summary.failed,
283
+ total: result.summary.symbolsRetried,
284
+ succeeded: result.summary.symbolsRecovered,
285
+ failed: result.summary.symbolsStillFailing,
286
+ iterations: result.iterations,
287
+ maxIterationsReached: result.summary.maxIterationsReached,
344
288
  reportPath,
345
289
  }, null, 2));
346
290
 
347
- return report.summary.failed > 0 ? 1 : 0;
291
+ return result.summary.symbolsStillFailing > 0 ? 1 : 0;
348
292
  }
349
293
 
350
294
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aabadin/project-memory-context",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Portable project memory context CLI — bootstraps semantic enrichment workflows for any AI coding agent.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "type": "module",
@@ -0,0 +1,120 @@
1
+ export const MAX_RETRY_ITERATIONS = 5;
2
+
3
+ function collectPreviousErrors(entry) {
4
+ const fromAttempts = (entry.attempts ?? [])
5
+ .filter((attempt) => attempt.status === 'failed' && attempt.errorMessage)
6
+ .map((attempt) => ({
7
+ provider: attempt.provider ?? null,
8
+ errorType: attempt.errorType ?? null,
9
+ message: attempt.errorMessage,
10
+ failedAt: attempt.endedAt ?? attempt.startedAt ?? null,
11
+ }));
12
+
13
+ if (fromAttempts.length > 0) {
14
+ return fromAttempts;
15
+ }
16
+
17
+ return entry.error
18
+ ? [{ provider: null, errorType: null, message: entry.error, failedAt: entry.failedAt ?? null }]
19
+ : [];
20
+ }
21
+
22
+ export function collectRetryCandidates(worklist) {
23
+ const bySymbol = new Map();
24
+
25
+ for (const entry of worklist) {
26
+ if (entry.status !== 'error') continue;
27
+ const existing = bySymbol.get(entry.symbolKey);
28
+ const previousErrors = collectPreviousErrors(entry);
29
+
30
+ if (!existing) {
31
+ bySymbol.set(entry.symbolKey, {
32
+ ...entry,
33
+ previousErrors: [...previousErrors],
34
+ });
35
+ continue;
36
+ }
37
+
38
+ existing.previousErrors.push(...previousErrors);
39
+ if ((entry.attempts?.length ?? 0) >= (existing.attempts?.length ?? 0)) {
40
+ Object.assign(existing, entry, { previousErrors: existing.previousErrors });
41
+ }
42
+ }
43
+
44
+ return [...bySymbol.values()];
45
+ }
46
+
47
+ export function buildRetryState({ status, pid, projectRoot, startedAt, heartbeatAt, finishedAt = null, lastError = null }) {
48
+ return {
49
+ status,
50
+ pid,
51
+ projectRoot,
52
+ startedAt,
53
+ heartbeatAt,
54
+ finishedAt,
55
+ lastError,
56
+ };
57
+ }
58
+
59
+ export async function runRetryLoop({
60
+ worklist,
61
+ maxIterations = MAX_RETRY_ITERATIONS,
62
+ retrySymbol,
63
+ }) {
64
+ const reportBySymbol = new Map(
65
+ collectRetryCandidates(worklist).map((candidate) => [
66
+ candidate.symbolKey,
67
+ {
68
+ symbolKey: candidate.symbolKey,
69
+ name: candidate.name,
70
+ filePath: candidate.filePath,
71
+ kind: candidate.kind,
72
+ language: candidate.language,
73
+ previousErrors: candidate.previousErrors,
74
+ iterationResults: [],
75
+ finalStatus: 'error',
76
+ memoryId: null,
77
+ contentPreview: null,
78
+ },
79
+ ]),
80
+ );
81
+
82
+ let iterations = 0;
83
+
84
+ while (iterations < maxIterations) {
85
+ const currentCandidates = collectRetryCandidates(worklist);
86
+ if (currentCandidates.length === 0) break;
87
+
88
+ iterations += 1;
89
+ for (const symbol of currentCandidates) {
90
+ const outcome = await retrySymbol(symbol, iterations);
91
+ const reportEntry = reportBySymbol.get(symbol.symbolKey);
92
+ reportEntry.iterationResults.push({ iteration: iterations, ...outcome });
93
+ reportEntry.finalStatus = outcome.status === 'succeeded' ? 'enriched' : 'error';
94
+ reportEntry.memoryId = outcome.memoryId ?? reportEntry.memoryId;
95
+ reportEntry.contentPreview = outcome.contentPreview ?? reportEntry.contentPreview;
96
+
97
+ if (outcome.status === 'succeeded') {
98
+ const worklistEntry = worklist.find((e) => e.symbolKey === symbol.symbolKey);
99
+ if (worklistEntry) {
100
+ worklistEntry.status = 'enriched';
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ const symbols = [...reportBySymbol.values()];
107
+ const symbolsRecovered = symbols.filter((item) => item.finalStatus === 'enriched').length;
108
+ const symbolsStillFailing = symbols.length - symbolsRecovered;
109
+
110
+ return {
111
+ iterations,
112
+ symbols,
113
+ summary: {
114
+ symbolsRetried: symbols.length,
115
+ symbolsRecovered,
116
+ symbolsStillFailing,
117
+ maxIterationsReached: symbolsStillFailing > 0 && iterations === maxIterations,
118
+ },
119
+ };
120
+ }
@@ -1,31 +1,24 @@
1
1
  ---
2
2
  name: retry-errors
3
- description: Re-enrich symbols that previously failed using Ollama directly, with a detailed per-symbol report.
3
+ description: Re-enrich symbols still in error status through PMC's fallback chain, with a per-symbol report.
4
4
  argument-hint: "[--limit N] [--model MODEL] [--concurrency N] [--timeout MS]"
5
5
  allowed-tools:
6
6
  - Bash
7
7
  ---
8
8
 
9
9
  <objective>
10
- Retry enrichment for all symbols currently in error status, using only the local Ollama provider. Generates a per-symbol report showing what each previous error was and whether the retry succeeded.
10
+ Retry enrichment for symbols currently in error status, deduped by symbol, while preserving a per-symbol report of previous failures and retry outcomes.
11
11
  </objective>
12
12
 
13
13
  <execution>
14
14
  Run:
15
15
 
16
16
  ```bash
17
- {{PMC_BIN}} retry-errors . --model "qwen2.5-coder:14b" --concurrency 1 --timeout 300000
17
+ {{PMC_BIN}} retry-errors . --concurrency 1 --timeout 300000
18
18
  ```
19
19
 
20
- Options:
21
- - `--limit N` — Only retry the first N error entries (useful for testing)
22
- - `--model MODEL` — Ollama model to use (default: from config)
23
- - `--concurrency N` — Parallel slots (recommended: 1 for Ollama)
24
- - `--timeout MS` — Timeout per symbol in ms (default: 600000 = 10min)
25
- - `--report PATH` — Custom path for the JSON report
26
-
27
- The command reads the worklist, finds all entries with `status: "error"`, and re-enriches them using only the local-model (Ollama) provider. A JSON report is saved to `.planning/project-memory-context/enrichment/retry-report.json` with:
20
+ The command reads the worklist, retries each unique `symbolKey` through the configured fallback chain (`local-model -> cloud-api -> agent-subagent`), and stops when all symbols recover or 5 iterations complete. A JSON report is saved to `.planning/project-memory-context/enrichment/retry-report.json` with:
28
21
  - Previous error details for each symbol (error type, message, provider)
29
- - Retry result (succeeded/failed, elapsed time, content preview)
30
- - Overall summary counts
31
- </execution>
22
+ - Retry result per iteration (succeeded/failed, elapsed time, content preview)
23
+ - Overall summary: symbols retried, symbols recovered, symbols still failing, max iterations reached
24
+ </execution>