zames_pro 2.60.0 → 2.63.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.
@@ -0,0 +1,197 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { execFile } from 'child_process';
5
+ import { writeJsonAtomic } from './fsutil.js';
6
+ // Checkpoints / rewind (BACKLOG B3).
7
+ //
8
+ // Undo (src/undo.ts) reverts ONE file write. A checkpoint snapshots the WHOLE
9
+ // working tree at the start of a task, so a risky task can be rolled back with
10
+ // a single `/rewind`. A tarball — not a git stash — is used on purpose: it
11
+ // works in a non-git directory and never touches the operator's index/stash.
12
+ //
13
+ // Machine codes only ('not_found', 'archive_missing', ...): the caller
14
+ // (src/index.ts) localizes them through the i18n catalog, exactly like
15
+ // undo.ts. Keeping prose out of this module is also why no Russian leaks here
16
+ // (see test/agent-facing-english.test.ts).
17
+ const CHECKPOINT_DIR = path.join(os.homedir(), '.zames', 'checkpoints');
18
+ // Never snapshotted and never deleted on restore: dependencies, the git object
19
+ // store (the operator's own safety net), build output and the agent's scratch
20
+ // dir. Everything else is part of the snapshot. Patterns are anchored to the
21
+ // archive root ('./x') so a nested `src/tmp` is NOT accidentally excluded.
22
+ export const CHECKPOINT_EXCLUDES = ['node_modules', '.git', 'dist', 'tmp'];
23
+ // ---------- pure helpers (unit-tested without tar) ----------
24
+ export function tarCreateArgs(archive, _dir) {
25
+ const args = ['-czf', archive];
26
+ for (const e of CHECKPOINT_EXCLUDES)
27
+ args.push('--exclude=./' + e);
28
+ args.push('-C', _dir, '.');
29
+ return args;
30
+ }
31
+ export function tarExtractArgs(archive, dir) {
32
+ return ['-xzf', archive, '-C', dir];
33
+ }
34
+ /** Keep only the newest `max` records (returns a new array; order preserved). */
35
+ export function rotateRecords(records, max) {
36
+ if (max <= 0)
37
+ return [];
38
+ if (records.length <= max)
39
+ return records.slice();
40
+ return records.slice(records.length - max);
41
+ }
42
+ /**
43
+ * Resolve a `/rewind` selector against records sorted NEWEST FIRST.
44
+ * `1` = most recent; a non-numeric selector is matched as a stamp prefix.
45
+ */
46
+ export function pickRecord(records, selector) {
47
+ const sel = String(selector ?? '').trim();
48
+ if (!sel)
49
+ return null;
50
+ if (/^\d+$/.test(sel)) {
51
+ const n = Number(sel);
52
+ // A small number is an index (1 = newest). An out-of-range number is
53
+ // still tried as a stamp prefix below, so `/rewind 1730000` works.
54
+ if (n >= 1 && n <= records.length)
55
+ return records[n - 1];
56
+ }
57
+ return records.find((r) => String(r.stamp).startsWith(sel)) || null;
58
+ }
59
+ // ---------- tar / fs plumbing ----------
60
+ function runTar(args, cwd, timeout = 60_000) {
61
+ return new Promise((resolve) => {
62
+ execFile('tar', args, { cwd, timeout, maxBuffer: 1024 * 1024 * 8, windowsHide: true }, (err, _stdout, stderr) => {
63
+ if (!err)
64
+ return resolve({ code: 0, stderr: '' });
65
+ const raw = err.code;
66
+ const code = typeof raw === 'number' ? raw : 127;
67
+ resolve({ code, stderr: String(stderr || err.message || '') });
68
+ });
69
+ });
70
+ }
71
+ async function copyTree(from, to) {
72
+ await fs.mkdir(to, { recursive: true });
73
+ const entries = await fs.readdir(from, { withFileTypes: true });
74
+ for (const e of entries) {
75
+ const src = path.join(from, e.name);
76
+ const dst = path.join(to, e.name);
77
+ if (e.isDirectory())
78
+ await copyTree(src, dst);
79
+ else if (e.isSymbolicLink()) {
80
+ const link = await fs.readlink(src).catch(() => null);
81
+ if (link !== null)
82
+ await fs.symlink(link, dst).catch(() => { });
83
+ }
84
+ else
85
+ await fs.copyFile(src, dst);
86
+ }
87
+ }
88
+ // ---------- store ----------
89
+ export class CheckpointStore {
90
+ enabled;
91
+ maxBackups;
92
+ dir;
93
+ constructor({ enabled = true, maxBackups = 50, dir = CHECKPOINT_DIR, } = {}) {
94
+ this.enabled = enabled;
95
+ this.maxBackups = maxBackups;
96
+ this.dir = dir;
97
+ }
98
+ get indexFile() {
99
+ return path.join(this.dir, 'index.json');
100
+ }
101
+ async _ensure() {
102
+ await fs.mkdir(this.dir, { recursive: true });
103
+ }
104
+ async _readIndex() {
105
+ try {
106
+ const data = JSON.parse(await fs.readFile(this.indexFile, 'utf-8'));
107
+ return Array.isArray(data) ? data : [];
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ }
113
+ /**
114
+ * Snapshot `workdir` into a tarball. Best-effort: a missing tar, an
115
+ * unreadable dir or a timeout yields null, never a thrown error — a failed
116
+ * checkpoint must not abort the task it was meant to protect.
117
+ */
118
+ async create(workdir, label) {
119
+ if (!this.enabled)
120
+ return null;
121
+ await this._ensure();
122
+ const stamp = Date.now();
123
+ const file = path.join(this.dir, `${stamp}.tgz`);
124
+ const res = await runTar(tarCreateArgs(file, workdir), workdir);
125
+ if (res.code !== 0) {
126
+ await fs.rm(file, { force: true }).catch(() => { });
127
+ return null;
128
+ }
129
+ const record = { stamp, file, workdir };
130
+ if (label)
131
+ record.label = label;
132
+ const history = await this._readIndex();
133
+ history.push(record);
134
+ const kept = rotateRecords(history, this.maxBackups);
135
+ // Drop the archives that fell out of the retention window.
136
+ const keptStamps = new Set(kept.map((r) => r.stamp));
137
+ for (const r of history) {
138
+ if (!keptStamps.has(r.stamp))
139
+ await fs.rm(r.file, { force: true }).catch(() => { });
140
+ }
141
+ writeJsonAtomic(this.indexFile, kept);
142
+ return record;
143
+ }
144
+ /** Newest-first list, capped at `count`. */
145
+ async list(count = 10) {
146
+ const history = await this._readIndex();
147
+ return history.slice().reverse().slice(0, count);
148
+ }
149
+ /**
150
+ * Restore the working tree to a checkpoint. Backs up the CURRENT tree first
151
+ * (a 'pre-rewind' checkpoint), so the rewind itself is reversible. Returns
152
+ * machine codes; the caller localizes them.
153
+ */
154
+ async restore(selector) {
155
+ if (!this.enabled)
156
+ return { ok: false, reason: 'disabled' };
157
+ const history = await this._readIndex();
158
+ const newestFirst = history.slice().reverse();
159
+ const record = pickRecord(newestFirst, selector);
160
+ if (!record)
161
+ return { ok: false, reason: 'not_found' };
162
+ const exists = await fs
163
+ .stat(record.file)
164
+ .then(() => true)
165
+ .catch(() => false);
166
+ if (!exists)
167
+ return { ok: false, reason: 'archive_missing' };
168
+ // Backup the CURRENT tree before touching it.
169
+ const backup = await this.create(record.workdir, 'pre-rewind');
170
+ // Extract to a temp dir FIRST; only a successful extract may touch workdir
171
+ // (otherwise a bad archive would wipe the tree).
172
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'zames-rewind-'));
173
+ try {
174
+ const res = await runTar(tarExtractArgs(record.file, tmp), tmp);
175
+ if (res.code !== 0)
176
+ return { ok: false, reason: 'extract_failed' };
177
+ // Full restore: drop everything in workdir except the excludes, then
178
+ // copy the snapshot in. A partial extract would leave behind files the
179
+ // task created after the checkpoint.
180
+ const entries = await fs
181
+ .readdir(record.workdir)
182
+ .catch(() => []);
183
+ for (const e of entries) {
184
+ if (CHECKPOINT_EXCLUDES.includes(e))
185
+ continue;
186
+ await fs
187
+ .rm(path.join(record.workdir, e), { recursive: true, force: true })
188
+ .catch(() => { });
189
+ }
190
+ await copyTree(tmp, record.workdir);
191
+ return { ok: true, record, backup };
192
+ }
193
+ finally {
194
+ await fs.rm(tmp, { recursive: true, force: true }).catch(() => { });
195
+ }
196
+ }
197
+ }
package/dist/commands.js CHANGED
@@ -82,6 +82,8 @@ export function summarizeTranscript(entries) {
82
82
  turns: 0,
83
83
  toolCalls: 0,
84
84
  toolCounts: {},
85
+ toolMs: 0,
86
+ toolTime: {},
85
87
  durationMs: 0,
86
88
  startedAt: null,
87
89
  autoCompacts: 0,
@@ -98,6 +100,14 @@ export function summarizeTranscript(entries) {
98
100
  const tool = String(e.tool ?? '?');
99
101
  stats.toolCounts[tool] = (stats.toolCounts[tool] || 0) + 1;
100
102
  }
103
+ if (e.type === 'tool_result') {
104
+ const ms = Number(e.durationMs);
105
+ if (Number.isFinite(ms) && ms > 0) {
106
+ stats.toolMs += ms;
107
+ const tool = String(e.tool ?? '?');
108
+ stats.toolTime[tool] = (stats.toolTime[tool] || 0) + ms;
109
+ }
110
+ }
101
111
  if (typeof e.elapsed === 'number' && e.elapsed > stats.durationMs) {
102
112
  stats.durationMs = e.elapsed;
103
113
  }
@@ -179,6 +189,19 @@ export function renderCost(stats, transcriptFile, tokenUsage = null, t = transla
179
189
  for (const [name, n] of top)
180
190
  lines.push(' ' + name + ': ' + n);
181
191
  }
192
+ // T-D3: where the time went. Sort by total ms, show the top tools with the
193
+ // count and a human duration, so Read→Edit cycles and slow Bash calls are
194
+ // visible at a glance.
195
+ const byTime = Object.entries(stats.toolTime || {})
196
+ .filter(([, ms]) => ms > 0)
197
+ .sort((a, b) => b[1] - a[1]);
198
+ if (byTime.length) {
199
+ lines.push(t('cost.by_time', { dur: formatDuration(stats.toolMs) }));
200
+ for (const [name, ms] of byTime.slice(0, 8)) {
201
+ const count = stats.toolCounts[name] ?? 0;
202
+ lines.push(' ' + name + ': ' + formatDuration(ms) + ' ×' + count);
203
+ }
204
+ }
182
205
  lines.push(t('cost.duration', { dur: formatDuration(stats.durationMs) }));
183
206
  if (stats.startedAt)
184
207
  lines.push(t('cost.started', { v: stats.startedAt }));
@@ -615,6 +638,118 @@ export function rewriteFailedAttachments(text, failed) {
615
638
  }
616
639
  return out;
617
640
  }
