@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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +386 -2
  3. package/dist/cli/index.d.ts +2 -0
  4. package/dist/cli/index.js +451 -0
  5. package/dist/core/agent.d.ts +21 -0
  6. package/dist/core/agent.js +140 -0
  7. package/dist/core/checkpoint.d.ts +42 -0
  8. package/dist/core/checkpoint.js +233 -0
  9. package/dist/core/config.d.ts +5 -0
  10. package/dist/core/config.js +36 -0
  11. package/dist/core/cost.d.ts +19 -0
  12. package/dist/core/cost.js +78 -0
  13. package/dist/core/doctor.d.ts +7 -0
  14. package/dist/core/doctor.js +69 -0
  15. package/dist/core/git.d.ts +27 -0
  16. package/dist/core/git.js +107 -0
  17. package/dist/core/index-builder.d.ts +11 -0
  18. package/dist/core/index-builder.js +84 -0
  19. package/dist/core/init.d.ts +13 -0
  20. package/dist/core/init.js +52 -0
  21. package/dist/core/landmarks.d.ts +19 -0
  22. package/dist/core/landmarks.js +323 -0
  23. package/dist/core/mapper.d.ts +28 -0
  24. package/dist/core/mapper.js +357 -0
  25. package/dist/core/memory.d.ts +44 -0
  26. package/dist/core/memory.js +199 -0
  27. package/dist/core/project.d.ts +12 -0
  28. package/dist/core/project.js +34 -0
  29. package/dist/core/retrieval.d.ts +27 -0
  30. package/dist/core/retrieval.js +180 -0
  31. package/dist/core/seed.d.ts +25 -0
  32. package/dist/core/seed.js +196 -0
  33. package/dist/core/service.d.ts +76 -0
  34. package/dist/core/service.js +228 -0
  35. package/dist/core/where.d.ts +14 -0
  36. package/dist/core/where.js +86 -0
  37. package/dist/index.d.ts +11 -0
  38. package/dist/index.js +11 -0
  39. package/dist/mcp/server.d.ts +65 -0
  40. package/dist/mcp/server.js +121 -0
  41. package/dist/schemas/index.d.ts +68 -0
  42. package/dist/schemas/index.js +43 -0
  43. package/dist/templates/index.d.ts +21 -0
  44. package/dist/templates/index.js +62 -0
  45. package/dist/utils/fs.d.ts +15 -0
  46. package/dist/utils/fs.js +73 -0
  47. package/dist/utils/managed.d.ts +5 -0
  48. package/dist/utils/managed.js +41 -0
  49. package/dist/utils/secrets.d.ts +3 -0
  50. package/dist/utils/secrets.js +23 -0
  51. package/package.json +65 -4
