@vishnuvmx/recall-memory 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +386 -2
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +451 -0
- package/dist/core/agent.d.ts +21 -0
- package/dist/core/agent.js +140 -0
- package/dist/core/checkpoint.d.ts +42 -0
- package/dist/core/checkpoint.js +233 -0
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +36 -0
- package/dist/core/cost.d.ts +19 -0
- package/dist/core/cost.js +78 -0
- package/dist/core/doctor.d.ts +7 -0
- package/dist/core/doctor.js +69 -0
- package/dist/core/git.d.ts +27 -0
- package/dist/core/git.js +107 -0
- package/dist/core/index-builder.d.ts +11 -0
- package/dist/core/index-builder.js +84 -0
- package/dist/core/init.d.ts +13 -0
- package/dist/core/init.js +52 -0
- package/dist/core/landmarks.d.ts +19 -0
- package/dist/core/landmarks.js +323 -0
- package/dist/core/mapper.d.ts +28 -0
- package/dist/core/mapper.js +357 -0
- package/dist/core/memory.d.ts +44 -0
- package/dist/core/memory.js +199 -0
- package/dist/core/project.d.ts +12 -0
- package/dist/core/project.js +34 -0
- package/dist/core/retrieval.d.ts +27 -0
- package/dist/core/retrieval.js +180 -0
- package/dist/core/seed.d.ts +25 -0
- package/dist/core/seed.js +196 -0
- package/dist/core/service.d.ts +76 -0
- package/dist/core/service.js +228 -0
- package/dist/core/where.d.ts +14 -0
- package/dist/core/where.js +86 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/mcp/server.d.ts +65 -0
- package/dist/mcp/server.js +121 -0
- package/dist/schemas/index.d.ts +68 -0
- package/dist/schemas/index.js +43 -0
- package/dist/templates/index.d.ts +21 -0
- package/dist/templates/index.js +62 -0
- package/dist/utils/fs.d.ts +15 -0
- package/dist/utils/fs.js +73 -0
- package/dist/utils/managed.d.ts +5 -0
- package/dist/utils/managed.js +41 -0
- package/dist/utils/secrets.d.ts +3 -0
- package/dist/utils/secrets.js +23 -0
- package/package.json +65 -4
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, InvalidArgumentError } from 'commander';
|
|
3
|
+
import { costReport } from '../core/cost.js';
|
|
4
|
+
import { runDoctor } from '../core/doctor.js';
|
|
5
|
+
import { initProject } from '../core/init.js';
|
|
6
|
+
import { rebuildIndex } from '../core/index-builder.js';
|
|
7
|
+
import { formatHitsCompact } from '../core/retrieval.js';
|
|
8
|
+
import { agentRemember, approve, approveAll, archive, archiveAll, checkpoint, context, getProposals, log, map, propose, prune, status, undo, whereIs } from '../core/service.js';
|
|
9
|
+
import { seed } from '../core/seed.js';
|
|
10
|
+
import { requireInitialized } from '../core/project.js';
|
|
11
|
+
import { formatWhere } from '../core/where.js';
|
|
12
|
+
import { serveStdio } from '../mcp/server.js';
|
|
13
|
+
import { MEMORY_TYPES, PROFILE_TIERS } from '../schemas/index.js';
|
|
14
|
+
import { RecallError } from '../utils/fs.js';
|
|
15
|
+
const cwd = () => process.cwd();
|
|
16
|
+
const collect = (v, prev) => [...prev, v];
|
|
17
|
+
const parseType = (v) => {
|
|
18
|
+
if (!MEMORY_TYPES.includes(v))
|
|
19
|
+
throw new InvalidArgumentError(`Must be one of: ${MEMORY_TYPES.join(', ')}`);
|
|
20
|
+
return v;
|
|
21
|
+
};
|
|
22
|
+
const parseLimit = (v) => {
|
|
23
|
+
const n = Number(v);
|
|
24
|
+
if (!Number.isInteger(n) || n < 1)
|
|
25
|
+
throw new InvalidArgumentError('Must be a positive integer.');
|
|
26
|
+
return n;
|
|
27
|
+
};
|
|
28
|
+
const print = (s = '') => console.log(s);
|
|
29
|
+
const program = new Command('recall')
|
|
30
|
+
.description('Zero-maintenance, local-first project memory for AI coding agents. Plain Markdown, no network.')
|
|
31
|
+
.version('0.1.0');
|
|
32
|
+
program
|
|
33
|
+
.command('init')
|
|
34
|
+
.description('Create .recall/ and install the managed agent-instruction block (idempotent, never overwrites)')
|
|
35
|
+
.option('--no-agent', 'do not touch the agent instruction file')
|
|
36
|
+
.option('--profile <tier>', `model context tier (${PROFILE_TIERS.join('|')})`, 'standard')
|
|
37
|
+
.action((o) => {
|
|
38
|
+
if (!PROFILE_TIERS.includes(o.profile)) {
|
|
39
|
+
throw new InvalidArgumentError(`--profile must be one of: ${PROFILE_TIERS.join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
const r = initProject(cwd(), { agent: o.agent, profile: o.profile });
|
|
42
|
+
print(`Project root: ${r.project.root} (${r.project.source === 'cwd' ? 'no Git repo or .recall found; using current directory' : `found via ${r.project.source}`})`);
|
|
43
|
+
for (const f of r.created)
|
|
44
|
+
print(` created ${f}`);
|
|
45
|
+
for (const f of r.skipped)
|
|
46
|
+
print(` kept ${f}`);
|
|
47
|
+
print(`Agent instructions: ${r.agent}`);
|
|
48
|
+
print('\nDone. Agents now look things up and record learnings on their own; no further commands needed.');
|
|
49
|
+
print('Run `recall cost` to see exactly what Recall adds to your agent context.');
|
|
50
|
+
print('Nothing was staged or committed. Review with `git status`, then commit when ready.');
|
|
51
|
+
});
|
|
52
|
+
program
|
|
53
|
+
.command('status')
|
|
54
|
+
.description('Show memory counts, index health, agent integration and Recall-only Git changes')
|
|
55
|
+
.option('--json', 'output status as JSON')
|
|
56
|
+
.action((o) => {
|
|
57
|
+
const s = status(cwd());
|
|
58
|
+
if (o.json) {
|
|
59
|
+
print(JSON.stringify(s, null, 2));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
print(`Project root: ${s.project.root}`);
|
|
63
|
+
print('Initialized: yes');
|
|
64
|
+
const fmt = (o) => (Object.entries(o).map(([k, v]) => `${k}=${v}`).join(' ') || 'none');
|
|
65
|
+
print(`By type: ${fmt(s.byType)}`);
|
|
66
|
+
print(`By status: ${fmt(s.byStatus)}`);
|
|
67
|
+
if (s.problems)
|
|
68
|
+
print(`Malformed memory files: ${s.problems} (run \`recall doctor\`)`);
|
|
69
|
+
print(`Index: ${s.index}${s.index === 'up_to_date' ? '' : ' (run `recall rebuild`)'}`);
|
|
70
|
+
print(`Agent file ${s.agentFile}: ${s.agent}`);
|
|
71
|
+
if (s.tokens) {
|
|
72
|
+
print('On-demand files (never loaded unless explicitly read; see `recall cost` for always-on cost):');
|
|
73
|
+
for (const [file, tok] of Object.entries(s.tokens.files)) {
|
|
74
|
+
if (tok > 0)
|
|
75
|
+
print(` - ${file.padEnd(18)} ~${tok.toLocaleString()} tokens`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!s.git.available)
|
|
79
|
+
print('Git: not installed');
|
|
80
|
+
else if (!s.git.isRepo)
|
|
81
|
+
print('Git: not a repository (no change tracking)');
|
|
82
|
+
else if (!s.git.changes || s.git.changes.length === 0)
|
|
83
|
+
print('Recall changes (uncommitted): none');
|
|
84
|
+
else {
|
|
85
|
+
print('Recall changes (uncommitted, .recall/ only):');
|
|
86
|
+
for (const c of s.git.changes)
|
|
87
|
+
print(` ${c}`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
program
|
|
91
|
+
.command('remember')
|
|
92
|
+
.description('Save a memory. Active immediately when config `writes` is "auto" (default); similar titles update in place')
|
|
93
|
+
.argument('<text>', 'the memory text')
|
|
94
|
+
.option('-t, --title <title>', 'title (defaults to the first line of the text)')
|
|
95
|
+
.option('--type <type>', `one of ${MEMORY_TYPES.join('|')}`, parseType, 'fact')
|
|
96
|
+
.option('--tag <tag>', 'tag (repeatable)', collect, [])
|
|
97
|
+
.option('--source <ref>', 'source reference, e.g. a file path or URL (repeatable)', collect, [])
|
|
98
|
+
.option('--active', 'force active status regardless of write mode')
|
|
99
|
+
.option('--approved', 'alias for --active')
|
|
100
|
+
.option('--proposed', 'force proposed status (needs `recall approve`)')
|
|
101
|
+
.action((text, o) => {
|
|
102
|
+
const title = o.title ?? (text.split('\n')[0] ?? '').slice(0, 80);
|
|
103
|
+
const forced = o.active || o.approved ? 'active' : o.proposed ? 'proposed' : undefined;
|
|
104
|
+
const r = agentRemember(cwd(), { title, text, type: o.type, tags: o.tag, sources: o.source, status: forced });
|
|
105
|
+
const m = r.memory;
|
|
106
|
+
print(`${r.action === 'updated' ? 'Updated' : 'Saved'} ${m.id} (${m.type}, ${m.status}, ${m.verification}) -> .recall/${m.file}`);
|
|
107
|
+
if (m.status === 'proposed')
|
|
108
|
+
print(` Proposed: run \`recall approve ${m.id}\` to make it searchable.`);
|
|
109
|
+
});
|
|
110
|
+
program
|
|
111
|
+
.command('propose')
|
|
112
|
+
.description('Propose a memory (status: proposed). A human approves it with `recall approve`')
|
|
113
|
+
.argument('<text>')
|
|
114
|
+
.requiredOption('-t, --title <title>')
|
|
115
|
+
.option('--type <type>', `one of ${MEMORY_TYPES.join('|')}`, parseType, 'fact')
|
|
116
|
+
.option('--tag <tag>', 'tag (repeatable)', collect, [])
|
|
117
|
+
.action((text, o) => {
|
|
118
|
+
const m = propose(cwd(), { title: o.title, text, type: o.type, tags: o.tag, sources: ['proposed'] });
|
|
119
|
+
print(`Proposed ${m.id} -> .recall/${m.file}\nReview it, then run: recall approve ${m.id} (or: recall archive ${m.id})`);
|
|
120
|
+
});
|
|
121
|
+
program
|
|
122
|
+
.command('approve')
|
|
123
|
+
.description('Activate a proposed memory')
|
|
124
|
+
.argument('<id>')
|
|
125
|
+
.action((id) => {
|
|
126
|
+
const m = approve(cwd(), id);
|
|
127
|
+
print(`Approved ${m.id}: ${m.title}`);
|
|
128
|
+
});
|
|
129
|
+
program
|
|
130
|
+
.command('archive')
|
|
131
|
+
.description('Archive a memory (kept on disk, excluded from search; never deletes)')
|
|
132
|
+
.argument('<id>')
|
|
133
|
+
.action((id) => {
|
|
134
|
+
const m = archive(cwd(), id);
|
|
135
|
+
print(`Archived ${m.id}: ${m.title}`);
|
|
136
|
+
});
|
|
137
|
+
program
|
|
138
|
+
.command('context')
|
|
139
|
+
.description('Retrieve relevant memories by deterministic keyword search (compact, capped at ~400 tokens)')
|
|
140
|
+
.argument('<task>', 'task description')
|
|
141
|
+
.option('-n, --limit <n>', 'max results', parseLimit, 5)
|
|
142
|
+
.option('--format <format>', 'text|json', 'text')
|
|
143
|
+
.option('--verbose', 'show scores and status details (for humans; not capped)')
|
|
144
|
+
.option('--type <type>', `filter by memory type (${MEMORY_TYPES.join('|')})`, parseType)
|
|
145
|
+
.option('--tag <tag>', 'filter by tag')
|
|
146
|
+
.option('--include-proposed', 'include unapproved proposals')
|
|
147
|
+
.option('--include-archived', 'include archived memories')
|
|
148
|
+
.action((task, o) => {
|
|
149
|
+
if (o.format !== 'text' && o.format !== 'json')
|
|
150
|
+
throw new RecallError('--format must be text or json.');
|
|
151
|
+
const hits = context(cwd(), task, {
|
|
152
|
+
limit: o.limit,
|
|
153
|
+
type: o.type,
|
|
154
|
+
tag: o.tag,
|
|
155
|
+
includeProposed: !!o.includeProposed,
|
|
156
|
+
includeArchived: !!o.includeArchived,
|
|
157
|
+
});
|
|
158
|
+
if (o.format === 'json') {
|
|
159
|
+
print(JSON.stringify(hits.map((h) => ({
|
|
160
|
+
id: h.memory.id, title: h.memory.title, type: h.memory.type, status: h.memory.status,
|
|
161
|
+
verification: h.memory.verification, file: `.recall/${h.memory.file}`, score: h.score, excerpt: h.excerpt,
|
|
162
|
+
})), null, 2));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!o.verbose) {
|
|
166
|
+
print(formatHitsCompact(hits));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (hits.length === 0) {
|
|
170
|
+
print('No relevant memory found (keyword match on titles, tags, headings and body).');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
hits.forEach((h, i) => {
|
|
174
|
+
print(`${i + 1}. ${h.memory.title} [score: ${h.score} | ${h.memory.type} | ${h.memory.status} | ${h.memory.verification}]`);
|
|
175
|
+
print(` .recall/${h.memory.file}`);
|
|
176
|
+
if (h.excerpt)
|
|
177
|
+
print(` ${h.excerpt}`);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
program
|
|
181
|
+
.command('where')
|
|
182
|
+
.description('Find a symbol or file and print file:line rows (live, cached scan; cheaper than reading PROJECT_MAP.md)')
|
|
183
|
+
.argument('<name>', 'symbol name, partial name, or path fragment')
|
|
184
|
+
.option('-n, --limit <n>', 'max results', parseLimit, 15)
|
|
185
|
+
.action((name, o) => {
|
|
186
|
+
print(formatWhere(whereIs(cwd(), name, o.limit)));
|
|
187
|
+
});
|
|
188
|
+
program
|
|
189
|
+
.command('cost')
|
|
190
|
+
.description('Show what Recall adds to your agent context: always-on vs on-demand tokens (estimates)')
|
|
191
|
+
.option('--json', 'output as JSON')
|
|
192
|
+
.action((o) => {
|
|
193
|
+
const r = costReport(cwd());
|
|
194
|
+
if (o.json) {
|
|
195
|
+
print(JSON.stringify(r, null, 2));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const row = (l) => print(` ${l.source.padEnd(40)} ~${String(l.tokens).padStart(5)} tok${l.note ? ` (${l.note})` : ''}`);
|
|
199
|
+
print('Always-on (sent with every agent turn):');
|
|
200
|
+
r.alwaysOn.forEach(row);
|
|
201
|
+
print(` ${'TOTAL'.padEnd(40)} ~${String(r.alwaysOnTotal).padStart(5)} tok`);
|
|
202
|
+
print('\nMCP (only if configured):');
|
|
203
|
+
row(r.mcp);
|
|
204
|
+
print('\nOn-demand (only when the agent asks):');
|
|
205
|
+
r.onDemand.forEach(row);
|
|
206
|
+
print('\nEstimates use ~4 characters per token.');
|
|
207
|
+
if (r.warnings.length > 0) {
|
|
208
|
+
print('\nWarnings:');
|
|
209
|
+
for (const w of r.warnings)
|
|
210
|
+
print(` ! ${w}`);
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
program
|
|
215
|
+
.command('seed')
|
|
216
|
+
.description('Analyze git history and documentation to propose initial architectural memories')
|
|
217
|
+
.option('--from <source>', 'git|docs|all', 'all')
|
|
218
|
+
.option('-n, --limit <n>', 'max items to propose', parseLimit, 5)
|
|
219
|
+
.option('--dry-run', 'preview proposed memories without saving to disk')
|
|
220
|
+
.action((o) => {
|
|
221
|
+
if (o.from !== 'git' && o.from !== 'docs' && o.from !== 'all') {
|
|
222
|
+
throw new RecallError('--from must be git, docs, or all.');
|
|
223
|
+
}
|
|
224
|
+
const res = seed(cwd(), { from: o.from, limit: o.limit, dryRun: o.dryRun });
|
|
225
|
+
if (res.proposed.length === 0) {
|
|
226
|
+
print(`No candidates found to seed from ${res.sourcesUsed.join(', ') || o.from}.`);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (res.dryRun) {
|
|
230
|
+
print(`[Dry Run] Found ${res.proposed.length} candidate(s) to seed:`);
|
|
231
|
+
for (const p of res.proposed) {
|
|
232
|
+
print(` - [${p.type}] ${p.title} (source: ${p.source})`);
|
|
233
|
+
}
|
|
234
|
+
print('\nRun without --dry-run to propose them.');
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
print(`Seeded ${res.proposed.length} proposed memor${res.proposed.length === 1 ? 'y' : 'ies'}:`);
|
|
238
|
+
for (const p of res.proposed) {
|
|
239
|
+
print(` - ${p.id}: ${p.title} [${p.type}] (source: ${p.source})`);
|
|
240
|
+
}
|
|
241
|
+
print('\nRun `recall review` or `recall approve <id>` to activate them.');
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
program
|
|
245
|
+
.command('review')
|
|
246
|
+
.description('Review proposed memories and approve or archive them in batch')
|
|
247
|
+
.option('--approve-all', 'approve all currently proposed memories')
|
|
248
|
+
.option('--archive-all', 'archive all currently proposed memories')
|
|
249
|
+
.action((o) => {
|
|
250
|
+
if (o.approveAll) {
|
|
251
|
+
const approved = approveAll(cwd());
|
|
252
|
+
if (approved.length === 0) {
|
|
253
|
+
print('No proposed memories to approve.');
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
print(`Approved ${approved.length} memor${approved.length === 1 ? 'y' : 'ies'}:`);
|
|
257
|
+
for (const m of approved)
|
|
258
|
+
print(` - ${m.id}: ${m.title}`);
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (o.archiveAll) {
|
|
263
|
+
const archived = archiveAll(cwd());
|
|
264
|
+
if (archived.length === 0) {
|
|
265
|
+
print('No proposed memories to archive.');
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
print(`Archived ${archived.length} memor${archived.length === 1 ? 'y' : 'ies'}:`);
|
|
269
|
+
for (const m of archived)
|
|
270
|
+
print(` - ${m.id}: ${m.title}`);
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const proposals = getProposals(cwd());
|
|
275
|
+
if (proposals.length === 0) {
|
|
276
|
+
print('No pending proposed memories. Memory is up to date.');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
print(`Found ${proposals.length} pending proposed memor${proposals.length === 1 ? 'y' : 'ies'}:`);
|
|
280
|
+
for (const m of proposals) {
|
|
281
|
+
print(` ${m.id} [${m.type}] ${m.title}`);
|
|
282
|
+
print(` file: .recall/${m.file}`);
|
|
283
|
+
if (m.tags.length > 0)
|
|
284
|
+
print(` tags: ${m.tags.join(', ')}`);
|
|
285
|
+
}
|
|
286
|
+
print('\nTo approve all: recall review --approve-all');
|
|
287
|
+
print('To approve one: recall approve <id>');
|
|
288
|
+
print('To archive one: recall archive <id>');
|
|
289
|
+
});
|
|
290
|
+
program
|
|
291
|
+
.command('rebuild')
|
|
292
|
+
.description('Rebuild INDEX.md deterministically from valid memory files')
|
|
293
|
+
.action(() => {
|
|
294
|
+
const p = requireInitialized(cwd());
|
|
295
|
+
const l = rebuildIndex(p.recallDir);
|
|
296
|
+
print(`Index rebuilt: ${l.memories.length} memories, ${l.plainDocs.length} rule documents.`);
|
|
297
|
+
for (const pr of l.problems)
|
|
298
|
+
print(` skipped (malformed, not deleted): ${pr.file} - ${pr.error}`);
|
|
299
|
+
});
|
|
300
|
+
program
|
|
301
|
+
.command('undo')
|
|
302
|
+
.description('Discard uncommitted changes and remove untracked files in .recall/ (never touches gitignored files)')
|
|
303
|
+
.option('--dry-run', 'preview files that would be restored or deleted without modifying anything')
|
|
304
|
+
.option('-f, --force', 'force deletion of untracked memory files without interactive confirmation')
|
|
305
|
+
.action((o) => {
|
|
306
|
+
if (!o.dryRun && !o.force) {
|
|
307
|
+
const preview = undo(cwd(), { dryRun: true });
|
|
308
|
+
if (preview.cleaned && preview.cleaned.length > 0) {
|
|
309
|
+
throw new RecallError(`undo would delete ${preview.cleaned.length} untracked file(s) in .recall/:\n` +
|
|
310
|
+
preview.cleaned.map((f) => ` ${f}`).join('\n'), 'Pass --force to permanently delete untracked files, or --dry-run to inspect them safely.');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const res = undo(cwd(), { dryRun: o.dryRun });
|
|
314
|
+
if (!res.ok)
|
|
315
|
+
throw new RecallError(res.message);
|
|
316
|
+
print(res.message);
|
|
317
|
+
if (res.restored && res.restored.length > 0) {
|
|
318
|
+
print(' Tracked files restored:');
|
|
319
|
+
for (const f of res.restored)
|
|
320
|
+
print(` ${f}`);
|
|
321
|
+
}
|
|
322
|
+
if (res.cleaned && res.cleaned.length > 0) {
|
|
323
|
+
print(' Untracked files deleted:');
|
|
324
|
+
for (const f of res.cleaned)
|
|
325
|
+
print(` ${f}`);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
program
|
|
329
|
+
.command('doctor')
|
|
330
|
+
.description('Diagnose configuration, files, index, agent integration and Git (never repairs automatically)')
|
|
331
|
+
.action(() => {
|
|
332
|
+
const checks = runDoctor(cwd());
|
|
333
|
+
for (const c of checks) {
|
|
334
|
+
print(`${c.ok ? 'ok ' : 'FAIL'} ${c.name}: ${c.detail}`);
|
|
335
|
+
if (!c.ok && c.fix)
|
|
336
|
+
print(` fix: ${c.fix}`);
|
|
337
|
+
}
|
|
338
|
+
if (checks.some((c) => !c.ok))
|
|
339
|
+
process.exitCode = 1;
|
|
340
|
+
});
|
|
341
|
+
program
|
|
342
|
+
.command('log')
|
|
343
|
+
.description('Recent Recall changes (read-only Git history, or local changelog fallback)')
|
|
344
|
+
.option('-n, --limit <n>', 'max entries', parseLimit, 10)
|
|
345
|
+
.action((o) => {
|
|
346
|
+
const r = log(cwd(), o.limit);
|
|
347
|
+
if (r.source === 'git') {
|
|
348
|
+
print('Committed history (.recall/ only):');
|
|
349
|
+
if (r.committed.length === 0)
|
|
350
|
+
print(' (no commits touching .recall/ yet)');
|
|
351
|
+
for (const c of r.committed)
|
|
352
|
+
print(` ${c.hash} ${c.date} ${c.subject}`);
|
|
353
|
+
print('\nUncommitted changes:');
|
|
354
|
+
if (r.uncommitted.length === 0)
|
|
355
|
+
print(' none');
|
|
356
|
+
for (const u of r.uncommitted)
|
|
357
|
+
print(` ${u}`);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
print('Git history unavailable; showing the local changelog (NOT a complete history):');
|
|
361
|
+
if (r.changelog.length === 0)
|
|
362
|
+
print(' (empty)');
|
|
363
|
+
for (const l of r.changelog)
|
|
364
|
+
print(` ${l}`);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
program
|
|
368
|
+
.command('map')
|
|
369
|
+
.description('Generate or refresh .recall/PROJECT_MAP.md (directory topology, entry points, configs, landmarks)')
|
|
370
|
+
.option('--profile <tier>', `context tier (${PROFILE_TIERS.join('|')})`)
|
|
371
|
+
.action((o) => {
|
|
372
|
+
if (o.profile && !PROFILE_TIERS.includes(o.profile)) {
|
|
373
|
+
throw new InvalidArgumentError(`--profile must be one of: ${PROFILE_TIERS.join(', ')}`);
|
|
374
|
+
}
|
|
375
|
+
const file = map(cwd(), o.profile);
|
|
376
|
+
print(`Project map generated: ${file}`);
|
|
377
|
+
print('Agents should prefer `recall where <name>` (a few lines) over reading the whole map.');
|
|
378
|
+
});
|
|
379
|
+
program
|
|
380
|
+
.command('checkpoint')
|
|
381
|
+
.alias('session')
|
|
382
|
+
.description('Record an iteration checkpoint in .recall/CURRENT_STATE.md and the session log (normally run by the agent Stop hook)')
|
|
383
|
+
.argument('[summary]', 'summary of what was accomplished in this iteration')
|
|
384
|
+
.option('-n, --next <steps>', 'immediate next steps for following prompts (only if work remains half-done)')
|
|
385
|
+
.option('--clear', 'clear active state and mark all previous work as completed')
|
|
386
|
+
.option('--auto', 'auto-generate summary from git changes; skipped when nothing changed')
|
|
387
|
+
.option('--hook', 'agent lifecycle hook mode: auto checkpoint, print `{}` (hook JSON), never fail')
|
|
388
|
+
.option('--retention <days>', 'retention window in days for session log history (default: 14)')
|
|
389
|
+
.action((summary, o) => {
|
|
390
|
+
if (o.hook) {
|
|
391
|
+
try {
|
|
392
|
+
checkpoint(cwd(), undefined, { auto: true });
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// A memory bookkeeping failure must never block or break the agent loop.
|
|
396
|
+
}
|
|
397
|
+
print('{}');
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const retentionDays = o.retention ? parseInt(o.retention, 10) : undefined;
|
|
401
|
+
const res = checkpoint(cwd(), summary, { nextSteps: o.next, clear: o.clear, auto: o.auto, retentionDays });
|
|
402
|
+
if (res.skipped) {
|
|
403
|
+
print('No changes since the last checkpoint; nothing recorded.');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
print(`Checkpoint recorded [${res.timestamp}]:`);
|
|
407
|
+
print(` Summary: ${res.summary}`);
|
|
408
|
+
if (res.nextSteps)
|
|
409
|
+
print(` Next: ${res.nextSteps}`);
|
|
410
|
+
if (res.modifiedFiles.length > 0) {
|
|
411
|
+
print(` Active files (${res.modifiedFiles.length}):`);
|
|
412
|
+
for (const f of res.modifiedFiles.slice(0, 5))
|
|
413
|
+
print(` ${f}`);
|
|
414
|
+
if (res.modifiedFiles.length > 5)
|
|
415
|
+
print(` ... (${res.modifiedFiles.length - 5} more)`);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
program
|
|
419
|
+
.command('prune')
|
|
420
|
+
.description('Prune session log entries older than retention window (default: 14 days)')
|
|
421
|
+
.option('-d, --days <days>', 'retention window in days (default: 14)', '14')
|
|
422
|
+
.option('-m, --max <count>', 'maximum number of session entries to retain (default: 50)', '50')
|
|
423
|
+
.action((o) => {
|
|
424
|
+
const days = parseInt(o.days, 10);
|
|
425
|
+
const max = parseInt(o.max, 10);
|
|
426
|
+
const res = prune(cwd(), { retentionDays: days, maxEntries: max });
|
|
427
|
+
print(`Session log pruned:`);
|
|
428
|
+
print(` Removed: ${res.removedCount} old entries`);
|
|
429
|
+
print(` Remaining: ${res.remainingCount} entries within ${days}-day retention window.`);
|
|
430
|
+
});
|
|
431
|
+
program
|
|
432
|
+
.command('serve')
|
|
433
|
+
.description('Run a local MCP server over stdio exposing recall_context, recall_where and recall_remember')
|
|
434
|
+
.action(() => {
|
|
435
|
+
requireInitialized(cwd());
|
|
436
|
+
serveStdio(cwd());
|
|
437
|
+
});
|
|
438
|
+
try {
|
|
439
|
+
await program.parseAsync(process.argv);
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
if (err instanceof RecallError) {
|
|
443
|
+
console.error(`recall: ${err.message}`);
|
|
444
|
+
if (err.hint)
|
|
445
|
+
console.error(`hint: ${err.hint}`);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
console.error(`recall: unexpected error: ${err.message}`);
|
|
449
|
+
}
|
|
450
|
+
process.exitCode = 1;
|
|
451
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type AgentState = 'missing_file' | 'no_block' | 'outdated' | 'installed';
|
|
2
|
+
export declare function agentFilePath(root: string, agentFile: string): string;
|
|
3
|
+
export declare function agentState(root: string, agentFile: string): AgentState;
|
|
4
|
+
/**
|
|
5
|
+
* If CLAUDE.md exists and agentFile is AGENTS.md, ensure CLAUDE.md imports @AGENTS.md
|
|
6
|
+
*/
|
|
7
|
+
export declare function syncClaudeBridge(root: string, agentFile: string): void;
|
|
8
|
+
export declare const HOOK_NAME = "recall-session-checkpoint";
|
|
9
|
+
/**
|
|
10
|
+
* Runs via `sh -c` / `cmd /c` from the `.agents/` directory (Antigravity hook spec), so it must be a
|
|
11
|
+
* plain PATH command with no shell operators or repo-relative paths. `--hook` prints `{}` (valid hook
|
|
12
|
+
* JSON) and never fails the agent loop.
|
|
13
|
+
*/
|
|
14
|
+
export declare const HOOK_COMMAND = "recall checkpoint --hook";
|
|
15
|
+
/**
|
|
16
|
+
* Antigravity integration: static content only. Nothing here depends on session state, so repeated
|
|
17
|
+
* calls are byte-stable and never invalidate prompt caches.
|
|
18
|
+
*/
|
|
19
|
+
export declare function syncAntigravitySupport(root: string, agentFile?: string): void;
|
|
20
|
+
/** Insert or refresh the managed block; existing content outside it is preserved. */
|
|
21
|
+
export declare function installAgentBlock(root: string, agentFile: string): 'created' | 'updated' | 'unchanged';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { AGENT_BLOCK, ANTIGRAVITY_SKILL_BODY, LEGACY_SKILL_MARKER, SKILL_FRONTMATTER } from '../templates/index.js';
|
|
4
|
+
import { RecallError, atomicWrite, resolveInside } from '../utils/fs.js';
|
|
5
|
+
import { applyManagedBlock, extractManagedBlock, removeManagedBlock } from '../utils/managed.js';
|
|
6
|
+
export function agentFilePath(root, agentFile) {
|
|
7
|
+
return resolveInside(root, agentFile);
|
|
8
|
+
}
|
|
9
|
+
export function agentState(root, agentFile) {
|
|
10
|
+
const file = agentFilePath(root, agentFile);
|
|
11
|
+
if (!fs.existsSync(file))
|
|
12
|
+
return 'missing_file';
|
|
13
|
+
const block = extractManagedBlock(fs.readFileSync(file, 'utf8'));
|
|
14
|
+
if (block === null)
|
|
15
|
+
return 'no_block';
|
|
16
|
+
return block === AGENT_BLOCK.trim() ? 'installed' : 'outdated';
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* If CLAUDE.md exists and agentFile is AGENTS.md, ensure CLAUDE.md imports @AGENTS.md
|
|
20
|
+
*/
|
|
21
|
+
export function syncClaudeBridge(root, agentFile) {
|
|
22
|
+
if (agentFile !== 'AGENTS.md')
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
const claudePath = resolveInside(root, 'CLAUDE.md');
|
|
26
|
+
if (fs.existsSync(claudePath)) {
|
|
27
|
+
const content = fs.readFileSync(claudePath, 'utf8');
|
|
28
|
+
if (!content.includes('@AGENTS.md') && !content.includes('.recall/')) {
|
|
29
|
+
atomicWrite(claudePath, `${content.trimEnd()}\n\n@AGENTS.md\n`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Non-fatal if symlink or escape check fails
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Agent files Antigravity already loads as always-on rules. Writing a rule file too would double the cost. */
|
|
38
|
+
const NATIVELY_LOADED = new Set(['AGENTS.md', 'GEMINI.md']);
|
|
39
|
+
export const HOOK_NAME = 'recall-session-checkpoint';
|
|
40
|
+
/**
|
|
41
|
+
* Runs via `sh -c` / `cmd /c` from the `.agents/` directory (Antigravity hook spec), so it must be a
|
|
42
|
+
* plain PATH command with no shell operators or repo-relative paths. `--hook` prints `{}` (valid hook
|
|
43
|
+
* JSON) and never fails the agent loop.
|
|
44
|
+
*/
|
|
45
|
+
export const HOOK_COMMAND = 'recall checkpoint --hook';
|
|
46
|
+
function writeIfChanged(file, content) {
|
|
47
|
+
if (fs.existsSync(file) && fs.readFileSync(file, 'utf8') === content)
|
|
48
|
+
return;
|
|
49
|
+
atomicWrite(file, content);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Keep `.agents/rules/recall.md` from duplicating the agent file. Older versions wrote a dynamic,
|
|
53
|
+
* always-on copy of the protocol there; remove it (keeping any human-written rules).
|
|
54
|
+
*/
|
|
55
|
+
function syncRuleFile(rulesDir, agentFile) {
|
|
56
|
+
const rulePath = path.join(rulesDir, 'recall.md');
|
|
57
|
+
const existing = fs.existsSync(rulePath) ? fs.readFileSync(rulePath, 'utf8') : null;
|
|
58
|
+
if (NATIVELY_LOADED.has(agentFile)) {
|
|
59
|
+
if (existing === null)
|
|
60
|
+
return;
|
|
61
|
+
const stripped = removeManagedBlock(existing);
|
|
62
|
+
const leftover = stripped.replace(/^---[\s\S]*?---\s*/, '').trim();
|
|
63
|
+
if (!leftover)
|
|
64
|
+
fs.rmSync(rulePath);
|
|
65
|
+
else if (stripped !== existing)
|
|
66
|
+
atomicWrite(rulePath, stripped);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Custom agent file that Antigravity may not read: install the same static block as a rule.
|
|
70
|
+
fs.mkdirSync(rulesDir, { recursive: true });
|
|
71
|
+
const base = existing && existing.includes('trigger:') ? existing : `---\ntrigger: always_on\n---\n\n${existing ?? ''}`;
|
|
72
|
+
writeIfChanged(rulePath, applyManagedBlock(base, AGENT_BLOCK));
|
|
73
|
+
}
|
|
74
|
+
/** On-demand skill. Generated content lives in a managed block; user-customized skills are left alone. */
|
|
75
|
+
function syncSkill(skillDir) {
|
|
76
|
+
const skillPath = path.join(skillDir, 'SKILL.md');
|
|
77
|
+
const fresh = `${SKILL_FRONTMATTER}\n\n${applyManagedBlock('', ANTIGRAVITY_SKILL_BODY)}`;
|
|
78
|
+
if (!fs.existsSync(skillPath)) {
|
|
79
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
80
|
+
atomicWrite(skillPath, fresh);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const existing = fs.readFileSync(skillPath, 'utf8');
|
|
84
|
+
if (extractManagedBlock(existing) !== null) {
|
|
85
|
+
// Refresh frontmatter description too, since it is the only always-visible part of a skill.
|
|
86
|
+
const body = existing.replace(/^---[\s\S]*?---\s*/, '');
|
|
87
|
+
writeIfChanged(skillPath, `${SKILL_FRONTMATTER}\n\n${applyManagedBlock(body, ANTIGRAVITY_SKILL_BODY)}`);
|
|
88
|
+
}
|
|
89
|
+
else if (existing.includes(LEGACY_SKILL_MARKER)) {
|
|
90
|
+
atomicWrite(skillPath, fresh); // written by an older Recall version; safe to migrate
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function syncHook(agentsDir) {
|
|
94
|
+
const hooksPath = path.join(agentsDir, 'hooks.json');
|
|
95
|
+
let hooks = {};
|
|
96
|
+
if (fs.existsSync(hooksPath)) {
|
|
97
|
+
try {
|
|
98
|
+
hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return; // never clobber a hand-edited hooks.json we cannot parse
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
hooks[HOOK_NAME] = { Stop: [{ type: 'command', command: HOOK_COMMAND, timeout: 15 }] };
|
|
105
|
+
writeIfChanged(hooksPath, `${JSON.stringify(hooks, null, 2)}\n`);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Antigravity integration: static content only. Nothing here depends on session state, so repeated
|
|
109
|
+
* calls are byte-stable and never invalidate prompt caches.
|
|
110
|
+
*/
|
|
111
|
+
export function syncAntigravitySupport(root, agentFile = 'AGENTS.md') {
|
|
112
|
+
try {
|
|
113
|
+
const agentsDir = resolveInside(root, '.agents');
|
|
114
|
+
syncRuleFile(path.join(agentsDir, 'rules'), agentFile);
|
|
115
|
+
syncSkill(path.join(agentsDir, 'skills', 'recall'));
|
|
116
|
+
syncHook(agentsDir);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Non-fatal if .agents cannot be created or is outside boundaries
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Insert or refresh the managed block; existing content outside it is preserved. */
|
|
123
|
+
export function installAgentBlock(root, agentFile) {
|
|
124
|
+
const file = agentFilePath(root, agentFile);
|
|
125
|
+
const existed = fs.existsSync(file);
|
|
126
|
+
const before = existed ? fs.readFileSync(file, 'utf8') : '';
|
|
127
|
+
const after = applyManagedBlock(before, AGENT_BLOCK);
|
|
128
|
+
if (existed && before === after) {
|
|
129
|
+
syncClaudeBridge(root, agentFile);
|
|
130
|
+
syncAntigravitySupport(root, agentFile);
|
|
131
|
+
return 'unchanged';
|
|
132
|
+
}
|
|
133
|
+
if (existed && fs.lstatSync(file).isSymbolicLink()) {
|
|
134
|
+
throw new RecallError(`${agentFile} is a symbolic link; refusing to modify it.`);
|
|
135
|
+
}
|
|
136
|
+
atomicWrite(file, after);
|
|
137
|
+
syncClaudeBridge(root, agentFile);
|
|
138
|
+
syncAntigravitySupport(root, agentFile);
|
|
139
|
+
return existed ? 'updated' : 'created';
|
|
140
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface CheckpointOptions {
|
|
2
|
+
nextSteps?: string;
|
|
3
|
+
clear?: boolean;
|
|
4
|
+
auto?: boolean;
|
|
5
|
+
retentionDays?: number;
|
|
6
|
+
maxEntries?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface CheckpointResult {
|
|
9
|
+
timestamp: string;
|
|
10
|
+
summary: string;
|
|
11
|
+
nextSteps?: string;
|
|
12
|
+
modifiedFiles: string[];
|
|
13
|
+
/** True when an auto checkpoint was skipped because nothing changed since the last one. */
|
|
14
|
+
skipped?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface SessionLogEntry {
|
|
17
|
+
timestamp: string;
|
|
18
|
+
date: Date;
|
|
19
|
+
summary: string;
|
|
20
|
+
nextSteps?: string;
|
|
21
|
+
modifiedFiles?: string[];
|
|
22
|
+
rawText: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function getCurrentTimestamp(): string;
|
|
25
|
+
export declare function parseSessionLogEntries(logContent: string): SessionLogEntry[];
|
|
26
|
+
export interface PruneOptions {
|
|
27
|
+
retentionDays?: number;
|
|
28
|
+
maxEntries?: number;
|
|
29
|
+
now?: Date;
|
|
30
|
+
}
|
|
31
|
+
export declare function pruneSessionLog(logContent: string, opts?: PruneOptions): {
|
|
32
|
+
content: string;
|
|
33
|
+
retained: SessionLogEntry[];
|
|
34
|
+
removedCount: number;
|
|
35
|
+
};
|
|
36
|
+
export declare function pruneSessions(recallDir: string, opts?: PruneOptions): {
|
|
37
|
+
removedCount: number;
|
|
38
|
+
remainingCount: number;
|
|
39
|
+
};
|
|
40
|
+
export declare function buildCurrentStateMarkdown(result: CheckpointResult): string;
|
|
41
|
+
export declare function recordCheckpoint(root: string, recallDir: string, summary?: string, opts?: CheckpointOptions): CheckpointResult;
|
|
42
|
+
export declare function initialCurrentStateMarkdown(): string;
|