@agfpd/iapeer-memory 0.1.1 → 0.1.3

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/src/watcher.ts CHANGED
@@ -4,10 +4,14 @@
4
4
  * registration.ts/peerProfileStore.ts + live run):
5
5
  *
6
6
  * - the REGISTRANT is the IAP envelope's from-personality:
7
- * `iapeer send watcher --from index` puts the trigger into
7
+ * `iapeer send watcher --from claude-index` puts the trigger into
8
8
  * `<index-cwd>/.iapeer/peer-profile.json` → `notifier.triggers[]`,
9
9
  * owner=index (writing into a foreign profile is impossible by
10
10
  * invariant). We register from INDEX — the consumer of memoryd events.
11
+ * `--from` takes the full IDENTITY `<runtime>-<personality>` (iapeer
12
+ * cli fact, verified by e2e §A: bare "index" → exit 1 «invalid --from
13
+ * identity»); the durable trigger's `owner` stays the parsed
14
+ * PERSONALITY ("index") — readWatcherTrigger matching is unaffected.
11
15
  * - replies (✓ registered / teaching errors / list) go to the
12
16
  * from-personality SESSION, never to this script — so registration
13
17
  * success is verified by READING THE STATE, not the reply:
@@ -33,6 +37,17 @@ import fs from "node:fs";
33
37
  import path from "node:path";
34
38
 
35
39
  export const WATCHER_TRIGGER_ID = "iapeer-memory-memoryd";
40
+ /** Fail-open sweep (инверсия, ADR-015): check-gated hourly timer → index. */
41
+ export const SWEEP_TRIGGER_ID = "iapeer-memory-inbox-sweep";
42
+ /** Weekly DreamWeaver tick (директива «всё КРОМЕ DREAM_TICK — копирайтеру»):
43
+ * a notifier TIMER straight to the index — memoryd never emitted this event
44
+ * (И1 fact), so the copywriter never sees it. */
45
+ export const DREAM_TRIGGER_ID = "iapeer-memory-dream-tick";
46
+ /** The inverted pipeline's first receiver of memoryd events (директива
47
+ * Артура 10.06): the copywriter vets BEFORE placement and reports to the
48
+ * index; same-id re-registration REPLACES the trigger (notifier contract),
49
+ * so update/verify --repair re-target idempotently. */
50
+ export const DEFAULT_EVENT_TARGET = "copywriter";
36
51
 
37
52
  /** The watcher runs one executable file — a launcher wrapping the stable binary. */
38
53
  export function launcherScriptContent(binaryPath: string): string {
@@ -46,21 +61,99 @@ export function launcherScriptContent(binaryPath: string): string {
46
61
  ].join("\n");
47
62
  }
48
63
 
