pan-wizard 2.9.1 → 3.4.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/README.md +8 -8
- package/agents/pan-conductor.md +189 -0
- package/agents/pan-counterfactual.md +112 -0
- package/agents/pan-debugger.md +15 -1
- package/agents/pan-document_code.md +21 -0
- package/agents/pan-executor.md +16 -0
- package/agents/pan-hardener.md +113 -0
- package/agents/pan-integration-checker.md +2 -0
- package/agents/pan-knowledge.md +81 -0
- package/agents/pan-meta-reviewer.md +91 -0
- package/agents/pan-plan-checker.md +2 -0
- package/agents/pan-previewer.md +98 -0
- package/agents/pan-project-researcher.md +4 -4
- package/agents/pan-reviewer.md +2 -0
- package/agents/pan-verifier.md +2 -0
- package/bin/install-lib.cjs +197 -0
- package/bin/install.js +1999 -1959
- package/commands/pan/cost.md +132 -0
- package/commands/pan/exec-phase.md +15 -0
- package/commands/pan/focus-auto.md +18 -0
- package/commands/pan/focus-exec.md +10 -1
- package/commands/pan/knowledge.md +129 -0
- package/commands/pan/map-codebase.md +15 -0
- package/commands/pan/mcp-bridge.md +145 -0
- package/commands/pan/plan-phase.md +11 -0
- package/commands/pan/preview.md +114 -0
- package/commands/pan/profile.md +37 -0
- package/commands/pan/review-deep.md +128 -0
- package/commands/pan/verify-phase.md +11 -0
- package/commands/pan/what-if.md +146 -0
- package/hooks/dist/pan-cost-logger.js +102 -0
- package/hooks/dist/pan-statusline.js +154 -108
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/bridge.cjs +269 -0
- package/pan-wizard-core/bin/lib/bus.cjs +251 -0
- package/pan-wizard-core/bin/lib/codebase.cjs +118 -0
- package/pan-wizard-core/bin/lib/constants.cjs +39 -0
- package/pan-wizard-core/bin/lib/context-budget.cjs +27 -0
- package/pan-wizard-core/bin/lib/core.cjs +91 -6
- package/pan-wizard-core/bin/lib/cost.cjs +359 -0
- package/pan-wizard-core/bin/lib/focus.cjs +100 -2
- package/pan-wizard-core/bin/lib/init.cjs +5 -5
- package/pan-wizard-core/bin/lib/knowledge.cjs +331 -0
- package/pan-wizard-core/bin/lib/memory.cjs +252 -0
- package/pan-wizard-core/bin/lib/phase.cjs +40 -13
- package/pan-wizard-core/bin/lib/preview.cjs +480 -0
- package/pan-wizard-core/bin/lib/review-deep.cjs +280 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +4 -4
- package/pan-wizard-core/bin/lib/state.cjs +2 -2
- package/pan-wizard-core/bin/lib/verify.cjs +34 -1
- package/pan-wizard-core/bin/lib/whatif.cjs +289 -0
- package/pan-wizard-core/bin/pan-tools.cjs +239 -4
- package/pan-wizard-core/templates/playbook.md +53 -0
- package/pan-wizard-core/templates/preview-report.md +93 -0
- package/pan-wizard-core/templates/roadmap.md +24 -24
- package/pan-wizard-core/templates/state.md +12 -9
- package/pan-wizard-core/workflows/plan-phase.md +1 -1
- package/scripts/build-hooks.js +2 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge — grounded Q&A, multi-turn discussion, playbook generation
|
|
3
|
+
* (Spec B v2 Y-3, v3.2).
|
|
4
|
+
*
|
|
5
|
+
* Three modes, one module:
|
|
6
|
+
* - ask — retrieve candidate files + format citation context for the agent
|
|
7
|
+
* - discuss — session state CRUD for multi-turn conversations on a phase
|
|
8
|
+
* - playbook — read all agents' memory and cluster into sections
|
|
9
|
+
*
|
|
10
|
+
* All three leverage Spec A infrastructure:
|
|
11
|
+
* - E-1 caching for stable input prefixes (ask, discuss)
|
|
12
|
+
* - E-4 memory for playbook source + discuss state
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { output, error, safeReadFile, toPosix } = require('./core.cjs');
|
|
18
|
+
const { PLANNING_DIR } = require('./constants.cjs');
|
|
19
|
+
const { planningPath } = require('./utils.cjs');
|
|
20
|
+
const { listMemoryAgents, readMemory } = require('./memory.cjs');
|
|
21
|
+
|
|
22
|
+
const CONVERSATIONS_DIR = 'conversations';
|
|
23
|
+
const PLAYBOOK_FILE = 'playbook.md';
|
|
24
|
+
const DEFAULT_MAX_SOURCES = 20;
|
|
25
|
+
|
|
26
|
+
// ─── Mode: ask — retrieval for grounded Q&A ────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Candidate source directories that pan-knowledge agent can cite.
|
|
30
|
+
*/
|
|
31
|
+
const CITATION_ROOTS = [
|
|
32
|
+
'.planning/project.md',
|
|
33
|
+
'.planning/requirements.md',
|
|
34
|
+
'.planning/roadmap.md',
|
|
35
|
+
'.planning/state.md',
|
|
36
|
+
'.planning/standards.md',
|
|
37
|
+
'.planning/patterns.md',
|
|
38
|
+
'.planning/phases',
|
|
39
|
+
'.planning/milestones',
|
|
40
|
+
'.planning/memory',
|
|
41
|
+
'docs',
|
|
42
|
+
'CHANGELOG.md',
|
|
43
|
+
'README.md',
|
|
44
|
+
'CLAUDE.md',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Score a candidate file by naive keyword matching against the question.
|
|
49
|
+
* Not a vector index — just a frequency-based ranker so the agent reads
|
|
50
|
+
* the most relevant files first.
|
|
51
|
+
*/
|
|
52
|
+
function scoreRelevance(question, content) {
|
|
53
|
+
if (!content) return 0;
|
|
54
|
+
const words = question.toLowerCase().split(/\W+/).filter(w => w.length >= 3);
|
|
55
|
+
if (words.length === 0) return 0;
|
|
56
|
+
const body = content.toLowerCase();
|
|
57
|
+
let score = 0;
|
|
58
|
+
for (const w of words) {
|
|
59
|
+
const count = (body.match(new RegExp(`\\b${w.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\b`, 'g')) || []).length;
|
|
60
|
+
score += count;
|
|
61
|
+
}
|
|
62
|
+
return score;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Walk a path (file or directory, 1 level deep for .md files) and return
|
|
67
|
+
* {file, score} entries.
|
|
68
|
+
*/
|
|
69
|
+
function gatherCandidates(cwd, question) {
|
|
70
|
+
const candidates = [];
|
|
71
|
+
for (const rel of CITATION_ROOTS) {
|
|
72
|
+
const abs = path.join(cwd, rel);
|
|
73
|
+
let stat;
|
|
74
|
+
try { stat = fs.statSync(abs); } catch { continue; }
|
|
75
|
+
if (stat.isFile()) {
|
|
76
|
+
const content = safeReadFile(abs);
|
|
77
|
+
candidates.push({ file: toPosix(rel), score: scoreRelevance(question, content), bytes: Buffer.byteLength(content || '', 'utf-8') });
|
|
78
|
+
} else if (stat.isDirectory()) {
|
|
79
|
+
let entries = [];
|
|
80
|
+
try { entries = fs.readdirSync(abs); } catch { continue; }
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const entryAbs = path.join(abs, entry);
|
|
83
|
+
let entryStat;
|
|
84
|
+
try { entryStat = fs.statSync(entryAbs); } catch { continue; }
|
|
85
|
+
if (entryStat.isFile() && entry.endsWith('.md')) {
|
|
86
|
+
const entryRel = toPosix(path.join(rel, entry));
|
|
87
|
+
const content = safeReadFile(entryAbs);
|
|
88
|
+
candidates.push({ file: entryRel, score: scoreRelevance(question, content), bytes: Buffer.byteLength(content || '', 'utf-8') });
|
|
89
|
+
} else if (entryStat.isDirectory()) {
|
|
90
|
+
// One more level for phases/<NN>/ and milestones/
|
|
91
|
+
let sub = [];
|
|
92
|
+
try { sub = fs.readdirSync(entryAbs); } catch { continue; }
|
|
93
|
+
for (const s of sub) {
|
|
94
|
+
if (!s.endsWith('.md')) continue;
|
|
95
|
+
const subRel = toPosix(path.join(rel, entry, s));
|
|
96
|
+
const content = safeReadFile(path.join(entryAbs, s));
|
|
97
|
+
candidates.push({ file: subRel, score: scoreRelevance(question, content), bytes: Buffer.byteLength(content || '', 'utf-8') });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return candidates;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Retrieve ranked candidate sources for a question.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} cwd - Project root
|
|
110
|
+
* @param {string} question - User's natural-language question
|
|
111
|
+
* @param {Object} [opts] - {max_sources}
|
|
112
|
+
* @returns {Object} {question, sources: Array<{file, score, bytes}>, total_candidates}
|
|
113
|
+
*/
|
|
114
|
+
function ask(cwd, question, opts) {
|
|
115
|
+
if (typeof question !== 'string' || !question.trim()) {
|
|
116
|
+
return { error: 'question must be a non-empty string' };
|
|
117
|
+
}
|
|
118
|
+
const max = Math.max(1, Math.min(100, Number(opts?.max_sources) || DEFAULT_MAX_SOURCES));
|
|
119
|
+
const all = gatherCandidates(cwd, question);
|
|
120
|
+
const ranked = all
|
|
121
|
+
.filter(c => c.score > 0 || c.file.endsWith('project.md') || c.file.endsWith('requirements.md'))
|
|
122
|
+
.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file))
|
|
123
|
+
.slice(0, max);
|
|
124
|
+
return {
|
|
125
|
+
question,
|
|
126
|
+
sources: ranked,
|
|
127
|
+
total_candidates: all.length,
|
|
128
|
+
returned: ranked.length,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Mode: discuss — session state for multi-turn conversations ────────────
|
|
133
|
+
|
|
134
|
+
function conversationsDir(cwd) {
|
|
135
|
+
return path.join(planningPath(cwd), CONVERSATIONS_DIR);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function sessionFile(cwd, phaseNum) {
|
|
139
|
+
return path.join(conversationsDir(cwd), String(phaseNum), 'session.json');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read or initialize a discussion session for a phase.
|
|
144
|
+
*/
|
|
145
|
+
function loadSession(cwd, phaseNum) {
|
|
146
|
+
const file = sessionFile(cwd, phaseNum);
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
149
|
+
} catch {
|
|
150
|
+
return { phase: String(phaseNum), turns: [], created: new Date().toISOString() };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Append a user question + agent response turn.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} cwd
|
|
158
|
+
* @param {string} phaseNum
|
|
159
|
+
* @param {{role: 'user'|'agent', content: string, cites?: string[]}} turn
|
|
160
|
+
* @returns {{appended: true, turn_count: number, file: string}|{error: string}}
|
|
161
|
+
*/
|
|
162
|
+
function appendTurn(cwd, phaseNum, turn) {
|
|
163
|
+
if (!phaseNum) return { error: 'phaseNum required' };
|
|
164
|
+
if (!turn || !turn.role || !turn.content) return { error: 'turn requires role + content' };
|
|
165
|
+
if (turn.role !== 'user' && turn.role !== 'agent') {
|
|
166
|
+
return { error: 'turn.role must be "user" or "agent"' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const session = loadSession(cwd, phaseNum);
|
|
170
|
+
session.turns.push({
|
|
171
|
+
ts: new Date().toISOString(),
|
|
172
|
+
role: turn.role,
|
|
173
|
+
content: turn.content,
|
|
174
|
+
cites: Array.isArray(turn.cites) ? turn.cites : [],
|
|
175
|
+
});
|
|
176
|
+
session.last_updated = session.turns[session.turns.length - 1].ts;
|
|
177
|
+
|
|
178
|
+
const file = sessionFile(cwd, phaseNum);
|
|
179
|
+
try {
|
|
180
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
181
|
+
fs.writeFileSync(file, JSON.stringify(session, null, 2), 'utf-8');
|
|
182
|
+
} catch (e) {
|
|
183
|
+
return { error: `Failed to write session: ${e.message}` };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { appended: true, turn_count: session.turns.length, file: toPosix(path.relative(cwd, file)) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Mode: playbook — cluster agent memory into sections ───────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Category heuristics. Maps keyword patterns to playbook sections.
|
|
193
|
+
* Order matters — first match wins.
|
|
194
|
+
*/
|
|
195
|
+
const PLAYBOOK_CATEGORIES = [
|
|
196
|
+
{ name: 'Conventions', pattern: /\b(convention|style|format|naming|prefer)\b/i },
|
|
197
|
+
{ name: 'Gotchas', pattern: /\b(gotcha|pitfall|edge\s*case|surprise|careful)\b/i },
|
|
198
|
+
{ name: 'Decisions', pattern: /\b(decision|decided|chose|picked|adopt)\b/i },
|
|
199
|
+
{ name: 'Tool choices', pattern: /\b(library|package|framework|tool|alternative)\b/i },
|
|
200
|
+
{ name: 'Anti-patterns', pattern: /\b(anti.?pattern|avoid|do not|don't|never)\b/i },
|
|
201
|
+
{ name: 'Recurring gaps', pattern: /\brecurring\s+(gap|plan\s+gap|issue)\b/i },
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
function categorizeEntry(entry) {
|
|
205
|
+
for (const cat of PLAYBOOK_CATEGORIES) {
|
|
206
|
+
if (cat.pattern.test(entry)) return cat.name;
|
|
207
|
+
}
|
|
208
|
+
return 'General';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Generate a structured playbook from all agents' memory files.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} cwd - Project root
|
|
215
|
+
* @returns {Object} {sections: {name: [{agent, entry}]}, agent_count, entry_count}
|
|
216
|
+
*/
|
|
217
|
+
function buildPlaybook(cwd) {
|
|
218
|
+
const { agents } = listMemoryAgents(cwd);
|
|
219
|
+
const sections = {};
|
|
220
|
+
let entryCount = 0;
|
|
221
|
+
|
|
222
|
+
for (const a of agents) {
|
|
223
|
+
const mem = readMemory(cwd, a.agent);
|
|
224
|
+
if (!mem) continue;
|
|
225
|
+
for (const entry of mem.entries) {
|
|
226
|
+
const cat = categorizeEntry(entry);
|
|
227
|
+
if (!sections[cat]) sections[cat] = [];
|
|
228
|
+
sections[cat].push({ agent: a.agent, entry });
|
|
229
|
+
entryCount += 1;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
sections,
|
|
235
|
+
agent_count: agents.length,
|
|
236
|
+
entry_count: entryCount,
|
|
237
|
+
generated: new Date().toISOString(),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Serialize the playbook object to markdown and write to `.planning/playbook.md`.
|
|
243
|
+
*/
|
|
244
|
+
function writePlaybook(cwd, playbook) {
|
|
245
|
+
const lines = [];
|
|
246
|
+
lines.push('---');
|
|
247
|
+
lines.push('type: playbook');
|
|
248
|
+
lines.push(`generated: ${playbook.generated}`);
|
|
249
|
+
lines.push(`source_agents: ${playbook.agent_count}`);
|
|
250
|
+
lines.push(`entries: ${playbook.entry_count}`);
|
|
251
|
+
lines.push('---');
|
|
252
|
+
lines.push('');
|
|
253
|
+
lines.push('# PAN Playbook');
|
|
254
|
+
lines.push('');
|
|
255
|
+
lines.push(`Accumulated lessons across ${playbook.agent_count} agents and ${playbook.entry_count} memory entries. Regenerated from \`.planning/memory/*.md\`.`);
|
|
256
|
+
lines.push('');
|
|
257
|
+
|
|
258
|
+
const sectionOrder = [
|
|
259
|
+
...PLAYBOOK_CATEGORIES.map(c => c.name),
|
|
260
|
+
'General',
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
for (const section of sectionOrder) {
|
|
264
|
+
const items = playbook.sections[section];
|
|
265
|
+
if (!items || items.length === 0) continue;
|
|
266
|
+
lines.push(`## ${section}`);
|
|
267
|
+
lines.push('');
|
|
268
|
+
for (const item of items) {
|
|
269
|
+
lines.push(`- ${item.entry} _— from \`${item.agent}\`_`);
|
|
270
|
+
}
|
|
271
|
+
lines.push('');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const file = path.join(planningPath(cwd), PLAYBOOK_FILE);
|
|
275
|
+
try {
|
|
276
|
+
fs.writeFileSync(file, lines.join('\n'), 'utf-8');
|
|
277
|
+
} catch (e) {
|
|
278
|
+
return { error: `Failed to write playbook: ${e.message}` };
|
|
279
|
+
}
|
|
280
|
+
return { written: true, file: toPosix(path.relative(cwd, file)) };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── CLI wrappers ───────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
function cmdKnowledgeAsk(cwd, question, opts, raw) {
|
|
286
|
+
if (!question) error('Usage: knowledge ask <question>');
|
|
287
|
+
output(ask(cwd, question, opts), raw);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function cmdKnowledgeDiscuss(cwd, phaseNum, opts, raw) {
|
|
291
|
+
const subcmd = opts?.subcmd;
|
|
292
|
+
if (subcmd === 'read') {
|
|
293
|
+
output(loadSession(cwd, phaseNum), raw);
|
|
294
|
+
} else if (subcmd === 'append') {
|
|
295
|
+
output(appendTurn(cwd, phaseNum, {
|
|
296
|
+
role: opts.role,
|
|
297
|
+
content: opts.content,
|
|
298
|
+
cites: opts.cites ? opts.cites.split(',') : [],
|
|
299
|
+
}), raw);
|
|
300
|
+
} else {
|
|
301
|
+
error('Usage: knowledge discuss <phase> --subcmd read|append [--role user|agent --content "..."]');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function cmdKnowledgePlaybook(cwd, opts, raw) {
|
|
306
|
+
const playbook = buildPlaybook(cwd);
|
|
307
|
+
if (opts?.preview) {
|
|
308
|
+
output(playbook, raw);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const result = writePlaybook(cwd, playbook);
|
|
312
|
+
if (result.error) { output(result, raw); return; }
|
|
313
|
+
output({ ...result, agent_count: playbook.agent_count, entry_count: playbook.entry_count }, raw);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = {
|
|
317
|
+
ask,
|
|
318
|
+
loadSession,
|
|
319
|
+
appendTurn,
|
|
320
|
+
buildPlaybook,
|
|
321
|
+
writePlaybook,
|
|
322
|
+
scoreRelevance,
|
|
323
|
+
categorizeEntry,
|
|
324
|
+
cmdKnowledgeAsk,
|
|
325
|
+
cmdKnowledgeDiscuss,
|
|
326
|
+
cmdKnowledgePlaybook,
|
|
327
|
+
CITATION_ROOTS,
|
|
328
|
+
CONVERSATIONS_DIR,
|
|
329
|
+
PLAYBOOK_FILE,
|
|
330
|
+
PLAYBOOK_CATEGORIES,
|
|
331
|
+
};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory — cross-phase agent memory layer
|
|
3
|
+
*
|
|
4
|
+
* Each agent has an append-only memory log at `.planning/memory/<agent>.md`.
|
|
5
|
+
* Agents read their memory at start of each invocation and append lessons
|
|
6
|
+
* learned at end. Compaction keeps file size bounded.
|
|
7
|
+
*
|
|
8
|
+
* File format: a markdown file with a stable YAML frontmatter header and
|
|
9
|
+
* an append-only "## Entries" section containing one bullet per entry:
|
|
10
|
+
*
|
|
11
|
+
* ---
|
|
12
|
+
* agent: pan-planner
|
|
13
|
+
* created: 2026-04-18
|
|
14
|
+
* ---
|
|
15
|
+
*
|
|
16
|
+
* ## Entries
|
|
17
|
+
*
|
|
18
|
+
* - 2026-04-18: Prefer bulk writes over per-row commits for Postgres
|
|
19
|
+
* - 2026-04-19: ...
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { output, error } = require('./core.cjs');
|
|
25
|
+
const { PLANNING_DIR } = require('./constants.cjs');
|
|
26
|
+
const { planningPath } = require('./utils.cjs');
|
|
27
|
+
|
|
28
|
+
const MEMORY_DIR = 'memory';
|
|
29
|
+
const DEFAULT_MAX_ENTRIES = 500;
|
|
30
|
+
const AGENT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
31
|
+
|
|
32
|
+
function memoryDir(cwd) {
|
|
33
|
+
return path.join(planningPath(cwd), MEMORY_DIR);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function memoryFile(cwd, agent) {
|
|
37
|
+
return path.join(memoryDir(cwd), `${agent}.md`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validateAgentName(agent) {
|
|
41
|
+
if (typeof agent !== 'string' || !AGENT_NAME_RE.test(agent)) {
|
|
42
|
+
return `Invalid agent name: ${agent}. Must match ${AGENT_NAME_RE}`;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function today() {
|
|
48
|
+
return new Date().toISOString().slice(0, 10);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read the memory file for an agent.
|
|
53
|
+
* @param {string} cwd - Project root
|
|
54
|
+
* @param {string} agent - Agent name
|
|
55
|
+
* @returns {{agent: string, entries: string[], raw: string}|null}
|
|
56
|
+
*/
|
|
57
|
+
function readMemory(cwd, agent) {
|
|
58
|
+
const err = validateAgentName(agent);
|
|
59
|
+
if (err) return null;
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = fs.readFileSync(memoryFile(cwd, agent), 'utf-8');
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const entries = parseEntries(raw);
|
|
67
|
+
return { agent, entries, raw };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse bullet entries from a memory file's body.
|
|
72
|
+
* @param {string} raw - File contents
|
|
73
|
+
* @returns {string[]} ordered entries (oldest → newest, as stored)
|
|
74
|
+
*/
|
|
75
|
+
function parseEntries(raw) {
|
|
76
|
+
const entries = [];
|
|
77
|
+
const lines = raw.split(/\r?\n/);
|
|
78
|
+
let inEntries = false;
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
if (/^##\s+Entries\s*$/.test(line)) { inEntries = true; continue; }
|
|
81
|
+
if (inEntries && /^##\s+/.test(line)) break;
|
|
82
|
+
if (!inEntries) continue;
|
|
83
|
+
const match = line.match(/^-\s+(.+)$/);
|
|
84
|
+
if (match) entries.push(match[1]);
|
|
85
|
+
}
|
|
86
|
+
return entries;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Append a single entry to an agent's memory log. Creates file+dir if absent.
|
|
91
|
+
* Entries are prefixed with today's date automatically unless already prefixed.
|
|
92
|
+
* @param {string} cwd - Project root
|
|
93
|
+
* @param {string} agent - Agent name
|
|
94
|
+
* @param {string} entry - Single-line lesson (newlines will be collapsed)
|
|
95
|
+
* @returns {{appended: true, file: string, count: number}|{error: string}}
|
|
96
|
+
*/
|
|
97
|
+
function appendMemory(cwd, agent, entry) {
|
|
98
|
+
const err = validateAgentName(agent);
|
|
99
|
+
if (err) return { error: err };
|
|
100
|
+
if (typeof entry !== 'string' || !entry.trim()) {
|
|
101
|
+
return { error: 'entry must be a non-empty string' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const cleaned = entry.replace(/\r?\n/g, ' ').trim();
|
|
105
|
+
const datePrefixed = /^\d{4}-\d{2}-\d{2}:/.test(cleaned);
|
|
106
|
+
const finalEntry = datePrefixed ? cleaned : `${today()}: ${cleaned}`;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
fs.mkdirSync(memoryDir(cwd), { recursive: true });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return { error: `Failed to create memory dir: ${e.message}` };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const file = memoryFile(cwd, agent);
|
|
115
|
+
let existing = '';
|
|
116
|
+
try {
|
|
117
|
+
existing = fs.readFileSync(file, 'utf-8');
|
|
118
|
+
} catch {
|
|
119
|
+
// new file
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let contents;
|
|
123
|
+
if (!existing) {
|
|
124
|
+
contents = buildHeader(agent) + '\n\n## Entries\n\n- ' + finalEntry + '\n';
|
|
125
|
+
} else if (/##\s+Entries/.test(existing)) {
|
|
126
|
+
// Ensure file ends with newline, then append bullet.
|
|
127
|
+
const needsNl = !existing.endsWith('\n');
|
|
128
|
+
contents = existing + (needsNl ? '\n' : '') + `- ${finalEntry}\n`;
|
|
129
|
+
} else {
|
|
130
|
+
const needsNl = !existing.endsWith('\n');
|
|
131
|
+
contents = existing + (needsNl ? '\n' : '') + '\n## Entries\n\n- ' + finalEntry + '\n';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
fs.writeFileSync(file, contents, 'utf-8');
|
|
136
|
+
} catch (e) {
|
|
137
|
+
return { error: `Failed to write memory file: ${e.message}` };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const count = parseEntries(contents).length;
|
|
141
|
+
return { appended: true, file, count };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildHeader(agent) {
|
|
145
|
+
return `---\nagent: ${agent}\ncreated: ${today()}\n---`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Trim a memory file to the last N entries. Preserves frontmatter header.
|
|
150
|
+
* @param {string} cwd - Project root
|
|
151
|
+
* @param {string} agent - Agent name
|
|
152
|
+
* @param {number} maxEntries - Keep this many most-recent entries
|
|
153
|
+
* @returns {{compacted: true, kept: number, removed: number}|{error: string}}
|
|
154
|
+
*/
|
|
155
|
+
function compactMemory(cwd, agent, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
156
|
+
const err = validateAgentName(agent);
|
|
157
|
+
if (err) return { error: err };
|
|
158
|
+
const max = Number(maxEntries);
|
|
159
|
+
if (!Number.isFinite(max) || max < 1) {
|
|
160
|
+
return { error: `maxEntries must be a positive integer, got ${maxEntries}` };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const file = memoryFile(cwd, agent);
|
|
164
|
+
let raw;
|
|
165
|
+
try {
|
|
166
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
167
|
+
} catch {
|
|
168
|
+
return { error: `No memory file for agent: ${agent}` };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const entries = parseEntries(raw);
|
|
172
|
+
if (entries.length <= max) {
|
|
173
|
+
return { compacted: true, kept: entries.length, removed: 0 };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const keep = entries.slice(-max);
|
|
177
|
+
const removed = entries.length - keep.length;
|
|
178
|
+
|
|
179
|
+
const headerMatch = raw.match(/^---[\s\S]*?---/);
|
|
180
|
+
const header = headerMatch ? headerMatch[0] : buildHeader(agent);
|
|
181
|
+
const body = '\n\n## Entries\n\n' + keep.map(e => `- ${e}`).join('\n') + '\n';
|
|
182
|
+
try {
|
|
183
|
+
fs.writeFileSync(file, header + body, 'utf-8');
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return { error: `Failed to write memory file: ${e.message}` };
|
|
186
|
+
}
|
|
187
|
+
return { compacted: true, kept: keep.length, removed };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* List all agents that have a memory file.
|
|
192
|
+
* @param {string} cwd - Project root
|
|
193
|
+
* @returns {{agents: Array<{agent: string, entries: number}>}}
|
|
194
|
+
*/
|
|
195
|
+
function listMemoryAgents(cwd) {
|
|
196
|
+
let files;
|
|
197
|
+
try {
|
|
198
|
+
files = fs.readdirSync(memoryDir(cwd));
|
|
199
|
+
} catch {
|
|
200
|
+
return { agents: [] };
|
|
201
|
+
}
|
|
202
|
+
const agents = [];
|
|
203
|
+
for (const f of files) {
|
|
204
|
+
if (!f.endsWith('.md')) continue;
|
|
205
|
+
const name = f.slice(0, -3);
|
|
206
|
+
if (!AGENT_NAME_RE.test(name)) continue;
|
|
207
|
+
const mem = readMemory(cwd, name);
|
|
208
|
+
agents.push({ agent: name, entries: mem ? mem.entries.length : 0 });
|
|
209
|
+
}
|
|
210
|
+
agents.sort((a, b) => a.agent.localeCompare(b.agent));
|
|
211
|
+
return { agents };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── CLI command wrappers ────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
function cmdMemoryRead(cwd, agent, raw) {
|
|
217
|
+
if (!agent) { error('Usage: memory read <agent>'); }
|
|
218
|
+
const result = readMemory(cwd, agent);
|
|
219
|
+
if (!result) { output({ agent, entries: [], exists: false }, raw); return; }
|
|
220
|
+
output({ agent, entries: result.entries, exists: true }, raw);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function cmdMemoryAppend(cwd, agent, entry, raw) {
|
|
224
|
+
if (!agent || !entry) { error('Usage: memory append <agent> <entry>'); }
|
|
225
|
+
const result = appendMemory(cwd, agent, entry);
|
|
226
|
+
output(result, raw);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function cmdMemoryList(cwd, raw) {
|
|
230
|
+
output(listMemoryAgents(cwd), raw);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function cmdMemoryCompact(cwd, agent, maxEntries, raw) {
|
|
234
|
+
if (!agent) { error('Usage: memory compact <agent> [max]'); }
|
|
235
|
+
const result = compactMemory(cwd, agent, maxEntries || DEFAULT_MAX_ENTRIES);
|
|
236
|
+
output(result, raw);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = {
|
|
240
|
+
readMemory,
|
|
241
|
+
appendMemory,
|
|
242
|
+
compactMemory,
|
|
243
|
+
listMemoryAgents,
|
|
244
|
+
parseEntries,
|
|
245
|
+
validateAgentName,
|
|
246
|
+
cmdMemoryRead,
|
|
247
|
+
cmdMemoryAppend,
|
|
248
|
+
cmdMemoryList,
|
|
249
|
+
cmdMemoryCompact,
|
|
250
|
+
MEMORY_DIR,
|
|
251
|
+
DEFAULT_MAX_ENTRIES,
|
|
252
|
+
};
|