641
+ // Split a custom-command argument string into positional tokens, honoring
642
+ // single/double quotes (so two quoted words stay one token). Pure, so it is
643
+ // unit-tested.
644
+ export function splitCommandArgs(rest) {
645
+ const out = [];
646
+ const s = String(rest ?? '');
647
+ const SQ = String.fromCharCode(39);
648
+ const DQ = String.fromCharCode(34);
649
+ let cur = '';
650
+ let quote = '';
651
+ for (let i = 0; i < s.length; i++) {
652
+ const ch = s[i];
653
+ if (quote) {
654
+ if (ch === quote) {
655
+ quote = '';
656
+ continue;
657
+ }
658
+ cur += ch;
659
+ continue;
660
+ }
661
+ if (ch === SQ || ch === DQ) {
662
+ quote = ch;
663
+ continue;
664
+ }
665
+ if (ch.charCodeAt(0) <= 32) {
666
+ if (cur) {
667
+ out.push(cur);
668
+ cur = '';
669
+ }
670
+ continue;
671
+ }
672
+ cur += ch;
673
+ }
674
+ if (cur)
675
+ out.push(cur);
676
+ return out;
677
+ }
678
+ function isAllDigits(tok) {
679
+ if (!tok)
680
+ return false;
681
+ for (let i = 0; i < tok.length; i++) {
682
+ const c = tok.charCodeAt(i);
683
+ if (c < 48 || c > 57)
684
+ return false;
685
+ }
686
+ return true;
687
+ }
688
+ function isWordChar(ch) {
689
+ const c = ch.charCodeAt(0);
690
+ return ((c >= 48 && c <= 57) ||
691
+ (c >= 65 && c <= 90) ||
692
+ (c >= 97 && c <= 122) ||
693
+ ch === '_');
694
+ }
695
+ // Replace $1..$N and $name tokens in an already-expanded body. A whole word is
696
+ // read after $ so $foobar never matches the name foo. Pure.
697
+ function replaceDollarTokens(s, parts, names) {
698
+ let res = '';
699
+ let i = 0;
700
+ while (i < s.length) {
701
+ const ch = s[i];
702
+ if (ch !== '$') {
703
+ res += ch;
704
+ i++;
705
+ continue;
706
+ }
707
+ let j = i + 1;
708
+ let tok = '';
709
+ while (j < s.length && isWordChar(s[j])) {
710
+ tok += s[j];
711
+ j++;
712
+ }
713
+ if (!tok) {
714
+ res += ch;
715
+ i++;
716
+ continue;
717
+ }
718
+ if (isAllDigits(tok)) {
719
+ res += parts[Number(tok) - 1] ?? '';
720
+ }
721
+ else {
722
+ const idx = names.indexOf(tok);
723
+ res += idx >= 0 ? (parts[idx] ?? '') : '$' + tok;
724
+ }
725
+ i = j;
726
+ }
727
+ return res;
728
+ }
729
+ // Expand a custom-command body: {{args}} / $ARGUMENTS become the whole argument
730
+ // string, $1..$N the positional tokens, $name the declared positions. Pure, so
731
+ // it is unit-tested.
732
+ export function expandCommandArgs(body, rest, names = []) {
733
+ const parts = splitCommandArgs(rest);
734
+ let out = String(body ?? '');
735
+ out = out.split('{{args}}').join(rest);
736
+ out = out.split('$ARGUMENTS').join(rest);
737
+ out = replaceDollarTokens(out, parts, names);
738
+ return out.trim();
739
+ }
740
+ // Names declared in `arguments:` but not supplied on the command line (by
741
+ // position). Pure, so the caller can refuse an incomplete invocation.
742
+ export function missingCommandArgs(names, rest) {
743
+ if (!names || !names.length)
744
+ return [];
745
+ const parts = splitCommandArgs(rest);
746
+ const missing = [];
747
+ for (let i = 0; i < names.length; i++) {
748
+ if (!parts[i])
749
+ missing.push(names[i]);
750
+ }
751
+ return missing;
752
+ }
618
753
  // True when a `/config ...` line can be handled LIVE (while the agent is busy):