64
+ function writeExecutable(filePath: string, content: string): "written" | "identical" {
65
+ try {
66
+ if (fs.readFileSync(filePath, "utf-8") === content) return "identical";
67
+ } catch {
68
+ // absent — write
69
+ }
70
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
71
+ const tmp = `${filePath}.tmp`;
72
+ fs.writeFileSync(tmp, content, "utf-8");
73
+ fs.chmodSync(tmp, 0o755);
74
+ fs.renameSync(tmp, filePath);
75
+ return "written";
76
+ }
77
+
49
78
  export function writeLauncherScript(opts: {
50
79
  launcherPath: string;
51
80
  binaryPath: string;
52
81
  }): "written" | "identical" {
53
- const content = launcherScriptContent(opts.binaryPath);
82
+ return writeExecutable(opts.launcherPath, launcherScriptContent(opts.binaryPath));
83
+ }
84
+
85
+ /**
86
+ * Sweep check-script (fail-open, ADR-015): the notifier runs it before each
87
+ * sweep fire; exit 0 ⇔ stale drafts exist (the index is woken ONLY on a real
88
+ * backlog). Paths and the threshold are BAKED at generation — the notifier's
89
+ * env carries no IAPEER_MEMORY_* context. Covers BOTH inboxes: the human
90
+ * inbox has no memoryd emission yet (И1 fact) — the sweep is its bridge.
91
+ */
92
+ export function staleCheckScriptContent(opts: {
93
+ vaultPath: string;
94
+ inboxFolders: string[];
95
+ staleSecs: number;
96
+ }): string {
97
+ const dirs = opts.inboxFolders
98
+ .map((f) => `"${path.join(opts.vaultPath, f)}"`)
99
+ .join(" ");
100
+ return [
101
+ "#!/usr/bin/env bash",
102
+ "# iapeer-memory inbox-stale check — generated by init/update (package-owned).",
103
+ "# exit 0 = stale drafts exist (notifier fires the sweep), exit 1 = all clear.",
104
+ `T=${opts.staleSecs}`,
105
+ "now=$(date +%s)",
106
+ `for dir in ${dirs}; do`,
107
+ ' [ -d "$dir" ] || continue',
108
+ ' for f in "$dir"/*.md; do',
109
+ ' [ -e "$f" ] || continue',
110
+ ' m=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null) || continue',
111
+ ' [ $((now - m)) -ge "$T" ] && exit 0',
112
+ " done",
113
+ "done",
114
+ "exit 1",
115
+ "",
116
+ ].join("\n");
117
+ }
118
+
119
+ export function writeStaleCheckScript(opts: {
120
+ checkScriptPath: string;
121
+ vaultPath: string;
122
+ inboxFolders: string[];
123
+ staleSecs?: number;
124
+ }): "written" | "identical" {
125
+ return writeExecutable(
126
+ opts.checkScriptPath,
127
+ staleCheckScriptContent({
128
+ vaultPath: opts.vaultPath,
129
+ inboxFolders: opts.inboxFolders,
130
+ staleSecs: opts.staleSecs ?? 7200,
131
+ }),
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Declare the copywriter an ephemeral worker (clean window per delivery,
137
+ * M1/M2/M3 — core canon «wake_policy ephemeral»). The profile is a CORE
138
+ * file: no-clobber merge of exactly one key, atomic write.
139
+ */
140
+ export function patchWakePolicyEphemeral(
141
+ peerCwd: string,
142
+ ): "written" | "identical" | "missing-profile" {
143
+ const profilePath = path.join(peerCwd, ".iapeer", "peer-profile.json");
144
+ let profile: Record<string, unknown>;
54
145
  try {
55
- if (fs.readFileSync(opts.launcherPath, "utf-8") === content) return "identical";
146
+ const raw = JSON.parse(fs.readFileSync(profilePath, "utf-8")) as unknown;
147
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "missing-profile";
148
+ profile = raw as Record<string, unknown>;
56
149
  } catch {
57
- // absent — write
150
+ return "missing-profile";
58
151
  }
59
- fs.mkdirSync(path.dirname(opts.launcherPath), { recursive: true });
60
- const tmp = `${opts.launcherPath}.tmp`;
61
- fs.writeFileSync(tmp, content, "utf-8");
62
- fs.chmodSync(tmp, 0o755);
63
- fs.renameSync(tmp, opts.launcherPath);
152
+ if (profile.wake_policy === "ephemeral") return "identical";
153
+ profile.wake_policy = "ephemeral";
154
+ const tmp = `${profilePath}.tmp`;
155
+ fs.writeFileSync(tmp, `${JSON.stringify(profile, null, 2)}\n`, "utf-8");
156
+ fs.renameSync(tmp, profilePath);
64
157
  return "written";
65
158
  }
66
159
 
@@ -71,23 +164,96 @@ export function registrationMessage(opts: {
71
164
  }): string {
72
165
  return JSON.stringify({
73
166
  script: opts.script,
74
- target: opts.target ?? "index",
167
+ target: opts.target ?? DEFAULT_EVENT_TARGET,
75
168
  id: opts.id ?? WATCHER_TRIGGER_ID,
76
169
  });
77
170
  }
78
171
 
79
- export type IapSendResult = { ok: boolean; detail: string };
172
+ /** Sweep timer registration body (sent to the `timer` peer). The message is
173
+ * self-contained — it lands in a FRESH index session. */
174
+ export function sweepTimerMessage(opts: {
175
+ checkScriptPath: string;
176
+ every?: string;
177
+ target?: string;
178
+ id?: string;
179
+ }): string {
180
+ return JSON.stringify({
181
+ when: opts.every ?? "@every 1h",
182
+ message:
183
+ "INBOX_SWEEP: stale drafts sit in the inbox folders — the copywriter " +
184
+ "thread did not process them in time. Place them per your doctrine, " +
185
+ "unvetted: needs_review: true on each; the copywriter re-vets via " +
186
+ "PERMANENT_CHANGED when alive.",
187
+ target: opts.target ?? "index",
188
+ check: opts.checkScriptPath,
189
+ id: opts.id ?? SWEEP_TRIGGER_ID,
190
+ });
191
+ }
192
+
193
+ /** Weekly DreamWeaver tick (Mondays 04:00 by default — the human sleeps). */
194
+ export function dreamTimerMessage(opts?: {
195
+ cron?: string;
196
+ target?: string;
197
+ id?: string;
198
+ }): string {
199
+ return JSON.stringify({
200
+ when: opts?.cron ?? "0 4 * * 1",
201
+ message:
202
+ "DREAM_TICK: weekly agent-memory consolidation. Fan out DreamWeaver " +
203
+ "over the agent-memory subfolders (including your own), strictly one " +
204
+ "folder per task, sequentially — per your doctrine.",
205
+ target: opts?.target ?? "index",
206
+ id: opts?.id ?? DREAM_TRIGGER_ID,
207
+ });
208
+ }
209
+
210
+ export type IapSendResult = {
211
+ ok: boolean;
212
+ detail: string;
213
+ /** True when the test-sandbox fuse blocked the send — callers report a
214
+ * SKIP, not a failure (the iapeer `skipped-sandbox` precedent). */
215
+ suppressed?: boolean;
216
+ };
217
+
218
+ /** Default registrant personality (trigger owner + event target). */
219
+ const DEFAULT_REGISTRANT = "index";
220
+ /** Role peers are claude-runtime sessions (init creates them via `iapeer create`). */
221
+ const DEFAULT_FROM_RUNTIME = "claude";
222
+
223
+ /** `--from` wants the full identity `<runtime>-<personality>`, never a bare name. */
224
+ export function fromIdentity(personality: string, runtime = DEFAULT_FROM_RUNTIME): string {
225
+ return `${runtime}-${personality}`;
226
+ }
80
227
 
81
228
  function iapSend(opts: {
82
229
  message: string;
83
- from: string;
230
+ fromIdentity: string;
231
+ /** Notifier peer to address: `watcher` (events) or `timer` (cron/sweep). */
232
+ to?: "watcher" | "timer";
84
233
  iapeerBin?: string;
85
234
  }): IapSendResult {
235
+ // Hard fuse (incident 10.06, prod Index): verify-repair tests reached the
236
+ // LIVE watcher peer and registered crashlooping temp-path triggers — /tmp
237
+ // tmux sockets are host-global, no sandbox env can contain a real send.
238
+ // Test scripts set this env (package.json), mirroring IAPEER_TEST_SANDBOX.
239
+ // BOTH vars honoured (belt and braces): a test helper once stripped all
240
+ // IAPEER_MEMORY_* env before spawning the CLI — the ecosystem-wide
241
+ // IAPEER_TEST_SANDBOX survives generic prefix-stripping.
242
+ if (
243
+ process.env.IAPEER_MEMORY_SUPPRESS_IAP_SEND === "1" ||
244
+ process.env.IAPEER_TEST_SANDBOX === "1"
245
+ ) {
246
+ return {
247
+ ok: false,
248
+ suppressed: true,
249
+ detail: "iap send suppressed (test sandbox)",
250
+ };
251
+ }
86
252
  const bin = opts.iapeerBin ?? "iapeer";
87
253
  let proc: ReturnType<typeof Bun.spawnSync>;
88
254
  try {
89
255
  proc = Bun.spawnSync(
90
- [bin, "send", "watcher", "--from", opts.from, "--message", opts.message],
256
+ [bin, "send", opts.to ?? "watcher", "--from", opts.fromIdentity, "--message", opts.message],
91
257
  { stdout: "pipe", stderr: "pipe" },
92
258
  );
93
259
  } catch (err) {
@@ -108,55 +274,97 @@ function iapSend(opts: {
108
274
 
109
275
  export function registerWatcher(opts: {
110
276
  launcherPath: string;
111
- from?: string;
277
+ /** Registrant PERSONALITY (owner of the durable trigger + default target). */
278
+ registrant?: string;
279
+ /** Runtime prefix of the registrant's identity (claude for role peers). */
280
+ runtime?: string;
112
281
  target?: string;
113
282
  id?: string;
114
283
  iapeerBin?: string;
115
284
  }): IapSendResult {
285
+ const registrant = opts.registrant ?? DEFAULT_REGISTRANT;
116
286
  return iapSend({
117
- from: opts.from ?? "index",
287
+ fromIdentity: fromIdentity(registrant, opts.runtime),
118
288
  iapeerBin: opts.iapeerBin,
119
289
  message: registrationMessage({
120
290
  script: opts.launcherPath,
121
- target: opts.target ?? opts.from ?? "index",
291
+ // No fallback to the registrant here: the EVENT target default
292
+ // (copywriter, inverted pipeline) lives in registrationMessage.
293
+ target: opts.target,
122
294
  id: opts.id,
123
295
  }),
124
296
  });
125
297
  }
126
298
 
127
299
  export function unregisterWatcher(opts: {
128
- from?: string;
300
+ registrant?: string;
301
+ runtime?: string;
129
302
  id?: string;
130
303
  iapeerBin?: string;
131
304
  }): IapSendResult {
132
305
  return iapSend({
133
- from: opts.from ?? "index",
306
+ fromIdentity: fromIdentity(opts.registrant ?? DEFAULT_REGISTRANT, opts.runtime),
134
307
  iapeerBin: opts.iapeerBin,
135
308
  message: JSON.stringify({ cmd: "unregister", id: opts.id ?? WATCHER_TRIGGER_ID }),
136
309
  });
137
310
  }
138
311
 
312
+ /** Register a timer trigger (sweep/dream) — body built by *TimerMessage(). */
313
+ export function registerTimer(opts: {
314
+ message: string;
315
+ registrant?: string;
316
+ runtime?: string;
317
+ iapeerBin?: string;
318
+ }): IapSendResult {
319
+ return iapSend({
320
+ to: "timer",
321
+ fromIdentity: fromIdentity(opts.registrant ?? DEFAULT_REGISTRANT, opts.runtime),
322
+ iapeerBin: opts.iapeerBin,
323
+ message: opts.message,
324
+ });
325
+ }
326
+
327
+ export function unregisterTimer(opts: {
328
+ id: string;
329
+ registrant?: string;
330
+ runtime?: string;
331
+ iapeerBin?: string;
332
+ }): IapSendResult {
333
+ return iapSend({
334
+ to: "timer",
335
+ fromIdentity: fromIdentity(opts.registrant ?? DEFAULT_REGISTRANT, opts.runtime),
336
+ iapeerBin: opts.iapeerBin,
337
+ message: JSON.stringify({ cmd: "unregister", id: opts.id }),
338
+ });
339
+ }
340
+
139
341
  export type WatcherTrigger = {
342
+ /** Durable role token: "event" (watcher) | "time" (timer) — notifier fact. */
140
343
  role: string;
141
344
  id: string;
142
345
  owner: string;
143
346
  target?: string;
144
347
  script?: string;
348
+ /** Timer-only: the check-gate script path. */
349
+ check?: string;
145
350
  heartbeatSec?: number;
146
351
  topic?: string;
147
352
  };
148
353
 
149
354
  /**
150
- * Read the durable trigger from the registrant's peer profile — the
355
+ * Read a durable trigger from the registrant's peer profile — the
151
356
  * canonical storage contract (sanctioned). Never throws.
152
357
  */
153
358
  export function readWatcherTrigger(opts: {
154
359
  registrantCwd: string;
155
360
  id?: string;
156
361
  owner?: string;
362
+ /** "event" (default, watcher triggers) or "time" (timers). */
363
+ role?: "event" | "time";
157
364
  }): WatcherTrigger | null {
158
365
  const id = opts.id ?? WATCHER_TRIGGER_ID;
159
366
  const owner = opts.owner ?? "index";
367
+ const role = opts.role ?? "event";
160
368
  try {
161
369
  const profile = JSON.parse(
162
370
  fs.readFileSync(
@@ -166,7 +374,7 @@ export function readWatcherTrigger(opts: {
166
374
  ) as { notifier?: { triggers?: WatcherTrigger[] } };
167
375
  const triggers = profile.notifier?.triggers ?? [];
168
376
  return (
169
- triggers.find((t) => t.role === "event" && t.id === id && t.owner === owner) ??
377
+ triggers.find((t) => t.role === role && t.id === id && t.owner === owner) ??
170
378
  null
171
379
  );
172
380
  } catch {