@agfpd/iapeer-memory 0.2.8 → 0.2.9

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.2.9",
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.2.9"
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";
@@ -68,6 +69,11 @@ Commands:
68
69
  batched tasks, from the LIVE registry (read-only).
69
70
  --gate: no output, exit 0 iff there is work (the
70
71
  notifier check that decides if DreamWeaver wakes)
72
+ archive-stale [--commit] deliberate backlog archiver (lean §2.2a): move
73
+ pre-existing stale notes to the archive. Dry-run by
74
+ default (lists what would move); --commit executes.
75
+ 03_Projects is excluded; memoryd archives ongoing
76
+ staleness incrementally on its own.
71
77
  render index|fragment|doctrine|guide
72
78
  render one artifact explicitly (memoryd does this
73
79
  continuously; render is the manual/scripted path)
@@ -134,6 +140,8 @@ export async function main(argv: string[]): Promise<number> {
134
140
  return cmdInstallBinary(rest, egress);
135
141
  case "dream-collect":
136
142
  return cmdDreamCollect(rest, egress);
143
+ case "archive-stale":
144
+ return cmdArchiveStale(rest);
137
145
  case "provision-peer":
138
146
  return cmdProvisionPeer(rest, egress);
139
147
  case "unprovision-peer":
@@ -0,0 +1,87 @@
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
+ * `03_Projects` is excluded (a completed phase is a project record, not
14
+ * displaced knowledge — `isArchivableZone`). memoryd reindexes the moves on
15
+ * its next pass (or on restart); the verb itself only moves files.
16
+ */
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import {
21
+ configFromEnv,
22
+ snapshotVault,
23
+ shouldArchive,
24
+ archiveTargetRel,
25
+ } from "@agfpd/iapeer-memory-core";
26
+
27
+ export function cmdArchiveStale(argv: string[]): number {
28
+ const commit = argv.includes("--commit");
29
+ const config = configFromEnv();
30
+ const vault = config.vaultPath;
31
+ const taxonomy = config.taxonomy;
32
+
33
+ // Candidates: notes in the monitored content folders carrying a final
34
+ // status, EXCLUDING 03_Projects (filtered by shouldArchive → isArchivableZone).
35
+ const snap = snapshotVault(vault, taxonomy);
36
+ const reserved = new Set<string>(); // archive targets claimed within this run
37
+ const moves: Array<{ from: string; to: string }> = [];
38
+ for (const rel of snap.keys()) {
39
+ let content: string;
40
+ try {
41
+ content = fs.readFileSync(path.join(vault, rel), "utf-8");
42
+ } catch {
43
+ continue;
44
+ }
45
+ if (!shouldArchive(rel, content, taxonomy)) continue;
46
+ const to = archiveTargetRel(
47
+ path.basename(rel),
48
+ taxonomy,
49
+ (r) => reserved.has(r) || fs.existsSync(path.join(vault, r)),
50
+ );
51
+ reserved.add(to);
52
+ moves.push({ from: rel, to });
53
+ }
54
+
55
+ if (moves.length === 0) {
56
+ console.log("archive-stale: no stale notes outside the archive — nothing to do.");
57
+ return 0;
58
+ }
59
+
60
+ if (!commit) {
61
+ console.log(
62
+ `archive-stale (DRY-RUN): ${moves.length} stale note(s) WOULD move to ${taxonomy.folders.archive}/:`,
63
+ );
64
+ for (const m of moves) console.log(` ${m.from} → ${m.to}`);
65
+ console.log(
66
+ `\nPass --commit to move them. (03_Projects is excluded — completed phases stay project-local.)`,
67
+ );
68
+ return 0;
69
+ }
70
+
71
+ let moved = 0;
72
+ for (const m of moves) {
73
+ const toAbs = path.join(vault, m.to);
74
+ try {
75
+ fs.mkdirSync(path.dirname(toAbs), { recursive: true });
76
+ fs.renameSync(path.join(vault, m.from), toAbs);
77
+ moved += 1;
78
+ console.log(` moved: ${m.from} → ${m.to}`);
79
+ } catch (err) {
80
+ console.error(` FAILED: ${m.from} (${String(err)})`);
81
+ }
82
+ }
83
+ console.log(
84
+ `archive-stale: moved ${moved}/${moves.length}. memoryd reindexes on its next pass (or restart).`,
85
+ );
86
+ return 0;
87
+ }
@@ -39,6 +39,13 @@ import {
39
39
  getTaxonomy,
40
40
  isLocaleId,
41
41
  resolveAgentName,
42
+ resolveZone,
43
+ splitFrontmatter,
44
+ parseNoteTags,
45
+ parseDictionaryTags,
46
+ tagGateProblems,
47
+ tagsDictionarySourceRel,
48
+ type TaxonomyPreset,
42
49
  } from "@agfpd/iapeer-memory-core";
43
50
  import { memoryPaths, type MemoryPaths } from "../paths.js";
44
51
  import { DEFAULT_HEARTBEAT_STALE_MS } from "./verify.js";
@@ -113,15 +120,6 @@ export type PostWriteResult = {
113
120
  output: string | null;
114
121
  };
115
122
 
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
123
  export function runPostWrite(
126
124
  eventJson: string,
127
125
  env: Record<string, string | undefined> = process.env,
@@ -182,26 +180,194 @@ export function runPostWrite(
182
180
  stamp: true,
183
181
  });
184
182
 
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
-
183
+ // TAG GATE (lean §3): validate the canon note's tags against the dictionary
184
+ // and teach the author to fix any problem (unknown tag / no tag). The guard
185
+ // stays SILENT on a clean write (§2.3) — output is non-null ONLY on a
186
+ // problem. RUNTIME-AGNOSTIC: codex supports PostToolUse `additionalContext`
187
+ // too (official codex hooks docs the earlier «claude-only» was wrong), so
188
+ // the SAME schema reaches both runtimes. `files` already covers claude
189
+ // Write/Edit and codex apply_patch (multi-file).
190
+ const problems = collectTagProblems(files, vault, taxonomy);
191
+ const output = problems.length
192
+ ? JSON.stringify({
193
+ hookSpecificOutput: {
194
+ hookEventName: "PostToolUse",
195
+ additionalContext: tagTeaching(problems),
196
+ },
197
+ })
198
+ : null;
202
199
  return { stamped: true, output };
203
200
  }
