@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,233 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { repoChanges } from './git.js';
|
|
5
|
+
import { atomicWrite, resolveInside, today } from '../utils/fs.js';
|
|
6
|
+
export function getCurrentTimestamp() {
|
|
7
|
+
const d = new Date();
|
|
8
|
+
return d.toISOString().replace('T', ' ').substring(0, 19);
|
|
9
|
+
}
|
|
10
|
+
export function parseSessionLogEntries(logContent) {
|
|
11
|
+
const entries = [];
|
|
12
|
+
const lines = logContent.split('\n');
|
|
13
|
+
let currentHeader = null;
|
|
14
|
+
let currentLines = [];
|
|
15
|
+
let summary = '';
|
|
16
|
+
let nextSteps;
|
|
17
|
+
let modifiedFiles;
|
|
18
|
+
const flush = () => {
|
|
19
|
+
if (currentHeader) {
|
|
20
|
+
entries.push({
|
|
21
|
+
timestamp: currentHeader.timestamp,
|
|
22
|
+
date: currentHeader.date,
|
|
23
|
+
summary: summary || 'Task checkpoint',
|
|
24
|
+
nextSteps,
|
|
25
|
+
modifiedFiles,
|
|
26
|
+
rawText: currentLines.join('\n').trim(),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
currentHeader = null;
|
|
30
|
+
currentLines = [];
|
|
31
|
+
summary = '';
|
|
32
|
+
nextSteps = undefined;
|
|
33
|
+
modifiedFiles = undefined;
|
|
34
|
+
};
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const match = line.match(/^###\s*\[(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})\]\s*Checkpoint/i);
|
|
37
|
+
if (match) {
|
|
38
|
+
flush();
|
|
39
|
+
const ts = match[1];
|
|
40
|
+
const parsedDate = new Date(ts.replace(' ', 'T') + 'Z');
|
|
41
|
+
currentHeader = {
|
|
42
|
+
timestamp: ts,
|
|
43
|
+
date: isNaN(parsedDate.getTime()) ? new Date() : parsedDate,
|
|
44
|
+
};
|
|
45
|
+
currentLines.push(line);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (currentHeader) {
|
|
49
|
+
currentLines.push(line);
|
|
50
|
+
const summaryMatch = line.match(/^-\s*\*\*Summary\*\*:\s*(.*)$/i);
|
|
51
|
+
if (summaryMatch) {
|
|
52
|
+
summary = summaryMatch[1].trim();
|
|
53
|
+
}
|
|
54
|
+
const nextMatch = line.match(/^-\s*\*\*Next\*\*:\s*(.*)$/i);
|
|
55
|
+
if (nextMatch) {
|
|
56
|
+
nextSteps = nextMatch[1].trim();
|
|
57
|
+
}
|
|
58
|
+
const modMatch = line.match(/^-\s*\*\*Modified\*\*:\s*(.*)$/i);
|
|
59
|
+
if (modMatch) {
|
|
60
|
+
modifiedFiles = modMatch[1].split(',').map((f) => f.trim()).filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
flush();
|
|
65
|
+
return entries;
|
|
66
|
+
}
|
|
67
|
+
export function pruneSessionLog(logContent, opts = {}) {
|
|
68
|
+
const retentionDays = opts.retentionDays ?? 14;
|
|
69
|
+
const maxEntries = opts.maxEntries ?? 50;
|
|
70
|
+
const now = opts.now ?? new Date();
|
|
71
|
+
const cutoffTime = now.getTime() - retentionDays * 24 * 60 * 60 * 1000;
|
|
72
|
+
const entries = parseSessionLogEntries(logContent);
|
|
73
|
+
if (entries.length === 0) {
|
|
74
|
+
return {
|
|
75
|
+
content: logContent.trim() ? logContent.trim() : '# Session Log',
|
|
76
|
+
retained: [],
|
|
77
|
+
removedCount: 0,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// Filter out entries older than retention cutoff (e.g. 14 days)
|
|
81
|
+
let retained = entries.filter((e) => e.date.getTime() >= cutoffTime);
|
|
82
|
+
// If total entries still exceed maxEntries, keep only the newest ones
|
|
83
|
+
if (retained.length > maxEntries) {
|
|
84
|
+
retained = retained.slice(-maxEntries);
|
|
85
|
+
}
|
|
86
|
+
const removedCount = entries.length - retained.length;
|
|
87
|
+
const header = '# Session Log';
|
|
88
|
+
const body = retained.map((e) => e.rawText).join('\n\n');
|
|
89
|
+
const content = body ? `${header}\n\n${body}\n` : `${header}\n`;
|
|
90
|
+
return {
|
|
91
|
+
content,
|
|
92
|
+
retained,
|
|
93
|
+
removedCount,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function pruneSessions(recallDir, opts = {}) {
|
|
97
|
+
const logFile = path.join(recallDir, 'sessions', 'session-log.md');
|
|
98
|
+
if (!fs.existsSync(logFile)) {
|
|
99
|
+
return { removedCount: 0, remainingCount: 0 };
|
|
100
|
+
}
|
|
101
|
+
let retentionDays = 14;
|
|
102
|
+
let maxEntries = 50;
|
|
103
|
+
try {
|
|
104
|
+
const cfg = loadConfig(recallDir);
|
|
105
|
+
if (cfg.retentionDays)
|
|
106
|
+
retentionDays = cfg.retentionDays;
|
|
107
|
+
if (cfg.maxLogEntries)
|
|
108
|
+
maxEntries = cfg.maxLogEntries;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Non-fatal fallback to defaults
|
|
112
|
+
}
|
|
113
|
+
if (opts.retentionDays !== undefined)
|
|
114
|
+
retentionDays = opts.retentionDays;
|
|
115
|
+
if (opts.maxEntries !== undefined)
|
|
116
|
+
maxEntries = opts.maxEntries;
|
|
117
|
+
const raw = fs.readFileSync(logFile, 'utf8');
|
|
118
|
+
const res = pruneSessionLog(raw, { retentionDays, maxEntries, now: opts.now });
|
|
119
|
+
atomicWrite(logFile, res.content);
|
|
120
|
+
return { removedCount: res.removedCount, remainingCount: res.retained.length };
|
|
121
|
+
}
|
|
122
|
+
export function buildCurrentStateMarkdown(result) {
|
|
123
|
+
const lines = [
|
|
124
|
+
'# Previous Iteration State (Historical Context Only)',
|
|
125
|
+
'',
|
|
126
|
+
`> **Last Updated**: ${result.timestamp}`,
|
|
127
|
+
'> **AI AGENTS**: This records what happened in the PREVIOUS session. DO NOT execute these files or next steps unless the user explicitly asks to continue that specific work. Always prioritize the user\'s current prompt.',
|
|
128
|
+
'',
|
|
129
|
+
'## Previous Progress',
|
|
130
|
+
result.summary,
|
|
131
|
+
'',
|
|
132
|
+
];
|
|
133
|
+
if (result.nextSteps) {
|
|
134
|
+
lines.push('## Previous Task Next Steps (Conditional on User Request)', result.nextSteps, '');
|
|
135
|
+
}
|
|
136
|
+
lines.push('## Files Modified in Previous Task');
|
|
137
|
+
if (result.modifiedFiles.length === 0) {
|
|
138
|
+
lines.push('_Working directory clean or no uncommitted changes._');
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
for (const file of result.modifiedFiles) {
|
|
142
|
+
lines.push(`- \`${file}\``);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
lines.push('');
|
|
146
|
+
return lines.join('\n');
|
|
147
|
+
}
|
|
148
|
+
export function recordCheckpoint(root, recallDir, summary, opts = {}) {
|
|
149
|
+
const rawChanges = repoChanges(root) ?? [];
|
|
150
|
+
const modifiedFiles = opts.clear ? [] : rawChanges.slice(0, 20); // Top 20 active files
|
|
151
|
+
let cleanSummary = summary?.trim();
|
|
152
|
+
if (opts.clear) {
|
|
153
|
+
cleanSummary = 'Previous task completed. Ready for new instructions.';
|
|
154
|
+
}
|
|
155
|
+
else if (!cleanSummary || cleanSummary === 'Task checkpoint' || opts.auto) {
|
|
156
|
+
if (modifiedFiles.length > 0) {
|
|
157
|
+
const sample = modifiedFiles.slice(0, 3).join(', ');
|
|
158
|
+
const extra = modifiedFiles.length > 3 ? ` (+${modifiedFiles.length - 3} more)` : '';
|
|
159
|
+
cleanSummary = `Session update: modified ${sample}${extra}`;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
cleanSummary = 'Session chat iteration completed (working directory clean)';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const timestamp = getCurrentTimestamp();
|
|
166
|
+
const sessionLogDir = path.join(recallDir, 'sessions');
|
|
167
|
+
const logFile = path.join(sessionLogDir, 'session-log.md');
|
|
168
|
+
const existingRaw = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : '';
|
|
169
|
+
// The Stop hook fires after every agent turn; don't log a new entry when nothing changed.
|
|
170
|
+
if (opts.auto && !opts.clear) {
|
|
171
|
+
const last = parseSessionLogEntries(existingRaw).at(-1);
|
|
172
|
+
const lastFiles = last?.modifiedFiles ?? [];
|
|
173
|
+
const same = lastFiles.length === modifiedFiles.length && lastFiles.every((f, i) => f === modifiedFiles[i]);
|
|
174
|
+
if (last && same && last.summary.startsWith('Session ')) {
|
|
175
|
+
return { timestamp: last.timestamp, summary: last.summary, modifiedFiles, skipped: true };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const result = {
|
|
179
|
+
timestamp,
|
|
180
|
+
summary: cleanSummary,
|
|
181
|
+
nextSteps: opts.clear ? undefined : opts.nextSteps?.trim(),
|
|
182
|
+
modifiedFiles,
|
|
183
|
+
};
|
|
184
|
+
const currentStateFile = resolveInside(recallDir, 'CURRENT_STATE.md');
|
|
185
|
+
atomicWrite(currentStateFile, buildCurrentStateMarkdown(result));
|
|
186
|
+
fs.mkdirSync(sessionLogDir, { recursive: true });
|
|
187
|
+
const entryLines = [
|
|
188
|
+
`### [${timestamp}] Checkpoint`,
|
|
189
|
+
`- **Summary**: ${cleanSummary}`,
|
|
190
|
+
];
|
|
191
|
+
if (result.nextSteps) {
|
|
192
|
+
entryLines.push(`- **Next**: ${result.nextSteps}`);
|
|
193
|
+
}
|
|
194
|
+
if (modifiedFiles.length > 0) {
|
|
195
|
+
entryLines.push(`- **Modified**: ${modifiedFiles.join(', ')}`);
|
|
196
|
+
}
|
|
197
|
+
const logEntry = `${entryLines.join('\n')}\n\n`;
|
|
198
|
+
let retentionDays = 14;
|
|
199
|
+
let maxEntries = 50;
|
|
200
|
+
try {
|
|
201
|
+
const cfg = loadConfig(recallDir);
|
|
202
|
+
if (cfg.retentionDays)
|
|
203
|
+
retentionDays = cfg.retentionDays;
|
|
204
|
+
if (cfg.maxLogEntries)
|
|
205
|
+
maxEntries = cfg.maxLogEntries;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Non-fatal if config missing or uninitialized
|
|
209
|
+
}
|
|
210
|
+
if (opts.retentionDays !== undefined)
|
|
211
|
+
retentionDays = opts.retentionDays;
|
|
212
|
+
if (opts.maxEntries !== undefined)
|
|
213
|
+
maxEntries = opts.maxEntries;
|
|
214
|
+
const existingLog = existingRaw ? existingRaw.trimEnd() : '# Session Log';
|
|
215
|
+
const combinedLog = `${existingLog}\n\n${logEntry}`;
|
|
216
|
+
const pruned = pruneSessionLog(combinedLog, { retentionDays, maxEntries });
|
|
217
|
+
atomicWrite(logFile, pruned.content);
|
|
218
|
+
// Deliberately no agent-file or project-map rewrites here: checkpoints run from a hook after every
|
|
219
|
+
// session, and mutating always-on agent instructions would invalidate provider prompt caches.
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
export function initialCurrentStateMarkdown() {
|
|
223
|
+
return [
|
|
224
|
+
'# Project State (Ready)',
|
|
225
|
+
'',
|
|
226
|
+
`> **Initialized**: ${today()}`,
|
|
227
|
+
'> **AI AGENTS**: Project initialized. Always prioritize the user\'s immediate prompt.',
|
|
228
|
+
'',
|
|
229
|
+
'## Status',
|
|
230
|
+
'Ready for tasks. No previous work in progress.',
|
|
231
|
+
'',
|
|
232
|
+
].join('\n');
|
|
233
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type RecallConfig } from '../schemas/index.js';
|
|
2
|
+
export declare function configPath(recallDir: string): string;
|
|
3
|
+
export declare function defaultConfig(): RecallConfig;
|
|
4
|
+
export declare function loadConfig(recallDir: string): RecallConfig;
|
|
5
|
+
export declare function writeConfig(recallDir: string, cfg: RecallConfig): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { configSchema } from '../schemas/index.js';
|
|
4
|
+
import { RecallError, atomicWrite } from '../utils/fs.js';
|
|
5
|
+
export function configPath(recallDir) {
|
|
6
|
+
return path.join(recallDir, 'config.json');
|
|
7
|
+
}
|
|
8
|
+
export function defaultConfig() {
|
|
9
|
+
return { version: 1, agentFile: 'AGENTS.md', profile: 'standard', retentionDays: 14, maxLogEntries: 50, writes: 'auto' };
|
|
10
|
+
}
|
|
11
|
+
export function loadConfig(recallDir) {
|
|
12
|
+
const file = configPath(recallDir);
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
throw new RecallError(`Cannot read ${file}: ${err.message}`);
|
|
19
|
+
}
|
|
20
|
+
let json;
|
|
21
|
+
try {
|
|
22
|
+
json = JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new RecallError(`${file} is not valid JSON.`, 'Fix the syntax or restore it from Git (`git checkout -- .recall/config.json`).');
|
|
26
|
+
}
|
|
27
|
+
const parsed = configSchema.safeParse(json);
|
|
28
|
+
if (!parsed.success) {
|
|
29
|
+
const msg = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ');
|
|
30
|
+
throw new RecallError(`Invalid config: ${msg}`, 'Fix .recall/config.json or restore it from Git.');
|
|
31
|
+
}
|
|
32
|
+
return parsed.data;
|
|
33
|
+
}
|
|
34
|
+
export function writeConfig(recallDir, cfg) {
|
|
35
|
+
atomicWrite(configPath(recallDir), `${JSON.stringify(cfg, null, 2)}\n`);
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Rough estimate (~4 chars/token for English/code). Labelled as an estimate everywhere it is shown. */
|
|
2
|
+
export declare const estimateTokens: (text: string) => number;
|
|
3
|
+
export interface CostLine {
|
|
4
|
+
source: string;
|
|
5
|
+
tokens: number;
|
|
6
|
+
note?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CostReport {
|
|
9
|
+
/** Sent on every agent turn. */
|
|
10
|
+
alwaysOn: CostLine[];
|
|
11
|
+
alwaysOnTotal: number;
|
|
12
|
+
/** Only sent if the MCP server is configured. */
|
|
13
|
+
mcp: CostLine;
|
|
14
|
+
/** Only paid when the agent explicitly reads/calls it. */
|
|
15
|
+
onDemand: CostLine[];
|
|
16
|
+
/** Problems that make Recall cost more than it should (fixed by re-running `recall init`). */
|
|
17
|
+
warnings: string[];
|
|
18
|
+
}
|
|
19
|
+
export declare function costReport(start: string): CostReport;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { HOOK_COMMAND, HOOK_NAME } from './agent.js';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { requireInitialized } from './project.js';
|
|
6
|
+
import { TOOLS } from '../mcp/server.js';
|
|
7
|
+
import { extractManagedBlock } from '../utils/managed.js';
|
|
8
|
+
/** Rough estimate (~4 chars/token for English/code). Labelled as an estimate everywhere it is shown. */
|
|
9
|
+
export const estimateTokens = (text) => Math.ceil(text.length / 4);
|
|
10
|
+
function read(file) {
|
|
11
|
+
try {
|
|
12
|
+
return fs.readFileSync(file, 'utf8');
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function costReport(start) {
|
|
19
|
+
const p = requireInitialized(start);
|
|
20
|
+
let agentFile = 'AGENTS.md';
|
|
21
|
+
try {
|
|
22
|
+
agentFile = loadConfig(p.recallDir).agentFile;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* surfaced by `recall doctor` */
|
|
26
|
+
}
|
|
27
|
+
const alwaysOn = [];
|
|
28
|
+
const warnings = [];
|
|
29
|
+
const agentText = read(path.join(p.root, agentFile));
|
|
30
|
+
const agentBlock = agentText ? extractManagedBlock(agentText) : null;
|
|
31
|
+
alwaysOn.push({ source: `${agentFile} (Recall block only)`, tokens: agentBlock ? estimateTokens(agentBlock) : 0, note: agentBlock ? undefined : 'not installed' });
|
|
32
|
+
const ruleText = read(path.join(p.root, '.agents', 'rules', 'recall.md'));
|
|
33
|
+
const ruleBlock = ruleText ? extractManagedBlock(ruleText) : null;
|
|
34
|
+
if (ruleBlock) {
|
|
35
|
+
alwaysOn.push({ source: '.agents/rules/recall.md (Recall block)', tokens: estimateTokens(ruleBlock) });
|
|
36
|
+
if (agentFile === 'AGENTS.md' || agentFile === 'GEMINI.md') {
|
|
37
|
+
warnings.push('.agents/rules/recall.md duplicates the AGENTS.md instructions (legacy). Run `recall init` to remove it.');
|
|
38
|
+
}
|
|
39
|
+
if (/Current Working State|Previous Progress/.test(ruleBlock)) {
|
|
40
|
+
warnings.push('Always-on rule embeds session state, which changes every session and defeats prompt caching. Run `recall init`.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const skillText = read(path.join(p.root, '.agents', 'skills', 'recall', 'SKILL.md'));
|
|
44
|
+
const skillDesc = skillText ? /description:\s*(.*)/.exec(skillText)?.[1] ?? '' : '';
|
|
45
|
+
if (skillText)
|
|
46
|
+
alwaysOn.push({ source: 'skill name + description', tokens: estimateTokens(`recall ${skillDesc}`) });
|
|
47
|
+
const hooksText = read(path.join(p.root, '.agents', 'hooks.json'));
|
|
48
|
+
if (hooksText) {
|
|
49
|
+
try {
|
|
50
|
+
const hooks = JSON.parse(hooksText);
|
|
51
|
+
const cmd = hooks[HOOK_NAME]?.Stop?.[0]?.command;
|
|
52
|
+
if (cmd && cmd !== HOOK_COMMAND) {
|
|
53
|
+
warnings.push(`Checkpoint hook uses an outdated command (\`${cmd}\`) that does not run outside the Recall repo. Run \`recall init\`.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
warnings.push('.agents/hooks.json is not valid JSON.');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const onDemand = [];
|
|
61
|
+
const addFile = (rel, note) => {
|
|
62
|
+
const t = read(path.join(p.recallDir, rel));
|
|
63
|
+
if (t !== null)
|
|
64
|
+
onDemand.push({ source: `.recall/${rel}`, tokens: estimateTokens(t), note });
|
|
65
|
+
};
|
|
66
|
+
if (skillText)
|
|
67
|
+
onDemand.push({ source: 'skill body', tokens: estimateTokens(skillText) - estimateTokens(skillDesc), note: 'loaded when the agent picks the skill' });
|
|
68
|
+
onDemand.push({ source: '`recall context` response', tokens: 400, note: 'hard cap' });
|
|
69
|
+
addFile('CURRENT_STATE.md', 'read only for continue/resume');
|
|
70
|
+
addFile('PROJECT_MAP.md', 'for humans; agents use `recall where`');
|
|
71
|
+
return {
|
|
72
|
+
alwaysOn,
|
|
73
|
+
alwaysOnTotal: alwaysOn.reduce((s, l) => s + l.tokens, 0),
|
|
74
|
+
mcp: { source: `MCP tool schemas (${TOOLS.length} tools)`, tokens: estimateTokens(JSON.stringify(TOOLS)), note: 'only if `recall serve` is configured' },
|
|
75
|
+
onDemand,
|
|
76
|
+
warnings,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { agentState } from './agent.js';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { gitInfo, recallChanges } from './git.js';
|
|
6
|
+
import { brokenIndexLinks, indexState } from './index-builder.js';
|
|
7
|
+
import { loadAll } from './memory.js';
|
|
8
|
+
import { findProject } from './project.js';
|
|
9
|
+
const REQUIRED = ['rules', 'decisions', 'lessons', 'tasks', 'sessions', 'logs'];
|
|
10
|
+
export function runDoctor(start) {
|
|
11
|
+
const p = findProject(start);
|
|
12
|
+
const checks = [];
|
|
13
|
+
const add = (name, ok, detail, fix) => {
|
|
14
|
+
checks.push({ name, ok, detail, ...(fix ? { fix } : {}) });
|
|
15
|
+
};
|
|
16
|
+
add('project root', true, `${p.root} (found via ${p.source})`);
|
|
17
|
+
if (!p.initialized) {
|
|
18
|
+
add('initialized', false, 'No .recall/config.json found.', 'Run `recall init`.');
|
|
19
|
+
return checks;
|
|
20
|
+
}
|
|
21
|
+
let agentFile = 'AGENTS.md';
|
|
22
|
+
try {
|
|
23
|
+
agentFile = loadConfig(p.recallDir).agentFile;
|
|
24
|
+
add('config', true, '.recall/config.json is valid');
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
add('config', false, err.message, 'Fix .recall/config.json or `git checkout -- .recall/config.json`.');
|
|
28
|
+
}
|
|
29
|
+
for (const d of REQUIRED) {
|
|
30
|
+
const ok = fs.existsSync(path.join(p.recallDir, d));
|
|
31
|
+
if (!ok)
|
|
32
|
+
add(`directory ${d}/`, false, 'missing', `Run \`recall init\` (safe to repeat) or mkdir .recall/${d}`);
|
|
33
|
+
}
|
|
34
|
+
for (const f of ['MEMORY.md', 'INDEX.md', 'PROJECT_MAP.md', 'CURRENT_STATE.md']) {
|
|
35
|
+
const ok = fs.existsSync(path.join(p.recallDir, f));
|
|
36
|
+
if (!ok) {
|
|
37
|
+
let fix = 'Run `recall init`.';
|
|
38
|
+
if (f === 'INDEX.md')
|
|
39
|
+
fix = 'Run `recall rebuild`.';
|
|
40
|
+
if (f === 'PROJECT_MAP.md')
|
|
41
|
+
fix = 'Run `recall map`.';
|
|
42
|
+
add(f, false, 'missing', fix);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const loaded = loadAll(p.recallDir);
|
|
46
|
+
if (loaded.problems.length === 0)
|
|
47
|
+
add('memory files', true, `${loaded.memories.length} valid memories`);
|
|
48
|
+
for (const pr of loaded.problems)
|
|
49
|
+
add(`memory ${pr.file}`, false, pr.error, 'Edit the file to fix its frontmatter. Recall will not delete it.');
|
|
50
|
+
const state = indexState(p.recallDir);
|
|
51
|
+
add('index', state === 'up_to_date', state, state === 'up_to_date' ? undefined : 'Run `recall rebuild`.');
|
|
52
|
+
const broken = brokenIndexLinks(p.recallDir);
|
|
53
|
+
if (broken.length > 0)
|
|
54
|
+
add('index links', false, `broken: ${broken.join(', ')}`, 'Run `recall rebuild` or fix hand-written links.');
|
|
55
|
+
try {
|
|
56
|
+
const a = agentState(p.root, agentFile);
|
|
57
|
+
add('agent instructions', a === 'installed', `${agentFile}: ${a}`, a === 'installed' ? undefined : 'Run `recall init` to install/refresh the managed block.');
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
add('agent instructions', false, err.message);
|
|
61
|
+
}
|
|
62
|
+
const g = gitInfo(p.root);
|
|
63
|
+
add('git', g.available, g.available ? (g.isRepo ? 'repository detected' : 'git installed, but not inside a repository') : 'git not installed (history features reduced)');
|
|
64
|
+
if (g.isRepo) {
|
|
65
|
+
const ch = recallChanges(p.root);
|
|
66
|
+
add('uncommitted recall changes', true, ch ? `${ch.length} path(s)` : 'unknown');
|
|
67
|
+
}
|
|
68
|
+
return checks;
|
|
69
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface GitInfo {
|
|
2
|
+
available: boolean;
|
|
3
|
+
isRepo: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function gitInfo(cwd: string): GitInfo;
|
|
6
|
+
/** Uncommitted changes limited to the .recall path only. */
|
|
7
|
+
export declare function recallChanges(root: string, recallRel?: string): string[] | null;
|
|
8
|
+
/** Uncommitted changed file paths across the repository (excluding .recall). */
|
|
9
|
+
export declare function repoChanges(root: string): string[] | null;
|
|
10
|
+
export interface Commit {
|
|
11
|
+
hash: string;
|
|
12
|
+
date: string;
|
|
13
|
+
subject: string;
|
|
14
|
+
}
|
|
15
|
+
/** Committed history for .recall only. Returns null when unavailable. */
|
|
16
|
+
export declare function recallHistory(root: string, limit: number, recallRel?: string): Commit[] | null;
|
|
17
|
+
export interface UndoResult {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
message: string;
|
|
20
|
+
dryRun?: boolean;
|
|
21
|
+
restored?: string[];
|
|
22
|
+
cleaned?: string[];
|
|
23
|
+
}
|
|
24
|
+
/** Revert all uncommitted changes and delete untracked files in .recall/ (never touches gitignored files) */
|
|
25
|
+
export declare function undoRecall(root: string, recallRel?: string, opts?: {
|
|
26
|
+
dryRun?: boolean;
|
|
27
|
+
}): UndoResult;
|
package/dist/core/git.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
function run(args, cwd) {
|
|
3
|
+
// --no-optional-locks keeps status strictly read-only (no index refresh writes).
|
|
4
|
+
const r = spawnSync('git', ['--no-optional-locks', '-c', 'core.quotepath=off', ...args], {
|
|
5
|
+
cwd,
|
|
6
|
+
encoding: 'utf8',
|
|
7
|
+
windowsHide: true,
|
|
8
|
+
});
|
|
9
|
+
if (r.error || r.status !== 0)
|
|
10
|
+
return { ok: false, out: '' };
|
|
11
|
+
return { ok: true, out: r.stdout };
|
|
12
|
+
}
|
|
13
|
+
export function gitInfo(cwd) {
|
|
14
|
+
const v = spawnSync('git', ['--version'], { encoding: 'utf8', windowsHide: true });
|
|
15
|
+
if (v.error || v.status !== 0)
|
|
16
|
+
return { available: false, isRepo: false };
|
|
17
|
+
const r = run(['rev-parse', '--is-inside-work-tree'], cwd);
|
|
18
|
+
return { available: true, isRepo: r.ok && r.out.trim() === 'true' };
|
|
19
|
+
}
|
|
20
|
+
/** Uncommitted changes limited to the .recall path only. */
|
|
21
|
+
export function recallChanges(root, recallRel = '.recall') {
|
|
22
|
+
const r = run(['status', '--porcelain', '--', recallRel], root);
|
|
23
|
+
if (!r.ok)
|
|
24
|
+
return null;
|
|
25
|
+
return r.out.split('\n').filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
/** Uncommitted changed file paths across the repository (excluding .recall). */
|
|
28
|
+
export function repoChanges(root) {
|
|
29
|
+
const r = run(['status', '--porcelain', '-uall'], root);
|
|
30
|
+
if (!r.ok)
|
|
31
|
+
return null;
|
|
32
|
+
const files = [];
|
|
33
|
+
for (const line of r.out.split('\n')) {
|
|
34
|
+
if (!line || line.length < 4)
|
|
35
|
+
continue;
|
|
36
|
+
// Porcelain v1 format is: XY <path> or XY <orig> -> <new>
|
|
37
|
+
let p = line.slice(3).trim();
|
|
38
|
+
if (p.includes(' -> ')) {
|
|
39
|
+
p = p.split(' -> ')[1].trim();
|
|
40
|
+
}
|
|
41
|
+
if (p.startsWith('"') && p.endsWith('"')) {
|
|
42
|
+
p = p.slice(1, -1);
|
|
43
|
+
}
|
|
44
|
+
const normalized = p.replace(/\\/g, '/');
|
|
45
|
+
if (!normalized.startsWith('.recall/') && normalized !== '.recall') {
|
|
46
|
+
files.push(normalized);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [...new Set(files)];
|
|
50
|
+
}
|
|
51
|
+
/** Committed history for .recall only. Returns null when unavailable. */
|
|
52
|
+
export function recallHistory(root, limit, recallRel = '.recall') {
|
|
53
|
+
const r = run(['log', `-n${limit}`, '--format=%h%x09%ad%x09%s', '--date=short', '--', recallRel], root);
|
|
54
|
+
if (!r.ok)
|
|
55
|
+
return null;
|
|
56
|
+
return r.out
|
|
57
|
+
.split('\n')
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.map((l) => {
|
|
60
|
+
const [hash = '', date = '', ...rest] = l.split('\t');
|
|
61
|
+
return { hash, date, subject: rest.join('\t') };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Revert all uncommitted changes and delete untracked files in .recall/ (never touches gitignored files) */
|
|
65
|
+
export function undoRecall(root, recallRel = '.recall', opts = {}) {
|
|
66
|
+
const info = gitInfo(root);
|
|
67
|
+
if (!info.isRepo) {
|
|
68
|
+
return { ok: false, message: 'Not inside a Git repository. Cannot revert via Git.' };
|
|
69
|
+
}
|
|
70
|
+
const diffWorktree = run(['diff', '--name-only', '--', recallRel], root).out.split('\n').filter(Boolean);
|
|
71
|
+
const diffStaged = run(['diff', '--cached', '--name-only', '--', recallRel], root).out.split('\n').filter(Boolean);
|
|
72
|
+
const tracked = [...new Set([...diffWorktree, ...diffStaged])];
|
|
73
|
+
const cleanDry = run(['clean', '-nd', '--', recallRel], root).out.split('\n').filter(Boolean)
|
|
74
|
+
.map((l) => l.replace(/^Would remove\s+/, ''));
|
|
75
|
+
if (opts.dryRun) {
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
dryRun: true,
|
|
79
|
+
message: `[dry-run] Would restore ${tracked.length} tracked file(s) and delete ${cleanDry.length} untracked file(s). Gitignored files are never touched.`,
|
|
80
|
+
restored: tracked,
|
|
81
|
+
cleaned: cleanDry,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// 1. Restore tracked changes (both staged and worktree)
|
|
85
|
+
const hasHead = run(['rev-parse', '--verify', 'HEAD'], root).ok;
|
|
86
|
+
if (hasHead) {
|
|
87
|
+
let res = run(['restore', '--staged', '--worktree', '--', recallRel], root);
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
run(['reset', 'HEAD', '--', recallRel], root);
|
|
90
|
+
run(['checkout', '--', recallRel], root);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
run(['rm', '--cached', '-r', '-f', '--quiet', '--', recallRel], root);
|
|
95
|
+
}
|
|
96
|
+
// 2. Remove untracked files and directories within .recall/
|
|
97
|
+
const cleanRes = run(['clean', '-fd', '--', recallRel], root);
|
|
98
|
+
if (!cleanRes.ok) {
|
|
99
|
+
return { ok: false, message: `Failed to clean untracked files in ${recallRel}.` };
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
message: `Reverted all uncommitted changes and removed untracked files in ${recallRel}. Gitignored files were untouched.`,
|
|
104
|
+
restored: tracked,
|
|
105
|
+
cleaned: cleanDry,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type LoadResult } from './memory.js';
|
|
2
|
+
/** Deterministic: depends only on memory metadata, sorted by id. */
|
|
3
|
+
export declare function buildIndexBlock(loaded: LoadResult): string;
|
|
4
|
+
export declare function indexPath(recallDir: string): string;
|
|
5
|
+
export declare function renderIndex(existing: string, loaded: LoadResult): string;
|
|
6
|
+
/** Rebuild INDEX.md; text outside the managed block is preserved. */
|
|
7
|
+
export declare function rebuildIndex(recallDir: string): LoadResult;
|
|
8
|
+
export type IndexState = 'missing' | 'up_to_date' | 'stale' | 'no_managed_block';
|
|
9
|
+
export declare function indexState(recallDir: string): IndexState;
|
|
10
|
+
/** Relative link targets in INDEX.md that do not exist. */
|
|
11
|
+
export declare function brokenIndexLinks(recallDir: string): string[];
|