@agfpd/iapeer-memory 0.2.8 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer-memory",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "iapeer-memory — peer memory for the iapeer ecosystem: vault, memoryd (index/search/MCP-http), layer-5 context fragments, role doctrines. The package IS the system; the claude/codex plugins are thin session sockets (docs/10-distribution.md, ADR-009).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@agfpd/iapeer-memory-core": "0.2.8"
30
+ "@agfpd/iapeer-memory-core": "0.3.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/bun": "^1.2.0",
package/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ import { cmdInstallBinary } from "./commands/install-binary.js";
25
25
  import { cmdMemoryd } from "./commands/memoryd.js";
26
26
  import { cmdMigrate } from "./commands/migrate.js";
27
27
  import { cmdDreamCollect } from "./commands/dream-collect.js";
28
+ import { cmdArchiveStale } from "./commands/archive-stale.js";
28
29
  import { cmdProvisionPeer, cmdUnprovisionPeer } from "./commands/provision-peer.js";
29
30
  import { cmdRender } from "./commands/render.js";
30
31
  import { cmdStatus } from "./commands/status.js";
@@ -42,7 +43,6 @@ Commands:
42
43
  uninstall [--keep-binary] remove the system: slot declaration + binary
43
44
  (vault and config are kept — user-owned)
44
45
  status read-only diagnostics: verify + slot + MCP probe
45
- + inbox load
46
46
  verify [--repair] check (and repair) the live surfaces: config,
47
47
  memory-provider slot, memoryd heartbeat, role
48
48
  doctrine versions
@@ -68,6 +68,11 @@ Commands:
68
68
  batched tasks, from the LIVE registry (read-only).
69
69
  --gate: no output, exit 0 iff there is work (the
70
70
  notifier check that decides if DreamWeaver wakes)
71
+ archive-stale [--commit] deliberate backlog archiver (lean §2.2a): move
72
+ pre-existing stale notes to the archive. Dry-run by
73
+ default (lists what would move); --commit executes.
74
+ ALL content folders incl. 03_Projects (unified rule);
75
+ memoryd archives ongoing staleness on its own.
71
76
  render index|fragment|doctrine|guide
72
77
  render one artifact explicitly (memoryd does this
73
78
  continuously; render is the manual/scripted path)
