@agfpd/iapeer-memory 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,358 @@
1
+ /**
2
+ * `iapeer-memory init` — provision the system on this host (docs/10
3
+ * §Install-flow). IDEMPOTENT: every step is safe to re-run; re-running
4
+ * init is the official repair path for a half-provisioned host.
5
+ *
6
+ * Interactivity contract (fixed with the iapeer core, memory-slot doc):
7
+ * the PROVIDER owns the install questions. tty + missing answers →
8
+ * interactive prompts (storage path, locale, optional search endpoint,
9
+ * human name); all answers via flags → fully silent; NON-tty (or
10
+ * --non-interactive) without an explicit --vault → refusal (silent
11
+ * provisioning of a default storage path is forbidden). `iapeer onboard`
12
+ * runs this init with inherited stdio and may pass `--human <personality>`
13
+ * (sent only when exactly one natural peer exists in the registry).
14
+ *
15
+ * Step order: deps → vault → config → binary → templates → role peers +
16
+ * doctrines + roles manifest → watcher registration → slot declaration →
17
+ * native-memory sweep (core verb, soft-skip on old cores) → host-wide
18
+ * guide fragment. Ecosystem steps are skippable (--skip-ecosystem) for
19
+ * sandboxed runs; the binary compile is skippable (--skip-binary) for
20
+ * fast tests.
21
+ */
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import {
26
+ getTaxonomy,
27
+ isLocaleId,
28
+ renderDoctrine,
29
+ writeHostWideGuideFragment,
30
+ type LocaleId,
31
+ } from "@agfpd/iapeer-memory-core";
32
+ import { installBinary } from "../binary.js";
33
+ import { memoryPaths } from "../paths.js";
34
+ import { provisionVault, writeDefaultConfig } from "../provision.js";
35
+ import { writeRolesManifest, type RoleEntry } from "../roles.js";
36
+ import { writeSlot } from "../slot.js";
37
+ import {
38
+ guideText,
39
+ materialiseTemplates,
40
+ roleTemplatePath,
41
+ ROLE_NAMES,
42
+ } from "../templates/index.js";
43
+ import { packageVersion } from "../version.js";
44
+ import { registerWatcher, writeLauncherScript } from "../watcher.js";
45
+
46
+ type InitFlags = {
47
+ vault?: string;
48
+ locale?: string;
49
+ human?: string;
50
+ embeddingEndpoint?: string;
51
+ rerankerEndpoint?: string;
52
+ nonInteractive: boolean;
53
+ skipDeps: boolean;
54
+ skipEcosystem: boolean;
55
+ skipBinary: boolean;
56
+ iapeerBin: string;
57
+ };
58
+
59
+ function parseFlags(argv: string[]): InitFlags | null {
60
+ const f: InitFlags = {
61
+ nonInteractive: false,
62
+ skipDeps: false,
63
+ skipEcosystem: false,
64
+ skipBinary: false,
65
+ iapeerBin: "iapeer",
66
+ };
67
+ for (let i = 0; i < argv.length; i++) {
68
+ const a = argv[i];
69
+ const take = (): string | undefined => argv[++i];
70
+ switch (a) {
71
+ case "--vault": f.vault = take(); break;
72
+ case "--locale": f.locale = take(); break;
73
+ case "--human": f.human = take(); break;
74
+ case "--embedding-endpoint": f.embeddingEndpoint = take(); break;
75
+ case "--reranker-endpoint": f.rerankerEndpoint = take(); break;
76
+ case "--non-interactive": f.nonInteractive = true; break;
77
+ case "--skip-deps": f.skipDeps = true; break;
78
+ case "--skip-ecosystem": f.skipEcosystem = true; break;
79
+ case "--skip-binary": f.skipBinary = true; break;
80
+ case "--iapeer-bin": f.iapeerBin = take() ?? "iapeer"; break;
81
+ default:
82
+ console.error(`iapeer-memory init: unknown flag: ${a}`);
83
+ return null;
84
+ }
85
+ }
86
+ return f;
87
+ }
88
+
89
+ // ── iapeer registry helpers ──────────────────────────────────────────────────
90
+
91
+ type PeerInfo = { personality: string; intelligence?: string; cwd?: string };
92
+
93
+ type RunResult = { exitCode: number; stdout: string; stderr: string };
94
+
95
+ /** spawnSync that never throws — a missing binary is a result, not a crash. */
96
+ function run(cmd: string[]): RunResult {
97
+ try {
98
+ const proc = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "pipe" });
99
+ return {
100
+ exitCode: proc.exitCode,
101
+ stdout: proc.stdout.toString(),
102
+ stderr: proc.stderr.toString(),
103
+ };
104
+ } catch (err) {
105
+ return { exitCode: 127, stdout: "", stderr: String(err) };
106
+ }
107
+ }
108
+
109
+ function listPeers(iapeerBin: string): PeerInfo[] | null {
110
+ const proc = run([iapeerBin, "list", "--json"]);
111
+ if (proc.exitCode !== 0) return null;
112
+ try {
113
+ return JSON.parse(proc.stdout) as PeerInfo[];
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /** «Don't ask what the stack already knows»: exactly one natural peer → its name. */
120
+ function naturalPeerDefault(peers: PeerInfo[] | null): string | null {
121
+ const naturals = (peers ?? []).filter((p) => p.intelligence === "natural");
122
+ return naturals.length === 1 ? naturals[0].personality : null;
123
+ }
124
+
125
+ // ── interactive prompts (tty only) ───────────────────────────────────────────
126
+
127
+ function ask(question: string, fallback: string): string {
128
+ // Bun's global prompt(); returns null on EOF.
129
+ const answer = prompt(`${question}${fallback ? ` [${fallback}]` : ""}:`);
130
+ const trimmed = (answer ?? "").trim();
131
+ return trimmed || fallback;
132
+ }
133
+
134
+ // ── the command ──────────────────────────────────────────────────────────────
135
+
136
+ export async function cmdInit(argv: string[]): Promise<number> {
137
+ const flags = parseFlags(argv);
138
+ if (!flags) return 2;
139
+
140
+ const interactive =
141
+ !flags.nonInteractive && Boolean(process.stdin.isTTY && process.stdout.isTTY);
142
+
143
+ // ── resolve answers (provider owns the questions) ──
144
+ let vault = flags.vault ?? "";
145
+ let localeRaw = flags.locale ?? "";
146
+ let human = flags.human ?? "";
147
+ let embeddingEndpoint = flags.embeddingEndpoint ?? "";
148
+ const rerankerEndpoint = flags.rerankerEndpoint ?? "";
149
+
150
+ const peers = flags.skipEcosystem ? null : listPeers(flags.iapeerBin);
151
+ const humanDefault = flags.human ?? naturalPeerDefault(peers) ?? "";
152
+
153
+ if (!vault) {
154
+ if (!interactive) {
155
+ console.error(
156
+ "iapeer-memory init: no tty and no --vault — refusing to silently " +
157
+ "provision a default storage path. Pass --vault PATH (and --locale).",
158
+ );
159
+ return 2;
160
+ }
161
+ vault = ask("Vault (storage) path", path.join(process.env.HOME ?? "~", "iapeer-memory-vault"));
162
+ }
163
+ if (!localeRaw) {
164
+ localeRaw = interactive ? ask("Vault locale (en|ru)", "en") : "en";
165
+ }
166
+ if (!isLocaleId(localeRaw)) {
167
+ console.error(`iapeer-memory init: unknown locale "${localeRaw}" (expected en|ru)`);
168
+ return 2;
169
+ }
170
+ const locale: LocaleId = localeRaw;
171
+ if (!human) {
172
+ human = interactive
173
+ ? ask("Human owner personality (empty = no human role)", humanDefault)
174
+ : humanDefault; // the stack knew it — use it, don't ask
175
+ }
176
+ if (!embeddingEndpoint && interactive) {
177
+ embeddingEndpoint = ask(
178
+ "Embedding endpoint (OpenAI-compatible; empty = BM25-only)",
179
+ "",
180
+ );
181
+ }
182
+
183
+ const paths = memoryPaths();
184
+ const iapeerDir = path.dirname(paths.slotPath);
185
+ const version = packageVersion();
186
+ let failures = 0;
187
+ const step = (name: string, detail: string, ok = true): void => {
188
+ if (!ok) failures++;
189
+ console.log(`${ok ? "ok " : "FAIL"} ${name.padEnd(10)} ${detail}`);
190
+ };
191
+
192
+ // 1. dependencies
193
+ if (flags.skipDeps) {
194
+ step("deps", "skipped (--skip-deps)");
195
+ } else {
196
+ const ver = run([flags.iapeerBin, "version"]);
197
+ if (ver.exitCode !== 0) {
198
+ step("deps", "iapeer foundation not found on PATH — install it first (npx @agfpd/iapeer)", false);
199
+ } else {
200
+ const coreVersion = ver.stdout.trim();
201
+ const hasNotifier = (peers ?? []).some((p) => p.personality === "watcher");
202
+ step(
203
+ "deps",
204
+ `iapeer ${coreVersion}` +
205
+ (hasNotifier ? ", notifier watcher peer present" : ""),
206
+ hasNotifier,
207
+ );
208
+ if (!hasNotifier) {
209
+ console.log(
210
+ " deps notifier-runtime watcher peer missing — install the notifier " +
211
+ "runtime (iapeer install-runtime notifier), then re-run init",
212
+ );
213
+ }
214
+ }
215
+ }
216
+
217
+ // 2. vault
218
+ fs.mkdirSync(vault, { recursive: true });
219
+ const prov = provisionVault({ vaultPath: vault, taxonomy: getTaxonomy(locale) });
220
+ step(
221
+ "vault",
222
+ `${vault} (${prov.createdDirs.length} dirs + ${prov.createdFiles.length} seeds created, ${prov.kept.length} kept)`,
223
+ );
224
+
225
+ // 3. config (operator-owned; written once)
226
+ const cfg = writeDefaultConfig({
227
+ configFile: paths.configFile,
228
+ vaultPath: vault,
229
+ locale,
230
+ human: human || null,
231
+ embeddingEndpoint: embeddingEndpoint || null,
232
+ rerankerEndpoint: rerankerEndpoint || null,
233
+ });
234
+ step("config", `${paths.configFile} (${cfg})`);
235
+
236
+ // 4. stable binary
237
+ if (flags.skipBinary) {
238
+ step("binary", "skipped (--skip-binary)");
239
+ } else {
240
+ const bin = installBinary({ outPath: paths.binaryPath });
241
+ step(
242
+ "binary",
243
+ bin.action === "compiled"
244
+ ? `${bin.outPath} (${Math.round(bin.bytes / 1024 / 1024)}MB)`
245
+ : bin.action === "skipped-compiled"
246
+ ? `kept existing ${bin.outPath} (running from the installed binary)`
247
+ : `compile failed — ${bin.detail}`,
248
+ bin.action !== "failed",
249
+ );
250
+ }
251
+
252
+ // 5. templates (package-owned)
253
+ const tmpl = materialiseTemplates({ templatesDir: paths.templatesDir, locale });
254
+ step("templates", `${paths.templatesDir} (${tmpl.written.length} written, ${tmpl.identical.length} identical)`);
255
+
256
+ // 6. role peers + doctrines + manifest
257
+ if (flags.skipEcosystem) {
258
+ step("roles", "skipped (--skip-ecosystem)");
259
+ } else {
260
+ const roleEntries: RoleEntry[] = [];
261
+ let rolesOk = true;
262
+ let createdAny = false;
263
+ for (const role of ROLE_NAMES) {
264
+ const exists = (peers ?? []).some((p) => p.personality === role);
265
+ if (!exists) {
266
+ const created = run([flags.iapeerBin, "create", role]);
267
+ if (created.exitCode !== 0) {
268
+ rolesOk = false;
269
+ console.log(` roles create ${role} failed: ${created.stderr.trim()}`);
270
+ continue;
271
+ }
272
+ createdAny = true;
273
+ }
274
+ }
275
+ // peerCwd: the registry FACT when the core exposes it (`cwd` in
276
+ // `iapeer list --json` — iapeer 39de94b, next core release), otherwise
277
+ // the core's DOCUMENTED create default (no --path — требование Артура;
278
+ // IAPEER_ROOT-aware). Forward-compatible: switches to the fact
279
+ // automatically once the deployed core ships the field.
280
+ const freshPeers = createdAny ? listPeers(flags.iapeerBin) : peers;
281
+ for (const role of ROLE_NAMES) {
282
+ const registryCwd = (freshPeers ?? []).find((p) => p.personality === role)?.cwd;
283
+ const peerCwd = registryCwd || path.join(iapeerDir, "peers", role);
284
+ const template = roleTemplatePath(paths.templatesDir, locale, role);
285
+ const rendered = renderDoctrine({ templatePath: template, peerCwd, version });
286
+ if (rendered.action === "missing-template") {
287
+ rolesOk = false;
288
+ console.log(` roles ${role}: template missing at ${template}`);
289
+ continue;
290
+ }
291
+ roleEntries.push({ role, peerCwd, template });
292
+ }
293
+ writeRolesManifest({ rolesManifestPath: paths.rolesManifestPath, roles: roleEntries });
294
+ step(
295
+ "roles",
296
+ `${roleEntries.map((r) => r.role).join(", ")} (doctrines v${version}, manifest ${paths.rolesManifestPath})`,
297
+ rolesOk && roleEntries.length === ROLE_NAMES.length,
298
+ );
299
+ }
300
+
301
+ // 7. watcher registration (registrant = index; reply goes to the index
302
+ // session — confirmation is the durable profile, checked by verify)
303
+ if (flags.skipEcosystem) {
304
+ step("watcher", "skipped (--skip-ecosystem)");
305
+ } else {
306
+ writeLauncherScript({ launcherPath: paths.launcherPath, binaryPath: paths.binaryPath });
307
+ const sent = registerWatcher({
308
+ launcherPath: paths.launcherPath,
309
+ iapeerBin: flags.iapeerBin,
310
+ });
311
+ step(
312
+ "watcher",
313
+ sent.ok
314
+ ? `registration sent (${paths.launcherPath} → target index); confirm: iapeer-memory verify`
315
+ : `registration failed — ${sent.detail}`,
316
+ sent.ok,
317
+ );
318
+ }
319
+
320
+ // 8. slot declaration (the contract: written by the provider, atomic)
321
+ const slot = writeSlot({
322
+ slotPath: paths.slotPath,
323
+ version,
324
+ heartbeat: paths.heartbeatPath,
325
+ });
326
+ step(
327
+ "slot",
328
+ slot.action === "refused-foreign"
329
+ ? `slot held by foreign provider "${slot.existing?.provider}" — uninstall it first`
330
+ : `${paths.slotPath} (${slot.action}, v${version})`,
331
+ slot.action !== "refused-foreign",
332
+ );
333
+
334
+ // 9. native-memory sweep — the core's lever (one home of runtime forms);
335
+ // soft-skip when the verb is unavailable (older core), verify re-runs later.
336
+ if (flags.skipEcosystem) {
337
+ step("sweep", "skipped (--skip-ecosystem)");
338
+ } else {
339
+ const sweep = run([flags.iapeerBin, "native-memory", "off", "--all"]);
340
+ step(
341
+ "sweep",
342
+ sweep.exitCode === 0
343
+ ? `native auto-memory off across the fleet (${sweep.stdout.trim().split("\n").pop() ?? "ok"})`
344
+ : `soft-skip: core verb unavailable (${sweep.stderr.trim().slice(0, 120) || `exit ${sweep.exitCode}`}) — upgrade the iapeer core and re-run`,
345
+ );
346
+ }
347
+
348
+ // 10. host-wide guide fragment (layer 5 — reaches every peer on next wakes)
349
+ const guidePath = writeHostWideGuideFragment(iapeerDir, guideText(locale));
350
+ step("guide", guidePath);
351
+
352
+ console.log(
353
+ failures
354
+ ? `\ninit finished with ${failures} problem(s) — re-run init (idempotent) or iapeer-memory verify --repair`
355
+ : "\ninit complete — check the chain: iapeer-memory status",
356
+ );
357
+ return failures ? 1 : 0;
358
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `iapeer-memory install-binary` — compile the stable CLI binary into
3
+ * `~/.local/bin/iapeer-memory` (init step / repair path). Must run from the
4
+ * package SOURCE (npx / checkout); the installed binary cannot rebuild
5
+ * itself (see binary.ts header).
6
+ */
7
+
8
+ import { installBinary } from "../binary.js";
9
+ import { memoryPaths } from "../paths.js";
10
+
11
+ export function cmdInstallBinary(argv: string[]): number {
12
+ let outPath = memoryPaths().binaryPath;
13
+ for (let i = 0; i < argv.length; i++) {
14
+ if (argv[i] === "--out") {
15
+ const v = argv[++i];
16
+ if (!v) {
17
+ console.error("iapeer-memory install-binary: --out requires a value");
18
+ return 2;
19
+ }
20
+ outPath = v;
21
+ } else {
22
+ console.error(`iapeer-memory install-binary: unknown flag: ${argv[i]}`);
23
+ return 2;
24
+ }
25
+ }
26
+
27
+ const outcome = installBinary({ outPath });
28
+ switch (outcome.action) {
29
+ case "compiled":
30
+ console.log(
31
+ `install-binary: compiled ${outcome.outPath} (${Math.round(outcome.bytes / 1024 / 1024)}MB)`,
32
+ );
33
+ return 0;
34
+ case "skipped-compiled":
35
+ console.error(
36
+ "install-binary: running FROM the installed binary — sources unavailable; " +
37
+ "re-install via: npx @agfpd/iapeer-memory install-binary",
38
+ );
39
+ return 1;
40
+ case "failed":
41
+ console.error(`install-binary: compile failed — ${outcome.detail}`);
42
+ return 1;
43
+ }
44
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `iapeer-memory memoryd` — run the daemon in the foreground (ADR-004).
3
+ *
4
+ * iapeer-memory memoryd [--mcp-port N | --no-mcp] [--human NAME]
5
+ *
6
+ * This IS the watcher script the notifier supervises: stdout carries the
7
+ * event signal lines (INBOX_NEW / PERMANENT_* — core emits them), stderr
8
+ * carries logs, SIGTERM/SIGINT shut down cleanly (flush + close). All state
9
+ * paths come from the shared `paths.ts` namespace — the heartbeat lands
10
+ * exactly where `verify` reads it, by construction.
11
+ *
12
+ * - MCP port: `--mcp-port` > IAPEER_MEMORY_MCP_PORT/config file > 8766
13
+ * (config default, ADR-012); `--no-mcp` disables the endpoint;
14
+ * - human-edit detection: `--human` > IAPEER_MEMORY_HUMAN_NAME; absent →
15
+ * detection off (⚖7 — the human role is optional);
16
+ * - fresh-edit window: IAPEER_MEMORY_FRESH_EDIT_WINDOW_S (default in core).
17
+ */
18
+
19
+ import fs from "node:fs";
20
+ import { configFromEnv, startMemoryd } from "@agfpd/iapeer-memory-core";
21
+ import { memoryPaths } from "../paths.js";
22
+
23
+ export async function cmdMemoryd(argv: string[]): Promise<number> {
24
+ let mcpPort: number | undefined;
25
+ let noMcp = false;
26
+ let human: string | null = null;
27
+
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
30
+ switch (a) {
31
+ case "--mcp-port": {
32
+ const v = Number(argv[++i]);
33
+ if (!Number.isInteger(v) || v < 0 || v > 65535) {
34
+ console.error(`iapeer-memory memoryd: invalid --mcp-port: ${argv[i]}`);
35
+ return 2;
36
+ }
37
+ mcpPort = v;
38
+ break;
39
+ }
40
+ case "--no-mcp":
41
+ noMcp = true;
42
+ break;
43
+ case "--human":
44
+ human = argv[++i] ?? null;
45
+ break;
46
+ default:
47
+ console.error(`iapeer-memory memoryd: unknown flag: ${a}`);
48
+ return 2;
49
+ }
50
+ }
51
+
52
+ const config = configFromEnv();
53
+ const paths = memoryPaths();
54
+ for (const dir of [paths.stateDir, paths.cacheDir, paths.logsDir]) {
55
+ fs.mkdirSync(dir, { recursive: true });
56
+ }
57
+ // pid file — uninstall's stop handle (best-effort; stale files are
58
+ // harmless: the reader checks liveness before signalling).
59
+ fs.writeFileSync(paths.pidPath, `${process.pid}\n`);
60
+
61
+ const freshEditWindowRaw = process.env.IAPEER_MEMORY_FRESH_EDIT_WINDOW_S;
62
+ const freshEditWindowS =
63
+ freshEditWindowRaw && Number.isFinite(Number(freshEditWindowRaw))
64
+ ? Number(freshEditWindowRaw)
65
+ : undefined;
66
+
67
+ const handle = await startMemoryd({
68
+ config,
69
+ heartbeatPath: paths.heartbeatPath,
70
+ hashStatePath: paths.hashStatePath,
71
+ tagsMirrorPath: paths.tagsMirrorPath,
72
+ humanName: human ?? process.env.IAPEER_MEMORY_HUMAN_NAME ?? null,
73
+ freshEditWindowS,
74
+ mcpPort: noMcp ? null : mcpPort,
75
+ });
76
+
77
+ return await new Promise<number>((resolve) => {
78
+ let closing = false;
79
+ const shutdown = (signal: string) => {
80
+ if (closing) return;
81
+ closing = true;
82
+ console.error(`memoryd: ${signal} — shutting down`);
83
+ handle
84
+ .close()
85
+ .then(() => {
86
+ try {
87
+ fs.unlinkSync(paths.pidPath);
88
+ } catch {
89
+ // best effort
90
+ }
91
+ resolve(0);
92
+ })
93
+ .catch((err) => {
94
+ console.error(`memoryd: close failed: ${String(err)}`);
95
+ resolve(1);
96
+ });
97
+ };
98
+ process.on("SIGINT", () => shutdown("SIGINT"));
99
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
100
+ });
101
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `iapeer-memory migrate` — move a harness's built-in auto-memory into the
3
+ * vault's agent-memory zone (core engine; the SOURCE directory is
4
+ * adapter-scoped and always explicit — the engine never guesses where a
5
+ * harness keeps its memories, ADR/нюанс: codex format is gated on live
6
+ * verification, P5).
7
+ *
8
+ * iapeer-memory migrate --source DIR --agent NAME [--apply]
9
+ * [--backup-root DIR]
10
+ *
11
+ * Dry-run by default: prints the plan (per-file mapping + subtype counts)
12
+ * and exits 0. `--apply` runs the real migration: per-file backup → convert
13
+ * + atomic write → unlink source; exit 1 if any file errored.
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import {
18
+ applyMigration,
19
+ configFromEnv,
20
+ planMigration,
21
+ resolveAgentName,
22
+ } from "@agfpd/iapeer-memory-core";
23
+ import { memoryPaths } from "../paths.js";
24
+ import path from "node:path";
25
+
26
+ export function cmdMigrate(argv: string[]): number {
27
+ let source = "";
28
+ let agent: string | null = null;
29
+ let apply = false;
30
+ let backupRoot = "";
31
+
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const a = argv[i];
34
+ switch (a) {
35
+ case "--source":
36
+ source = argv[++i] ?? "";
37
+ break;
38
+ case "--agent":
39
+ agent = argv[++i] ?? null;
40
+ break;
41
+ case "--apply":
42
+ apply = true;
43
+ break;
44
+ case "--backup-root":
45
+ backupRoot = argv[++i] ?? "";
46
+ break;
47
+ default:
48
+ console.error(`iapeer-memory migrate: unknown flag: ${a}`);
49
+ return 2;
50
+ }
51
+ }
52
+
53
+ const resolvedAgent = resolveAgentName(agent);
54
+ if (!source || !resolvedAgent) {
55
+ console.error(
56
+ "iapeer-memory migrate: --source DIR is required, and an agent " +
57
+ "(--agent or PEER_PERSONALITY) must resolve",
58
+ );
59
+ return 2;
60
+ }
61
+ if (!fs.existsSync(source) || !fs.statSync(source).isDirectory()) {
62
+ console.error(`iapeer-memory migrate: source is not a directory: ${source}`);
63
+ return 1;
64
+ }
65
+
66
+ const config = configFromEnv();
67
+ const paths = memoryPaths();
68
+
69
+ if (!apply) {
70
+ const plan = planMigration({
71
+ sourceDir: source,
72
+ agent: resolvedAgent,
73
+ vault: config.vaultPath,
74
+ taxonomy: config.taxonomy,
75
+ });
76
+ console.log(`migrate (dry-run): ${plan.source} → ${plan.target}`);
77
+ for (const f of plan.files) {
78
+ console.log(
79
+ f.error
80
+ ? ` !! ${f.name}: ${f.error}`
81
+ : ` ${f.name}: ${f.oldType} → ${f.subtype}`,
82
+ );
83
+ }
84
+ if (plan.skippedSystem.length) {
85
+ console.log(` system (backup-only): ${plan.skippedSystem.join(", ")}`);
86
+ }
87
+ if (plan.skippedAlreadyInTarget.length) {
88
+ console.log(` already in target: ${plan.skippedAlreadyInTarget.join(", ")}`);
89
+ }
90
+ console.log(
91
+ ` total to migrate: ${plan.totalToMigrate} ` +
92
+ `(${Object.entries(plan.subtypeCounts).map(([k, v]) => `${k}: ${v}`).join(", ") || "none"})`,
93
+ );
94
+ console.log("run again with --apply to migrate");
95
+ return 0;
96
+ }
97
+
98
+ const result = applyMigration({
99
+ sourceDir: source,
100
+ agent: resolvedAgent,
101
+ vault: config.vaultPath,
102
+ backupRoot: backupRoot || path.join(paths.stateDir, "migrate-backups"),
103
+ taxonomy: config.taxonomy,
104
+ });
105
+ console.log(
106
+ `migrate: ${result.migrated.length} migrated, ${result.skipped.length} skipped, ` +
107
+ `${result.errors.length} errors; backup: ${result.backupDir}` +
108
+ `${result.sourceRemoved ? "; source dir removed" : ""}`,
109
+ );
110
+ for (const e of result.errors) console.error(` !! ${e}`);
111
+ return result.errors.length ? 1 : 0;
112
+ }