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.
- package/CHANGELOG.md +93 -2
- package/README.md +64 -23
- package/dist/agent-loop.js +55 -2
- package/dist/backlog.js +166 -0
- package/dist/checkpoint.js +197 -0
- package/dist/commands.js +157 -0
- package/dist/compact.js +2 -2
- package/dist/config.js +20 -0
- package/dist/context.js +88 -1
- package/dist/i18n.js +121 -0
- package/dist/index.js +390 -26
- package/dist/input.js +3 -2
- package/dist/path-token.js +75 -0
- package/dist/permissions.js +98 -0
- package/dist/system-prompt.js +39 -2
- package/logo-small.jpg +0 -0
- package/package.json +3 -2
- package/scripts/coverage-gate.mjs +2 -0
|
@@ -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
|
+
}
|