@aabadin/project-memory-context 0.1.5 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/context.mjs +291 -15
- package/cli/enrich-queue.mjs +83 -2
- package/cli/install-pmc.mjs +20 -0
- package/cli/query.mjs +136 -0
- package/cli/retry-errors.mjs +359 -0
- package/cli/status.mjs +56 -2
- package/mcp/pmc-query-server.mjs +90 -0
- package/package.json +3 -2
- package/src/command-dispatch.mjs +2 -0
- package/src/plugin-config.mjs +8 -0
- package/src/query/load-artifacts.mjs +96 -0
- package/src/query/orchestrator.mjs +175 -0
- package/src/retrieval/context-renderer-v1.mjs +53 -0
- package/src/retrieval/target-resolver.mjs +57 -0
- package/src/setup-bootstrap.mjs +1 -0
- package/src/template-installer.mjs +56 -4
- package/templates/claude-code/CLAUDE.md.snippet +18 -3
- package/templates/cursor/.cursorrules.snippet +18 -3
- package/templates/opencode/commands/get-context.md +22 -5
- package/templates/pmc-skill/SKILL.md +34 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
4
|
+
import { resolve, dirname, basename } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { appendProviderEvent, withRecordedAttempt } from '../src/enrichment-attempts.mjs';
|
|
8
|
+
import { resolveEnrichmentConfig, PMC_ENRICHMENT_CONFIG_FILE } from '../src/enrichment-config.mjs';
|
|
9
|
+
import { runEnrichmentWithFallback } from '../src/enrichment-driver.mjs';
|
|
10
|
+
import { createLocalModelProvider } from '../src/providers/local-model-provider.mjs';
|
|
11
|
+
import { appendSyncEntry, createSyncEntry } from '../src/sync-manifest.mjs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
async function loadJson(path) {
|
|
17
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function loadOptionalJson(path, fallback = null) {
|
|
21
|
+
try {
|
|
22
|
+
return await loadJson(path);
|
|
23
|
+
} catch {
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function saveJson(path, data) {
|
|
29
|
+
await mkdir(dirname(path), { recursive: true });
|
|
30
|
+
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeKey(key) {
|
|
34
|
+
return key.replace(/[^a-zA-Z0-9_-]+/g, '_');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function buildSymbolPrompt(symbol, projectRoot, maxCodeLines = 80) {
|
|
38
|
+
const absoluteFile = resolve(projectRoot, symbol.filePath);
|
|
39
|
+
const content = await readFile(absoluteFile, 'utf8');
|
|
40
|
+
const lines = content.split('\n');
|
|
41
|
+
const totalSymbolLines = symbol.range.endLine - symbol.range.startLine + 1;
|
|
42
|
+
let codeSection = lines.slice(symbol.range.startLine - 1, symbol.range.endLine).join('\n');
|
|
43
|
+
let truncated = false;
|
|
44
|
+
if (totalSymbolLines > maxCodeLines) {
|
|
45
|
+
const header = lines.slice(symbol.range.startLine - 1, symbol.range.startLine - 1 + Math.floor(maxCodeLines * 0.6)).join('\n');
|
|
46
|
+
const footer = lines.slice(symbol.range.endLine - Math.floor(maxCodeLines * 0.4), symbol.range.endLine).join('\n');
|
|
47
|
+
codeSection = header + '\n // ... truncated ...\n' + footer;
|
|
48
|
+
truncated = true;
|
|
49
|
+
}
|
|
50
|
+
const importsSection = lines.slice(0, symbol.range.startLine - 1).filter(l => /^\s*import\b/.test(l) || /^\s*using\b/.test(l)).join('\n');
|
|
51
|
+
return `Symbol: ${symbol.name}\nKind: ${symbol.kind}\nLanguage: ${symbol.language}\nLocation: ${symbol.filePath}:${symbol.range.startLine}-${symbol.range.endLine}${truncated ? ` (truncated from ${totalSymbolLines} lines)` : ''}\n\nContext (imports):\n${importsSection || '(none)'}\n\nCode:\n${codeSection}\n\nReturn a compact structured explanation (max 150 words) with:\n- responsibility\n- primary inputs\n- output\n- immediate dependencies\n- role in module`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseArgs(argv) {
|
|
55
|
+
const args = { concurrency: 4, timeoutMs: 600000, model: null, baseUrl: null, reportFile: null, limit: null };
|
|
56
|
+
for (let i = 2; i < argv.length; i++) {
|
|
57
|
+
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
|
+
else if (arg === '--model' && argv[i + 1]) { args.model = argv[++i]; }
|
|
61
|
+
else if (arg === '--base-url' && argv[i + 1]) { args.baseUrl = argv[++i]; }
|
|
62
|
+
else if (arg === '--report' && argv[i + 1]) { args.reportFile = argv[++i]; }
|
|
63
|
+
else if (arg === '--limit' && argv[i + 1]) { args.limit = parseInt(argv[++i], 10); }
|
|
64
|
+
else if (arg === '--help' || arg === '-h') { args.help = true; }
|
|
65
|
+
}
|
|
66
|
+
return args;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function printHelp() {
|
|
70
|
+
console.log(`
|
|
71
|
+
PMC Retry Errors — Re-enriches symbols that failed using Ollama directly
|
|
72
|
+
|
|
73
|
+
Usage: node tools/project-memory-context/cli/retry-errors.mjs [options]
|
|
74
|
+
|
|
75
|
+
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
|
|
83
|
+
`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function main() {
|
|
87
|
+
const rawArgs = process.argv.slice(2);
|
|
88
|
+
const projectRoot = rawArgs.find(a => !a.startsWith('-')) || process.cwd();
|
|
89
|
+
const args = parseArgs(process.argv);
|
|
90
|
+
if (args.help) { printHelp(); return 0; }
|
|
91
|
+
|
|
92
|
+
const enrichmentDir = resolve(projectRoot, '.planning/project-memory-context/enrichment');
|
|
93
|
+
await mkdir(enrichmentDir, { recursive: true });
|
|
94
|
+
|
|
95
|
+
const worklistFile = resolve(enrichmentDir, 'worklist.json');
|
|
96
|
+
const symbolIndexFile = resolve(enrichmentDir, 'symbol-index.json');
|
|
97
|
+
|
|
98
|
+
let worklist;
|
|
99
|
+
try {
|
|
100
|
+
worklist = await loadJson(worklistFile);
|
|
101
|
+
} catch {
|
|
102
|
+
console.error('[retry] No worklist found.');
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const errorEntries = worklist.filter(e => e.status === 'error');
|
|
107
|
+
if (errorEntries.length === 0) {
|
|
108
|
+
console.log('[retry] No error entries found in worklist. Nothing to retry.');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (args.limit && args.limit < errorEntries.length) {
|
|
113
|
+
console.error(`[retry] Limiting to first ${args.limit} of ${errorEntries.length} error entries`);
|
|
114
|
+
errorEntries.splice(args.limit);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let symbolIndex = {};
|
|
118
|
+
try { symbolIndex = await loadJson(symbolIndexFile); } catch {}
|
|
119
|
+
|
|
120
|
+
const projectConfig = await loadOptionalJson(resolve(projectRoot, '.opencode', PMC_ENRICHMENT_CONFIG_FILE));
|
|
121
|
+
const globalConfig = await loadOptionalJson(resolve(homedir(), '.config', 'opencode', PMC_ENRICHMENT_CONFIG_FILE));
|
|
122
|
+
let config = resolveEnrichmentConfig({ projectConfig, globalConfig, env: process.env });
|
|
123
|
+
|
|
124
|
+
config.preferredModes = ['local-model'];
|
|
125
|
+
|
|
126
|
+
if (args.model) {
|
|
127
|
+
config.localModel.model = args.model;
|
|
128
|
+
}
|
|
129
|
+
if (args.baseUrl) {
|
|
130
|
+
config.localModel.baseUrl = args.baseUrl;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const projectSlug = process.env.PMC_PROJECT_SLUG || basename(projectRoot);
|
|
134
|
+
|
|
135
|
+
const providers = [createLocalModelProvider()];
|
|
136
|
+
|
|
137
|
+
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`);
|
|
142
|
+
console.error(`[retry] ═════════════════════════════════════════════════════\n`);
|
|
143
|
+
|
|
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
|
+
};
|
|
151
|
+
|
|
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
|
+
}
|
|
164
|
+
|
|
165
|
+
const queue = [...errorEntries];
|
|
166
|
+
const activeSlots = new Map();
|
|
167
|
+
const results = [];
|
|
168
|
+
let slotIdCounter = 0;
|
|
169
|
+
|
|
170
|
+
async function dispatchSlot() {
|
|
171
|
+
while (queue.length > 0) {
|
|
172
|
+
const entry = queue.shift();
|
|
173
|
+
|
|
174
|
+
entry.status = 'pending';
|
|
175
|
+
delete entry.error;
|
|
176
|
+
delete entry.failedAt;
|
|
177
|
+
|
|
178
|
+
const slotId = slotIdCounter++;
|
|
179
|
+
const startTime = Date.now();
|
|
180
|
+
|
|
181
|
+
activeSlots.set(slotId, { entry, startTime });
|
|
182
|
+
|
|
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,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const elapsed = Date.now() - startTime;
|
|
193
|
+
activeSlots.delete(slotId);
|
|
194
|
+
|
|
195
|
+
for (const attempt of result.attempts ?? []) {
|
|
196
|
+
await appendProviderEvent(enrichmentDir, { symbolKey: entry.symbolKey, name: entry.name, ...attempt });
|
|
197
|
+
}
|
|
198
|
+
|
|
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
|
+
};
|
|
208
|
+
|
|
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`);
|
|
213
|
+
|
|
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
|
+
}
|
|
286
|
+
|
|
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: [],
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
results.push(reportEntry);
|
|
309
|
+
report.summary.failed++;
|
|
310
|
+
|
|
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}]`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
await saveJson(worklistFile, worklist);
|
|
316
|
+
await saveJson(symbolIndexFile, symbolIndex);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const slots = [];
|
|
321
|
+
for (let i = 0; i < args.concurrency; i++) {
|
|
322
|
+
slots.push(dispatchSlot());
|
|
323
|
+
}
|
|
324
|
+
await Promise.all(slots);
|
|
325
|
+
|
|
326
|
+
report.results = results;
|
|
327
|
+
report.finishedAt = new Date().toISOString();
|
|
328
|
+
report.totalElapsedMs = results.reduce((a, r) => a + (r.elapsedMs ?? 0), 0);
|
|
329
|
+
|
|
330
|
+
const reportPath = args.reportFile || resolve(enrichmentDir, 'retry-report.json');
|
|
331
|
+
await saveJson(reportPath, report);
|
|
332
|
+
|
|
333
|
+
console.error(`\n[retry] ═════════════════════════════════════════════════════`);
|
|
334
|
+
console.error(`[retry] DONE — ${report.summary.succeeded} succeeded, ${report.summary.failed} failed, ${report.summary.skipped} skipped`);
|
|
335
|
+
console.error(`[retry] Report: ${reportPath}`);
|
|
336
|
+
console.error(`[retry] ═════════════════════════════════════════════════════\n`);
|
|
337
|
+
|
|
338
|
+
console.log(JSON.stringify({
|
|
339
|
+
ok: true,
|
|
340
|
+
command: 'retry-errors',
|
|
341
|
+
total: errorEntries.length,
|
|
342
|
+
succeeded: report.summary.succeeded,
|
|
343
|
+
failed: report.summary.failed,
|
|
344
|
+
reportPath,
|
|
345
|
+
}, null, 2));
|
|
346
|
+
|
|
347
|
+
return report.summary.failed > 0 ? 1 : 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
351
|
+
const exitCode = await main().catch(err => {
|
|
352
|
+
console.error('[retry] FATAL:', err.message);
|
|
353
|
+
return 1;
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
if (exitCode !== 0) {
|
|
357
|
+
process.exit(exitCode);
|
|
358
|
+
}
|
|
359
|
+
}
|
package/cli/status.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
|
|
6
6
|
import { detectAgentType, resolveConfigDirs } from '../src/platform.mjs';
|
|
7
7
|
|
|
8
|
+
const DEFAULT_STALE_AFTER_SECONDS = 90;
|
|
9
|
+
|
|
8
10
|
export function summarizeWorklist(worklist) {
|
|
9
11
|
return {
|
|
10
12
|
pending: worklist.filter((e) => e.status === 'pending' || e.status === 'stale').length,
|
|
@@ -21,6 +23,52 @@ async function readJsonSafe(filePath) {
|
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
function toIsoString(value) {
|
|
27
|
+
if (!value) return null;
|
|
28
|
+
const date = new Date(value);
|
|
29
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function heartbeatIsFresh(heartbeatAt, now, staleAfterSeconds = DEFAULT_STALE_AFTER_SECONDS) {
|
|
33
|
+
if (!heartbeatAt) return false;
|
|
34
|
+
const heartbeat = new Date(heartbeatAt).getTime();
|
|
35
|
+
const current = new Date(now).getTime();
|
|
36
|
+
if (!Number.isFinite(heartbeat) || !Number.isFinite(current)) return false;
|
|
37
|
+
return current - heartbeat <= staleAfterSeconds * 1000;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function deriveRuntimeState(queueState, now, staleAfterSeconds = DEFAULT_STALE_AFTER_SECONDS) {
|
|
41
|
+
if (!queueState || typeof queueState !== 'object') {
|
|
42
|
+
return { state: 'idle', runtime: null };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const runtime = {
|
|
46
|
+
pid: Number.isInteger(queueState.pid) ? queueState.pid : null,
|
|
47
|
+
startedAt: toIsoString(queueState.startedAt),
|
|
48
|
+
heartbeatAt: toIsoString(queueState.heartbeatAt),
|
|
49
|
+
finishedAt: toIsoString(queueState.finishedAt),
|
|
50
|
+
staleAfterSeconds,
|
|
51
|
+
lastError: queueState.lastError ?? null,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (queueState.status === 'finished') {
|
|
55
|
+
return { state: 'finished', runtime };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (queueState.status === 'failed') {
|
|
59
|
+
return { state: 'failed', runtime };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (queueState.status === 'running') {
|
|
63
|
+
return {
|
|
64
|
+
state: heartbeatIsFresh(runtime.heartbeatAt, now, staleAfterSeconds) ? 'running' : 'stalled',
|
|
65
|
+
runtime,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { state: 'idle', runtime: null };
|
|
70
|
+
}
|
|
71
|
+
|
|
24
72
|
async function getLastSyncTimestamp(enrichmentDir) {
|
|
25
73
|
const syncManifest = join(enrichmentDir, 'sync-manifest.json');
|
|
26
74
|
try {
|
|
@@ -31,16 +79,20 @@ async function getLastSyncTimestamp(enrichmentDir) {
|
|
|
31
79
|
}
|
|
32
80
|
}
|
|
33
81
|
|
|
34
|
-
export async function buildStatusReport({ projectRoot = process.cwd() } = {}) {
|
|
82
|
+
export async function buildStatusReport({ projectRoot = process.cwd(), now = new Date().toISOString() } = {}) {
|
|
35
83
|
const dirs = resolveConfigDirs(projectRoot);
|
|
36
84
|
const planningDir = join(projectRoot, '.planning', 'project-memory-context');
|
|
37
85
|
const enrichmentDir = join(planningDir, 'enrichment');
|
|
38
86
|
const worklistPath = join(enrichmentDir, 'worklist.json');
|
|
39
87
|
const installStatePath = join(planningDir, 'install.json');
|
|
88
|
+
const queueStatePath = join(enrichmentDir, 'queue-state.json');
|
|
40
89
|
|
|
41
90
|
const worklist = await readJsonSafe(worklistPath);
|
|
42
91
|
const installState = await readJsonSafe(installStatePath);
|
|
92
|
+
const queueState = await readJsonSafe(queueStatePath);
|
|
43
93
|
const lastSync = await getLastSyncTimestamp(enrichmentDir);
|
|
94
|
+
const { state, runtime } = deriveRuntimeState(queueState, now);
|
|
95
|
+
const worklistSummary = Array.isArray(worklist) ? summarizeWorklist(worklist) : null;
|
|
44
96
|
|
|
45
97
|
return {
|
|
46
98
|
ok: true,
|
|
@@ -49,7 +101,9 @@ export async function buildStatusReport({ projectRoot = process.cwd() } = {}) {
|
|
|
49
101
|
configLocation: dirs.projectConfig,
|
|
50
102
|
agentType: detectAgentType(projectRoot),
|
|
51
103
|
installState: installState ? { installedAt: installState.installedAt, version: installState.version } : null,
|
|
52
|
-
|
|
104
|
+
state,
|
|
105
|
+
runtime,
|
|
106
|
+
worklist: worklistSummary,
|
|
53
107
|
lastSync,
|
|
54
108
|
};
|
|
55
109
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import { createQueryOrchestrator } from '../src/query/orchestrator.mjs';
|
|
7
|
+
|
|
8
|
+
const projectRoot = process.env.PMC_PROJECT_ROOT || process.cwd();
|
|
9
|
+
const orchestrator = createQueryOrchestrator({ projectRoot });
|
|
10
|
+
|
|
11
|
+
function textResult(value) {
|
|
12
|
+
return {
|
|
13
|
+
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function errorResult(toolName, error) {
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: 'text', text: `${toolName} failed: ${String(error)}` }],
|
|
20
|
+
isError: true,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const server = new McpServer({
|
|
25
|
+
name: 'pmc-query',
|
|
26
|
+
version: '0.1.5',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
server.tool(
|
|
30
|
+
'pmc_query_project',
|
|
31
|
+
'Query PMC project context and symbol artifacts using a natural-language question.',
|
|
32
|
+
{
|
|
33
|
+
question: z.string().describe('Natural-language project question'),
|
|
34
|
+
},
|
|
35
|
+
async ({ question }) => {
|
|
36
|
+
try {
|
|
37
|
+
return textResult(await orchestrator.query(question));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return errorResult('pmc_query_project', error);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
server.tool(
|
|
45
|
+
'pmc_search_symbols',
|
|
46
|
+
'Search PMC symbols by semantic summary and optional file path filter.',
|
|
47
|
+
{
|
|
48
|
+
query: z.string().describe('Search query for symbols'),
|
|
49
|
+
file: z.string().optional().describe('Optional normalized file path filter'),
|
|
50
|
+
},
|
|
51
|
+
async ({ query, file }) => {
|
|
52
|
+
try {
|
|
53
|
+
return textResult(await orchestrator.searchSymbols(query, file));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return errorResult('pmc_search_symbols', error);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
server.tool(
|
|
61
|
+
'pmc_get_dependents',
|
|
62
|
+
'List symbols that depend on the given symbol key.',
|
|
63
|
+
{
|
|
64
|
+
symbol: z.string().describe('Normalized PMC symbol key'),
|
|
65
|
+
},
|
|
66
|
+
async ({ symbol }) => {
|
|
67
|
+
try {
|
|
68
|
+
return textResult(await orchestrator.getDependents(symbol));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return errorResult('pmc_get_dependents', error);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
server.tool(
|
|
76
|
+
'pmc_get_dependencies',
|
|
77
|
+
'List symbols the given symbol key depends on.',
|
|
78
|
+
{
|
|
79
|
+
symbol: z.string().describe('Normalized PMC symbol key'),
|
|
80
|
+
},
|
|
81
|
+
async ({ symbol }) => {
|
|
82
|
+
try {
|
|
83
|
+
return textResult(await orchestrator.getDependencies(symbol));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return errorResult('pmc_get_dependencies', error);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
await server.connect(new StdioServerTransport());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aabadin/project-memory-context",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"./retrieval": "./src/retrieval/query-engine.mjs"
|
|
12
12
|
},
|
|
13
13
|
"bin": {
|
|
14
|
-
"pmc": "bin/pmc.mjs"
|
|
14
|
+
"pmc": "bin/pmc.mjs",
|
|
15
|
+
"pmc-query-server": "mcp/pmc-query-server.mjs"
|
|
15
16
|
},
|
|
16
17
|
"scripts": {
|
|
17
18
|
"test": "node --test tests/*.test.mjs",
|
package/src/command-dispatch.mjs
CHANGED
|
@@ -14,6 +14,8 @@ const COMMANDS = new Map([
|
|
|
14
14
|
['install-pmc', 'cli/install-pmc.mjs'],
|
|
15
15
|
['new-project', 'cli/new-project.mjs'],
|
|
16
16
|
['project-context', 'cli/project-context.mjs'],
|
|
17
|
+
['query', 'cli/query.mjs'],
|
|
18
|
+
['retry-errors', 'cli/retry-errors.mjs'],
|
|
17
19
|
['sanitize', 'cli/sanitize.mjs'],
|
|
18
20
|
['setup', 'cli/setup.mjs'],
|
|
19
21
|
['status', 'cli/status.mjs'],
|
package/src/plugin-config.mjs
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
export function buildInjectedPmcConfig({ installState }) {
|
|
2
2
|
return {
|
|
3
3
|
mcp: {
|
|
4
|
+
'pmc-query': {
|
|
5
|
+
type: 'local',
|
|
6
|
+
command: ['npx', '--yes', '--package', '@aabadin/project-memory-context', 'pmc-query-server'],
|
|
7
|
+
enabled: true,
|
|
8
|
+
environment: {
|
|
9
|
+
PMC_PROJECT_ROOT: installState.projectRoot,
|
|
10
|
+
},
|
|
11
|
+
},
|
|
4
12
|
'pmc-agent-memory': {
|
|
5
13
|
type: 'local',
|
|
6
14
|
command: ['npx', '-y', '@aabadin/agent-memory-mcp'],
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
function normalizePath(filePath) {
|
|
5
|
+
return String(filePath ?? '').replace(/\\/g, '/');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function readJson(filePath, fallback) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(await readFile(filePath, 'utf8'));
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error && error.code === 'ENOENT') {
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readJsonDirectory(directoryPath) {
|
|
20
|
+
try {
|
|
21
|
+
const entries = await readdir(directoryPath, { withFileTypes: true });
|
|
22
|
+
return entries
|
|
23
|
+
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'))
|
|
24
|
+
.map((entry) => entry.name)
|
|
25
|
+
.sort((a, b) => a.localeCompare(b));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error && error.code === 'ENOENT') {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function loadMemories(projectContextDir) {
|
|
35
|
+
const materializedDir = join(projectContextDir, 'materialized');
|
|
36
|
+
const materializedEntries = await readJsonDirectory(materializedDir);
|
|
37
|
+
const directory = materializedEntries.length > 0 ? materializedDir : projectContextDir;
|
|
38
|
+
const fileNames = materializedEntries.length > 0
|
|
39
|
+
? materializedEntries
|
|
40
|
+
: await readJsonDirectory(projectContextDir);
|
|
41
|
+
|
|
42
|
+
const memories = [];
|
|
43
|
+
for (const fileName of fileNames) {
|
|
44
|
+
const path = join(directory, fileName);
|
|
45
|
+
const memory = await readJson(path, null);
|
|
46
|
+
if (memory) {
|
|
47
|
+
memories.push({ ...memory, path });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return memories;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseSymbol(symbolKey, entry, semanticSummary) {
|
|
55
|
+
const parts = String(symbolKey ?? '').split('|');
|
|
56
|
+
return {
|
|
57
|
+
symbolKey,
|
|
58
|
+
filePath: parts.length >= 2 ? normalizePath(parts[1]) : '',
|
|
59
|
+
name: parts.length >= 2 ? parts[parts.length - 2] : '',
|
|
60
|
+
graphNodeId: entry?.graphNodeId ?? null,
|
|
61
|
+
memoryId: entry?.memoryId ?? null,
|
|
62
|
+
status: entry?.status ?? null,
|
|
63
|
+
semanticSummary,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function loadQueryArtifacts(projectRoot) {
|
|
68
|
+
const pmcRoot = join(projectRoot, '.planning', 'project-memory-context');
|
|
69
|
+
const projectContextDir = join(pmcRoot, 'project-context');
|
|
70
|
+
const symbolIndexPath = join(pmcRoot, 'enrichment', 'symbol-index.json');
|
|
71
|
+
const graphPath = join(pmcRoot, 'graph', 'graph.json');
|
|
72
|
+
|
|
73
|
+
const memories = await loadMemories(projectContextDir);
|
|
74
|
+
const symbolIndex = await readJson(symbolIndexPath, {});
|
|
75
|
+
const graph = await readJson(graphPath, {});
|
|
76
|
+
const nodes = graph.nodes ?? [];
|
|
77
|
+
const edges = graph.edges ?? graph.links ?? [];
|
|
78
|
+
|
|
79
|
+
const nodeById = new Map(nodes.map((node) => [node?.id, node]));
|
|
80
|
+
const symbols = Object.entries(symbolIndex).map(([symbolKey, entry]) => {
|
|
81
|
+
const semanticSummary = String(
|
|
82
|
+
entry?.semanticSummary
|
|
83
|
+
?? nodeById.get(entry?.graphNodeId)?.metadata?.semanticSummary
|
|
84
|
+
?? '',
|
|
85
|
+
).trim();
|
|
86
|
+
|
|
87
|
+
return parseSymbol(symbolKey, entry, semanticSummary);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
memories,
|
|
92
|
+
symbols,
|
|
93
|
+
nodes,
|
|
94
|
+
edges,
|
|
95
|
+
};
|
|
96
|
+
}
|