@agfpd/iapeer-memory 0.2.7 → 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 +2 -2
- package/src/cli.ts +17 -7
- package/src/commands/archive-stale.ts +87 -0
- package/src/commands/dream-collect.ts +645 -0
- package/src/commands/hook.ts +197 -28
- package/src/commands/init.ts +11 -2
- package/src/commands/render.ts +4 -1
- package/src/commands/update.ts +13 -2
- package/src/commands/verify.ts +20 -3
- package/src/fleet.ts +1 -1
- package/src/paths.ts +7 -0
- package/src/provision.ts +23 -0
- package/src/templates/roles-en.ts +82 -56
- package/src/templates/roles-ru.ts +80 -51
- package/src/watcher.ts +56 -14
- package/src/commands/dream-paths.ts +0 -153
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `iapeer-memory dream-collect [--gate] [--iapeer-bin P]` — the DETERMINISTIC
|
|
3
|
+
* pre-filter of the weekly dream-tick (Фаза «Оптимизация dream-tick
|
|
4
|
+
* детерминированным предфильтром», boris/Артур 15.06).
|
|
5
|
+
*
|
|
6
|
+
* Zero LLM. It discovers WHAT to consolidate so the LLM only JUDGES:
|
|
7
|
+
* - a fixed time window `now − windowDays` (default 7d) BY MTIME — not
|
|
8
|
+
* «since last tick» (the host powers off; last-tick lies, the Фаза's
|
|
9
|
+
* accepted edge case);
|
|
10
|
+
* - per author (= each agent-memory subfolder): the in-window notes, the
|
|
11
|
+
* author's in-window session transcripts RESOLVED TO CONCRETE FILES
|
|
12
|
+
* (claude per-peer dir; codex host-wide pool filtered by the payload's
|
|
13
|
+
* `session_meta.payload.cwd` == realpath(cwd) — host fact, verified
|
|
14
|
+
* against a live rollout), and deterministic candidate flags inside the
|
|
15
|
+
* in-window notes (long `description`; broken path/env references);
|
|
16
|
+
* - a folder with no in-window activity is SKIPPED — it never reaches an
|
|
17
|
+
* LLM (the «папка без работы → ноль LLM» criterion).
|
|
18
|
+
*
|
|
19
|
+
* OUTPUT is Q1-AGNOSTIC and FLAT: `{vault, windowDays, folders[], skipped[]}`.
|
|
20
|
+
* It deliberately does NOT pre-package task-units / batching: whether
|
|
21
|
+
* DreamWeaver processes the folders sequentially in one window or fans out
|
|
22
|
+
* subagents (open question Q1, escalated to Артур) is a LAST layer that sits
|
|
23
|
+
* on this structure without touching the deterministic core.
|
|
24
|
+
*
|
|
25
|
+
* `--gate` runs the same collection but suppresses stdout and exits 0 iff
|
|
26
|
+
* there is ANY active folder (1 ⇔ a dead week). It is the notifier `check`
|
|
27
|
+
* gate: a closed gate means DreamWeaver is NEVER woken — true zero-LLM.
|
|
28
|
+
*
|
|
29
|
+
* SOURCE = the LIVE registry (`iapeer list --json`), not fleet.json — same
|
|
30
|
+
* freshness proof as dream-paths (birth does not touch fleet.json; the
|
|
31
|
+
* SessionStart kick is heartbeat-gated, so a newborn is invisible to the map
|
|
32
|
+
* for weeks). READ-ONLY: one registry spawn + vault/transcript readdir +
|
|
33
|
+
* realpath. No writes, no signals.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import fs from "node:fs";
|
|
37
|
+
import os from "node:os";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import { getTaxonomy, isLocaleId, splitFrontmatter } from "@agfpd/iapeer-memory-core";
|
|
40
|
+
import type { Egress } from "../egress.js";
|
|
41
|
+
import { queryRegistry, type FleetPeer } from "../fleet.js";
|
|
42
|
+
|
|
43
|
+
const DAY_MS = 86_400_000;
|
|
44
|
+
|
|
45
|
+
/** Claude projects-dir slug — every non-alphanumeric of the REGISTRY cwd → '-'
|
|
46
|
+
* (the live disk form, e.g. `/Users/x/.iapeer/peers/index` →
|
|
47
|
+
* `-Users-x--iapeer-peers-index`; the dot yields the double dash). Claude
|
|
48
|
+
* slugs the path the session launched in, verbatim — NOT realpath. */
|
|
49
|
+
export function claudeProjectSlug(cwd: string): string {
|
|
50
|
+
return cwd.replace(/[^A-Za-z0-9]/g, "-");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const DEFAULT_WINDOW_DAYS = 7;
|
|
54
|
+
export const DEFAULT_DESC_MAXLEN = 250;
|
|
55
|
+
/** >threshold new notes in a week → a dedicated 1:1 subagent (Q3, Артур). */
|
|
56
|
+
export const DEFAULT_BATCH_THRESHOLD = 20;
|
|
57
|
+
/** Cap on a grouped subagent's volume (sum of per-folder weights) — small
|
|
58
|
+
* folders accrete up to it, then a new group opens (Q3, Артур). */
|
|
59
|
+
export const DEFAULT_GROUP_CAP = 20;
|
|
60
|
+
/** Per-folder cap on transcript files handed to phase D — the MOST RECENT N
|
|
61
|
+
* by mtime (boris 15.06: an ephemeral worker emits hundreds of mechanical
|
|
62
|
+
* sessions a week; scanning all is costly and low-value). Cap by N, never by
|
|
63
|
+
* role name — universal, no hardcode. */
|
|
64
|
+
export const DEFAULT_TRANSCRIPT_CAP = 20;
|
|
65
|
+
|
|
66
|
+
export type CandidateFlag = "long-desc" | "broken-ref";
|
|
67
|
+
|
|
68
|
+
export type NoteCandidate = {
|
|
69
|
+
/** Absolute path of the in-window note. */
|
|
70
|
+
path: string;
|
|
71
|
+
/** Deterministic flags — `[]` means «new but nothing obvious to fix»
|
|
72
|
+
* (still part of the dedup working set). */
|
|
73
|
+
flags: CandidateFlag[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type ResolvedTranscripts = {
|
|
77
|
+
runtime: "claude" | "codex";
|
|
78
|
+
/** Concrete files in window — the LLM never globs, it reads this list. */
|
|
79
|
+
files: string[];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type CollectedFolder = {
|
|
83
|
+
agent: string;
|
|
84
|
+
path: string;
|
|
85
|
+
/** All in-window notes — the batching basis (Фаза: «по числу НОВЫХ
|
|
86
|
+
* заметок за неделю») and the dedup working set. */
|
|
87
|
+
newNotes: NoteCandidate[];
|
|
88
|
+
/** Convenience counts (consumer batches on newNotesCount). */
|
|
89
|
+
newNotesCount: number;
|
|
90
|
+
candidateCount: number;
|
|
91
|
+
transcripts: ResolvedTranscripts[];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type CollectResult = {
|
|
95
|
+
vault: string;
|
|
96
|
+
windowDays: number;
|
|
97
|
+
folders: CollectedFolder[];
|
|
98
|
+
/** Authors skipped for zero in-window activity — legibility, not work. */
|
|
99
|
+
skipped: string[];
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ── candidate detection (pure) ─────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** The `description` scalar from a note's frontmatter (one level of quote
|
|
105
|
+
* unwrap; multi-line block scalars are not used in our notes). */
|
|
106
|
+
export function descriptionValue(content: string): string {
|
|
107
|
+
const [fm] = splitFrontmatter(content);
|
|
108
|
+
if (!fm) return "";
|
|
109
|
+
const m = /^description:[^\S\n]*(.*)$/m.exec(fm);
|
|
110
|
+
if (!m) return "";
|
|
111
|
+
let v = m[1].trim();
|
|
112
|
+
if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v.endsWith(v[0])) {
|
|
113
|
+
v = v.slice(1, -1);
|
|
114
|
+
}
|
|
115
|
+
return v;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Path-like tokens anchored to common absolute roots (host-portable set) or
|
|
119
|
+
* a leading `~`. Inclusive by design — a false positive is a FLAG, the
|
|
120
|
+
* subagent judges it (boris: tolerant). */
|
|
121
|
+
const PATH_TOKEN_RE =
|
|
122
|
+
/(~|\/(?:Users|home|root|private|tmp|var|opt|usr|etc|Volumes|Applications|mnt|srv))\/[A-Za-z0-9._\-/]+/g;
|
|
123
|
+
/** Env references scoped to the PROJECT namespaces — the ones notes document
|
|
124
|
+
* and that go stale on a rename (a bare `$VAR` example is too noisy to flag;
|
|
125
|
+
* `IAPEER_*`/`MERGEMIND_*` not set in the env is a real stale-config signal). */
|
|
126
|
+
const ENV_TOKEN_RE = /\$\{?((?:IAPEER|MERGEMIND)[A-Z0-9_]*)\}?/g;
|
|
127
|
+
|
|
128
|
+
export type RefIo = {
|
|
129
|
+
existsSync: (p: string) => boolean;
|
|
130
|
+
env: Record<string, string | undefined>;
|
|
131
|
+
home: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Broken path/env references in a note body. Paths: existence-checked
|
|
135
|
+
* (`~` expanded); envs: undefined-in-env. Returns the offending tokens. */
|
|
136
|
+
export function detectBrokenRefs(
|
|
137
|
+
body: string,
|
|
138
|
+
io: RefIo,
|
|
139
|
+
): { paths: string[]; envs: string[] } {
|
|
140
|
+
const paths = new Set<string>();
|
|
141
|
+
for (const m of body.matchAll(PATH_TOKEN_RE)) {
|
|
142
|
+
const tok = m[0].replace(/[.,;:)`'"]+$/, "");
|
|
143
|
+
if (tok === "~" || tok.length < 4) continue;
|
|
144
|
+
const abs = tok.startsWith("~") ? path.join(io.home, tok.slice(1)) : tok;
|
|
145
|
+
if (!io.existsSync(abs)) paths.add(tok);
|
|
146
|
+
}
|
|
147
|
+
const envs = new Set<string>();
|
|
148
|
+
for (const m of body.matchAll(ENV_TOKEN_RE)) {
|
|
149
|
+
const name = m[1];
|
|
150
|
+
const val = io.env[name];
|
|
151
|
+
if (!(typeof val === "string" && val.length > 0)) envs.add(name);
|
|
152
|
+
}
|
|
153
|
+
return { paths: [...paths], envs: [...envs] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function noteFlags(
|
|
157
|
+
content: string,
|
|
158
|
+
io: RefIo & { descMaxLen: number },
|
|
159
|
+
): CandidateFlag[] {
|
|
160
|
+
const flags: CandidateFlag[] = [];
|
|
161
|
+
if (descriptionValue(content).length > io.descMaxLen) flags.push("long-desc");
|
|
162
|
+
const [, body] = splitFrontmatter(content);
|
|
163
|
+
const broken = detectBrokenRefs(body || content, io);
|
|
164
|
+
if (broken.paths.length > 0 || broken.envs.length > 0) flags.push("broken-ref");
|
|
165
|
+
return flags;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── note + transcript collection (filesystem) ──────────────────────────────
|
|
169
|
+
|
|
170
|
+
export type CollectIo = RefIo & {
|
|
171
|
+
nowMs: number;
|
|
172
|
+
windowDays: number;
|
|
173
|
+
descMaxLen: number;
|
|
174
|
+
/** Per-folder transcript cap (most-recent N by mtime; ≤0 = uncapped). */
|
|
175
|
+
transcriptCap: number;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** Top-level in-window `.md` notes of one author folder (no recursion — the
|
|
179
|
+
* `archive` subfolder and any nested dirs are out of scope). */
|
|
180
|
+
export function collectNewNotes(folderPath: string, io: CollectIo): NoteCandidate[] {
|
|
181
|
+
let entries: fs.Dirent[];
|
|
182
|
+
try {
|
|
183
|
+
entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
|
184
|
+
} catch {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
const cutoff = io.nowMs - io.windowDays * DAY_MS;
|
|
188
|
+
const out: NoteCandidate[] = [];
|
|
189
|
+
for (const e of entries) {
|
|
190
|
+
if (!e.isFile() || e.name.startsWith(".") || !e.name.endsWith(".md")) continue;
|
|
191
|
+
const fp = path.join(folderPath, e.name);
|
|
192
|
+
let mtimeMs: number;
|
|
193
|
+
try {
|
|
194
|
+
mtimeMs = fs.statSync(fp).mtimeMs;
|
|
195
|
+
} catch {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (mtimeMs < cutoff) continue;
|
|
199
|
+
let content = "";
|
|
200
|
+
try {
|
|
201
|
+
content = fs.readFileSync(fp, "utf-8");
|
|
202
|
+
} catch {
|
|
203
|
+
// unreadable — still a candidate (no flags), the subagent will see it
|
|
204
|
+
}
|
|
205
|
+
out.push({ path: fp, flags: noteFlags(content, io) });
|
|
206
|
+
}
|
|
207
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Read just the first JSONL line of a rollout file (the session_meta record);
|
|
211
|
+
* `base_instructions` can make that line large, so read until the newline
|
|
212
|
+
* with a cap rather than slurping the whole session. */
|
|
213
|
+
function readFirstLine(fp: string, capBytes = 1_048_576): string | null {
|
|
214
|
+
let fd: number;
|
|
215
|
+
try {
|
|
216
|
+
fd = fs.openSync(fp, "r");
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const chunk = Buffer.alloc(65_536);
|
|
222
|
+
const parts: Buffer[] = [];
|
|
223
|
+
let total = 0;
|
|
224
|
+
let pos = 0;
|
|
225
|
+
while (total < capBytes) {
|
|
226
|
+
const n = fs.readSync(fd, chunk, 0, chunk.length, pos);
|
|
227
|
+
if (n <= 0) break;
|
|
228
|
+
pos += n;
|
|
229
|
+
const nl = chunk.indexOf(0x0a);
|
|
230
|
+
if (nl >= 0 && nl < n) {
|
|
231
|
+
parts.push(Buffer.from(chunk.subarray(0, nl)));
|
|
232
|
+
return Buffer.concat(parts).toString("utf-8");
|
|
233
|
+
}
|
|
234
|
+
parts.push(Buffer.from(chunk.subarray(0, n)));
|
|
235
|
+
total += n;
|
|
236
|
+
}
|
|
237
|
+
return Buffer.concat(parts).toString("utf-8");
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
} finally {
|
|
241
|
+
fs.closeSync(fd);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The codex session's working directory (`session_meta.payload.cwd`) — the
|
|
246
|
+
* realpath the session launched in (host fact, verified live). */
|
|
247
|
+
export function readCodexSessionCwd(fp: string): string | null {
|
|
248
|
+
const line = readFirstLine(fp);
|
|
249
|
+
if (line === null) return null;
|
|
250
|
+
try {
|
|
251
|
+
const obj = JSON.parse(line) as { payload?: { cwd?: unknown } };
|
|
252
|
+
const cwd = obj?.payload?.cwd;
|
|
253
|
+
return typeof cwd === "string" ? cwd : null;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A transcript file with its mtime — carried so the per-folder cap can keep
|
|
260
|
+
* the MOST RECENT N across runtimes. */
|
|
261
|
+
export type TFile = { path: string; mtimeMs: number };
|
|
262
|
+
|
|
263
|
+
/** Claude per-peer transcripts in window: readdir one projects dir. */
|
|
264
|
+
export function resolveClaudeTranscripts(projectsDir: string, cutoffMs: number): TFile[] {
|
|
265
|
+
let entries: fs.Dirent[];
|
|
266
|
+
try {
|
|
267
|
+
entries = fs.readdirSync(projectsDir, { withFileTypes: true });
|
|
268
|
+
} catch {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
const out: TFile[] = [];
|
|
272
|
+
for (const e of entries) {
|
|
273
|
+
if (!e.isFile() || !e.name.endsWith(".jsonl")) continue;
|
|
274
|
+
const fp = path.join(projectsDir, e.name);
|
|
275
|
+
try {
|
|
276
|
+
const mtimeMs = fs.statSync(fp).mtimeMs;
|
|
277
|
+
if (mtimeMs >= cutoffMs) out.push({ path: fp, mtimeMs });
|
|
278
|
+
} catch {
|
|
279
|
+
// vanished mid-walk — skip
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Codex pool is HOST-WIDE: index every in-window rollout by its session cwd
|
|
286
|
+
* ONCE (a single recursive walk), so per-author lookup is O(1). Returns a
|
|
287
|
+
* realpath(cwd) → files map. */
|
|
288
|
+
export function indexCodexSessionsByCwd(
|
|
289
|
+
sessionsRoot: string,
|
|
290
|
+
cutoffMs: number,
|
|
291
|
+
): Map<string, TFile[]> {
|
|
292
|
+
const byCwd = new Map<string, TFile[]>();
|
|
293
|
+
const walk = (dir: string): void => {
|
|
294
|
+
let entries: fs.Dirent[];
|
|
295
|
+
try {
|
|
296
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
297
|
+
} catch {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
for (const e of entries) {
|
|
301
|
+
const fp = path.join(dir, e.name);
|
|
302
|
+
if (e.isDirectory()) {
|
|
303
|
+
walk(fp);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (!e.isFile() || !e.name.startsWith("rollout-") || !e.name.endsWith(".jsonl")) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
let mtimeMs: number;
|
|
310
|
+
try {
|
|
311
|
+
mtimeMs = fs.statSync(fp).mtimeMs;
|
|
312
|
+
} catch {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (mtimeMs < cutoffMs) continue;
|
|
316
|
+
const cwd = readCodexSessionCwd(fp);
|
|
317
|
+
if (cwd === null) continue;
|
|
318
|
+
const entry = { path: fp, mtimeMs };
|
|
319
|
+
const list = byCwd.get(cwd);
|
|
320
|
+
if (list) list.push(entry);
|
|
321
|
+
else byCwd.set(cwd, [entry]);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
walk(sessionsRoot);
|
|
325
|
+
for (const list of byCwd.values()) list.sort((a, b) => a.path.localeCompare(b.path));
|
|
326
|
+
return byCwd;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function realpathOrSelf(p: string): string {
|
|
330
|
+
try {
|
|
331
|
+
return fs.realpathSync(p);
|
|
332
|
+
} catch {
|
|
333
|
+
return p;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Resolve a peer's in-window transcripts, capped per FOLDER to the most
|
|
338
|
+
* recent `cap` by mtime ACROSS runtimes (`cap ≤ 0` = uncapped), then grouped
|
|
339
|
+
* back per runtime (files sorted by path for deterministic output). An
|
|
340
|
+
* entry is emitted for every runtime the peer declares, even if the cap left
|
|
341
|
+
* it empty — the consumer sees which runtimes were considered. */
|
|
342
|
+
export function resolveTranscripts(
|
|
343
|
+
peer: FleetPeer,
|
|
344
|
+
opts: { home: string; cutoffMs: number; codexIndex: Map<string, TFile[]>; cap: number },
|
|
345
|
+
): ResolvedTranscripts[] {
|
|
346
|
+
const tagged: Array<{ runtime: "claude" | "codex"; file: TFile }> = [];
|
|
347
|
+
const runtimes: Array<"claude" | "codex"> = [];
|
|
348
|
+
if (peer.runtimes.includes("claude")) {
|
|
349
|
+
runtimes.push("claude");
|
|
350
|
+
const dir = path.join(opts.home, ".claude", "projects", claudeProjectSlug(peer.cwd));
|
|
351
|
+
for (const file of resolveClaudeTranscripts(dir, opts.cutoffMs)) {
|
|
352
|
+
tagged.push({ runtime: "claude", file });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (peer.runtimes.includes("codex")) {
|
|
356
|
+
runtimes.push("codex");
|
|
357
|
+
for (const file of opts.codexIndex.get(realpathOrSelf(peer.cwd)) ?? []) {
|
|
358
|
+
tagged.push({ runtime: "codex", file });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// Keep the most recent `cap` across BOTH runtimes (the heavy ephemeral case
|
|
362
|
+
// is a single runtime flooding phase D).
|
|
363
|
+
let kept = tagged;
|
|
364
|
+
if (opts.cap > 0 && tagged.length > opts.cap) {
|
|
365
|
+
kept = [...tagged].sort((a, b) => b.file.mtimeMs - a.file.mtimeMs).slice(0, opts.cap);
|
|
366
|
+
}
|
|
367
|
+
return runtimes.map((runtime) => ({
|
|
368
|
+
runtime,
|
|
369
|
+
files: kept
|
|
370
|
+
.filter((t) => t.runtime === runtime)
|
|
371
|
+
.map((t) => t.file.path)
|
|
372
|
+
.sort(),
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── orchestration ──────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
export function collect(opts: {
|
|
379
|
+
vault: string;
|
|
380
|
+
agentMemoryFolder: string;
|
|
381
|
+
peers: FleetPeer[];
|
|
382
|
+
home: string;
|
|
383
|
+
io: CollectIo;
|
|
384
|
+
}): CollectResult {
|
|
385
|
+
const memoryRoot = path.join(opts.vault, opts.agentMemoryFolder);
|
|
386
|
+
let entries: fs.Dirent[];
|
|
387
|
+
try {
|
|
388
|
+
entries = fs.readdirSync(memoryRoot, { withFileTypes: true });
|
|
389
|
+
} catch {
|
|
390
|
+
return { vault: opts.vault, windowDays: opts.io.windowDays, folders: [], skipped: [] };
|
|
391
|
+
}
|
|
392
|
+
const cutoffMs = opts.io.nowMs - opts.io.windowDays * DAY_MS;
|
|
393
|
+
const byPersonality = new Map(opts.peers.map((p) => [p.personality, p]));
|
|
394
|
+
// Build the codex pool index once iff any peer runs codex.
|
|
395
|
+
const codexIndex = opts.peers.some((p) => p.runtimes.includes("codex"))
|
|
396
|
+
? indexCodexSessionsByCwd(path.join(opts.home, ".codex", "sessions"), cutoffMs)
|
|
397
|
+
: new Map<string, TFile[]>();
|
|
398
|
+
|
|
399
|
+
const folders: CollectedFolder[] = [];
|
|
400
|
+
const skipped: string[] = [];
|
|
401
|
+
const dirs = entries
|
|
402
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
403
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
404
|
+
|
|
405
|
+
for (const e of dirs) {
|
|
406
|
+
const agent = e.name;
|
|
407
|
+
const folderPath = path.join(memoryRoot, agent);
|
|
408
|
+
const newNotes = collectNewNotes(folderPath, opts.io);
|
|
409
|
+
const peer = byPersonality.get(agent);
|
|
410
|
+
const transcripts = peer
|
|
411
|
+
? resolveTranscripts(peer, {
|
|
412
|
+
home: opts.home,
|
|
413
|
+
cutoffMs,
|
|
414
|
+
codexIndex,
|
|
415
|
+
cap: opts.io.transcriptCap,
|
|
416
|
+
})
|
|
417
|
+
: [];
|
|
418
|
+
const hasTranscripts = transcripts.some((t) => t.files.length > 0);
|
|
419
|
+
if (newNotes.length === 0 && !hasTranscripts) {
|
|
420
|
+
skipped.push(`${agent} (no activity in window)`);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
folders.push({
|
|
424
|
+
agent,
|
|
425
|
+
path: folderPath,
|
|
426
|
+
newNotes,
|
|
427
|
+
newNotesCount: newNotes.length,
|
|
428
|
+
candidateCount: newNotes.filter((n) => n.flags.length > 0).length,
|
|
429
|
+
transcripts,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return { vault: opts.vault, windowDays: opts.io.windowDays, folders, skipped };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The notifier `check` gate — REGISTRY-FREE on purpose. It runs in the
|
|
437
|
+
* NOTIFIER's launchd env, where spawning `iapeer list` is unproven (no other
|
|
438
|
+
* notifier-env script does it; the sweep check is pure bash). Depending on the
|
|
439
|
+
* registry here would silently fail the gate closed and the whole tick would
|
|
440
|
+
* never fire. So the gate answers a narrower, robust question with vault
|
|
441
|
+
* readdir + mtime ALONE: «is there ANY in-window agent-memory note?» — short-
|
|
442
|
+
* circuiting on the first hit.
|
|
443
|
+
*
|
|
444
|
+
* Semantic gap accepted: a week with NO new notes but fresh sessions
|
|
445
|
+
* (transcript-only activity) does NOT open the gate, so phase D waits a week.
|
|
446
|
+
* Rare, bounded, and errs toward NOT waking — the token-minimisation side.
|
|
447
|
+
* The FULL `collect()` (run by DreamWeaver in a real peer shell WITH PATH)
|
|
448
|
+
* still resolves transcripts via the registry.
|
|
449
|
+
*/
|
|
450
|
+
export function gateHasWork(opts: {
|
|
451
|
+
vault: string;
|
|
452
|
+
agentMemoryFolder: string;
|
|
453
|
+
nowMs: number;
|
|
454
|
+
windowDays: number;
|
|
455
|
+
}): boolean {
|
|
456
|
+
const memoryRoot = path.join(opts.vault, opts.agentMemoryFolder);
|
|
457
|
+
let dirs: fs.Dirent[];
|
|
458
|
+
try {
|
|
459
|
+
dirs = fs.readdirSync(memoryRoot, { withFileTypes: true });
|
|
460
|
+
} catch {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
const cutoff = opts.nowMs - opts.windowDays * DAY_MS;
|
|
464
|
+
for (const d of dirs) {
|
|
465
|
+
if (!d.isDirectory() || d.name.startsWith(".")) continue;
|
|
466
|
+
const sub = path.join(memoryRoot, d.name);
|
|
467
|
+
let files: fs.Dirent[];
|
|
468
|
+
try {
|
|
469
|
+
files = fs.readdirSync(sub, { withFileTypes: true });
|
|
470
|
+
} catch {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
for (const f of files) {
|
|
474
|
+
if (!f.isFile() || f.name.startsWith(".") || !f.name.endsWith(".md")) continue;
|
|
475
|
+
try {
|
|
476
|
+
if (fs.statSync(path.join(sub, f.name)).mtimeMs >= cutoff) return true;
|
|
477
|
+
} catch {
|
|
478
|
+
// vanished — keep scanning
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── batching (the packaging layer; Q1 fan-out + Q3 rule, Артур 15.06) ───────
|
|
486
|
+
//
|
|
487
|
+
// DETERMINISTIC, sits ON TOP of the flat core — DreamWeaver fans out exactly
|
|
488
|
+
// one subagent per task. The core `collect()` stays batching-agnostic; this
|
|
489
|
+
// layer is the only place the >threshold/group rule lives, so a change to the
|
|
490
|
+
// fan-out shape never touches discovery.
|
|
491
|
+
|
|
492
|
+
export type DreamTask = {
|
|
493
|
+
/** `folder` — a single >threshold folder gets the whole subagent (1:1);
|
|
494
|
+
* `grouped` — several ≤threshold folders share one subagent (up to cap). */
|
|
495
|
+
kind: "folder" | "grouped";
|
|
496
|
+
folders: CollectedFolder[];
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
/** Weight of one folder toward a group's cap: its in-window note count, but
|
|
500
|
+
* at least 1 so a transcript-only folder still consumes a slot (its subagent
|
|
501
|
+
* reads the transcripts — real context load). */
|
|
502
|
+
function folderWeight(f: CollectedFolder): number {
|
|
503
|
+
return Math.max(f.newNotesCount, 1);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function batchTasks(
|
|
507
|
+
folders: CollectedFolder[],
|
|
508
|
+
opts: { threshold: number; groupCap: number },
|
|
509
|
+
): DreamTask[] {
|
|
510
|
+
const tasks: DreamTask[] = [];
|
|
511
|
+
let group: CollectedFolder[] = [];
|
|
512
|
+
let groupWeight = 0;
|
|
513
|
+
const flush = (): void => {
|
|
514
|
+
if (group.length > 0) {
|
|
515
|
+
tasks.push({ kind: "grouped", folders: group });
|
|
516
|
+
group = [];
|
|
517
|
+
groupWeight = 0;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
for (const f of folders) {
|
|
521
|
+
if (f.newNotesCount > opts.threshold) {
|
|
522
|
+
tasks.push({ kind: "folder", folders: [f] });
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const w = folderWeight(f);
|
|
526
|
+
// Close a non-empty group before it would overflow (a single folder
|
|
527
|
+
// already at/over the cap becomes its own group of one).
|
|
528
|
+
if (group.length > 0 && groupWeight + w > opts.groupCap) flush();
|
|
529
|
+
group.push(f);
|
|
530
|
+
groupWeight += w;
|
|
531
|
+
}
|
|
532
|
+
flush();
|
|
533
|
+
return tasks;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── CLI ────────────────────────────────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
function envNumber(name: string, fallback: number): number {
|
|
539
|
+
const v = process.env[name];
|
|
540
|
+
if (typeof v !== "string" || v.length === 0) return fallback;
|
|
541
|
+
const n = Number(v);
|
|
542
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Write the whole payload to stdout SYNCHRONOUSLY. The collector's JSON is
|
|
547
|
+
* large (140KB+ on a real fleet) and DreamWeaver CAPTURES it through a PIPE;
|
|
548
|
+
* `console.log` to a pipe is an async write that the runtime may truncate on
|
|
549
|
+
* exit (observed: 141KB written to a file, cut to ~128KB through a pipe —
|
|
550
|
+
* invalid JSON would break the whole tick). `writeSync(1, …)` guarantees the
|
|
551
|
+
* bytes land before the process returns; EAGAIN (a full non-blocking pipe) is
|
|
552
|
+
* retried, partial writes are looped.
|
|
553
|
+
*/
|
|
554
|
+
function writeStdout(s: string): void {
|
|
555
|
+
const buf = Buffer.from(s, "utf-8");
|
|
556
|
+
let off = 0;
|
|
557
|
+
while (off < buf.length) {
|
|
558
|
+
try {
|
|
559
|
+
off += fs.writeSync(1, buf, off, buf.length - off);
|
|
560
|
+
} catch (err) {
|
|
561
|
+
if ((err as { code?: string }).code === "EAGAIN") continue;
|
|
562
|
+
throw err;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function cmdDreamCollect(argv: string[], egress: Egress): number {
|
|
568
|
+
let iapeerBin: string | undefined;
|
|
569
|
+
let gate = false;
|
|
570
|
+
for (let i = 0; i < argv.length; i++) {
|
|
571
|
+
const a = argv[i];
|
|
572
|
+
if (a === "--iapeer-bin") iapeerBin = argv[++i];
|
|
573
|
+
else if (a === "--gate") gate = true;
|
|
574
|
+
else {
|
|
575
|
+
console.error(`iapeer-memory dream-collect: unknown flag: ${a}`);
|
|
576
|
+
return 2;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const vault = process.env.IAPEER_MEMORY_VAULT_PATH ?? "";
|
|
581
|
+
if (!vault) {
|
|
582
|
+
console.error(
|
|
583
|
+
"iapeer-memory dream-collect: IAPEER_MEMORY_VAULT_PATH is not set — not provisioned",
|
|
584
|
+
);
|
|
585
|
+
return 1;
|
|
586
|
+
}
|
|
587
|
+
const localeRaw = process.env.IAPEER_MEMORY_LOCALE || "en";
|
|
588
|
+
if (!isLocaleId(localeRaw)) {
|
|
589
|
+
console.error(`iapeer-memory dream-collect: unknown locale "${localeRaw}"`);
|
|
590
|
+
return 1;
|
|
591
|
+
}
|
|
592
|
+
const agentMemoryFolder = getTaxonomy(localeRaw).folders.agentMemory;
|
|
593
|
+
const windowDays = envNumber("IAPEER_MEMORY_DREAM_WINDOW_DAYS", DEFAULT_WINDOW_DAYS);
|
|
594
|
+
|
|
595
|
+
if (gate) {
|
|
596
|
+
// REGISTRY-FREE (runs in the notifier's env): exit 0 ⇔ there is an
|
|
597
|
+
// in-window note (the notifier fires DreamWeaver); exit 1 ⇔ a dead week
|
|
598
|
+
// (NOBODY is woken — true zero-LLM).
|
|
599
|
+
const work = gateHasWork({ vault, agentMemoryFolder, nowMs: Date.now(), windowDays });
|
|
600
|
+
return work ? 0 : 1;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const q = queryRegistry(egress, { iapeerBin });
|
|
604
|
+
if ("error" in q) {
|
|
605
|
+
// LOUD: a silent empty collection would re-create the masked-dead-phase
|
|
606
|
+
// class — the caller reports instead of guessing the fleet.
|
|
607
|
+
console.error(`iapeer-memory dream-collect: live registry unavailable — ${q.error}`);
|
|
608
|
+
return 1;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const result = collect({
|
|
612
|
+
vault,
|
|
613
|
+
agentMemoryFolder,
|
|
614
|
+
peers: q.peers,
|
|
615
|
+
home: os.homedir(),
|
|
616
|
+
io: {
|
|
617
|
+
nowMs: Date.now(),
|
|
618
|
+
windowDays,
|
|
619
|
+
descMaxLen: envNumber("IAPEER_MEMORY_DREAM_DESC_MAXLEN", DEFAULT_DESC_MAXLEN),
|
|
620
|
+
transcriptCap: envNumber("IAPEER_MEMORY_DREAM_TRANSCRIPT_CAP", DEFAULT_TRANSCRIPT_CAP),
|
|
621
|
+
existsSync: fs.existsSync,
|
|
622
|
+
env: process.env,
|
|
623
|
+
home: os.homedir(),
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
const batchThreshold = envNumber("IAPEER_MEMORY_DREAM_BATCH_THRESHOLD", DEFAULT_BATCH_THRESHOLD);
|
|
628
|
+
const groupCap = envNumber("IAPEER_MEMORY_DREAM_GROUP_CAP", DEFAULT_GROUP_CAP);
|
|
629
|
+
const tasks = batchTasks(result.folders, { threshold: batchThreshold, groupCap });
|
|
630
|
+
writeStdout(
|
|
631
|
+
`${JSON.stringify(
|
|
632
|
+
{
|
|
633
|
+
vault: result.vault,
|
|
634
|
+
windowDays: result.windowDays,
|
|
635
|
+
batchThreshold,
|
|
636
|
+
groupCap,
|
|
637
|
+
tasks,
|
|
638
|
+
skipped: result.skipped,
|
|
639
|
+
},
|
|
640
|
+
null,
|
|
641
|
+
2,
|
|
642
|
+
)}\n`,
|
|
643
|
+
);
|
|
644
|
+
return 0;
|
|
645
|
+
}
|