@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 +2 -2
- package/src/cli.ts +8 -1
- package/src/commands/archive-stale.ts +88 -0
- package/src/commands/hook.ts +230 -28
- package/src/commands/init.ts +54 -37
- package/src/commands/memoryd.ts +16 -3
- package/src/commands/render.ts +4 -1
- package/src/commands/status.ts +4 -22
- package/src/commands/uninstall.ts +2 -2
- package/src/commands/update.ts +39 -31
- package/src/commands/verify.ts +27 -43
- package/src/paths.ts +3 -3
- package/src/provision.ts +22 -1
- package/src/surfaces/claude.ts +17 -12
- package/src/templates/guide-en.ts +63 -63
- package/src/templates/guide-ru.ts +60 -56
- package/src/templates/roles-en.ts +123 -147
- package/src/templates/roles-ru.ts +107 -134
- package/src/templates/skills.ts +13 -15
- package/src/watcher.ts +7 -74
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer-memory",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
+
}
|
package/src/commands/hook.ts
CHANGED
|
@@ -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
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
302
|
-
|
|
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": {
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
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
|
-
? `
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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):
|
package/src/commands/memoryd.ts
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
package/src/commands/render.ts
CHANGED
|
@@ -125,7 +125,10 @@ function renderFragment(argv: string[]): number {
|
|
|
125
125
|
logs: paths.logsDir,
|
|
126
126
|
},
|
|
127
127
|
authorIndexPath: indexFile,
|
|
128
|
-
|
|
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}`);
|