@staix/agent-mem 0.7.0 → 0.8.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/.claude-plugin/plugin.json +1 -1
- package/README.md +10 -2
- package/package.json +1 -1
- package/src/cli.ts +72 -11
- package/src/commands-table.ts +14 -8
- package/src/index.ts +6 -0
- package/src/mcp-memory.ts +28 -1
- package/src/mcp.ts +1 -0
- package/src/memory/candidates.ts +86 -2
- package/src/memory/consolidate.ts +78 -0
- package/src/memory/consolidation-clusters.ts +56 -0
- package/src/memory/consolidation-model.ts +97 -0
- package/src/memory/doctor.ts +11 -8
- package/src/memory/eval-fixture.ts +8 -0
- package/src/memory/eval-set-ko.json +90 -0
- package/src/memory/eval.ts +5 -3
- package/src/memory/evidence.ts +76 -0
- package/src/memory/external-import.ts +267 -0
- package/src/memory/platform.ts +12 -1
- package/src/memory/project-export.ts +57 -22
- package/src/memory/project-import.ts +147 -0
- package/src/memory/store.ts +63 -9
- package/src/memory/sync-cadence.ts +15 -5
- package/src/memory/sync.ts +16 -4
- package/src/setup-toml.ts +5 -0
- package/src/setup.ts +6 -6
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, extname, join, relative, sep } from "node:path";
|
|
4
|
+
import { aliasedKeys, resolveAlias } from "./aliases.ts";
|
|
5
|
+
import { CandidateQueueFull, CandidateStore, candidateInputId, type CandidateInput } from "./candidates.ts";
|
|
6
|
+
import { pendingSummary, type PendingSummary } from "./candidate-pending.ts";
|
|
7
|
+
import { realPath } from "./platform.ts";
|
|
8
|
+
import { stripPrivateDeep } from "./private-spans.ts";
|
|
9
|
+
import { looksSecret } from "./screen.ts";
|
|
10
|
+
|
|
11
|
+
/** Discovery and read bounds for one import: at most 256 files, 512 directories, 512 entries per directory, 16 directory levels, 1 MiB per file and 100 new candidates. */
|
|
12
|
+
export const EXTERNAL_IMPORT_LIMITS = { maxFiles: 256, maxDirectories: 512, maxEntriesPerDirectory: 512, maxDepth: 16, maxFileBytes: 1024 * 1024, maxCandidatesPerRun: 100, candidateTextChars: 3000, titleChars: 200 } as const;
|
|
13
|
+
|
|
14
|
+
export type ExternalMemorySource = "claude-auto" | "codex";
|
|
15
|
+
export interface ExternalImportResult {
|
|
16
|
+
source: ExternalMemorySource;
|
|
17
|
+
project: string;
|
|
18
|
+
files: number;
|
|
19
|
+
read: number;
|
|
20
|
+
added: number;
|
|
21
|
+
queueFull: boolean;
|
|
22
|
+
limitReached: boolean;
|
|
23
|
+
skipped: { rules: number; policy: number; invalid: number; truncated: number };
|
|
24
|
+
pending: PendingSummary;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type SourceFile = { path: string; relative: string };
|
|
28
|
+
type RawRecord = { text: string; title?: string; line?: number };
|
|
29
|
+
type Discovery = { files: SourceFile[]; rules: number };
|
|
30
|
+
|
|
31
|
+
function entryKind(path: string): "file" | "directory" | "symlink" | "other" | undefined {
|
|
32
|
+
try {
|
|
33
|
+
const stat = lstatSync(path);
|
|
34
|
+
if (stat.isSymbolicLink()) return "symlink";
|
|
35
|
+
if (stat.isFile()) return "file";
|
|
36
|
+
if (stat.isDirectory()) return "directory";
|
|
37
|
+
return "other";
|
|
38
|
+
} catch { return undefined; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sourceRoot(path: string): string | undefined {
|
|
42
|
+
if (entryKind(path) !== "directory") return undefined;
|
|
43
|
+
try {
|
|
44
|
+
const root = realPath(path);
|
|
45
|
+
for (let dir = root;; dir = dirname(dir)) {
|
|
46
|
+
if (entryKind(dir) !== "directory") return undefined;
|
|
47
|
+
const parent = dirname(dir);
|
|
48
|
+
if (parent === dir) return root;
|
|
49
|
+
}
|
|
50
|
+
} catch { return undefined; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function names(path: string): string[] {
|
|
54
|
+
let dir;
|
|
55
|
+
try {
|
|
56
|
+
dir = opendirSync(path);
|
|
57
|
+
const output: string[] = [];
|
|
58
|
+
while (output.length < EXTERNAL_IMPORT_LIMITS.maxEntriesPerDirectory) {
|
|
59
|
+
const entry = dir.readSync();
|
|
60
|
+
if (!entry) break;
|
|
61
|
+
output.push(entry.name);
|
|
62
|
+
}
|
|
63
|
+
return output;
|
|
64
|
+
} catch { return []; }
|
|
65
|
+
finally { dir?.closeSync(); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ruleFile(path: string): boolean {
|
|
69
|
+
const name = basename(path).toLowerCase();
|
|
70
|
+
return name === "claude.md" || name === "agents.md" || name === "agents.override.md" || name.endsWith(".local.md");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function useful(path: string, source: ExternalMemorySource): boolean {
|
|
74
|
+
const extension = extname(path).toLowerCase();
|
|
75
|
+
return source === "claude-auto" ? extension === ".md" : extension === ".md" || extension === ".txt" || extension === ".jsonl";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function discoverClaude(root: string, projectDirectory?: string): Discovery {
|
|
79
|
+
const files: SourceFile[] = [];
|
|
80
|
+
let rules = 0, directories = 1;
|
|
81
|
+
for (const project of projectDirectory === undefined ? names(root) : [projectDirectory]) {
|
|
82
|
+
if (directories >= EXTERNAL_IMPORT_LIMITS.maxDirectories || files.length >= EXTERNAL_IMPORT_LIMITS.maxFiles) break;
|
|
83
|
+
const projectPath = join(root, project), projectKind = entryKind(projectPath);
|
|
84
|
+
if (projectKind === "symlink") { rules++; continue; }
|
|
85
|
+
if (projectKind !== "directory") continue;
|
|
86
|
+
directories++;
|
|
87
|
+
const memoryPath = join(projectPath, "memory"), memoryKind = entryKind(memoryPath);
|
|
88
|
+
if (memoryKind === "symlink") { rules++; continue; }
|
|
89
|
+
if (memoryKind !== "directory") continue;
|
|
90
|
+
if (directories >= EXTERNAL_IMPORT_LIMITS.maxDirectories) break;
|
|
91
|
+
directories++;
|
|
92
|
+
for (const name of names(memoryPath)) {
|
|
93
|
+
if (files.length >= EXTERNAL_IMPORT_LIMITS.maxFiles) break;
|
|
94
|
+
const path = join(memoryPath, name), kind = entryKind(path);
|
|
95
|
+
if (kind === "symlink") { rules++; continue; }
|
|
96
|
+
if (kind !== "file" || !useful(path, "claude-auto")) continue;
|
|
97
|
+
if (ruleFile(path)) { rules++; continue; }
|
|
98
|
+
files.push({ path, relative: [project, "memory", name].join("/") });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { files, rules };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function discoverCodex(root: string): Discovery {
|
|
105
|
+
const files: SourceFile[] = [];
|
|
106
|
+
let rules = 0, directories = 0;
|
|
107
|
+
const pending: { path: string; relative: string; depth: number }[] = [{ path: root, relative: "", depth: 0 }];
|
|
108
|
+
while (pending.length && directories < EXTERNAL_IMPORT_LIMITS.maxDirectories && files.length < EXTERNAL_IMPORT_LIMITS.maxFiles) {
|
|
109
|
+
const current = pending.shift()!;
|
|
110
|
+
if (entryKind(current.path) !== "directory") continue;
|
|
111
|
+
directories++;
|
|
112
|
+
const directoriesToAdd: { path: string; relative: string; depth: number }[] = [];
|
|
113
|
+
for (const name of names(current.path)) {
|
|
114
|
+
if (files.length >= EXTERNAL_IMPORT_LIMITS.maxFiles) break;
|
|
115
|
+
const path = join(current.path, name), relative = current.relative ? `${current.relative}/${name}` : name;
|
|
116
|
+
const kind = entryKind(path);
|
|
117
|
+
if (kind === "symlink") { rules++; continue; }
|
|
118
|
+
if (kind === "directory") {
|
|
119
|
+
if (basename(path).toLowerCase() === "rules") { rules++; continue; }
|
|
120
|
+
if (current.depth < EXTERNAL_IMPORT_LIMITS.maxDepth) directoriesToAdd.push({ path, relative, depth: current.depth + 1 });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (kind !== "file" || !useful(path, "codex")) continue;
|
|
124
|
+
if (ruleFile(path)) { rules++; continue; }
|
|
125
|
+
files.push({ path, relative });
|
|
126
|
+
}
|
|
127
|
+
pending.push(...directoriesToAdd);
|
|
128
|
+
}
|
|
129
|
+
return { files, rules };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readBounded(path: string, root: string): { text: string; truncated: boolean } | undefined {
|
|
133
|
+
const tail = relative(root, path);
|
|
134
|
+
if (!tail || tail === ".." || tail.startsWith(`..${sep}`) || entryKind(path) !== "file") return undefined;
|
|
135
|
+
for (let dir = dirname(path);; dir = dirname(dir)) {
|
|
136
|
+
if (entryKind(dir) !== "directory") return undefined;
|
|
137
|
+
if (dir === root) break;
|
|
138
|
+
if (dir === dirname(dir)) return undefined;
|
|
139
|
+
}
|
|
140
|
+
let fd: number | undefined;
|
|
141
|
+
try {
|
|
142
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
143
|
+
if (!fstatSync(fd).isFile()) return undefined;
|
|
144
|
+
const buffer = Buffer.alloc(EXTERNAL_IMPORT_LIMITS.maxFileBytes + 1);
|
|
145
|
+
let read = 0;
|
|
146
|
+
while (read < buffer.length) {
|
|
147
|
+
const count = readSync(fd, buffer, read, buffer.length - read, read);
|
|
148
|
+
if (!count) break;
|
|
149
|
+
read += count;
|
|
150
|
+
}
|
|
151
|
+
return { text: buffer.subarray(0, Math.min(read, EXTERNAL_IMPORT_LIMITS.maxFileBytes)).toString("utf8"), truncated: read > EXTERNAL_IMPORT_LIMITS.maxFileBytes };
|
|
152
|
+
} catch { return undefined; }
|
|
153
|
+
finally { if (fd !== undefined) try { closeSync(fd); } catch { /* already closed */ } }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function records(file: SourceFile, text: string): (RawRecord | undefined)[] {
|
|
157
|
+
if (extname(file.path).toLowerCase() !== ".jsonl") return [text.trim() && !text.includes("\0") ? { text } : undefined];
|
|
158
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
159
|
+
if (lines.at(-1) === "") lines.pop();
|
|
160
|
+
return lines.map((line, index): RawRecord | undefined => {
|
|
161
|
+
try {
|
|
162
|
+
const value: unknown = JSON.parse(line);
|
|
163
|
+
if (typeof value === "string") return value.trim() && !value.includes("\0") ? { text: value, line: index + 1 } : undefined;
|
|
164
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
165
|
+
const record = value as { text?: unknown; title?: unknown };
|
|
166
|
+
if (typeof record.text !== "string" || !record.text.trim() || record.text.includes("\0") || record.title !== undefined && typeof record.title !== "string") return undefined;
|
|
167
|
+
return { text: record.text, ...(typeof record.title === "string" ? { title: record.title } : {}), line: index + 1 };
|
|
168
|
+
} catch { return undefined; }
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function chunks(text: string): string[] {
|
|
173
|
+
const output: string[] = [];
|
|
174
|
+
let rest = text.trim();
|
|
175
|
+
while (rest.length > EXTERNAL_IMPORT_LIMITS.candidateTextChars) {
|
|
176
|
+
let end = EXTERNAL_IMPORT_LIMITS.candidateTextChars;
|
|
177
|
+
const newline = rest.lastIndexOf("\n", end);
|
|
178
|
+
if (newline >= Math.floor(end / 2)) end = newline + 1;
|
|
179
|
+
if (end > 0 && /[\uD800-\uDBFF]/.test(rest[end - 1]!)) end--;
|
|
180
|
+
const chunk = rest.slice(0, end).trim();
|
|
181
|
+
if (chunk) output.push(chunk);
|
|
182
|
+
rest = rest.slice(end).trimStart();
|
|
183
|
+
}
|
|
184
|
+
if (rest) output.push(rest);
|
|
185
|
+
return output;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function cleanTitle(text: string): string {
|
|
189
|
+
return text.replace(/[\x00-\x1f\x7f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function boundedTitle(text: string): string {
|
|
193
|
+
const clean = cleanTitle(text), max = EXTERNAL_IMPORT_LIMITS.titleChars;
|
|
194
|
+
if (clean.length <= max) return clean;
|
|
195
|
+
let left = 80, right = max - left - 3;
|
|
196
|
+
if (/[\uD800-\uDBFF]/.test(clean[left - 1]!)) left--;
|
|
197
|
+
let tail = clean.length - right;
|
|
198
|
+
if (tail > 0 && /[\uDC00-\uDFFF]/.test(clean[tail]!)) tail++;
|
|
199
|
+
return `${clean.slice(0, left)}...${clean.slice(tail)}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function title(source: ExternalMemorySource, file: SourceFile, record: RawRecord, part: number, parts: number): string {
|
|
203
|
+
const location = `${file.relative}${record.line === undefined ? "" : `#${record.line}`}`;
|
|
204
|
+
const suffix = parts > 1 ? ` (part ${part + 1}/${parts})` : "";
|
|
205
|
+
return boundedTitle(`${source}: ${location}${record.title?.trim() ? `: ${record.title}` : ""}${suffix}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function portable(path: string): string { return path.split(sep).join("/"); }
|
|
209
|
+
function claudeProjectDirectory(cwd: string): string { return realPath(cwd).replace(/[/.]/g, "-"); }
|
|
210
|
+
|
|
211
|
+
export async function importExternalMemory(opts: {
|
|
212
|
+
base: string;
|
|
213
|
+
project: string;
|
|
214
|
+
source: ExternalMemorySource;
|
|
215
|
+
from?: string;
|
|
216
|
+
cwd?: string;
|
|
217
|
+
env?: NodeJS.ProcessEnv;
|
|
218
|
+
policy: (value: unknown) => boolean | Promise<boolean>;
|
|
219
|
+
}): Promise<ExternalImportResult> {
|
|
220
|
+
if (opts.source !== "claude-auto" && opts.source !== "codex") throw new Error("unknown external memory source");
|
|
221
|
+
const env = opts.env ?? process.env, home = env.HOME || homedir();
|
|
222
|
+
const configured = opts.source === "claude-auto" ? env.CLAUDE_CONFIG_DIR : env.CODEX_HOME;
|
|
223
|
+
const source = opts.from ?? (configured?.trim() ? join(configured, opts.source === "claude-auto" ? "projects" : "memories") : join(home, opts.source === "claude-auto" ? ".claude/projects" : ".codex/memories"));
|
|
224
|
+
const root = sourceRoot(source), project = resolveAlias(opts.base, opts.project), aliases = aliasedKeys(opts.base, project);
|
|
225
|
+
const projectDirectory = opts.from === undefined && opts.source === "claude-auto" ? claudeProjectDirectory(opts.cwd ?? process.cwd()) : undefined;
|
|
226
|
+
const discovery = root ? opts.source === "claude-auto" ? discoverClaude(root, projectDirectory) : discoverCodex(root) : { files: [], rules: 0 };
|
|
227
|
+
const result: ExternalImportResult = { source: opts.source, project, files: 0, read: 0, added: 0, queueFull: false, limitReached: false, skipped: { rules: discovery.rules, policy: 0, invalid: 0, truncated: 0 }, pending: { count: 0, oldestExpiresInDays: null, dueWithin7Days: 0, unshown: 0 } };
|
|
228
|
+
const store = new CandidateStore({ memoryDir: opts.base, reconcile: { canonical: project, aliases } });
|
|
229
|
+
try {
|
|
230
|
+
for (const file of discovery.files) {
|
|
231
|
+
result.files++;
|
|
232
|
+
const input = readBounded(file.path, root!);
|
|
233
|
+
if (!input) { result.skipped.invalid++; continue; }
|
|
234
|
+
if (input.truncated) result.skipped.truncated++;
|
|
235
|
+
for (const sourceRecord of records(file, input.text)) {
|
|
236
|
+
result.read++;
|
|
237
|
+
if (!sourceRecord) { result.skipped.invalid++; continue; }
|
|
238
|
+
const raw = stripPrivateDeep(sourceRecord);
|
|
239
|
+
if (!raw.text.trim() || raw.title !== undefined && !raw.title.trim()) { result.skipped.invalid++; continue; }
|
|
240
|
+
const sourceFile = { ...file, relative: portable(file.relative) };
|
|
241
|
+
const whole: CandidateInput = { project, kind: "memory", text: raw.text, title: title(opts.source, sourceFile, raw, 0, 1), noteKind: "finding" };
|
|
242
|
+
if (looksSecret(`${whole.title ?? ""}\n${whole.text}`) || !await opts.policy(whole)) { result.skipped.policy++; continue; }
|
|
243
|
+
const parts = chunks(raw.text);
|
|
244
|
+
if (!parts.length) { result.skipped.invalid++; continue; }
|
|
245
|
+
for (let index = 0; index < parts.length; index++) {
|
|
246
|
+
if (result.added >= EXTERNAL_IMPORT_LIMITS.maxCandidatesPerRun) { result.limitReached = true; break; }
|
|
247
|
+
const record = stripPrivateDeep<CandidateInput>({ project, kind: "memory", text: parts[index]!, title: title(opts.source, sourceFile, raw, index, parts.length), noteKind: "finding" });
|
|
248
|
+
if (record.kind !== "memory" || !record.text.trim() || record.title !== undefined && !record.title.trim()) { result.skipped.invalid++; continue; }
|
|
249
|
+
if (looksSecret(`${record.title ?? ""}\n${record.text}`) || !await opts.policy(record)) { result.skipped.policy++; continue; }
|
|
250
|
+
const id = candidateInputId(record);
|
|
251
|
+
if (store.get(id, project)) continue;
|
|
252
|
+
try { store.put(record, "public"); }
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (!(error instanceof CandidateQueueFull)) throw error;
|
|
255
|
+
result.queueFull = true;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
result.added++;
|
|
259
|
+
}
|
|
260
|
+
if (result.queueFull || result.limitReached) break;
|
|
261
|
+
}
|
|
262
|
+
if (result.queueFull || result.limitReached) break;
|
|
263
|
+
}
|
|
264
|
+
} finally { store.close(); }
|
|
265
|
+
result.pending = pendingSummary(opts.base, [project, ...aliases], Date.now());
|
|
266
|
+
return result;
|
|
267
|
+
}
|
package/src/memory/platform.ts
CHANGED
|
@@ -99,7 +99,18 @@ export function memoryGitEnv(env: NodeJS.ProcessEnv = process.env, options: { re
|
|
|
99
99
|
/** spawnSync form of the same call: the env option is merged with memoryGitEnv, everything else passes through. */
|
|
100
100
|
export function memoryGitSync(args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string> {
|
|
101
101
|
const argv = memoryGitArgv(args);
|
|
102
|
-
return childProcess().spawnSync(argv[0]!, argv.slice(1), { ...options, env: memoryGitEnv(options.env ?? process.env) });
|
|
102
|
+
return childProcess().spawnSync(argv[0]!, argv.slice(1), { ...(ambientGitTimeout !== undefined ? { timeout: ambientGitTimeout, killSignal: "SIGKILL" as const } : {}), ...options, env: memoryGitEnv(options.env ?? process.env) });
|
|
103
|
+
}
|
|
104
|
+
let ambientGitTimeout: number | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* Runs `work` with a timeout on every `memoryGitSync` call that sets none of its own: a synchronous git call blocks the event loop, so a signal sent to this process alone waits for it.
|
|
107
|
+
* A call that times out reads as a failed git (`error` set, no status), which every caller already treats as "unknown". The setting is process-wide while `work` runs, so use it only in a
|
|
108
|
+
* command that does nothing else at the same time (`amem doctor`).
|
|
109
|
+
* ponytail: one global instead of a parameter on every caller between doctor and its git calls (project key, repository root, visibility). Upgrade: thread an options argument through them.
|
|
110
|
+
*/
|
|
111
|
+
export async function withGitTimeout<T>(ms: number, work: () => Promise<T>): Promise<T> {
|
|
112
|
+
const prior = ambientGitTimeout; ambientGitTimeout = ms;
|
|
113
|
+
try { return await work(); } finally { ambientGitTimeout = prior; }
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
/**
|
|
@@ -11,14 +11,14 @@ import { aliasedKeys, shardDir } from "./shard.ts";
|
|
|
11
11
|
import { WorkListStore } from "./work-lists.ts";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* #15 T5 / D3: `amem project export <key> --to <file>`. One project's capture rows, curated notes, goals and work lists as
|
|
15
|
-
* written once to a new file with mode 0600. It changes no content file of the home: it opens the capture databases read-only and the curated store only to
|
|
14
|
+
* #15 T5 / D3 and F17: `amem project export <key> --to <file>`. One project's capture rows, curated notes, goals and work lists as a portable JSON bundle,
|
|
15
|
+
* JSONL stream or Markdown document, written once to a new file with mode 0600. It changes no content file of the home: it opens the capture databases read-only and the curated store only to
|
|
16
16
|
* read, and only derived files (the curated `index.db`, `search.db`, SQLite sidecars) may refresh, as they do for any read. A destination inside the home is refused.
|
|
17
17
|
*
|
|
18
18
|
* The bundle leaves the home, so it is screened like sync and promotion are: a secret-looking value is withheld, and so is anything the host policy (when
|
|
19
19
|
* one is passed) declines. `withheld` counts the capture rows, notes and goals left out; a work list the policy declines is left out silently by its own store.
|
|
20
|
-
* A forgotten project cannot be exported (the person's decision stands); an archived one can.
|
|
21
|
-
*
|
|
20
|
+
* A forgotten project cannot be exported (the person's decision stands); an archived one can. A failure removes the partial file it created.
|
|
21
|
+
* ponytail: JSONL capture, goal and work-list rows stream, but curated authored rows are materialized to preserve ids. Add a bounded authored-row iterator before raising shard limits.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
export const EXPORT_SCHEMA = "agent-mem.project-export/v1";
|
|
@@ -27,8 +27,10 @@ export const EXPORT_SCHEMA = "agent-mem.project-export/v1";
|
|
|
27
27
|
* the file in the gap between them, which is the only way to show that O_EXCL, not the check, keeps an export from replacing someone else's file.
|
|
28
28
|
*/
|
|
29
29
|
export const exportHooks: { afterCheck?: (file: string) => void } = {};
|
|
30
|
+
export type ProjectExportFormat = "json" | "jsonl" | "markdown";
|
|
30
31
|
export interface ExportCounts { capture: number; curated: number; goals: number; workLists: number }
|
|
31
|
-
export interface ExportResult { file: string; project: string; counts: ExportCounts; withheld: number }
|
|
32
|
+
export interface ExportResult { file: string; project: string; format: ProjectExportFormat; counts: ExportCounts; withheld: number }
|
|
33
|
+
type ExportSection = keyof ExportCounts;
|
|
32
34
|
const PAGE = 500;
|
|
33
35
|
|
|
34
36
|
interface CaptureRowRaw { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string }
|
|
@@ -47,8 +49,25 @@ function destination(base: string, to: string): string {
|
|
|
47
49
|
return join(parentReal, name);
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
function markdownInline(text: string): string {
|
|
53
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function markdownRecord(section: ExportSection, value: unknown): string {
|
|
57
|
+
const row = value as Record<string, unknown>;
|
|
58
|
+
const body = section === "capture" ? row.excerpt : section === "curated" ? row.text : undefined;
|
|
59
|
+
const identity = typeof row.id === "string" ? ` ${row.id}` : "";
|
|
60
|
+
const metadata = { ...row };
|
|
61
|
+
if (section === "capture") delete metadata.excerpt;
|
|
62
|
+
if (section === "curated") delete metadata.text;
|
|
63
|
+
const lines = [`### ${section}${identity}`, "", ` ${JSON.stringify(metadata)}`, ""];
|
|
64
|
+
if (typeof body === "string") lines.push(...body.split(/\r\n|\r|\n/).map(line => ` ${line}`), "");
|
|
65
|
+
return `${lines.join("\n")}\n`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function exportProject(base: string, key: string, opts: { to: string; policy?: MemoryPolicy; format?: ProjectExportFormat }): Promise<ExportResult> {
|
|
69
|
+
const project = requireShard(base, key), format = opts.format ?? "json";
|
|
70
|
+
if (!["json", "jsonl", "markdown"].includes(format)) throw new Error("export format must be json, jsonl or markdown");
|
|
52
71
|
if (projectState(base, project) === "forgotten") throw new Error(`project ${project} is forgotten: it cannot be exported`);
|
|
53
72
|
const file = destination(base, opts.to), policy = opts.policy ?? createStandalonePolicy();
|
|
54
73
|
exportHooks.afterCheck?.(file);
|
|
@@ -59,10 +78,24 @@ export async function exportProject(base: string, key: string, opts: { to: strin
|
|
|
59
78
|
try { fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
60
79
|
catch { throw new Error("the destination exists; export never overwrites"); }
|
|
61
80
|
const out = (text: string) => { writeSync(fd, text); };
|
|
81
|
+
const exportedAt = new Date().toISOString();
|
|
82
|
+
let first = true, section: ExportSection = "capture";
|
|
83
|
+
const begin = (jsonPrefix: string, next: ExportSection, heading: string) => {
|
|
84
|
+
section = next; first = true;
|
|
85
|
+
if (format === "json") out(jsonPrefix);
|
|
86
|
+
else if (format === "markdown") out(`\n## ${heading}\n\n`);
|
|
87
|
+
};
|
|
88
|
+
const emit = (value: unknown) => {
|
|
89
|
+
if (format === "json") out(`${first ? "" : ","}${JSON.stringify(value)}`);
|
|
90
|
+
else if (format === "jsonl") out(`${JSON.stringify({ schema: EXPORT_SCHEMA, project, type: section, value })}\n`);
|
|
91
|
+
else out(markdownRecord(section, value));
|
|
92
|
+
first = false;
|
|
93
|
+
};
|
|
62
94
|
const stores: { close(): void }[] = [];
|
|
63
95
|
try {
|
|
64
|
-
|
|
65
|
-
|
|
96
|
+
if (format === "jsonl") out(`${JSON.stringify({ schema: EXPORT_SCHEMA, project, exportedAt, type: "header", aliases: aliasedKeys(base, project) })}\n`);
|
|
97
|
+
else if (format === "markdown") out(`# Project export ${markdownInline(JSON.stringify(project))}\n\nExported at ${exportedAt}\n`);
|
|
98
|
+
begin(`{"schema":${JSON.stringify(EXPORT_SCHEMA)},"project":${JSON.stringify(project)},"exportedAt":${JSON.stringify(exportedAt)},"capture":[`, "capture", "Capture");
|
|
66
99
|
const shard = walkShards(base).find(candidate => candidate.key === project);
|
|
67
100
|
for (const dir of shard?.machineDirs ?? []) {
|
|
68
101
|
const dbFile = join(dir, "capture.db");
|
|
@@ -76,40 +109,42 @@ export async function exportProject(base: string, key: string, opts: { to: strin
|
|
|
76
109
|
after = r.id;
|
|
77
110
|
const row = { at: r.at, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt };
|
|
78
111
|
if (!await allowed(row)) { withheld++; continue; }
|
|
79
|
-
|
|
112
|
+
emit(row); counts.capture++;
|
|
80
113
|
}
|
|
81
114
|
if (rows.length < PAGE) break;
|
|
82
115
|
}
|
|
83
116
|
} finally { db.close(); }
|
|
84
117
|
}
|
|
85
|
-
|
|
118
|
+
begin(`],"curated":[`, "curated", "Curated notes");
|
|
86
119
|
const store = openCuratedForRead(base, project);
|
|
87
120
|
if (store) stores.push(store);
|
|
88
121
|
// The project's own rows and those of a key aliased to it; another project's shared note is not this project's and stays out of its bundle.
|
|
89
122
|
const keys = new Set([project, ...aliasedKeys(base, project)]);
|
|
90
|
-
|
|
123
|
+
const authored = format === "jsonl" ? new Map((store?.portableNotes(project) ?? []).map(row => [row.id, row])) : undefined;
|
|
91
124
|
for (const view of store?.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }) ?? []) {
|
|
92
125
|
if (!keys.has(view.project)) continue;
|
|
93
|
-
|
|
94
|
-
|
|
126
|
+
const value = format === "jsonl" ? authored!.get(view.id) : view;
|
|
127
|
+
if (!value) throw new Error("exported note's authored row is unavailable");
|
|
128
|
+
if (!await allowed(value)) { withheld++; continue; }
|
|
129
|
+
emit(value); counts.curated++;
|
|
95
130
|
}
|
|
96
|
-
|
|
97
|
-
first = true;
|
|
131
|
+
begin(`],"goals":[`, "goals", "Goals");
|
|
98
132
|
for (const goal of store?.goals(project) ?? []) {
|
|
99
133
|
if (!keys.has(goal.project)) continue;
|
|
100
134
|
if (!await allowed(goal)) { withheld++; continue; }
|
|
101
|
-
|
|
135
|
+
emit(goal); counts.goals++;
|
|
102
136
|
}
|
|
103
|
-
|
|
104
|
-
first = true;
|
|
137
|
+
begin(`],"workLists":[`, "workLists", "Work lists");
|
|
105
138
|
// The bundle holds what the project's own reads show: the canonical work log and that of every key aliased to it (the same reconciliation the CLI, MCP and brief use).
|
|
106
139
|
const workKeys = [project, ...aliasedKeys(base, project)];
|
|
107
140
|
if (workKeys.some(key => existsSync(join(shardDir(base, key), "work")))) {
|
|
108
141
|
const work = new WorkListStore({ base, project, policy: allowed, aliases: workKeys.slice(1) });
|
|
109
142
|
stores.push({ close: () => work.close() });
|
|
110
|
-
for (const list of await work.read(undefined, true)) {
|
|
143
|
+
for (const list of await work.read(undefined, true)) { emit(list); counts.workLists++; }
|
|
111
144
|
}
|
|
112
|
-
out(`],"counts":${JSON.stringify(counts)},"withheld":${withheld}}\n`);
|
|
145
|
+
if (format === "json") out(`],"counts":${JSON.stringify(counts)},"withheld":${withheld}}\n`);
|
|
146
|
+
else if (format === "jsonl") out(`${JSON.stringify({ schema: EXPORT_SCHEMA, project, type: "counts", counts, withheld })}\n`);
|
|
147
|
+
else out(`\n## Counts\n\n${JSON.stringify({ counts, withheld })}\n`);
|
|
113
148
|
} catch (error) {
|
|
114
149
|
try { closeSync(fd); } catch { /* already closed */ }
|
|
115
150
|
rmSync(file, { force: true }); // the file is ours: O_EXCL created it, so a partial bundle is removed, never left behind
|
|
@@ -118,5 +153,5 @@ export async function exportProject(base: string, key: string, opts: { to: strin
|
|
|
118
153
|
for (const store of stores) { try { store.close(); } catch { /* best effort */ } }
|
|
119
154
|
}
|
|
120
155
|
closeSync(fd);
|
|
121
|
-
return { file, project, counts, withheld };
|
|
156
|
+
return { file, project, format, counts, withheld };
|
|
122
157
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants, createReadStream, fstatSync, openSync } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { legacyEntries } from "./bridge.ts";
|
|
6
|
+
import { stripPrivateDeep, stripPrivateSpans } from "./private-spans.ts";
|
|
7
|
+
import { EXPORT_SCHEMA } from "./project-export.ts";
|
|
8
|
+
import { aliasedKeys } from "./shard.ts";
|
|
9
|
+
import { looksSecret, type MemoryStore, type NoteInput } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
export const PROJECT_IMPORT_MAX_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
export const PROJECT_IMPORT_MAX_LINE_BYTES = 64 * 1024;
|
|
13
|
+
export const PROJECT_IMPORT_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
14
|
+
const NOTE_KEYS = new Set(["schema", "id", "at", "machine", "op", "project", "scope", "tier", "kind", "peer", "title", "text", "task", "about", "supersedes", "promotedFrom", "source", "expires"]);
|
|
15
|
+
const OPERATIONS_WARNING = "Supersede, demote, settle and forget operations are not exported; re-import restores authored note rows only.";
|
|
16
|
+
|
|
17
|
+
type ImportedNote = { id: string; at: string; machine: string; note: NoteInput };
|
|
18
|
+
type Provenance = { source: Record<string, number>; peer: Record<string, number>; scope: Record<string, number> };
|
|
19
|
+
export interface ProjectImportResult {
|
|
20
|
+
applied: boolean;
|
|
21
|
+
project: string;
|
|
22
|
+
read: number;
|
|
23
|
+
added: number;
|
|
24
|
+
wouldAdd: number;
|
|
25
|
+
skipped: { invalid: number; policy: number; other: number };
|
|
26
|
+
provenance: Provenance;
|
|
27
|
+
warnings: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function importedNote(value: unknown, projects: readonly string[]): ImportedNote | undefined {
|
|
31
|
+
try {
|
|
32
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !Object.keys(value).every(key => NOTE_KEYS.has(key))) return undefined;
|
|
33
|
+
const bytes = Buffer.from(`${JSON.stringify(value)}\n`);
|
|
34
|
+
const [row] = legacyEntries([{ source: "legacy-local", path: "log/project-import.jsonl", bytes, offset: bytes.length,
|
|
35
|
+
hash: createHash("sha256").update(bytes).digest("hex"), local: false, tailBytes: 0 }], { strict: true });
|
|
36
|
+
if (row?.op !== "note" || !projects.includes(row.project)) return undefined;
|
|
37
|
+
const { id, at, machine, ...rest } = row;
|
|
38
|
+
const note = Object.fromEntries(Object.entries(rest).filter(([key]) => key !== "schema")) as NoteInput;
|
|
39
|
+
return { id, at, machine, note };
|
|
40
|
+
} catch { return undefined; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function increment(counts: Map<string, number>, value: string | undefined): void {
|
|
44
|
+
const key = value ?? "unspecified";
|
|
45
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function provenance(notes: Iterable<ImportedNote>): Provenance {
|
|
49
|
+
const source = new Map<string, number>(), peer = new Map<string, number>(), scope = new Map<string, number>();
|
|
50
|
+
for (const { note } of notes) {
|
|
51
|
+
increment(source, note.source);
|
|
52
|
+
increment(peer, note.peer);
|
|
53
|
+
increment(scope, note.scope);
|
|
54
|
+
}
|
|
55
|
+
return { source: Object.fromEntries(source), peer: Object.fromEntries(peer), scope: Object.fromEntries(scope) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function unchangedAfterNormalization(note: NoteInput): boolean {
|
|
59
|
+
return stripPrivateSpans(note.text).slice(0, 4000) === note.text
|
|
60
|
+
&& (typeof note.title !== "string" || stripPrivateSpans(note.title).slice(0, 200) === note.title);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Preview by default; confirm applies only after every row, relationship, identity and policy decision has passed without a curated write. */
|
|
64
|
+
export async function importProject(store: MemoryStore, opts: {
|
|
65
|
+
from: string;
|
|
66
|
+
project: string;
|
|
67
|
+
confirm?: boolean;
|
|
68
|
+
allowShared?: boolean;
|
|
69
|
+
policy: (note: NoteInput) => boolean | Promise<boolean>;
|
|
70
|
+
}): Promise<ProjectImportResult> {
|
|
71
|
+
const fd = openSync(opts.from, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
72
|
+
const stream = createReadStream(opts.from, { fd, encoding: "utf8" });
|
|
73
|
+
const result: ProjectImportResult = { applied: opts.confirm === true, project: opts.project, read: 0, added: 0, wouldAdd: 0,
|
|
74
|
+
skipped: { invalid: 0, policy: 0, other: 0 }, provenance: { source: {}, peer: {}, scope: {} }, warnings: [OPERATIONS_WARNING] };
|
|
75
|
+
let header = false, bytes = 0;
|
|
76
|
+
let aliases: string[] = [];
|
|
77
|
+
const pending = new Map<string, ImportedNote>(), accepted: ImportedNote[] = [];
|
|
78
|
+
try {
|
|
79
|
+
const stat = fstatSync(fd);
|
|
80
|
+
if (!stat.isFile() || stat.size > PROJECT_IMPORT_MAX_BYTES) throw new Error("project import needs a regular JSONL file of at most 64 MiB");
|
|
81
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
82
|
+
try {
|
|
83
|
+
for await (const line of lines) {
|
|
84
|
+
bytes += Buffer.byteLength(line, "utf8") + 1;
|
|
85
|
+
if (bytes > PROJECT_IMPORT_MAX_BYTES) throw new Error("project import exceeded 64 MiB while reading");
|
|
86
|
+
if (Buffer.byteLength(line, "utf8") > PROJECT_IMPORT_MAX_LINE_BYTES) { result.skipped.invalid++; continue; }
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
let row: Record<string, unknown>;
|
|
89
|
+
try {
|
|
90
|
+
const parsed: unknown = JSON.parse(line);
|
|
91
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
|
92
|
+
row = parsed as Record<string, unknown>;
|
|
93
|
+
} catch { result.skipped.invalid++; continue; }
|
|
94
|
+
if (row.schema !== EXPORT_SCHEMA || row.project !== opts.project) throw new Error("project import schema or project mismatch");
|
|
95
|
+
if (row.type === "header") {
|
|
96
|
+
if (header) throw new Error("project import has more than one header");
|
|
97
|
+
if (!Array.isArray(row.aliases) || !row.aliases.every(alias => typeof alias === "string")) throw new Error("project import aliases are invalid");
|
|
98
|
+
aliases = [...row.aliases] as string[];
|
|
99
|
+
const available = aliasedKeys(store.profile.home, opts.project);
|
|
100
|
+
if (aliases.some(alias => !available.includes(alias))) throw new Error("project import requires the exported aliases in the target home");
|
|
101
|
+
header = true; continue;
|
|
102
|
+
}
|
|
103
|
+
if (!header) throw new Error("project import needs its header first");
|
|
104
|
+
if (row.type === "counts") continue;
|
|
105
|
+
if (row.type !== "curated") { result.skipped.other++; continue; }
|
|
106
|
+
result.read++;
|
|
107
|
+
const imported = importedNote(row.value, [opts.project, ...aliases]);
|
|
108
|
+
if (!imported || Date.parse(imported.at) > store.readNow() + PROJECT_IMPORT_FUTURE_SKEW_MS
|
|
109
|
+
|| imported.note.scope === "shared" && opts.allowShared !== true || !unchangedAfterNormalization(imported.note)) {
|
|
110
|
+
result.skipped.invalid++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const prior = pending.get(imported.id);
|
|
114
|
+
if (prior) {
|
|
115
|
+
if (!isDeepStrictEqual(prior, imported)) throw new Error("project import has conflicting rows for one entry id");
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
pending.set(imported.id, imported);
|
|
119
|
+
}
|
|
120
|
+
} finally { lines.close(); }
|
|
121
|
+
if (!header) throw new Error("project import has no header");
|
|
122
|
+
|
|
123
|
+
for (const imported of pending.values()) {
|
|
124
|
+
if (["supersedes", "promotedFrom"].some(field => {
|
|
125
|
+
const target = imported.note[field as "supersedes" | "promotedFrom"] as string | null | undefined;
|
|
126
|
+
return target !== undefined && target !== null && !pending.has(target);
|
|
127
|
+
})) {
|
|
128
|
+
result.skipped.invalid++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const screened = stripPrivateDeep(imported.note);
|
|
132
|
+
if (looksSecret(JSON.stringify(screened)) || !await opts.policy(screened)) { result.skipped.policy++; continue; }
|
|
133
|
+
accepted.push(imported);
|
|
134
|
+
}
|
|
135
|
+
result.provenance = provenance(accepted);
|
|
136
|
+
const existing = new Set(store.portableNotes(opts.project).map(note => note.id));
|
|
137
|
+
result.wouldAdd = accepted.filter(note => !existing.has(note.id)).length;
|
|
138
|
+
if (opts.confirm === true) {
|
|
139
|
+
for (const imported of accepted) {
|
|
140
|
+
const written = store.importNote(imported.note, imported.at, { machine: imported.machine, expectedId: imported.id });
|
|
141
|
+
if (written.added) result.added++;
|
|
142
|
+
}
|
|
143
|
+
store.refresh();
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
} finally { stream.destroy(); }
|
|
147
|
+
}
|