@agfpd/iapeer-memory 0.1.13 → 0.2.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,172 @@
1
+ /**
2
+ * `iapeer-memory provision-peer|unprovision-peer --cwd <abs> --runtime <r>
3
+ * --personality <p> [--occasion <o>]` — the provider half of the iapeer
4
+ * v1.2 slot contract
5
+ * (ADR-009 v1.2: direct per-peer surfaces; the core shells into THIS command
6
+ * at peer birth/sweeps and never learns the surface forms).
7
+ *
8
+ * Contract obligations (§7, agreed with the iapeer core 10.06):
9
+ * - argv form, absolute paths, no shell — the core spawns us directly;
10
+ * - IDEMPOTENT: re-running repairs, never corrupts (every surface is a
11
+ * read-merge-write of our own keys only, atomic);
12
+ * - tolerant of PARALLEL calls — the host-wide provision lock serialises
13
+ * bodies (lock.ts);
14
+ * - {occasion} dictionary: birth | sweep-on | off-peer | off-all | remove.
15
+ * We have NO host-global surfaces (требование Артура №3 «глобально не
16
+ * класть» — even the codex MCP form is project-local), so the ref-count
17
+ * distinction off-peer vs off-all is moot here: every occasion of the
18
+ * un-verb means «strip this peer's surfaces». Validated, logged, not
19
+ * branched on.
20
+ *
21
+ * Runtime forms: claude — hooks + mcp + skills (surfaces/claude.ts);
22
+ * codex — per-peer MCP via `<cwd>/.codex/config.toml` (surfaces/codex.ts;
23
+ * hooks/skills are the P5 experiment). Exit: 0 ok, 1 a surface failed,
24
+ * 2 usage.
25
+ */
26
+
27
+ import fs from "node:fs";
28
+ import { memoryPaths } from "../paths.js";
29
+ import {
30
+ provisionClaudePeer,
31
+ unprovisionClaudePeer,
32
+ type SurfaceOutcome,
33
+ } from "../surfaces/claude.js";
34
+ import { provisionCodexPeer, unprovisionCodexPeer } from "../surfaces/codex.js";
35
+ import { withProvisionLock } from "../surfaces/lock.js";
36
+
37
+ /** The memoryd MCP port FACT of this host (config.env is already loaded into
38
+ * the process env by the CLI boot) — baked literally into both MCP surface
39
+ * forms (no env substitution in either, D2 decision). Shared by the verbs
40
+ * here and the fleet sweeps in init/update/verify. Same resolution as
41
+ * status.ts. */
42
+ export function mcpPort(): number {
43
+ return Number(process.env.IAPEER_MEMORY_MCP_PORT || "") || 8766;
44
+ }
45
+
46
+ export const OCCASIONS = ["birth", "sweep-on", "off-peer", "off-all", "remove"] as const;
47
+ export type Occasion = (typeof OCCASIONS)[number];
48
+
49
+ const RUNTIMES = ["claude", "codex"] as const;
50
+
51
+ type Flags = {
52
+ cwd: string;
53
+ runtime: (typeof RUNTIMES)[number];
54
+ occasion: Occasion;
55
+ personality: string;
56
+ };
57
+
58
+ function parseFlags(
59
+ verb: "provision-peer" | "unprovision-peer",
60
+ argv: string[],
61
+ defaultOccasion: Occasion,
62
+ ): Flags | null {
63
+ let cwd = "";
64
+ let runtime = "";
65
+ let occasion: string = defaultOccasion;
66
+ let personality = "";
67
+ for (let i = 0; i < argv.length; i++) {
68
+ const a = argv[i];
69
+ switch (a) {
70
+ case "--cwd": cwd = argv[++i] ?? ""; break;
71
+ case "--runtime": runtime = argv[++i] ?? ""; break;
72
+ case "--occasion": occasion = argv[++i] ?? ""; break;
73
+ case "--personality": personality = argv[++i] ?? ""; break;
74
+ default:
75
+ console.error(`iapeer-memory ${verb}: unknown flag: ${a}`);
76
+ return null;
77
+ }
78
+ }
79
+ if (!cwd || !cwd.startsWith("/")) {
80
+ console.error(`iapeer-memory ${verb}: --cwd must be an absolute path (got "${cwd}")`);
81
+ return null;
82
+ }
83
+ if (!(RUNTIMES as readonly string[]).includes(runtime)) {
84
+ console.error(`iapeer-memory ${verb}: --runtime must be one of ${RUNTIMES.join("|")} (got "${runtime}")`);
85
+ return null;
86
+ }
87
+ if (!(OCCASIONS as readonly string[]).includes(occasion)) {
88
+ console.error(`iapeer-memory ${verb}: --occasion must be one of ${OCCASIONS.join("|")} (got "${occasion}")`);
89
+ return null;
90
+ }
91
+ // claude provision bakes the LITERAL identity header (battle form of the
92
+ // core's own .mcp.json) — without the personality there is nothing honest
93
+ // to bake. The core's executor supports the {personality} placeholder
94
+ // (agreed 11.06); the package sweep reads it from fleet.json. The un-verb
95
+ // needs no identity: removal matches our key/marks, not the header value.
96
+ if (verb === "provision-peer" && runtime === "claude" && !personality) {
97
+ console.error(
98
+ "iapeer-memory provision-peer: --personality is required for --runtime claude " +
99
+ "(the literal MCP identity header is baked at provision time)",
100
+ );
101
+ return null;
102
+ }
103
+ return {
104
+ cwd,
105
+ runtime: runtime as Flags["runtime"],
106
+ occasion: occasion as Occasion,
107
+ personality,
108
+ };
109
+ }
110
+
111
+ function report(verb: string, flags: Flags, outcomes: SurfaceOutcome[]): number {
112
+ let failed = false;
113
+ for (const o of outcomes) {
114
+ if (o.action === "failed") failed = true;
115
+ console.log(
116
+ `${o.action === "failed" ? "FAIL" : "ok "} ${o.surface.padEnd(7)} ${o.action}${o.detail ? ` — ${o.detail}` : ""} (${o.path})`,
117
+ );
118
+ }
119
+ console.log(
120
+ `${verb}: ${flags.runtime} peer at ${flags.cwd} (occasion: ${flags.occasion})` +
121
+ (failed ? " — FAILED; re-run is the repair path (idempotent)" : "") +
122
+ "\npickup: surfaces apply on the peer's NEXT session start (live sessions do not re-read them)",
123
+ );
124
+ return failed ? 1 : 0;
125
+ }
126
+
127
+ export function cmdProvisionPeer(argv: string[]): number {
128
+ const flags = parseFlags("provision-peer", argv, "sweep-on");
129
+ if (!flags) return 2;
130
+ if (!fs.existsSync(flags.cwd)) {
131
+ console.error(`iapeer-memory provision-peer: cwd does not exist: ${flags.cwd}`);
132
+ return 1;
133
+ }
134
+ const paths = memoryPaths();
135
+ const locked = withProvisionLock({
136
+ stateDir: paths.stateDir,
137
+ fn: () =>
138
+ flags.runtime === "codex"
139
+ ? provisionCodexPeer({ cwd: flags.cwd, port: mcpPort() })
140
+ : provisionClaudePeer({
141
+ cwd: flags.cwd,
142
+ hooksDir: paths.hooksDir,
143
+ port: mcpPort(),
144
+ personality: flags.personality,
145
+ }),
146
+ });
147
+ if (!locked.acquired) {
148
+ console.error(`iapeer-memory provision-peer: ${locked.detail}`);
149
+ return 1;
150
+ }
151
+ return report("provision-peer", flags, locked.result);
152
+ }
153
+
154
+ export function cmdUnprovisionPeer(argv: string[]): number {
155
+ const flags = parseFlags("unprovision-peer", argv, "off-peer");
156
+ if (!flags) return 2;
157
+ // a vanished cwd is a VALID un-provision target (occasion=remove races the
158
+ // peer directory removal) — every surface simply reports `absent`
159
+ const paths = memoryPaths();
160
+ const locked = withProvisionLock({
161
+ stateDir: paths.stateDir,
162
+ fn: () =>
163
+ flags.runtime === "codex"
164
+ ? unprovisionCodexPeer({ cwd: flags.cwd })
165
+ : unprovisionClaudePeer({ cwd: flags.cwd }),
166
+ });
167
+ if (!locked.acquired) {
168
+ console.error(`iapeer-memory unprovision-peer: ${locked.detail}`);
169
+ return 1;
170
+ }
171
+ return report("unprovision-peer", flags, locked.result);
172
+ }
@@ -14,6 +14,7 @@ import {
14
14
  isLocaleId,
15
15
  prepareSqliteRuntime,
16
16
  } from "@agfpd/iapeer-memory-core";