@@ -134,6 +139,8 @@ export async function main(argv: string[]): Promise<number> {
134
139
  return cmdInstallBinary(rest, egress);
135
140
  case "dream-collect":
136
141
  return cmdDreamCollect(rest, egress);
142
+ case "archive-stale":
143
+ return cmdArchiveStale(rest);
137
144
  case "provision-peer":
138
145
  return cmdProvisionPeer(rest, egress);
139
146
  case "unprovision-peer":
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `iapeer-memory archive-stale [--commit]` — the DELIBERATE backlog archiver
3
+ * (lean §2.2a, decision boris 15.06).
4
+ *
5
+ * memoryd archives notes incrementally as they BECOME stale (an edit fires the
6
+ * change pass). Pre-existing stale notes are NOT swept on startup (that would
7
+ * be a mass move as a side-effect of a daemon boot — banned by §1: bulk
8
+ * actions are deliberate and verifiable). This verb is that deliberate path:
9
+ *
10
+ * archive-stale DRY-RUN — list what would move + a count
11
+ * archive-stale --commit actually move them
12
+ *
13
+ * Scope = ALL content folders, INCLUDING `03_Projects` (unified rule, decision
14
+ * Артур 15.06: a completed phase/project is stale like any note and archives
15
+ * too — `isArchivableZone`). memoryd reindexes the moves on its next pass (or
16
+ * on restart); the verb itself only moves files.
17
+ */
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import {
22
+ configFromEnv,
23
+ snapshotVault,
24
+ shouldArchive,
25
+ archiveTargetRel,
26
+ } from "@agfpd/iapeer-memory-core";
27
+
28
+ export function cmdArchiveStale(argv: string[]): number {
29
+ const commit = argv.includes("--commit");
30
+ const config = configFromEnv();
31
+ const vault = config.vaultPath;
32
+ const taxonomy = config.taxonomy;
33
+
34
+ // Candidates: notes in the monitored content folders carrying a final
35
+ // status — ALL content folders incl. 03_Projects (shouldArchive → isArchivableZone).
36
+ const snap = snapshotVault(vault, taxonomy);
37
+ const reserved = new Set<string>(); // archive targets claimed within this run
38
+ const moves: Array<{ from: string; to: string }> = [];
39
+ for (const rel of snap.keys()) {
40
+ let content: string;
41
+ try {
42
+ content = fs.readFileSync(path.join(vault, rel), "utf-8");
43
+ } catch {
44
+ continue;
45
+ }
46
+ if (!shouldArchive(rel, content, taxonomy)) continue;
47
+ const to = archiveTargetRel(
48
+ path.basename(rel),
49
+ taxonomy,
50
+ (r) => reserved.has(r) || fs.existsSync(path.join(vault, r)),
51
+ );
52
+ reserved.add(to);
53
+ moves.push({ from: rel, to });
54
+ }
55
+
56
+ if (moves.length === 0) {
57
+ console.log("archive-stale: no stale notes outside the archive — nothing to do.");
58
+ return 0;
59
+ }
60
+
61
+ if (!commit) {
62
+ console.log(
63
+ `archive-stale (DRY-RUN): ${moves.length} stale note(s) WOULD move to ${taxonomy.folders.archive}/:`,
64
+ );
65
+ for (const m of moves) console.log(` ${m.from} → ${m.to}`);
66
+ console.log(
67
+ `\nPass --commit to move them. (All content folders incl. 03_Projects — a completed phase/project archives like any stale note; memoryd archives ongoing staleness on its own.)`,
68
+ );
69
+ return 0;
70
+ }
71
+
72
+ let moved = 0;
73
+ for (const m of moves) {
74
+ const toAbs = path.join(vault, m.to);
75
+ try {
76
+ fs.mkdirSync(path.dirname(toAbs), { recursive: true });
77
+ fs.renameSync(path.join(vault, m.from), toAbs);
78
+ moved += 1;
79
+ console.log(` moved: ${m.from} → ${m.to}`);
80
+ } catch (err) {
81
+ console.error(` FAILED: ${m.from} (${String(err)})`);
82
+ }
83
+ }
84
+ console.log(
85
+ `archive-stale: moved ${moved}/${moves.length}. memoryd reindexes on its next pass (or restart).`,
86
+ );
87
+ return 0;
88
+ }
@@ -39,6 +39,15 @@ import {
39
39
  getTaxonomy,
40
40
  isLocaleId,
41
41
  resolveAgentName,
42
+ resolveZone,
43
+ splitFrontmatter,
44
+ parseNoteTags,
45
+ parseDictionaryTags,
46
+ tagGateProblems,
47
+ tagsDictionarySourceRel,
48
+ DEFAULT_DEDUP_THRESHOLD,
49
+ DEFAULT_LINK_HINT_THRESHOLD,
50
+ type TaxonomyPreset,
42
51
  } from "@agfpd/iapeer-memory-core";
43
52
  import { memoryPaths, type MemoryPaths } from "../paths.js";
44
53
  import { DEFAULT_HEARTBEAT_STALE_MS } from "./verify.js";
@@ -113,15 +122,6 @@ export type PostWriteResult = {
113
122
  output: string | null;
114
123
  };
115
124
 
116
- export function reminderText(inboxFolder: string): string {
117
- return (
118
- "[iapeer-memory] New note in your agent memory. Check the guide's " +
119
- "canon-vs-memory filter: does any part of it belong to the team's shared " +
120
- `knowledge? If yes — also drop a draft into ${inboxFolder}/ and mention ` +
121
- "this note inline as [[Title]] in the draft body; the Index will link them."
122
- );
123
- }
124
-
125
125
  export function runPostWrite(
126
126
  eventJson: string,
127
127
  env: Record<string, string | undefined> = process.env,
@@ -182,26 +182,225 @@ export function runPostWrite(
182
182
  stamp: true,
183
183
  });
184
184
 
185
- // Reminder: ONLY on a claude Write (new note) in the author's OWN memory
186
- // folder an Edit-loop must not spam the context (reference semantics).
187
- // The codex branch NEVER emits: hookSpecificOutput.additionalContext is a
188
- // claude protocol codex support is unverified (upstream issue #19385
189
- // ASKS for it, which reads as «not there»; fact-checked stance, not a gap).
190
- const ownMemoryDir =
191
- path.join(vault, taxonomy.folders.agentMemory, agent) + path.sep;
192
- const output =
193
- tool === "Write" && files[0]!.startsWith(ownMemoryDir)
194
- ? JSON.stringify({
195
- hookSpecificOutput: {
196
- hookEventName: "PostToolUse",
197
- additionalContext: reminderText(taxonomy.folders.inbox),
198
- },
199
- })
200
- : null;
201
-
185
+ // TAG GATE (lean §3): validate the canon note's tags against the dictionary
186
+ // and teach the author to fix any problem (unknown tag / no tag). The guard
187
+ // stays SILENT on a clean write (§2.3) — output is non-null ONLY on a
188
+ // problem. RUNTIME-AGNOSTIC: codex supports PostToolUse `additionalContext`
189
+ // too (official codex hooks docs the earlier «claude-only» was wrong), so
190
+ // the SAME schema reaches both runtimes. `files` already covers claude
191
+ // Write/Edit and codex apply_patch (multi-file).
192
+ const problems = collectTagProblems(files, vault, taxonomy);
193
+ const output = problems.length
194
+ ? JSON.stringify({
195
+ hookSpecificOutput: {
196
+ hookEventName: "PostToolUse",
197
+ additionalContext: tagTeaching(problems),
198
+ },
199
+ })
200
+ : null;
202
201
  return { stamped: true, output };
203
202
  }