619
754
  // the text subcommands only touch config values, never the chat. The bare
620
755
  // `/config` and `/config menu` (which pause the editor and read keys) are NOT
@@ -781,6 +916,28 @@ export function buildImprovePrompt(item) {
781
916
  '(files changed, tests run, whether BACKLOG/CHANGELOG were updated).');
782
917
  }
783
918
  // ---------- /review ----------
919
+ // Commands that only make sense while developing zames itself. A regular user
920
+ // who installed the package must not see them in /help or the «/» hints, and
921
+ // the main loop rejects them unless dev mode is on (--dev / config.hotReload).
922
+ // Kept here (pure) so the list has ONE source of truth and is unit-tested.
923
+ export const DEV_ONLY_COMMANDS = [
924
+ '/improve',
925
+ '/backlog',
926
+ '/self-review',
927
+ '/self-fix',
928
+ '/self-done',
929
+ '/self-list',
930
+ '/self-diff',
931
+ '/self-apply',
932
+ ];
933
+ /** True when the typed line invokes a dev-only command. */
934
+ export function isDevOnlyCommand(text) {
935
+ const name = String(text ?? '')
936
+ .trim()
937
+ .split(/\s+/)[0]
938
+ .toLowerCase();
939
+ return DEV_ONLY_COMMANDS.find((c) => c === name) || null;
940
+ }
784
941
  export function buildReviewPrompt(focus, hasStaged = false) {
785
942
  const scope = hasStaged ? 'staged' : 'uncommitted';
786
943
  const f = String(focus ?? '').trim();
package/dist/compact.js CHANGED
@@ -14,7 +14,7 @@ export const COMPACT_FALLBACK_LIMIT = 40;
14
14
  * The caller owns the browser and the throttle; this function only sends.
15
15
  */
16
16
  export async function performCompact(opts) {
17
- const { browser, workdir, locale, task, transcript = null, ui = null, buildSystemPrompt, tools, answerTimeoutMs, fallbackLimit = COMPACT_FALLBACK_LIMIT, skipUiLock = false, quiet = false, } = opts;
17
+ const { browser, workdir, locale, task, transcript = null, ui = null, buildSystemPrompt, tools, answerTimeoutMs, fallbackLimit = COMPACT_FALLBACK_LIMIT, skipUiLock = false, quiet = false, selfImprovement = false, } = opts;
18
18
  const t = translate(locale);
19
19
  const log = (msg) => {
20
20
  if (!quiet)
@@ -124,7 +124,7 @@ export async function performCompact(opts) {
124
124
  });
125
125
  // 2) New chat + system prompt + the summary as the first message.
126
126
  await browser.newChat();
127
- await browser.ask(buildSystemPrompt({ workdir, tools, locale }), {
127
+ await browser.ask(buildSystemPrompt({ workdir, tools, locale, selfImprovement }), {
128
128
  timeout: 60_000,
129
129
  agent: false,
130
130
  });
package/dist/config.js CHANGED
@@ -22,6 +22,12 @@ export const DEFAULTS = {
22
22
  enabled: true,
23
23
  maxBackups: 200,
24
24
  },
25
+ // BACKLOG B3: snapshot the whole working tree at the start of each task so
26
+ // /rewind can roll a risky task back in one step.
27
+ checkpoint: {
28
+ enabled: true,
29
+ maxBackups: 50,
30
+ },
25
31
  transcript: {
26
32
  enabled: true,
27
33
  dir: path.join(os.homedir(), '.zames', 'logs'),
@@ -162,6 +168,20 @@ export const CONFIG_SCHEMA = [
162
168
  labelKey: 'cfg.f.undo_maxBackups',
163
169
  groupKey: 'cfg.group.undo',
164
170
  },
171
+ {
172
+ path: 'checkpoint.enabled',
173
+ type: 'boolean',
174
+ labelKey: 'cfg.f.checkpoint_enabled',
175
+ groupKey: 'cfg.group.checkpoint',
176
+ },
177
+ {
178
+ path: 'checkpoint.maxBackups',
179
+ type: 'number',
180
+ min: 1,
181
+ max: 1000,
182
+ labelKey: 'cfg.f.checkpoint_maxBackups',
183
+ groupKey: 'cfg.group.checkpoint',
184
+ },
165
185
  {
166
186
  path: 'transcript.enabled',
167
187
  type: 'boolean',
package/dist/context.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'fs/promises';
2
+ import { statSync } from 'fs';
2
3
  import path from 'path';
3
4
  import os from 'os';
4
5
  // Built-in skills, always available unless a project/global skill with the
@@ -169,11 +170,21 @@ function parseFrontmatter(raw) {
169
170
  const userInvokable = typeof out['user-invokable'] === 'string'
170
171
  ? out['user-invokable'].toLowerCase() !== 'false'
171
172
  : undefined;
173
+ const argumentsList = typeof out['arguments'] === 'string'
174
+ ? out['arguments']
175
+ .split(/[,\s]+/)
176
+ .map((s) => s.trim())
177
+ .filter(Boolean)
178
+ : undefined;
172
179
  return {
173
180
  name: out.name,
174
181
  description: out.description,
175
182
  allowedTools,
176
183
  userInvokable,
184
+ argumentHint: typeof out['argument-hint'] === 'string'
185
+ ? out['argument-hint']
186
+ : undefined,
187
+ arguments: argumentsList,
177
188
  };
178
189
  }
179
190
  export function skillBody(raw) {
@@ -302,6 +313,8 @@ async function loadCommandsFromDir(dir) {
302
313
  description: (fm.description || '').replace(/\s+/g, ' ').trim(),
303
314
  path: full,
304
315
  body: skillBody(raw),
316
+ argumentHint: fm.argumentHint,
317
+ arguments: fm.arguments,
305
318
  });
306
319
  }
307
320
  return out;
@@ -376,13 +389,14 @@ async function loadMemoryChain(workdir) {
376
389
  }
377
390
  return out;
378
391
  }
379
- export async function loadProjectContext(workdir) {
392
+ export async function loadProjectContext(workdir, touchPaths = []) {
380
393
  const [agents, memory, skills, commands] = await Promise.all([
381
394
  loadAgentsChain(workdir).catch(() => []),
382
395
  loadMemoryChain(workdir).catch(() => []),
383
396
  loadSkills(workdir).catch(() => []),
384
397
  loadCommands(workdir).catch(() => []),
385
398
  ]);
399
+ const scopedAgents = await loadScopedAgents(workdir, touchPaths).catch(() => []);
386
400
  let used = 0;
387
401
  const withinBudget = (files) => {
388
402
  const kept = [];
@@ -396,8 +410,81 @@ export async function loadProjectContext(workdir) {
396
410
  };
397
411
  return {
398
412
  agents: withinBudget(agents),
413
+ // Scoped files are dropped silently when the total budget is already spent
414
+ // on the main chain: a nested AGENTS.md is an optimization, not a hard
415
+ // requirement, and must never push out a top-level instruction.
416
+ scopedAgents: withinBudget(scopedAgents),
399
417
  memory: withinBudget(memory),
400
418
  skills,
401
419
  commands,
402
420
  };
403
421
  }
422
+ // A directory is "touched" by a task when the task text mentions a path inside
423
+ // it (a `@ref`, a quoted path, a bare `src/foo.ts`). We look for the deepest
424
+ // existing directories named in the text and collect AGENTS.md/MEMORY.md from
425
+ // them and their ancestors below `workdir`. Implemented by scanning word tokens
426
+ // rather than a regex over the whole text so a long prompt stays cheap.
427
+ function touchedDirs(workdir, text) {
428
+ const root = path.resolve(workdir);
429
+ const out = new Set();
430
+ const words = String(text || '')
431
+ .split(/\s+/)
432
+ .map((w) => w.replace(/^[\('"\[]+/, '').replace(/[\),.;:'"\]]+$/, ''));
433
+ for (const w of words) {
434
+ if (!w || w.length > 300)
435
+ continue;
436
+ // Only path-looking tokens (contain a separator or a known file extension).
437
+ if (!/[\\/]/.test(w) && !/\.[A-Za-z0-9]{1,8}$/.test(w))
438
+ continue;
439
+ const abs = path.isAbsolute(w) ? w : path.resolve(root, w);
440
+ if (abs !== root && !abs.startsWith(root + path.sep))
441
+ continue;
442
+ // Walk from the deepest directory that actually exists up to the root.
443
+ let dir = abs;
444
+ let st = statSyncSafe(dir);
445
+ while (!st && dir !== root && dir.startsWith(root + path.sep)) {
446
+ dir = path.dirname(dir);
447
+ st = statSyncSafe(dir);
448
+ }
449
+ if (!st || !st.isDirectory())
450
+ continue;
451
+ let cur = dir;
452
+ while (cur.startsWith(root + path.sep)) {
453
+ out.add(cur);
454
+ cur = path.dirname(cur);
455
+ }
456
+ }
457
+ return [...out];
458
+ }
459
+ function statSyncSafe(p) {
460
+ try {
461
+ return statSync(p);
462
+ }
463
+ catch {
464
+ return null;
465
+ }
466
+ }
467
+ async function loadScopedAgents(workdir, touchPaths) {
468
+ if (!touchPaths.length)
469
+ return [];
470
+ const root = path.resolve(workdir);
471
+ // Collect every directory named by the task, deepest first, so the most
472
+ // specific instructions come first and win the budget.
473
+ const dirs = touchedDirs(root, touchPaths.join(' '));
474
+ dirs.sort((a, b) => b.length - a.length);
475
+ const out = [];
476
+ const seen = new Set();
477
+ for (const dir of dirs) {
478
+ for (const names of [AGENT_NAMES, MEMORY_NAMES]) {
479
+ const p = await findNamedFile(dir, names);
480
+ if (!p || seen.has(p))
481
+ continue;
482
+ const f = await readIfFile(p, MAX_FILE_BASE);
483
+ if (!f)
484
+ continue;
485
+ seen.add(p);
486
+ out.push(f);
487
+ }
488
+ }
489
+ return out;
490
+ }