17
+ import type { Egress } from "../egress.js";
17
18
  import { memoryPaths } from "../paths.js";
18
19
  import { readSlot } from "../slot.js";
19
20
  import { packageVersion } from "../version.js";
@@ -50,10 +51,13 @@ export function searchPipelineLine(env: Record<string, string | undefined>): str
50
51
 
51
52
  /** Live pipeline from the running memoryd — the same per-component statuses
52
53
  * every vault_search returns. Null when memoryd is unreachable. */
53
- export async function probeSearchPipeline(port: number): Promise<string | null> {
54
+ export async function probeSearchPipeline(
55
+ egress: Egress,
56
+ port: number,
57
+ ): Promise<string | null> {
54
58
  ensureLoopbackNotProxied(); // fleet-class: proxy-env lies about live loopback ports
55
59
  try {
56
- const res = await fetch(`http://127.0.0.1:${port}/mcp`, {
60
+ const res = await egress.fetch(`http://127.0.0.1:${port}/mcp`, {
57
61
  method: "POST",
58
62
  headers: {
59
63
  "content-type": "application/json",
@@ -85,10 +89,13 @@ export async function probeSearchPipeline(port: number): Promise<string | null>
85
89
  }
86
90
  }
87
91
 
88
- async function probeMcp(port: number): Promise<{ line: string; alive: boolean }> {
92
+ async function probeMcp(
93
+ egress: Egress,
94
+ port: number,
95
+ ): Promise<{ line: string; alive: boolean }> {
89
96
  ensureLoopbackNotProxied(); // fleet-class: proxy-env lies about live loopback ports
90
97
  try {
91
- const res = await fetch(`http://127.0.0.1:${port}/mcp`, {
98
+ const res = await egress.fetch(`http://127.0.0.1:${port}/mcp`, {
92
99
  method: "POST",
93
100
  headers: { "content-type": "application/json" },
94
101
  body: "{}",
@@ -102,7 +109,7 @@ async function probeMcp(port: number): Promise<{ line: string; alive: boolean }>
102
109
  }
103
110
  }
104
111
 
105
- export async function cmdStatus(argv: string[]): Promise<number> {
112
+ export async function cmdStatus(argv: string[], egress: Egress): Promise<number> {
106
113
  if (argv.length) {
107
114
  console.error(`iapeer-memory status: unknown flag: ${argv[0]}`);
108
115
  return 2;
@@ -111,7 +118,7 @@ export async function cmdStatus(argv: string[]): Promise<number> {
111
118
  const version = packageVersion();
112
119
  console.log(`iapeer-memory v${version}`);
113
120
 
114
- const results = runVerify({ repair: false });
121
+ const results = runVerify(egress, { repair: false });
115
122
  const width = Math.max(...results.map((r) => r.name.length), 12);
116
123
  for (const r of results) {
117
124
  const mark =
@@ -128,11 +135,11 @@ export async function cmdStatus(argv: string[]): Promise<number> {
128
135
  );
129
136
 
130
137
  const port = Number(process.env.IAPEER_MEMORY_MCP_PORT || "") || 8766;
131
- const mcp = await probeMcp(port);
138
+ const mcp = await probeMcp(egress, port);
132
139
  console.log(` ${"mcp-endpoint".padEnd(width)} ${mcp.line}`);
133
140
  // The live pipeline is only probed when the endpoint is alive — a dead
134
141
  // port already told us everything (and the static view says the rest).
135
- const livePipeline = mcp.alive ? await probeSearchPipeline(port) : null;
142
+ const livePipeline = mcp.alive ? await probeSearchPipeline(egress, port) : null;
136
143
  console.log(
137
144
  ` ${"search".padEnd(width)} ` +
138
145
  (livePipeline ?? `${searchPipelineLine(process.env)} (memoryd down — static view)`),
@@ -3,11 +3,12 @@
3
3
  * host. SYMMETRY OBLIGATION of the memory-slot contract: the provider that
4
4
  * writes the slot declaration removes it.
5
5
  *
6
- * What it removes: slot declaration (own only a foreign slot is refused),
7
- * the compiled binary. What it deliberately KEEPS: the vault (user data),
8
- * the package config (operator-owned), state/cache (cheap to rebuild, may
9
- * hold migrate backups!). What lands in P3c: notifier deregistration +
10
- * memoryd stop (the watcher owns the process lifecycle).
6
+ * What it removes: direct session surfaces across the fleet (ADR-009 v1.2
7
+ * own entries/keys/dirs only, swept BEFORE the declaration falls), the slot
8
+ * declaration (own only a foreign slot is refused), notifier triggers,
9
+ * memoryd (verified-pid stop), the compiled binary. What it deliberately
10
+ * KEEPS: the vault (user data), the package config (operator-owned),
11
+ * state/cache (cheap to rebuild, may hold migrate backups!).
11
12
  *
12
13
  * Native auto-memory of the fleet is NOT restored (contract decision,
13
14
  * c968219): silent re-enabling would quietly resurrect split memory across
@@ -16,9 +17,14 @@
16
17
  */
17
18
 
18
19
  import fs from "node:fs";
20
+ import type { Egress } from "../egress.js";
19
21
  import { memoryPaths } from "../paths.js";
20
22
  import { removeBinary } from "../binary.js";
23
+ import { readFleetMap } from "../fleet.js";
21
24
  import { applyMemoryPlugin, readSlot, removeSlot, SLOT_PROVIDER } from "../slot.js";
25
+ import { withProvisionLock } from "../surfaces/lock.js";
26
+ import { sweepUnprovision } from "../surfaces/sweep.js";
27
+ import { guardedUnlinkSync } from "@agfpd/iapeer-memory-core";
22
28
  import {
23
29
  DREAM_TRIGGER_ID,
24
30
  SWEEP_TRIGGER_ID,
@@ -34,18 +40,12 @@ import {
34
40
  * the command closes the "signal a stranger" class. Probe failure → false
35
41
  * (never signal on uncertainty).
36
42
  */
37
- export function pidLooksLikeOurs(pid: number): boolean {
38
- try {
39
- const proc = Bun.spawnSync(["ps", "-o", "command=", "-p", String(pid)], {
40
- stdout: "pipe",
41
- stderr: "pipe",
42
- });
43
- if (proc.exitCode !== 0) return false;
44
- const command = proc.stdout.toString().trim();
45
- return command.includes("memoryd");
46
- } catch {
47
- return false;
48
- }
43
+ export function pidLooksLikeOurs(egress: Egress, pid: number): boolean {
44
+ // `ps` probe — egress allowance 3 (read-only lookup FEEDING the verified
45
+ // kill; refusing it would break the guard itself). Never throws.
46
+ const proc = egress.spawnSync(["ps", "-o", "command=", "-p", String(pid)]);
47
+ if (proc.spawnError || proc.exitCode !== 0) return false;
48
+ return proc.stdout.trim().includes("memoryd");
49
49
  }
50
50
 
51
51
  /**
@@ -56,30 +56,27 @@ export function pidLooksLikeOurs(pid: number): boolean {
56
56
  * by uninstall (stop) and update (managed restart: SIGTERM → the notifier
57
57
  * watcher relaunches via the launcher with the fresh binary, ADR-010).
58
58
  */
59
- export function stopMemorydByPidFile(pidPath: string): string {
59
+ export function stopMemorydByPidFile(egress: Egress, pidPath: string): string {
60
60
  let line = "not running (no pid file)";
61
61
  try {
62
62
  const pid = Number(fs.readFileSync(pidPath, "utf-8").trim());
63
63
  if (Number.isInteger(pid) && pid > 1) {
64
- if (!pidLooksLikeOurs(pid)) {
64
+ if (!pidLooksLikeOurs(egress, pid)) {
65
65
  line = `pid file points at a non-memoryd process (${pid}) — NOT signalling; stale file removed`;
66
66
  } else {
67
- try {
68
- process.kill(pid, "SIGTERM");
69
- line = `SIGTERM sent to pid ${pid} (command verified)`;
70
- } catch {
71
- line = `stale pid file (process ${pid} gone) — removed`;
72
- }
67
+ line = egress.kill(pid, "SIGTERM").delivered
68
+ ? `SIGTERM sent to pid ${pid} (command verified)`
69
+ : `stale pid file (process ${pid} gone) — removed`;
73
70
  }
74
71
  }
75
- fs.unlinkSync(pidPath);
72
+ guardedUnlinkSync(pidPath);
76
73
  } catch {
77
74
  // no pid file — nothing to stop
78
75
  }
79
76
  return line;
80
77
  }
81
78
 
82
- export function cmdUninstall(argv: string[]): number {
79
+ export function cmdUninstall(argv: string[], egress: Egress): number {
83
80
  let keepBinary = false;
84
81
  let iapeerBin = "iapeer";
85
82
  for (let i = 0; i < argv.length; i++) {
@@ -95,26 +92,59 @@ export function cmdUninstall(argv: string[]): number {
95
92
  const paths = memoryPaths();
96
93
  let failed = false;
97
94
 
98
- // Session plugin OFF across the fleet BEFORE removing the declaration —
99
- // the core verb DERIVES the plugin identity from the slot's v1.1 block;
100
- // once the declaration is gone there is nothing to derive from (agreed
101
- // order with the core, auto-removal: a dead provider's plugin must not
102
- // keep injecting). Guard: only when the slot is OURS — running `off`
103
- // against a foreign declaration would strip the FOREIGN provider's plugin.
104
- // codex nuance: the codex plugin is host-global, so `off` there is always
105
- // `--all` semantics; per-peer off on codex answers «use --all».
95
+ // Direct session surfaces OFF across the fleet BEFORE removing the
96
+ // declaration (ADR-009 v1.2 mirror symmetry: a dead provider's surfaces
97
+ // must not keep pointing at a void). Guard: only when the slot is OURS.
106
98
  const declared = readSlot(paths.slotPath);
107
99
  if (declared && declared.provider === SLOT_PROVIDER) {
108
- const off = applyMemoryPlugin({ mode: "off", iapeerBin });
109
- console.log(
110
- `plugin : ${
111
- off.suppressed
112
- ? "skipped (test sandbox core calls suppressed)"
113
- : off.ok
114
- ? "session plugin removed across the fleet (memory-plugin off --all; codex side is host-global)"
115
- : `off not applied (${off.detail.slice(0, 160)}) — manual fallback (works without the slot): per claude peer \`claude plugin uninstall iapeer-memory@agfpd --scope project\` from its cwd; codex (host-global): \`codex plugin remove iapeer-memory@agfpd\``
116
- }`,
117
- );
100
+ const fleet = readFleetMap(paths.fleetMapPath);
101
+ if (!fleet) {
102
+ console.log(
103
+ `surfaces : fleet map missing/unreadable (${paths.fleetMapPath}) — nothing swept; ` +
104
+ "manual per peer: iapeer-memory unprovision-peer --cwd <cwd> --runtime <r>",
105
+ );
106
+ } else {
107
+ const locked = withProvisionLock({
108
+ stateDir: paths.stateDir,
109
+ fn: () => sweepUnprovision({ fleet }),
110
+ });
111
+ if (!locked.acquired) {
112
+ console.log(`surfaces : ${locked.detail}`);
113
+ failed = true;
114
+ } else {
115
+ const { results, skipped } = locked.result;
116
+ const bad = results.filter((r) => !r.ok);
117
+ console.log(
118
+ `surfaces : stripped from ${results.length - bad.length}/${results.length} peer-runtime(s)` +
119
+ (skipped.length ? ` (${skipped.length} skipped)` : ""),
120
+ );
121
+ for (const b of bad) {
122
+ failed = true;
123
+ console.log(
124
+ `surfaces : FAIL ${b.personality}:${b.runtime} — ${b.outcomes
125
+ .filter((o) => o.action === "failed")
126
+ .map((o) => `${o.surface}: ${o.detail ?? "failed"}`)
127
+ .join("; ")}`,
128
+ );
129
+ }
130
+ }
131
+ }
132
+
133
+ // Legacy v1.1 path: the slot still carries a plugin block — sweep the
134
+ // session plugin off via the core verb WHILE the declaration is alive
135
+ // (it derives the identity from it; agreed order, auto-removal).
136
+ if (declared.plugin) {
137
+ const off = applyMemoryPlugin(egress, { mode: "off", iapeerBin });
138
+ console.log(
139
+ `plugin : ${
140
+ off.suppressed
141
+ ? "skipped (test sandbox — core calls suppressed)"
142
+ : off.ok
143
+ ? "legacy session plugin removed across the fleet (memory-plugin off --all; codex side is host-global)"
144
+ : `off not applied (${off.detail.slice(0, 160)}) — manual fallback (works without the slot): per claude peer \`claude plugin uninstall iapeer-memory@agfpd --scope project\` from its cwd; codex (host-global): \`codex plugin remove iapeer-memory@agfpd\``
145
+ }`,
146
+ );
147
+ }
118
148
  }
119
149
 
120
150
  const slot = removeSlot(paths.slotPath);
@@ -127,7 +157,7 @@ export function cmdUninstall(argv: string[]): number {
127
157
 
128
158
  // notifier wiring: best-effort unregister of all three triggers (not-found
129
159
  // is soft on the notifier side; teaching replies go to the index session).
130
- const unreg = unregisterWatcher({ iapeerBin });
160
+ const unreg = unregisterWatcher(egress, { iapeerBin });
131
161
  console.log(
132
162
  `watcher : ${
133
163
  unreg.ok
@@ -136,7 +166,7 @@ export function cmdUninstall(argv: string[]): number {
136
166
  }`,
137
167
  );
138
168
  for (const id of [SWEEP_TRIGGER_ID, DREAM_TRIGGER_ID]) {
139
- const t = unregisterTimer({ id, iapeerBin });
169
+ const t = unregisterTimer(egress, { id, iapeerBin });
140
170
  console.log(
141
171
  `timer : ${
142
172
  t.ok
@@ -146,7 +176,7 @@ export function cmdUninstall(argv: string[]): number {
146
176
  );
147
177
  }
148
178
 
149
- console.log(`memoryd : ${stopMemorydByPidFile(paths.pidPath)}`);
179
+ console.log(`memoryd : ${stopMemorydByPidFile(egress, paths.pidPath)}`);
150
180
 
151
181
  if (keepBinary) {
152
182
  console.log(`binary : kept (${paths.binaryPath})`);