@@ -0,0 +1,357 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { scanLandmarks, formatLandmarksMarkdown } from './landmarks.js';
4
+ import { loadConfig } from './config.js';
5
+ import { atomicWrite, resolveInside, toPosix } from '../utils/fs.js';
6
+ import { applyManagedBlock } from '../utils/managed.js';
7
+ export const DEFAULT_IGNORED_DIRS = new Set([
8
+ 'node_modules',
9
+ '.git',
10
+ '.recall',
11
+ 'dist',
12
+ 'build',
13
+ 'out',
14
+ '.next',
15
+ '.nuxt',
16
+ '.turbo',
17
+ 'coverage',
18
+ '.cache',
19
+ '.venv',
20
+ 'venv',
21
+ '__pycache__',
22
+ '.idea',
23
+ '.vscode',
24
+ '.gemini',
25
+ 'target',
26
+ 'bin',
27
+ 'obj',
28
+ ]);
29
+ const ENTRY_FILENAMES = new Set([
30
+ 'index.ts',
31
+ 'index.js',
32
+ 'main.ts',
33
+ 'main.js',
34
+ 'app.ts',
35
+ 'app.js',
36
+ 'main.py',
37
+ 'app.py',
38
+ 'main.go',
39
+ 'main.rs',
40
+ 'index.html',
41
+ 'main.dart',
42
+ 'mainactivity.kt',
43
+ 'mainactivity.java',
44
+ 'appdelegate.swift',
45
+ ]);
46
+ const CONFIG_FILENAMES = new Set([
47
+ 'package.json',
48
+ 'tsconfig.json',
49
+ 'pyproject.toml',
50
+ 'requirements.txt',
51
+ 'Cargo.toml',
52
+ 'go.mod',
53
+ 'pom.xml',
54
+ 'build.gradle',
55
+ 'Makefile',
56
+ 'Dockerfile',
57
+ 'docker-compose.yml',
58
+ 'vite.config.ts',
59
+ 'vite.config.js',
60
+ 'next.config.js',
61
+ 'next.config.mjs',
62
+ ]);
63
+ const THEME_FILENAMES = new Set([
64
+ 'app_theme.dart',
65
+ 'theme.dart',
66
+ 'app_colors.dart',
67
+ 'colors.dart',
68
+ 'typography.dart',
69
+ 'tokens.css',
70
+ 'theme.ts',
71
+ 'theme.js',
72
+ 'tailwind.config.js',
73
+ 'tailwind.config.ts',
74
+ ]);
75
+ import ignore from 'ignore';
76
+ export function createGitignoreFilter(root) {
77
+ const ignoreFile = path.join(root, '.gitignore');
78
+ const ig = ignore();
79
+ if (fs.existsSync(ignoreFile)) {
80
+ try {
81
+ ig.add(fs.readFileSync(ignoreFile, 'utf8'));
82
+ }
83
+ catch {
84
+ // ignore syntax issues
85
+ }
86
+ }
87
+ return (relPath, isDir) => {
88
+ if (!relPath || relPath === '.')
89
+ return false;
90
+ const testPath = isDir ? (relPath.endsWith('/') ? relPath : `${relPath}/`) : relPath;
91
+ try {
92
+ return ig.ignores(testPath);
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ };
98
+ }
99
+ export function parseGitignore(root) {
100
+ const ignoreFile = path.join(root, '.gitignore');
101
+ const ignores = new Set();
102
+ if (!fs.existsSync(ignoreFile))
103
+ return ignores;
104
+ const lines = fs.readFileSync(ignoreFile, 'utf8').split('\n');
105
+ for (const line of lines) {
106
+ const trimmed = line.trim();
107
+ if (!trimmed || trimmed.startsWith('#'))
108
+ continue;
109
+ const cleaned = trimmed.replace(/^\/+|\/+$/g, '');
110
+ if (cleaned && !cleaned.includes('*') && !cleaned.includes('/')) {
111
+ ignores.add(cleaned);
112
+ }
113
+ }
114
+ return ignores;
115
+ }
116
+ export function scanProject(root, maxDepth = 3, opts) {
117
+ const isGitignored = createGitignoreFilter(root);
118
+ const entryPoints = [];
119
+ const configFiles = [];
120
+ const themeFiles = [];
121
+ const directories = [];
122
+ const treeLines = [];
123
+ function walk(currentDir, currentDepth, prefix) {
124
+ if (currentDepth > maxDepth)
125
+ return;
126
+ let entries = [];
127
+ try {
128
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
129
+ }
130
+ catch {
131
+ return;
132
+ }
133
+ const dirs = [];
134
+ const files = [];
135
+ const extCount = new Map();
136
+ for (const entry of entries) {
137
+ if (entry.name.startsWith('.') && entry.name !== '.gitignore' && entry.name !== '.env.example') {
138
+ if (entry.isDirectory())
139
+ continue;
140
+ }
141
+ const rel = toPosix(path.relative(root, path.join(currentDir, entry.name)));
142
+ if (entry.isDirectory()) {
143
+ if (DEFAULT_IGNORED_DIRS.has(entry.name) || isGitignored(rel, true))
144
+ continue;
145
+ dirs.push(entry);
146
+ }
147
+ else if (entry.isFile()) {
148
+ if (isGitignored(rel, false))
149
+ continue;
150
+ files.push(entry);
151
+ const ext = path.extname(entry.name).toLowerCase();
152
+ if (ext)
153
+ extCount.set(ext, (extCount.get(ext) ?? 0) + 1);
154
+ if (currentDepth <= 2 && CONFIG_FILENAMES.has(entry.name)) {
155
+ configFiles.push(rel);
156
+ }
157
+ const lowerName = entry.name.toLowerCase();
158
+ if (ENTRY_FILENAMES.has(lowerName) || lowerName.endsWith('sdk.kt') || lowerName.endsWith('plugin.kt')) {
159
+ entryPoints.push(rel);
160
+ }
161
+ if (THEME_FILENAMES.has(lowerName)) {
162
+ themeFiles.push(rel);
163
+ }
164
+ }
165
+ }
166
+ const relDir = toPosix(path.relative(root, currentDir));
167
+ if (relDir && relDir !== '.') {
168
+ const topExts = [...extCount.entries()]
169
+ .sort((a, b) => b[1] - a[1])
170
+ .slice(0, 3)
171
+ .map(([e]) => e);
172
+ directories.push({
173
+ path: relDir,
174
+ filesCount: files.length,
175
+ extensions: topExts,
176
+ });
177
+ }
178
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
179
+ files.sort((a, b) => a.name.localeCompare(b.name));
180
+ for (let i = 0; i < dirs.length; i++) {
181
+ const d = dirs[i];
182
+ const isLast = i === dirs.length - 1 && files.length === 0;
183
+ const pointer = isLast ? '\\-- ' : '|-- ';
184
+ treeLines.push(`${prefix}${pointer}${d.name}/`);
185
+ walk(path.join(currentDir, d.name), currentDepth + 1, `${prefix}${isLast ? ' ' : '| '}`);
186
+ }
187
+ if (currentDepth <= 1) {
188
+ const displayFiles = files.slice(0, 6);
189
+ for (let i = 0; i < displayFiles.length; i++) {
190
+ const f = displayFiles[i];
191
+ const isLast = i === displayFiles.length - 1 && displayFiles.length === files.length;
192
+ const pointer = isLast ? '\\-- ' : '|-- ';
193
+ treeLines.push(`${prefix}${pointer}${f.name}`);
194
+ }
195
+ if (files.length > displayFiles.length) {
196
+ treeLines.push(`${prefix}\\-- ... (${files.length - displayFiles.length} more files)`);
197
+ }
198
+ }
199
+ }
200
+ walk(root, 0, '');
201
+ let projectType = 'Generic project';
202
+ if (fs.existsSync(path.join(root, 'pubspec.yaml')))
203
+ projectType = 'Flutter / Dart';
204
+ else if (fs.existsSync(path.join(root, 'package.json')))
205
+ projectType = 'Node.js / TypeScript / JavaScript';
206
+ else if (fs.existsSync(path.join(root, 'Cargo.toml')))
207
+ projectType = 'Rust';
208
+ else if (fs.existsSync(path.join(root, 'pyproject.toml')) || fs.existsSync(path.join(root, 'requirements.txt')))
209
+ projectType = 'Python';
210
+ else if (fs.existsSync(path.join(root, 'go.mod')))
211
+ projectType = 'Go';
212
+ const profile = opts?.profile ?? 'standard';
213
+ let minLines = 40;
214
+ let maxFiles = 25;
215
+ let maxSymbols = 16;
216
+ if (profile === 'compact') {
217
+ minLines = 300;
218
+ maxFiles = 5;
219
+ maxSymbols = 10;
220
+ }
221
+ else if (profile === 'frontier') {
222
+ minLines = 15;
223
+ maxFiles = 60;
224
+ maxSymbols = 30;
225
+ }
226
+ const landmarks = scanLandmarks(root, minLines, maxFiles, maxSymbols, opts?.recallDir);
227
+ return {
228
+ projectType,
229
+ entryPoints: [...new Set(entryPoints)].sort(),
230
+ configFiles: [...new Set(configFiles)].sort(),
231
+ themeFiles: [...new Set(themeFiles)].sort(),
232
+ directories,
233
+ treeView: treeLines.join('\n'),
234
+ landmarks,
235
+ };
236
+ }
237
+ export function buildProjectMapMarkdown(data, maxChars = 6000) {
238
+ // Priority 1: Core Header, Entry Points, Design Tokens, Configs
239
+ const headerLines = [
240
+ '# Project Map & Topology',
241
+ '',
242
+ '> **AI AGENTS**: Use this blueprint to locate files directly. **DO NOT** execute recursive `list_dir` or crawl directories.',
243
+ '',
244
+ `**Project Type**: ${data.projectType}`,
245
+ '',
246
+ ];
247
+ if (data.entryPoints.length > 0) {
248
+ headerLines.push('## Primary Entry Points');
249
+ const shown = data.entryPoints.slice(0, 8);
250
+ for (const ep of shown)
251
+ headerLines.push(`- \`${ep}\``);
252
+ if (data.entryPoints.length > 8) {
253
+ headerLines.push(`- _... (${data.entryPoints.length - 8} more entry points)_`);
254
+ }
255
+ headerLines.push('');
256
+ }
257
+ if (data.themeFiles && data.themeFiles.length > 0) {
258
+ headerLines.push('## Design System & Theme Tokens');
259
+ headerLines.push('When modifying UI components, reuse tokens from:');
260
+ const shown = data.themeFiles.slice(0, 5);
261
+ for (const tf of shown)
262
+ headerLines.push(`- \`${tf}\``);
263
+ if (data.themeFiles.length > 5) {
264
+ headerLines.push(`- _... (${data.themeFiles.length - 5} more theme files)_`);
265
+ }
266
+ headerLines.push('');
267
+ }
268
+ if (data.configFiles.length > 0) {
269
+ headerLines.push('## Configuration Files');
270
+ const shown = data.configFiles.slice(0, 8);
271
+ for (const cf of shown)
272
+ headerLines.push(`- \`${cf}\``);
273
+ if (data.configFiles.length > 8) {
274
+ headerLines.push(`- _... (${data.configFiles.length - 8} more config files)_`);
275
+ }
276
+ headerLines.push('');
277
+ }
278
+ // Priority 2: High-Value Landmarks (Placed before directory trees so they are protected)
279
+ const landmarkSection = data.landmarks && data.landmarks.length > 0
280
+ ? `${formatLandmarksMarkdown(data.landmarks)}\n`
281
+ : '';
282
+ // Priority 3: Directory Topology Table
283
+ const dirTableLines = ['## Directory Topology'];
284
+ if (data.directories.length === 0) {
285
+ dirTableLines.push('_Flat directory structure._', '');
286
+ }
287
+ else {
288
+ dirTableLines.push('| Directory | Files | Primary Extensions |');
289
+ dirTableLines.push('| :--- | :--- | :--- |');
290
+ const displayDirs = data.directories.slice(0, 15);
291
+ for (const d of displayDirs) {
292
+ const exts = d.extensions.length > 0 ? d.extensions.join(', ') : '-';
293
+ dirTableLines.push(`| \`${d.path}/\` | ${d.filesCount} | ${exts} |`);
294
+ }
295
+ if (data.directories.length > 15) {
296
+ dirTableLines.push(`| _... (${data.directories.length - 15} more subdirectories)_ | - | _(use targeted live search)_ |`);
297
+ }
298
+ dirTableLines.push('');
299
+ }
300
+ // Priority 4: Structural Directory Tree View (Lowest priority section, dropped first on budget pressure)
301
+ const treeSection = data.treeView.trim()
302
+ ? `## Directory Tree (Top Levels)\n\`\`\`text\n${data.treeView}\n\`\`\`\n\n`
303
+ : '';
304
+ // Assembly via Priority Budgeting
305
+ let result = headerLines.join('\n') + (landmarkSection ? `${landmarkSection}\n` : '');
306
+ // Add Directory Table if it fits within maxChars (leaving at least 300 chars buffer)
307
+ const dirTableText = `${dirTableLines.join('\n')}\n`;
308
+ if (result.length + dirTableText.length < maxChars) {
309
+ result += dirTableText;
310
+ }
311
+ else {
312
+ result += '## Directory Topology\n> _[Directory table omitted to protect landmark budget. Use live targeted search.]_\n\n';
313
+ }
314
+ // Add Directory Tree View only if remaining budget permits
315
+ if (treeSection && result.length + treeSection.length < maxChars) {
316
+ result += treeSection;
317
+ }
318
+ else if (treeSection) {
319
+ const remaining = maxChars - result.length - 150;
320
+ if (remaining > 200) {
321
+ const cut = treeSection.slice(0, remaining);
322
+ const lastNl = cut.lastIndexOf('\n');
323
+ result += `${cut.slice(0, lastNl > 0 ? lastNl : cut.length)}\n\`\`\`\n> ... [Directory tree truncated to preserve landmark budget]\n\n`;
324
+ }
325
+ }
326
+ // Hard safety bound
327
+ if (result.length > maxChars) {
328
+ const cut = result.slice(0, maxChars - 160);
329
+ const lastNl = cut.lastIndexOf('\n');
330
+ result = `${cut.slice(0, lastNl > 0 ? lastNl : cut.length)}\n\n> ... [Project map capped at ${maxChars} chars to protect prompt budget.]\n`;
331
+ }
332
+ return result;
333
+ }
334
+ export const MAP_BLOCK_NAME = 'recall:project-map';
335
+ export function renderProjectMap(existing, data, profile = 'standard') {
336
+ const maxChars = profile === 'compact' ? 5600 : profile === 'frontier' ? 80000 : 24000;
337
+ const content = buildProjectMapMarkdown(data, maxChars);
338
+ const base = existing.trim() === '' ? '# Project Map\n' : existing;
339
+ return applyManagedBlock(base, content, MAP_BLOCK_NAME);
340
+ }
341
+ export function generateProjectMap(root, recallDir, profile) {
342
+ let effectiveProfile = profile ?? 'standard';
343
+ if (!profile) {
344
+ try {
345
+ effectiveProfile = loadConfig(recallDir).profile ?? 'standard';
346
+ }
347
+ catch {
348
+ effectiveProfile = 'standard';
349
+ }
350
+ }
351
+ const data = scanProject(root, 3, { profile: effectiveProfile, recallDir });
352
+ const file = resolveInside(recallDir, 'PROJECT_MAP.md');
353
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
354
+ const rendered = renderProjectMap(existing, data, effectiveProfile);
355
+ atomicWrite(file, rendered);
356
+ return file;
357
+ }
@@ -0,0 +1,44 @@
1
+ import { type Lifecycle, type MemoryMeta, type MemoryType, type Verification } from '../schemas/index.js';
2
+ export interface Memory extends MemoryMeta {
3
+ body: string;
4
+ /** Path relative to the .recall directory, POSIX separators. */
5
+ file: string;
6
+ }
7
+ export interface Problem {
8
+ file: string;
9
+ error: string;
10
+ }
11
+ export interface LoadResult {
12
+ memories: Memory[];
13
+ /** Plain Markdown docs in rules/ with no frontmatter (hand-written rules). */
14
+ plainDocs: string[];
15
+ problems: Problem[];
16
+ }
17
+ export declare const TYPE_DIR: Record<MemoryType, string>;
18
+ export declare const MEMORY_DIRS: readonly ["rules", "decisions", "lessons", "tasks"];
19
+ export declare function slugify(title: string): string;
20
+ export declare function loadAll(recallDir: string): LoadResult;
21
+ export interface NewMemory {
22
+ title: string;
23
+ text: string;
24
+ type?: MemoryType;
25
+ tags?: string[];
26
+ sources?: string[];
27
+ status?: Lifecycle;
28
+ }
29
+ export declare function createMemory(recallDir: string, input: NewMemory): Memory;
30
+ export declare function findMemory(recallDir: string, id: string): Memory;
31
+ export interface Patch {
32
+ status?: Lifecycle;
33
+ verification?: Verification;
34
+ }
35
+ export declare function updateMemory(recallDir: string, id: string, patch: Patch): Memory;
36
+ /**
37
+ * Replace a memory's text in place (same id and file). Latest knowledge wins so memory stays small;
38
+ * tags and sources are merged. Git history keeps the previous wording.
39
+ */
40
+ export declare function rewriteMemory(recallDir: string, id: string, input: NewMemory): Memory;
41
+ export declare function appendChangelog(recallDir: string, action: string, m: {
42
+ id: string;
43
+ title: string;
44
+ }): void;
@@ -0,0 +1,199 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import matter from 'gray-matter';
4
+ import { memoryMetaSchema, } from '../schemas/index.js';
5
+ import { RecallError, atomicWrite, resolveInside, toPosix, today } from '../utils/fs.js';
6
+ import { assertNoSecrets } from '../utils/secrets.js';
7
+ export const TYPE_DIR = {
8
+ decision: 'decisions',
9
+ lesson: 'lessons',
10
+ bugfix: 'lessons',
11
+ failed_approach: 'lessons',
12
+ task: 'tasks',
13
+ fact: 'rules',
14
+ convention: 'rules',
15
+ design_system: 'rules',
16
+ };
17
+ export const MEMORY_DIRS = ['rules', 'decisions', 'lessons', 'tasks'];
18
+ export function slugify(title) {
19
+ const s = title
20
+ .normalize('NFKC')
21
+ .toLowerCase()
22
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
23
+ .replace(/^-+|-+$/g, '')
24
+ .slice(0, 48)
25
+ .replace(/-+$/g, '');
26
+ return s || 'memory';
27
+ }
28
+ /** Only regular .md files directly in a memory dir; symlinks are never followed. */
29
+ function listMarkdown(recallDir, dir) {
30
+ const abs = path.join(recallDir, dir);
31
+ if (!fs.existsSync(abs))
32
+ return [];
33
+ return fs
34
+ .readdirSync(abs, { withFileTypes: true })
35
+ .filter((d) => d.isFile() && !d.isSymbolicLink() && d.name.toLowerCase().endsWith('.md'))
36
+ .map((d) => d.name)
37
+ .sort();
38
+ }
39
+ export function loadAll(recallDir) {
40
+ const result = { memories: [], plainDocs: [], problems: [] };
41
+ for (const dir of MEMORY_DIRS) {
42
+ for (const name of listMarkdown(recallDir, dir)) {
43
+ const rel = `${dir}/${name}`;
44
+ let raw;
45
+ try {
46
+ raw = fs.readFileSync(resolveInside(recallDir, dir, name), 'utf8');
47
+ }
48
+ catch (err) {
49
+ result.problems.push({ file: rel, error: err.message });
50
+ continue;
51
+ }
52
+ let parsed;
53
+ try {
54
+ parsed = matter(raw);
55
+ }
56
+ catch (err) {
57
+ result.problems.push({ file: rel, error: `Invalid frontmatter YAML: ${err.message.split('\n')[0]}` });
58
+ continue;
59
+ }
60
+ if (Object.keys(parsed.data).length === 0) {
61
+ if (dir === 'rules')
62
+ result.plainDocs.push(rel);
63
+ else
64
+ result.problems.push({ file: rel, error: 'Missing frontmatter.' });
65
+ continue;
66
+ }
67
+ const meta = memoryMetaSchema.safeParse(parsed.data);
68
+ if (!meta.success) {
69
+ const msg = meta.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
70
+ result.problems.push({ file: rel, error: `Invalid metadata: ${msg}` });
71
+ continue;
72
+ }
73
+ result.memories.push({ ...meta.data, body: parsed.content.trim(), file: rel });
74
+ }
75
+ }
76
+ const seen = new Map();
77
+ for (const m of result.memories) {
78
+ const prev = seen.get(m.id);
79
+ if (prev)
80
+ result.problems.push({ file: m.file, error: `Duplicate id ${m.id} (also in ${prev}).` });
81
+ else
82
+ seen.set(m.id, m.file);
83
+ }
84
+ result.memories.sort((a, b) => a.id.localeCompare(b.id));
85
+ return result;
86
+ }
87
+ function maxIdNumber(recallDir, loaded) {
88
+ let max = 0;
89
+ for (const m of loaded)
90
+ max = Math.max(max, Number(m.id.slice(4)));
91
+ // Also consider ids embedded in filenames so malformed files never cause id reuse.
92
+ for (const dir of MEMORY_DIRS) {
93
+ for (const name of listMarkdown(recallDir, dir)) {
94
+ const hit = /^mem_(\d+)/.exec(name);
95
+ if (hit)
96
+ max = Math.max(max, Number(hit[1]));
97
+ }
98
+ }
99
+ return max;
100
+ }
101
+ function serialize(meta, body) {
102
+ const ordered = {
103
+ id: meta.id,
104
+ title: meta.title,
105
+ type: meta.type,
106
+ created: meta.created,
107
+ updated: meta.updated,
108
+ tags: meta.tags,
109
+ status: meta.status,
110
+ verification: meta.verification,
111
+ ...(meta.sources.length > 0 ? { sources: meta.sources } : {}),
112
+ };
113
+ return matter.stringify(`\n${body.trim()}\n`, ordered);
114
+ }
115
+ export function createMemory(recallDir, input) {
116
+ const title = input.title.trim();
117
+ const text = input.text.trim();
118
+ if (!title)
119
+ throw new RecallError('A memory needs a non-empty title.');
120
+ if (!text)
121
+ throw new RecallError('A memory needs non-empty text.');
122
+ assertNoSecrets(`${title}\n${text}`);
123
+ const type = input.type ?? 'fact';
124
+ const dir = TYPE_DIR[type];
125
+ const loaded = loadAll(recallDir).memories;
126
+ let n = maxIdNumber(recallDir, loaded) + 1;
127
+ for (let attempt = 0; attempt < 100; attempt++, n++) {
128
+ const id = `mem_${String(n).padStart(3, '0')}`;
129
+ const date = today();
130
+ const meta = memoryMetaSchema.parse({
131
+ id,
132
+ title,
133
+ type,
134
+ created: date,
135
+ updated: date,
136
+ tags: [...new Set((input.tags ?? []).map((t) => t.trim()).filter(Boolean))],
137
+ status: input.status ?? (process.env.RECALL_AGENT === '1' ? 'proposed' : 'active'),
138
+ verification: 'unverified',
139
+ sources: input.sources ?? [],
140
+ });
141
+ const name = `${id}-${slugify(title)}.md`;
142
+ const abs = resolveInside(recallDir, dir, name);
143
+ const body = text.startsWith('#') ? text : `# ${title}\n\n${text}`;
144
+ try {
145
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
146
+ fs.writeFileSync(abs, serialize(meta, body), { encoding: 'utf8', flag: 'wx' });
147
+ }
148
+ catch (err) {
149
+ if (err.code === 'EEXIST')
150
+ continue;
151
+ throw err;
152
+ }
153
+ return { ...meta, body, file: toPosix(path.join(dir, name)) };
154
+ }
155
+ throw new RecallError('Could not allocate a unique memory id.');
156
+ }
157
+ export function findMemory(recallDir, id) {
158
+ const m = loadAll(recallDir).memories.find((x) => x.id === id);
159
+ if (!m)
160
+ throw new RecallError(`No valid memory with id ${id}.`, 'Run `recall status` or `recall doctor` to inspect memory health.');
161
+ return m;
162
+ }
163
+ export function updateMemory(recallDir, id, patch) {
164
+ const m = findMemory(recallDir, id);
165
+ const next = { ...m, ...patch, updated: today() };
166
+ const abs = resolveInside(recallDir, ...m.file.split('/'));
167
+ atomicWrite(abs, serialize(next, m.body));
168
+ return { ...next, body: m.body, file: m.file };
169
+ }
170
+ /**
171
+ * Replace a memory's text in place (same id and file). Latest knowledge wins so memory stays small;
172
+ * tags and sources are merged. Git history keeps the previous wording.
173
+ */
174
+ export function rewriteMemory(recallDir, id, input) {
175
+ const m = findMemory(recallDir, id);
176
+ const title = input.title.trim() || m.title;
177
+ const text = input.text.trim();
178
+ if (!text)
179
+ throw new RecallError('A memory needs non-empty text.');
180
+ assertNoSecrets(`${title}\n${text}`);
181
+ const next = memoryMetaSchema.parse({
182
+ ...m,
183
+ title,
184
+ updated: today(),
185
+ tags: [...new Set([...m.tags, ...(input.tags ?? []).map((t) => t.trim()).filter(Boolean)])],
186
+ sources: [...new Set([...m.sources, ...(input.sources ?? [])])],
187
+ status: input.status ?? m.status,
188
+ verification: 'unverified',
189
+ });
190
+ const body = text.startsWith('#') ? text : `# ${title}\n\n${text}`;
191
+ atomicWrite(resolveInside(recallDir, ...m.file.split('/')), serialize(next, body));
192
+ return { ...next, body, file: m.file };
193
+ }
194
+ export function appendChangelog(recallDir, action, m) {
195
+ const file = resolveInside(recallDir, 'logs', 'CHANGELOG.md');
196
+ const line = `- ${today()} ${action} ${m.id} "${m.title.replace(/\s+/g, ' ')}"\n`;
197
+ const prev = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '# Recall changelog\n\n';
198
+ atomicWrite(file, prev.endsWith('\n') ? prev + line : `${prev}\n${line}`);
199
+ }
@@ -0,0 +1,12 @@
1
+ export interface Project {
2
+ /** Directory that contains (or will contain) `.recall/`. */
3
+ root: string;
4
+ recallDir: string;
5
+ initialized: boolean;
6
+ /** How the root was found. */
7
+ source: 'recall' | 'git' | 'cwd';
8
+ }
9
+ export declare const RECALL_DIR = ".recall";
10
+ /** Nearest Recall config first, then nearest Git root, then the start directory. */
11
+ export declare function findProject(start?: string): Project;
12
+ export declare function requireInitialized(start?: string): Project;
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { RecallError, resolveInside } from '../utils/fs.js';
4
+ export const RECALL_DIR = '.recall';
5
+ function walkUp(start, test) {
6
+ let dir = path.resolve(start);
7
+ for (;;) {
8
+ if (test(dir))
9
+ return dir;
10
+ const parent = path.dirname(dir);
11
+ if (parent === dir)
12
+ return null;
13
+ dir = parent;
14
+ }
15
+ }
16
+ /** Nearest Recall config first, then nearest Git root, then the start directory. */
17
+ export function findProject(start = process.cwd()) {
18
+ const recallRoot = walkUp(start, (d) => fs.existsSync(path.join(d, RECALL_DIR, 'config.json')));
19
+ if (recallRoot) {
20
+ return { root: recallRoot, recallDir: path.join(recallRoot, RECALL_DIR), initialized: true, source: 'recall' };
21
+ }
22
+ const gitRoot = walkUp(start, (d) => fs.existsSync(path.join(d, '.git')));
23
+ const root = gitRoot ?? path.resolve(start);
24
+ return { root, recallDir: path.join(root, RECALL_DIR), initialized: false, source: gitRoot ? 'git' : 'cwd' };
25
+ }
26
+ export function requireInitialized(start) {
27
+ const p = findProject(start);
28
+ if (!p.initialized) {
29
+ throw new RecallError('Recall is not initialized in this project.', 'Run `recall init` first.');
30
+ }
31
+ // Refuse a .recall that is a symlink pointing elsewhere.
32
+ resolveInside(p.root, RECALL_DIR);
33
+ return p;
34
+ }
@@ -0,0 +1,27 @@
1
+ import type { MemoryType } from '../schemas/index.js';
2
+ import type { Memory } from './memory.js';
3
+ export interface SearchOptions {
4
+ limit?: number;
5
+ includeProposed?: boolean;
6
+ includeArchived?: boolean;
7
+ type?: MemoryType;
8
+ tag?: string;
9
+ minScore?: number;
10
+ }
11
+ export interface SearchHit {
12
+ memory: Memory;
13
+ score: number;
14
+ excerpt: string;
15
+ }
16
+ export declare function stemCandidates(word: string): string[];
17
+ export declare function stem(word: string): string;
18
+ export declare function tokenize(text: string): string[];
19
+ /** Deterministic keyword search. This is NOT semantic search. */
20
+ export declare function search(memories: Memory[], query: string, opts?: SearchOptions): SearchHit[];
21
+ /** Default cap for agent-facing context output: ~400 tokens at ~4 chars/token. */
22
+ export declare const CONTEXT_MAX_CHARS = 1600;
23
+ /**
24
+ * Agent-facing rendering: one header line + one excerpt line per hit, hard-capped.
25
+ * Hits scoring under 30% of the best are dropped: they cost tokens and rarely help.
26
+ */
27
+ export declare function formatHitsCompact(hits: SearchHit[], maxChars?: number): string;