204
203
 
204
+ /**
205
+ * Tag-gate problems across the just-written CANON files (lean §3). Reads the
206
+ * dictionary from the vault; FAIL-OPEN — an unreadable/empty dictionary (e.g.
207
+ * an evicted iCloud placeholder) yields no problems rather than rejecting
208
+ * every tag. The agent-memory (operative) zone is not gated (canon only).
209
+ */
210
+ export function collectTagProblems(
211
+ files: string[],
212
+ vault: string,
213
+ taxonomy: TaxonomyPreset,
214
+ ): string[] {
215
+ const dictRel = tagsDictionarySourceRel(taxonomy);
216
+ let allow: Set<string> | null = null;
217
+ try {
218
+ const dict = fs.readFileSync(path.join(vault, dictRel), "utf-8");
219
+ if (dict.trim()) allow = new Set(parseDictionaryTags(dict));
220
+ } catch {
221
+ // fail-open
222
+ }
223
+ if (!allow) return [];
224
+ const out: string[] = [];
225
+ for (const f of files) {
226
+ if (resolveZone(f, vault, taxonomy) !== "permanent") continue;
227
+ let fm: string;
228
+ try {
229
+ fm = splitFrontmatter(fs.readFileSync(f, "utf-8"))[0];
230
+ } catch {
231
+ continue;
232
+ }
233
+ const problems = tagGateProblems(parseNoteTags(fm), allow, {
234
+ requireAtLeastOne: true,
235
+ dictionaryRel: dictRel,
236
+ });
237
+ for (const p of problems) out.push(`${path.basename(f)}: ${p}`);
238
+ }
239
+ return out;
240
+ }
241
+
242
+ export function tagTeaching(problems: string[]): string {
243
+ return (
244
+ "[iapeer-memory] tag check — fix so this canon note indexes cleanly:\n" +
245
+ problems.map((p) => `- ${p}`).join("\n")
246
+ );
247
+ }
248
+
249
+ // ── dedup hint (lean §3a) ──────────────────────────────────────────────────────
250
+
251
+ /** Short fail-open budget for the dedup RPC — a slow/down memoryd must never
252
+ * hang a write-hook (same posture as the embedding circuit-breaker). */
253
+ export const DEDUP_TIMEOUT_MS = 1500;
254
+
255
+ type DedupResponse = {
256
+ enabled: boolean;
257
+ matches: Array<{ path: string; title: string; similarity: number }>;
258
+ };
259
+
260
+ /** Dedup band (≥ DEDUP_THRESHOLD → «possible duplicate», §3a) and link-hint
261
+ * band ([LINK_HINT_THRESHOLD, DEDUP_THRESHOLD) → «maybe link», §3b) for a
262
+ * canon write. One `/dedup` query, classified here. */
263
+ export type WriteHints = { dup: string[]; link: string[] };
264
+
265
+ function numEnv(v: string | undefined, fallback: number): number {
266
+ const n = Number(v);
267
+ return v !== undefined && !Number.isNaN(n) ? n : fallback;
268
+ }
269
+
270
+ /** POST the note body to memoryd's loopback /dedup RPC through the egress hub
271
+ * (loopback allowance — П6 topology), asking for matches ≥ queryThreshold.
272
+ * Fail-open: any error (timeout, refused, bad JSON) → null → silent. */
273
+ async function dedupFetch(
274
+ egress: Egress,
275
+ env: Record<string, string | undefined>,
276
+ content: string,
277
+ dupThreshold: number,
278
+ linkThreshold: number,
279
+ ): Promise<DedupResponse | null> {
280
+ const port = env.IAPEER_MEMORY_MCP_PORT || "8766";
281
+ const controller = new AbortController();
282
+ const timer = setTimeout(() => controller.abort(), DEDUP_TIMEOUT_MS);
283
+ try {
284
+ const res = await egress.fetch(`http://127.0.0.1:${port}/dedup`, {
285
+ method: "POST",
286
+ headers: { "Content-Type": "application/json" },
287
+ // Send BOTH band bounds so the daemon caps each band independently and
288
+ // the §3b link band is never starved by a burst of §3a dup matches.
289
+ body: JSON.stringify({ content, threshold: dupThreshold, linkThreshold }),
290
+ signal: controller.signal,
291
+ });
292
+ if (!res.ok) return null;
293
+ return (await res.json()) as DedupResponse;
294
+ } catch {
295
+ return null; // fail-open
296
+ } finally {
297
+ clearTimeout(timer);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Dedup + link hints for a just-written CANON note (lean §3a + §3b). ONE
303
+ * `/dedup` query (threshold = the lower band bound), classified into two
304
+ * contiguous bands: cosine ≥ DEDUP_THRESHOLD → «possible duplicate» (§3a);
305
+ * [LINK_HINT_THRESHOLD, DEDUP_THRESHOLD) → «maybe link» (§3b). Re-parses the
306
+ * event independently of `runPostWrite` (keeps that sync contract). Runtime-
307
+ * agnostic (Write/Edit + codex apply_patch). Canon-zone only. Embeddings-off →
308
+ * memoryd returns enabled:false → both bands empty (silent).
309
+ */
310
+ export async function collectDedupAndLinkHints(
311
+ eventJson: string,
312
+ egress: Egress,
313
+ env: Record<string, string | undefined> = process.env,
314
+ ): Promise<WriteHints> {
315
+ const empty: WriteHints = { dup: [], link: [] };
316
+ let event: {
317
+ tool_name?: string;
318
+ cwd?: string;
319
+ tool_input?: { file_path?: string };
320
+ tool_response?: unknown;
321
+ };
322
+ try {
323
+ event = JSON.parse(eventJson) as typeof event;
324
+ } catch {
325
+ return empty;
326
+ }
327
+ const tool = event.tool_name ?? "";
328
+ if (!POST_WRITE_TOOLS.has(tool)) return empty;
329
+ const vault = env.IAPEER_MEMORY_VAULT_PATH ?? "";
330
+ if (!vault) return empty;
331
+ const localeRaw = env.IAPEER_MEMORY_LOCALE || "en";
332
+ if (!isLocaleId(localeRaw)) return empty;
333
+ const taxonomy = getTaxonomy(localeRaw);
334
+
335
+ const dupThreshold = numEnv(env.IAPEER_MEMORY_DEDUP_THRESHOLD, DEFAULT_DEDUP_THRESHOLD);
336
+ const linkLow = numEnv(env.IAPEER_MEMORY_LINK_HINT_THRESHOLD, DEFAULT_LINK_HINT_THRESHOLD);
337
+
338
+ // Candidate files: claude Write/Edit carry one file_path; codex apply_patch
339
+ // carries a patch over possibly many (RUNTIME-AGNOSTIC — the additionalContext
340
+ // hint reaches both runtimes via the same channel).
341
+ const candidates =
342
+ tool === "apply_patch" ? applyPatchPaths(event) : [event.tool_input?.file_path ?? ""];
343
+ const vaultPrefix = vault.endsWith(path.sep) ? vault : vault + path.sep;
344
+ const files = candidates.filter(
345
+ (p) => p.endsWith(".md") && p.startsWith(vaultPrefix) && fs.existsSync(p),
346
+ );
347
+ const dup: string[] = [];
348
+ const link: string[] = [];
349
+ for (const file of files) {
350
+ if (resolveZone(file, vault, taxonomy) !== "permanent") continue; // canon only
351
+ let body: string;
352
+ try {
353
+ body = splitFrontmatter(fs.readFileSync(file, "utf-8"))[1];
354
+ } catch {
355
+ continue;
356
+ }
357
+ if (!body.trim()) continue;
358
+ const result = await dedupFetch(egress, env, body, dupThreshold, linkLow);
359
+ if (!result?.enabled || !result.matches?.length) continue;
360
+ for (const m of result.matches) {
361
+ if (path.basename(m.path) === path.basename(file)) continue; // self-guard (belt + braces)
362
+ const entry = `[[${m.title}]] (${Math.round(m.similarity * 100)}%)`;
363
+ if (m.similarity >= dupThreshold) dup.push(entry);
364
+ else if (m.similarity >= linkLow) link.push(entry);
365
+ }
366
+ }
367
+ return { dup, link };
368
+ }
369
+
370
+ /** Combine the sync tag-teaching output with the async dedup + link hints into
371
+ * one additionalContext blob (or null when all empty). */
372
+ export function mergeHookOutput(tagOutput: string | null, hints: WriteHints): string | null {
373
+ let ctx = "";
374
+ if (tagOutput) {
375
+ try {
376
+ ctx = (JSON.parse(tagOutput).hookSpecificOutput?.additionalContext as string) ?? "";
377
+ } catch {
378
+ ctx = "";
379
+ }
380
+ }
381
+ const add = (section: string) => {
382
+ ctx = ctx ? `${ctx}\n\n${section}` : section;
383
+ };
384
+ if (hints.dup.length) {
385
+ add(
386
+ "[iapeer-memory] possible duplicate(s) of this canon note — verify, then extend " +
387
+ "the existing note or keep only new material:\n" +
388
+ hints.dup.map((h) => `- ${h}`).join("\n"),
389
+ );
390
+ }
391
+ if (hints.link.length) {
392
+ add(
393
+ "[iapeer-memory] semantically close note(s) — consider linking [[…]] in the text " +
394
+ "if related (you decide; not every close note belongs):\n" +
395
+ hints.link.map((h) => `- ${h}`).join("\n"),
396
+ );
397
+ }
398
+ if (!ctx) return null;
399
+ return JSON.stringify({
400
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: ctx },
401
+ });
402
+ }
403
+
205
404
  // ── session-start ────────────────────────────────────────────────────────────
206
405
 
207
406
  export type SessionStartResult = {
@@ -298,8 +497,11 @@ export async function cmdHook(argv: string[], egress: Egress): Promise<number> {
298
497
  try {
299
498
  switch (event) {
300
499
  case "post-write": {
301
- const result = runPostWrite(await Bun.stdin.text());
302
- if (result.output) console.log(result.output);
500
+ const text = await Bun.stdin.text();
501
+ const result = runPostWrite(text); // sync: stamp + tag gate
502
+ const hints = await collectDedupAndLinkHints(text, egress); // async, fail-open §3a/§3b
503
+ const output = mergeHookOutput(result.output, hints);
504
+ if (output) console.log(output);
303
505
  return 0;
304
506
  }
305
507
  case "session-start": {
@@ -28,8 +28,11 @@ import {
28
28
  getTaxonomy,
29
29
  isLocaleId,
30
30
  renderDoctrine,
31
+ resolveMode,
32
+ curationPlan,
31
33
  writeHostWideGuideFragment,
32
34
  type LocaleId,
35
+ type MemoryMode,
33
36
  } from "@agfpd/iapeer-memory-core";
34
37
  import { installBinary } from "../binary.js";
35
38
  import { IAPEER_BIN, type Egress } from "../egress.js";
@@ -56,10 +59,8 @@ import {
56
59
  patchWakePolicyEphemeral,
57
60
  registerTimer,
58
61
  registerWatcher,
59
- sweepTimerMessage,
60
62
  writeDreamGateScript,
61
63
  writeLauncherScript,
62
- writeStaleCheckScript,
63
64
  } from "../watcher.js";
64
65
 
65
66
  type InitFlags = {
@@ -68,6 +69,8 @@ type InitFlags = {
68
69
  human?: string;
69
70
  embeddingEndpoint?: string;
70
71
  rerankerEndpoint?: string;
72
+ /** Curation mode (lean §7); default lean for new installs. */
73
+ mode?: string;
71
74
  nonInteractive: boolean;
72
75
  skipDeps: boolean;
73
76
  skipEcosystem: boolean;
@@ -98,6 +101,7 @@ function parseFlags(argv: string[]): InitFlags | null {
98
101
  case "--human": f.human = take(); break;
99
102
  case "--embedding-endpoint": f.embeddingEndpoint = take(); break;
100
103
  case "--reranker-endpoint": f.rerankerEndpoint = take(); break;
104
+ case "--mode": f.mode = take(); break;
101
105
  case "--non-interactive": f.nonInteractive = true; break;
102
106
  case "--skip-deps": f.skipDeps = true; break;
103
107
  case "--skip-ecosystem": f.skipEcosystem = true; break;
@@ -195,6 +199,21 @@ export async function cmdInit(argv: string[], egress: Egress): Promise<number> {
195
199
  return 2;
196
200
  }
197
201
  const locale: LocaleId = localeRaw;
202
+
203
+ // Curation mode (lean §7). Resolution: an explicit --mode wins; else PRESERVE
204
+ // the host's existing mode (cli.ts loads config.env into process.env before
205
+ // dispatch — a re-init of a curated host must NOT silently flip to lean and
206
+ // mis-wire its triggers, §10.3 / mode.ts); else default lean for a truly NEW
207
+ // install (cheap by default — the curation overlay is a deliberate opt-in).
208
+ const envMode = (process.env.IAPEER_MEMORY_MODE ?? "").trim().toLowerCase();
209
+ const preserved = envMode === "lean" || envMode === "curated" ? envMode : "lean";
210
+ const modeRaw = (flags.mode ?? preserved).trim().toLowerCase();
211
+ if (modeRaw !== "lean" && modeRaw !== "curated") {
212
+ console.error(`iapeer-memory init: --mode must be "lean" or "curated" (got "${flags.mode}")`);
213
+ return 2;
214
+ }
215
+ const mode: MemoryMode = modeRaw;
216
+ const plan = curationPlan(resolveMode({ ...process.env, IAPEER_MEMORY_MODE: mode }).roles);
198
217
  if (!human) {
199
218
  human = interactive
200
219
  ? ask("Human owner personality (empty = no human role)", humanDefault)
@@ -254,11 +273,12 @@ export async function cmdInit(argv: string[], egress: Egress): Promise<number> {
254
273
  configFile: paths.configFile,
255
274
  vaultPath: vault,
256
275
  locale,
276
+ mode,
257
277
  human: human || null,
258
278
  embeddingEndpoint: embeddingEndpoint || null,
259
279
  rerankerEndpoint: rerankerEndpoint || null,
260
280
  });
261
- step("config", `${paths.configFile} (${cfg})`);
281
+ step("config", `${paths.configFile} (${cfg}) mode=${mode}`);
262
282
 
263
283
  // 4. stable binary
264
284
  if (flags.skipBinary) {
@@ -372,10 +392,12 @@ export async function cmdInit(argv: string[], egress: Egress): Promise<number> {
372
392
  );
373
393
  }
374
394
 
375
- // 7. notifier wiring (ADR-015, инверсия): the EVENT trigger targets the
376
- // SCRIBER (first receiver); two TIMERS target the indexweekly
377
- // DREAM_TICK and the check-gated fail-open sweep. Registrant = index for
378
- // all three (one durable profile to verify); same-id re-send = replace.
395
+ // 7. notifier wiring GATED by the curation plan (lean §7). The WATCHER
396
+ // ALWAYS registers: its script LAUNCHES memoryd (the baseдетектор/архив/
397
+ // проекция/dedup runs in BOTH modes; never gated). Its forward target is
398
+ // the §7.1 conditional (scriber→index→placeholder), and memoryd SUPPRESSES
399
+ // curation emits in full-lean so the forward is empty. The SWEEP (→index)
400
+ // and DREAM (→dreamweaver) timers register ONLY when their role is proactive.
379
401
  if (flags.skipEcosystem) {
380
402
  step("watcher", "skipped (--skip-ecosystem)");
381
403
  step("timers", "skipped (--skip-ecosystem)");
@@ -383,6 +405,7 @@ export async function cmdInit(argv: string[], egress: Egress): Promise<number> {
383
405
  writeLauncherScript({ launcherPath: paths.launcherPath, binaryPath: paths.binaryPath });
384
406
  const sent = registerWatcher(egress, {
385
407
  launcherPath: paths.launcherPath,
408
+ target: plan.eventTarget ?? undefined, // null (full-lean) → default placeholder; memoryd emits nothing
386
409
  iapeerBin: flags.iapeerBin,
387
410
  });
388
411
  step(
@@ -390,41 +413,35 @@ export async function cmdInit(argv: string[], egress: Egress): Promise<number> {
390
413
  sent.suppressed
391
414
  ? "skipped (test sandbox — sends suppressed)"
392
415
  : sent.ok
393
- ? `registration sent (${paths.launcherPath} target scriber); confirm: iapeer-memory verify`
416
+ ? `registered (launches memoryd; curation target: ${plan.eventTarget ?? "none — lean: base runs, curation silent"}); confirm: iapeer-memory verify`
394
417
  : `registration failed — ${sent.detail}`,
395
418
  sent.ok || Boolean(sent.suppressed),
396
419
  );
397
420
 
398
- writeStaleCheckScript({
399
- checkScriptPath: paths.checkScriptPath,
400
- vaultPath: vault,
401
- inboxFolders: [getTaxonomy(locale).folders.inbox, getTaxonomy(locale).folders.inboxHuman],
402
- });
403
- const sweep = registerTimer(egress, {
404
- message: sweepTimerMessage({ checkScriptPath: paths.checkScriptPath }),
405
- iapeerBin: flags.iapeerBin,
406
- });
407
- writeDreamGateScript({
408
- dreamGateScriptPath: paths.dreamGateScriptPath,
409
- binaryPath: paths.binaryPath,
410
- });
411
- const dream = registerTimer(egress, {
412
- message: dreamTimerMessage({
413
- cron: process.env.IAPEER_MEMORY_DREAM_CRON,
421
+ if (plan.dream) {
422
+ writeDreamGateScript({
414
423
  dreamGateScriptPath: paths.dreamGateScriptPath,
415
- }),
416
- iapeerBin: flags.iapeerBin,
417
- });
418
- const timersSandboxed = sweep.suppressed && dream.suppressed;
419
- step(
420
- "timers",
421
- timersSandboxed
422
- ? "skipped (test sandbox — sends suppressed)"
423
- : sweep.ok && dream.ok
424
- ? `sweep (@every 1h, check ${paths.checkScriptPath}) + dream-tick (weekly, gated, → ${DREAM_TARGET})`
425
- : `sweep: ${sweep.ok ? "sent" : sweep.detail}; dream: ${dream.ok ? "sent" : dream.detail}`,
426
- Boolean(timersSandboxed) || (sweep.ok && dream.ok),
427
- );
424
+ binaryPath: paths.binaryPath,
425
+ });
426
+ const dream = registerTimer(egress, {
427
+ message: dreamTimerMessage({
428
+ cron: process.env.IAPEER_MEMORY_DREAM_CRON,
429
+ dreamGateScriptPath: paths.dreamGateScriptPath,
430
+ }),
431
+ iapeerBin: flags.iapeerBin,
432
+ });
433
+ step(
434
+ "dream",
435
+ dream.suppressed
436
+ ? "skipped (test sandbox)"
437
+ : dream.ok
438
+ ? `dream-tick (weekly, gated → ${DREAM_TARGET})`
439
+ : `dream: ${dream.detail}`,
440
+ dream.ok || Boolean(dream.suppressed),
441
+ );
442
+ } else {
443
+ step("dream", `not registered (mode ${mode}: dreamweaver not proactive)`);
444
+ }
428
445
  }
429
446
 
430
447
  // 8. slot + surfaces + v1.1 migration — ORDER MATTERS (ADR-009 v1.2):
@@ -4,8 +4,8 @@
4
4
  * iapeer-memory memoryd [--mcp-port N | --no-mcp] [--human NAME]
5
5
  *
6
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
7
+ * curation signal line (CURATOR_TICK — core emits it per cadence pass),
8
+ * stderr carries logs, SIGTERM/SIGINT shut down cleanly (flush + close). All state
9
9
  * paths come from the shared `paths.ts` namespace — the heartbeat lands
10
10
  * exactly where `verify` reads it, by construction.
11
11
  *
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import fs from "node:fs";
20
- import { configFromEnv, startMemoryd } from "@agfpd/iapeer-memory-core";
20
+ import { configFromEnv, startMemoryd, resolveMode, curationPlan } from "@agfpd/iapeer-memory-core";
21
21
  import { authorIndexPath, memoryPaths } from "../paths.js";
22
22
  import { guardedWriteFileSync, guardedUnlinkSync } from "@agfpd/iapeer-memory-core";
23
23
 
@@ -51,6 +51,17 @@ export async function cmdMemoryd(argv: string[]): Promise<number> {
51
51
  }
52
52
 
53
53
  const config = configFromEnv();
54
+ // Lean §7 emit-suppression: memoryd ALWAYS runs (the base — детектор/архив/
55
+ // проекция/dedup), but it EMITS the curation event (CURATOR_TICK) only when
56
+ // a proactive curation receiver exists (scriber ∥ index). Full-lean → a
57
+ // no-op emit: the curator tick still runs (the baseline stays current — a
58
+ // later lean→curated switch is clean), the watcher forwards nothing. The
59
+ // watcher trigger ITSELF always registers (it launches memoryd; never gated).
60
+ const { mode, roles } = resolveMode(process.env);
61
+ const emitCuration = curationPlan(roles).emit;
62
+ process.stderr.write(
63
+ `iapeer-memory memoryd: mode=${mode} curation-emit=${emitCuration ? "on" : "off (lean)"}\n`,
64
+ );
54
65
  const paths = memoryPaths();
55
66
  for (const dir of [paths.stateDir, paths.cacheDir, paths.logsDir]) {
56
67
  fs.mkdirSync(dir, { recursive: true });
@@ -67,6 +78,8 @@ export async function cmdMemoryd(argv: string[]): Promise<number> {
67
78
 
68
79
  const handle = await startMemoryd({
69
80
  config,
81
+ // full-lean → suppress curation emits (no-op); else core default (stdout).
82
+ emit: emitCuration ? undefined : () => {},
70
83
  heartbeatPath: paths.heartbeatPath,
71
84
  hashStatePath: paths.hashStatePath,
72
85
  tagsMirrorPath: paths.tagsMirrorPath,
@@ -125,7 +125,10 @@ function renderFragment(argv: string[]): number {
125
125
  logs: paths.logsDir,
126
126
  },
127
127
  authorIndexPath: indexFile,
128
- tagsDictionaryPath: agent === indexAgent ? paths.tagsMirrorPath : undefined,
128
+ // lean §3: the compact projection goes to EVERY peer (memoryd renders the
129
+ // projection file; a missing file is skipped gracefully by buildLayers).
130
+ tagsProjectionPath: paths.tagsProjectionPath,
131
+ tagsTitle: config.taxonomy.systemFiles.tagsDictionary,
129
132
  };
130
133
  const written = renderPeerFragment({ peerCwd, env });
131
134
  console.log(`render fragment: ${written}`);