204
201
 
202
+ /**
203
+ * Tag-gate problems across the just-written CANON files (lean §3). Reads the
204
+ * dictionary from the vault; FAIL-OPEN — an unreadable/empty dictionary (e.g.
205
+ * an evicted iCloud placeholder) yields no problems rather than rejecting
206
+ * every tag. Operative/inbox zones are not gated (canon only).
207
+ */
208
+ export function collectTagProblems(
209
+ files: string[],
210
+ vault: string,
211
+ taxonomy: TaxonomyPreset,
212
+ ): string[] {
213
+ const dictRel = tagsDictionarySourceRel(taxonomy);
214
+ let allow: Set<string> | null = null;
215
+ try {
216
+ const dict = fs.readFileSync(path.join(vault, dictRel), "utf-8");
217
+ if (dict.trim()) allow = new Set(parseDictionaryTags(dict));
218
+ } catch {
219
+ // fail-open
220
+ }
221
+ if (!allow) return [];
222
+ const out: string[] = [];
223
+ for (const f of files) {
224
+ if (resolveZone(f, vault, taxonomy) !== "permanent") continue;
225
+ let fm: string;
226
+ try {
227
+ fm = splitFrontmatter(fs.readFileSync(f, "utf-8"))[0];
228
+ } catch {
229
+ continue;
230
+ }
231
+ const problems = tagGateProblems(parseNoteTags(fm), allow, {
232
+ requireAtLeastOne: true,
233
+ dictionaryRel: dictRel,
234
+ });
235
+ for (const p of problems) out.push(`${path.basename(f)}: ${p}`);
236
+ }
237
+ return out;
238
+ }
239
+
240
+ export function tagTeaching(problems: string[]): string {
241
+ return (
242
+ "[iapeer-memory] tag check — fix so this canon note indexes cleanly:\n" +
243
+ problems.map((p) => `- ${p}`).join("\n")
244
+ );
245
+ }
246
+
247
+ // ── dedup hint (lean §3a) ──────────────────────────────────────────────────────
248
+
249
+ /** Short fail-open budget for the dedup RPC — a slow/down memoryd must never
250
+ * hang a write-hook (same posture as the embedding circuit-breaker). */
251
+ export const DEDUP_TIMEOUT_MS = 1500;
252
+
253
+ type DedupResponse = {
254
+ enabled: boolean;
255
+ matches: Array<{ path: string; title: string; similarity: number }>;
256
+ };
257
+
258
+ /** POST the note body to memoryd's loopback /dedup RPC through the egress hub
259
+ * (loopback allowance — П6 topology). Fail-open: any error (timeout,
260
+ * connection refused, bad JSON) → null → the caller stays silent. */
261
+ async function dedupFetch(
262
+ egress: Egress,
263
+ env: Record<string, string | undefined>,
264
+ content: string,
265
+ ): Promise<DedupResponse | null> {
266
+ const port = env.IAPEER_MEMORY_MCP_PORT || "8766";
267
+ const thr = env.IAPEER_MEMORY_DEDUP_THRESHOLD;
268
+ const body: { content: string; threshold?: number } = { content };
269
+ if (thr && !Number.isNaN(Number(thr))) body.threshold = Number(thr);
270
+ const controller = new AbortController();
271
+ const timer = setTimeout(() => controller.abort(), DEDUP_TIMEOUT_MS);
272
+ try {
273
+ const res = await egress.fetch(`http://127.0.0.1:${port}/dedup`, {
274
+ method: "POST",
275
+ headers: { "Content-Type": "application/json" },
276
+ body: JSON.stringify(body),
277
+ signal: controller.signal,
278
+ });
279
+ if (!res.ok) return null;
280
+ return (await res.json()) as DedupResponse;
281
+ } catch {
282
+ return null; // fail-open
283
+ } finally {
284
+ clearTimeout(timer);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Dedup hint lines for a just-written CANON note (lean §3a). Re-parses the
290
+ * event independently of `runPostWrite` (keeps that sync contract intact).
291
+ * Claude-only (Write/Edit → additionalContext); canon-zone only; queries
292
+ * memoryd by the note BODY. Embeddings-off → memoryd returns enabled:false →
293
+ * no hint (silent).
294
+ */
295
+ export async function collectDedupHints(
296
+ eventJson: string,
297
+ egress: Egress,
298
+ env: Record<string, string | undefined> = process.env,
299
+ ): Promise<string[]> {
300
+ let event: {
301
+ tool_name?: string;
302
+ cwd?: string;
303
+ tool_input?: { file_path?: string };
304
+ tool_response?: unknown;
305
+ };
306
+ try {
307
+ event = JSON.parse(eventJson) as typeof event;
308
+ } catch {
309
+ return [];
310
+ }
311
+ const tool = event.tool_name ?? "";
312
+ if (!POST_WRITE_TOOLS.has(tool)) return [];
313
+ const vault = env.IAPEER_MEMORY_VAULT_PATH ?? "";
314
+ if (!vault) return [];
315
+ const localeRaw = env.IAPEER_MEMORY_LOCALE || "en";
316
+ if (!isLocaleId(localeRaw)) return [];
317
+ const taxonomy = getTaxonomy(localeRaw);
318
+ // Candidate files: claude Write/Edit carry one file_path; codex apply_patch
319
+ // carries a patch over possibly many (RUNTIME-AGNOSTIC — codex receives the
320
+ // additionalContext hint via the same channel).
321
+ const candidates =
322
+ tool === "apply_patch" ? applyPatchPaths(event) : [event.tool_input?.file_path ?? ""];
323
+ const vaultPrefix = vault.endsWith(path.sep) ? vault : vault + path.sep;
324
+ const files = candidates.filter(
325
+ (p) => p.endsWith(".md") && p.startsWith(vaultPrefix) && fs.existsSync(p),
326
+ );
327
+ const hints: string[] = [];
328
+ for (const file of files) {
329
+ if (resolveZone(file, vault, taxonomy) !== "permanent") continue; // canon only (§3a)
330
+ let body: string;
331
+ try {
332
+ body = splitFrontmatter(fs.readFileSync(file, "utf-8"))[1];
333
+ } catch {
334
+ continue;
335
+ }
336
+ if (!body.trim()) continue;
337
+ const result = await dedupFetch(egress, env, body);
338
+ if (!result?.enabled || !result.matches?.length) continue;
339
+ for (const m of result.matches) {
340
+ if (path.basename(m.path) === path.basename(file)) continue; // self-guard (belt + braces)
341
+ hints.push(`[[${m.title}]] (${Math.round(m.similarity * 100)}%)`);
342
+ }
343
+ }
344
+ return hints;
345
+ }
346
+
347
+ /** Combine the sync tag-teaching output with async dedup hints into one
348
+ * additionalContext blob (or null when both are empty). */
349
+ export function mergeHookOutput(tagOutput: string | null, dedupHints: string[]): string | null {
350
+ let ctx = "";
351
+ if (tagOutput) {
352
+ try {
353
+ ctx = (JSON.parse(tagOutput).hookSpecificOutput?.additionalContext as string) ?? "";
354
+ } catch {
355
+ ctx = "";
356
+ }
357
+ }
358
+ if (dedupHints.length) {
359
+ const dedupText =
360
+ "[iapeer-memory] possible duplicate(s) of this canon note — verify, then extend " +
361
+ "the existing note or keep only new material:\n" +
362
+ dedupHints.map((h) => `- ${h}`).join("\n");
363
+ ctx = ctx ? `${ctx}\n\n${dedupText}` : dedupText;
364
+ }
365
+ if (!ctx) return null;
366
+ return JSON.stringify({
367
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: ctx },
368
+ });
369
+ }
370
+
205
371
  // ── session-start ────────────────────────────────────────────────────────────
206
372
 
207
373
  export type SessionStartResult = {
@@ -298,8 +464,11 @@ export async function cmdHook(argv: string[], egress: Egress): Promise<number> {
298
464
  try {
299
465
  switch (event) {
300
466
  case "post-write": {
301
- const result = runPostWrite(await Bun.stdin.text());
302
- if (result.output) console.log(result.output);
467
+ const text = await Bun.stdin.text();
468
+ const result = runPostWrite(text); // sync: stamp + tag gate
469
+ const dedupHints = await collectDedupHints(text, egress); // async, fail-open §3a
470
+ const output = mergeHookOutput(result.output, dedupHints);
471
+ if (output) console.log(output);
303
472
  return 0;
304
473
  }
305
474
  case "session-start": {
@@ -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}`);
package/src/paths.ts CHANGED
@@ -32,6 +32,8 @@ export type MemoryPaths = {
32
32
  heartbeatPath: string;
33
33
  hashStatePath: string;
34
34
  tagsMirrorPath: string;
35
+ /** Compact tags-dictionary projection, injected to all peers (lean §3). */
36
+ tagsProjectionPath: string;
35
37
  /** Roles manifest (written by init, read by verify/render): role → peerCwd/template. */
36
38
  rolesManifestPath: string;
37
39
  /** Rendered author indexes (`<agent>-vault-index.md` + `-full` variant). */
@@ -92,6 +94,7 @@ export function memoryPaths(
92
94
  heartbeatPath: path.join(stateDir, "memoryd.heartbeat"),
93
95
  hashStatePath: path.join(stateDir, "memoryd.hashes.json"),
94
96
  tagsMirrorPath: path.join(cacheDir, "tags-dictionary.md"),
97
+ tagsProjectionPath: path.join(cacheDir, "tags-projection.md"),
95
98
  rolesManifestPath: path.join(stateDir, "roles.json"),
96
99
  indexesDir: path.join(stateDir, "indexes"),
97
100
  pidPath: path.join(stateDir, "memoryd.pid"),
package/src/provision.ts CHANGED
@@ -176,6 +176,15 @@ export function defaultConfigContent(opts: ConfigContentOptions): string {
176
176
  "# Curator personalities exempt from needs_review stamping (ADR-006).",
177
177
  "# IAPEER_MEMORY_CURATOR_SET=index,scriber,dreamweaver",
178
178
  "",
179
+ "# Lean §3: per-tag boundary budget in the injected dictionary projection",
180
+ "# (×whole fleet — keep tight). Boundary text over this many chars is clipped.",
181
+ "# IAPEER_MEMORY_TAGS_BOUNDARY_MAXLEN=160",
182
+ "",
183
+ "# Lean §3a: dedup hint on canon writes — raw cosine similarity threshold",
184
+ "# above which an existing canon note is surfaced as a possible duplicate.",
185
+ "# Needs embeddings (semantic); with embeddings off the hint is silent.",
186
+ "# IAPEER_MEMORY_DEDUP_THRESHOLD=0.82",
187
+ "",
179
188
  "# Weekly dream-tick (deterministic pre-filter → DreamWeaver). Schedule is",
180
189
  "# 5-field cron; the window is days BY TIME (not since-last-tick).",
181
190
  "# IAPEER_MEMORY_DREAM_CRON=0 4 * * 1",