atom-agent 1.2.0 → 1.4.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 +97 -0
- package/README.md +13 -4
- package/atom.example.json +11 -0
- package/dist/App.js +923 -200
- package/dist/adapters.js +82 -13
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop.js +517 -76
- package/dist/cli.js +11 -3
- package/dist/compact.js +41 -15
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +5 -5
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +74 -36
- package/dist/session.js +23 -5
- package/dist/sessions.js +25 -6
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +246 -17
- package/dist/tools.js +44 -0
- package/dist/ui/diff-panel.js +5 -5
- package/dist/ui/diff-view.js +3 -2
- package/dist/ui/diff.js +7 -52
- package/dist/ui/modals.js +5 -5
- package/dist/ui/palette.js +1 -1
- package/dist/ui/side-by-side.js +7 -5
- package/dist/ui/status-bar.js +80 -5
- package/dist/ui/transcript.js +3 -3
- package/dist/zen.js +305 -75
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- package/package.json +6 -2
package/dist/goal.js
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { toolSignature } from "./agent/normalize.js";
|
|
2
|
+
// Declared-unverifiable checks on a `complete` report (ticket 06): checks
|
|
3
|
+
// the model could not run, recorded openly in the closing verdict — never a
|
|
4
|
+
// gate (they do not block completion). Bounded so a report stays a short
|
|
5
|
+
// transcript line, never a pasted log.
|
|
6
|
+
export const GOAL_UNVERIFIED_MAX_ITEMS = 10;
|
|
7
|
+
export const GOAL_UNVERIFIED_MAX_LENGTH = 200;
|
|
8
|
+
// Expected update_goal shape for validation details (mirrors the registry's
|
|
9
|
+
// expectedShape framing — the tool definition itself lives in src/tools.ts).
|
|
10
|
+
const UPDATE_GOAL_EXPECTED = `{"status": "continue" | "complete" | "blocked", "next"?: string, "reason"?: string, "unverified"?: string[]}`;
|
|
11
|
+
// Arg validation for update_goal (same detail-string contract as the
|
|
12
|
+
// registry validators — the loop wraps it with invalidCall, so bad args are
|
|
13
|
+
// a model mistake that records nothing). `continue` takes an optional
|
|
14
|
+
// non-empty next action; `complete`/`blocked` require a non-empty reason.
|
|
15
|
+
// `complete` also takes an optional `unverified` list of checks the model
|
|
16
|
+
// could not run (non-empty strings, capped in count and length — recorded
|
|
17
|
+
// openly in the verdict, never a gate). Unknown fields are ignored (same
|
|
18
|
+
// leniency as the registry validators).
|
|
19
|
+
export function validateUpdateGoalArgs(args) {
|
|
20
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
21
|
+
return `arguments for tool "update_goal" must be an object. Expected ${UPDATE_GOAL_EXPECTED}`;
|
|
22
|
+
}
|
|
23
|
+
const a = args;
|
|
24
|
+
const status = a["status"];
|
|
25
|
+
if (status !== "continue" && status !== "complete" && status !== "blocked") {
|
|
26
|
+
return (`field "status" for tool "update_goal" must be one of "continue", "complete", "blocked" ` +
|
|
27
|
+
`(got ${JSON.stringify(status) ?? String(status)}). Expected ${UPDATE_GOAL_EXPECTED}`);
|
|
28
|
+
}
|
|
29
|
+
if (status === "continue") {
|
|
30
|
+
const next = a["next"];
|
|
31
|
+
if (next !== undefined && (typeof next !== "string" || next.trim().length === 0)) {
|
|
32
|
+
return (`field "next" for tool "update_goal" must be a non-empty string when present ` +
|
|
33
|
+
`(got ${JSON.stringify(next) ?? String(next)}). Expected ${UPDATE_GOAL_EXPECTED}`);
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const reason = a["reason"];
|
|
38
|
+
if (typeof reason !== "string" || reason.trim().length === 0) {
|
|
39
|
+
return (`field "reason" for tool "update_goal" with status "${status}" must be a non-empty string. ` +
|
|
40
|
+
`Expected ${UPDATE_GOAL_EXPECTED}`);
|
|
41
|
+
}
|
|
42
|
+
if (status === "complete") {
|
|
43
|
+
const unverified = a["unverified"];
|
|
44
|
+
if (unverified !== undefined) {
|
|
45
|
+
if (!Array.isArray(unverified)) {
|
|
46
|
+
return (`field "unverified" for tool "update_goal" must be an array of non-empty strings ` +
|
|
47
|
+
`when present. Expected ${UPDATE_GOAL_EXPECTED}`);
|
|
48
|
+
}
|
|
49
|
+
if (unverified.length > GOAL_UNVERIFIED_MAX_ITEMS) {
|
|
50
|
+
return (`field "unverified" for tool "update_goal" must hold at most ` +
|
|
51
|
+
`${GOAL_UNVERIFIED_MAX_ITEMS} items (got ${unverified.length}). Expected ${UPDATE_GOAL_EXPECTED}`);
|
|
52
|
+
}
|
|
53
|
+
for (const item of unverified) {
|
|
54
|
+
if (typeof item !== "string" || item.trim().length === 0) {
|
|
55
|
+
return (`field "unverified" for tool "update_goal" must be an array of non-empty strings. ` +
|
|
56
|
+
`Expected ${UPDATE_GOAL_EXPECTED}`);
|
|
57
|
+
}
|
|
58
|
+
if (item.trim().length > GOAL_UNVERIFIED_MAX_LENGTH) {
|
|
59
|
+
return (`field "unverified" for tool "update_goal" must hold items of at most ` +
|
|
60
|
+
`${GOAL_UNVERIFIED_MAX_LENGTH} characters. Expected ${UPDATE_GOAL_EXPECTED}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
// Build the typed disposition from validated args (call only after
|
|
68
|
+
// validateUpdateGoalArgs returns null — anything else is a caller bug, and
|
|
69
|
+
// the unknown-status fallthrough below keeps it total rather than throwing).
|
|
70
|
+
export function updateGoalDisposition(args) {
|
|
71
|
+
const a = args;
|
|
72
|
+
if (a.status === "continue") {
|
|
73
|
+
return typeof a.next === "string" ? { status: "continue", next: a.next } : { status: "continue" };
|
|
74
|
+
}
|
|
75
|
+
if (a.status === "complete") {
|
|
76
|
+
const reason = a.reason;
|
|
77
|
+
// Defensive carry: validation already capped count/length, but the
|
|
78
|
+
// builder stays total on unvalidated input (judge verdicts, direct
|
|
79
|
+
// callers) — trim, drop empties, cap, and omit when nothing remains.
|
|
80
|
+
const raw = a.unverified;
|
|
81
|
+
if (Array.isArray(raw)) {
|
|
82
|
+
const items = raw
|
|
83
|
+
.filter((s) => typeof s === "string")
|
|
84
|
+
.map((s) => s.trim())
|
|
85
|
+
.filter((s) => s.length > 0)
|
|
86
|
+
.slice(0, GOAL_UNVERIFIED_MAX_ITEMS);
|
|
87
|
+
if (items.length > 0)
|
|
88
|
+
return { status: "complete", reason, unverified: items };
|
|
89
|
+
}
|
|
90
|
+
return { status: "complete", reason };
|
|
91
|
+
}
|
|
92
|
+
if (a.status === "blocked") {
|
|
93
|
+
return { status: "blocked", reason: a.reason };
|
|
94
|
+
}
|
|
95
|
+
return { status: "continue" };
|
|
96
|
+
}
|
|
97
|
+
// Same-value comparison for idempotence (absent next counts as empty —
|
|
98
|
+
// `{continue}` and `{continue, next:""}` can never both be valid anyway).
|
|
99
|
+
export function sameGoalDisposition(a, b) {
|
|
100
|
+
if (a.status === "continue" && b.status === "continue") {
|
|
101
|
+
return (a.next ?? "") === (b.next ?? "");
|
|
102
|
+
}
|
|
103
|
+
if (a.status === "complete" && b.status === "complete") {
|
|
104
|
+
if (a.reason !== b.reason)
|
|
105
|
+
return false;
|
|
106
|
+
const au = a.unverified ?? [];
|
|
107
|
+
const bu = b.unverified ?? [];
|
|
108
|
+
return au.length === bu.length && au.every((s, i) => s === bu[i]);
|
|
109
|
+
}
|
|
110
|
+
if (a.status === "blocked" && b.status === "blocked") {
|
|
111
|
+
return a.reason === b.reason;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
// Terminal verdict (ticket 03): the run's final text AND the pause notice —
|
|
116
|
+
// same `(…)` voice as the stop notices, carrying the model's reason. A
|
|
117
|
+
// `complete` may also carry declared-unverifiable checks (ticket 06): they
|
|
118
|
+
// print openly as a trailing `(unverified: …)` segment — recorded, never
|
|
119
|
+
// gated. Total: unvalidated input degrades to the bare verdict.
|
|
120
|
+
export function goalVerdictNotice(objective, status, reason, unverified) {
|
|
121
|
+
const base = `(goal ${status} — "${objective}": ${reason})`;
|
|
122
|
+
if (status !== "complete" || !Array.isArray(unverified))
|
|
123
|
+
return base;
|
|
124
|
+
const items = unverified
|
|
125
|
+
.filter((s) => typeof s === "string")
|
|
126
|
+
.map((s) => s.trim())
|
|
127
|
+
.filter((s) => s.length > 0)
|
|
128
|
+
.slice(0, GOAL_UNVERIFIED_MAX_ITEMS);
|
|
129
|
+
if (items.length === 0)
|
|
130
|
+
return base;
|
|
131
|
+
return `${base} (unverified: ${items.join(", ")})`;
|
|
132
|
+
}
|
|
133
|
+
// Success ack for a recorded report (a transcript-visible receipt —
|
|
134
|
+
// deliberately not an Error, so it never trips failure accounting).
|
|
135
|
+
export function goalReportAck(disposition) {
|
|
136
|
+
switch (disposition.status) {
|
|
137
|
+
case "continue":
|
|
138
|
+
return disposition.next !== undefined
|
|
139
|
+
? `(goal report recorded — "continue": ${disposition.next})`
|
|
140
|
+
: `(goal report recorded — "continue")`;
|
|
141
|
+
case "complete":
|
|
142
|
+
return `(goal report recorded — "complete": ${disposition.reason})`;
|
|
143
|
+
case "blocked":
|
|
144
|
+
return `(goal report recorded — "blocked": ${disposition.reason})`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Post-terminal rejection (ticket 03): the first terminal report sticks —
|
|
148
|
+
// anything after it changes nothing and reads as a notice (not an Error,
|
|
149
|
+
// so failure accounting stays quiet).
|
|
150
|
+
export function goalReportRejectedNotice(existing) {
|
|
151
|
+
return (`(update_goal already reported "${existing.status}" for this turn — ` +
|
|
152
|
+
`keeping the first report; further reports change nothing)`);
|
|
153
|
+
}
|
|
154
|
+
// Outside-goal structured error (ticket 03): with no live goal turn there is
|
|
155
|
+
// nothing to record into, so the call changes zero state — the Error:
|
|
156
|
+
// framing keeps it repairable, never silent.
|
|
157
|
+
export function goalReportOutsideError() {
|
|
158
|
+
return (`Error: update_goal is only available during an active goal turn ` +
|
|
159
|
+
`(no active goal — set one with /goal <objective>). Nothing was recorded.`);
|
|
160
|
+
}
|
|
161
|
+
// Pure arg parser: bare `/goal` (or whitespace-only) shows status,
|
|
162
|
+
// `/goal clear` / `/goal pause` / `/goal resume` (case-insensitive) manage
|
|
163
|
+
// the goal, everything else after `/goal ` is the objective verbatim (case
|
|
164
|
+
// preserved — it echoes back). A literal objective of "pause"/"resume"/
|
|
165
|
+
// "clear" reads as the command (same pre-existing ambiguity as "clear").
|
|
166
|
+
export function parseGoalCommand(raw) {
|
|
167
|
+
const text = raw.trim();
|
|
168
|
+
const rest = text === "/goal" ? "" : text.slice("/goal".length).trim();
|
|
169
|
+
if (rest === "")
|
|
170
|
+
return { kind: "status" };
|
|
171
|
+
const lowered = rest.toLowerCase();
|
|
172
|
+
if (lowered === "clear")
|
|
173
|
+
return { kind: "clear" };
|
|
174
|
+
if (lowered === "pause")
|
|
175
|
+
return { kind: "pause" };
|
|
176
|
+
if (lowered === "resume")
|
|
177
|
+
return { kind: "resume" };
|
|
178
|
+
return { kind: "set", objective: rest };
|
|
179
|
+
}
|
|
180
|
+
// Zero stats for a fresh goal (a set/replace always resets the counters).
|
|
181
|
+
export function emptyGoalStats() {
|
|
182
|
+
return { turns: 0, requests: 0, tokens: 0, workMs: 0 };
|
|
183
|
+
}
|
|
184
|
+
// Read view: absent stats (ticket-01 literals) count as zeros — never throw.
|
|
185
|
+
export function goalStatsOf(goal) {
|
|
186
|
+
const s = goal?.stats;
|
|
187
|
+
return {
|
|
188
|
+
turns: s?.turns ?? 0,
|
|
189
|
+
requests: s?.requests ?? 0,
|
|
190
|
+
tokens: s?.tokens ?? 0,
|
|
191
|
+
workMs: s?.workMs ?? 0,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// Token slice for one reported usage payload: total_tokens when the API
|
|
195
|
+
// sent it, else prompt + completion. Anything unreported counts as zero —
|
|
196
|
+
// goal spend, like session spend, is real reports only, never estimated.
|
|
197
|
+
export function goalTokensForUsage(u) {
|
|
198
|
+
const total = u.total_tokens;
|
|
199
|
+
if (typeof total === "number" && Number.isFinite(total))
|
|
200
|
+
return Math.max(0, Math.floor(total));
|
|
201
|
+
const prompt = typeof u.prompt_tokens === "number" && Number.isFinite(u.prompt_tokens) ? u.prompt_tokens : 0;
|
|
202
|
+
const completion = typeof u.completion_tokens === "number" && Number.isFinite(u.completion_tokens)
|
|
203
|
+
? u.completion_tokens
|
|
204
|
+
: 0;
|
|
205
|
+
return Math.max(0, Math.floor(prompt) + Math.floor(completion));
|
|
206
|
+
}
|
|
207
|
+
// Serialize the live goal for a session save: a deep copy (the save must
|
|
208
|
+
// never alias live state), or null when no goal is live. Total: never throws.
|
|
209
|
+
export function serializeGoalForPersist(goal) {
|
|
210
|
+
try {
|
|
211
|
+
if (!goal)
|
|
212
|
+
return null;
|
|
213
|
+
if (typeof goal.objective !== "string" || goal.objective.length === 0)
|
|
214
|
+
return null;
|
|
215
|
+
const s = goal.stats ?? emptyGoalStats();
|
|
216
|
+
return {
|
|
217
|
+
objective: goal.objective,
|
|
218
|
+
active: goal.active === true,
|
|
219
|
+
stats: {
|
|
220
|
+
turns: coerceGoalCounter(s.turns),
|
|
221
|
+
requests: coerceGoalCounter(s.requests),
|
|
222
|
+
tokens: coerceGoalCounter(s.tokens),
|
|
223
|
+
workMs: coerceGoalCounter(s.workMs),
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Restore a saved goal: valid records come back verbatim (counters intact,
|
|
232
|
+
// never reset); absent or corrupt data loads as no-goal (null) — never a
|
|
233
|
+
// throw, at most the caller's warning. Old saves without a goal key and
|
|
234
|
+
// records with a trashed stats block both land here safely.
|
|
235
|
+
export function restoreGoalFromPersist(value) {
|
|
236
|
+
try {
|
|
237
|
+
if (value === null || value === undefined)
|
|
238
|
+
return null;
|
|
239
|
+
if (typeof value !== "object" || Array.isArray(value))
|
|
240
|
+
return null;
|
|
241
|
+
const r = value;
|
|
242
|
+
if (typeof r["objective"] !== "string" || r["objective"].length === 0)
|
|
243
|
+
return null;
|
|
244
|
+
if (typeof r["active"] !== "boolean")
|
|
245
|
+
return null;
|
|
246
|
+
const stats = r["stats"];
|
|
247
|
+
if (stats === undefined)
|
|
248
|
+
return { objective: r["objective"], active: r["active"] };
|
|
249
|
+
if (typeof stats !== "object" || stats === null || Array.isArray(stats))
|
|
250
|
+
return null;
|
|
251
|
+
const s = stats;
|
|
252
|
+
return {
|
|
253
|
+
objective: r["objective"],
|
|
254
|
+
active: r["active"],
|
|
255
|
+
stats: {
|
|
256
|
+
turns: coerceGoalCounter(s["turns"]),
|
|
257
|
+
requests: coerceGoalCounter(s["requests"]),
|
|
258
|
+
tokens: coerceGoalCounter(s["tokens"]),
|
|
259
|
+
workMs: coerceGoalCounter(s["workMs"]),
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// One counter from untrusted save data: finite, floored, never negative —
|
|
268
|
+
// anything else reads as zero (a trashed counter must not trash the goal).
|
|
269
|
+
function coerceGoalCounter(value) {
|
|
270
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
271
|
+
? Math.floor(value)
|
|
272
|
+
: 0;
|
|
273
|
+
}
|
|
274
|
+
// Compact counters for status lines: raw below 1K, one-decimal K above.
|
|
275
|
+
export function formatGoalTokens(n) {
|
|
276
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n <= 0)
|
|
277
|
+
return "0";
|
|
278
|
+
const v = Math.floor(n);
|
|
279
|
+
if (v < 1000)
|
|
280
|
+
return `${v}`;
|
|
281
|
+
return `${(v / 1000).toFixed(1)}K`;
|
|
282
|
+
}
|
|
283
|
+
// Wall-clock rendering for status lines: 4s / 1m23s / 2h05m. Never throws.
|
|
284
|
+
export function formatGoalWorkMs(ms) {
|
|
285
|
+
if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0)
|
|
286
|
+
return "0s";
|
|
287
|
+
const s = Math.floor(ms / 1000);
|
|
288
|
+
if (s < 60)
|
|
289
|
+
return `${s}s`;
|
|
290
|
+
const m = Math.floor(s / 60);
|
|
291
|
+
if (m < 60)
|
|
292
|
+
return `${m}m${s % 60}s`;
|
|
293
|
+
return `${Math.floor(m / 60)}h${m % 60}m`;
|
|
294
|
+
}
|
|
295
|
+
// Stats segment for the status line (zeros when absent — a fresh goal still
|
|
296
|
+
// shows the shape so the counters are discoverable).
|
|
297
|
+
export function goalStatsText(stats) {
|
|
298
|
+
const s = stats ?? emptyGoalStats();
|
|
299
|
+
return (`turns ${s.turns} · requests ${s.requests} · ` +
|
|
300
|
+
`tokens ${formatGoalTokens(s.tokens)} · work ${formatGoalWorkMs(s.workMs)}`);
|
|
301
|
+
}
|
|
302
|
+
// Status line: goal text plus state plus cumulative stats, or the none-hint.
|
|
303
|
+
export function goalStatusText(goal) {
|
|
304
|
+
if (!goal)
|
|
305
|
+
return "(no goal — set one with /goal <objective>)";
|
|
306
|
+
return `(goal [${goal.active ? "active" : "paused"}] — ${goal.objective} — ${goalStatsText(goal.stats)})`;
|
|
307
|
+
}
|
|
308
|
+
// Set confirmation: echoes the objective; replacing an active goal says so.
|
|
309
|
+
export function goalSetNotice(objective, previous) {
|
|
310
|
+
if (previous)
|
|
311
|
+
return `(goal replaced — "${previous.objective}" replaced with "${objective}")`;
|
|
312
|
+
return `(goal set — "${objective}")`;
|
|
313
|
+
}
|
|
314
|
+
// Clear notice: ends the active goal, or a harmless no-op when absent.
|
|
315
|
+
export function goalClearNotice(previous) {
|
|
316
|
+
if (!previous)
|
|
317
|
+
return "(no goal — nothing to clear)";
|
|
318
|
+
return `(goal cleared — "${previous.objective}")`;
|
|
319
|
+
}
|
|
320
|
+
// Manual pause/resume notices: state flips live in App; these only describe.
|
|
321
|
+
export function goalPauseNotice(previous) {
|
|
322
|
+
if (!previous)
|
|
323
|
+
return "(no goal — nothing to pause)";
|
|
324
|
+
if (!previous.active)
|
|
325
|
+
return `(goal already paused — "${previous.objective}")`;
|
|
326
|
+
return `(goal paused — "${previous.objective}")`;
|
|
327
|
+
}
|
|
328
|
+
export function goalResumeNotice(previous) {
|
|
329
|
+
if (!previous)
|
|
330
|
+
return "(no goal — set one with /goal <objective>)";
|
|
331
|
+
if (previous.active)
|
|
332
|
+
return `(goal already active — "${previous.objective}")`;
|
|
333
|
+
return `(goal resumed — "${previous.objective}")`;
|
|
334
|
+
}
|
|
335
|
+
// Auto-continue follow-up (tickets 02–03): the ONLY continuation message
|
|
336
|
+
// the goal seam pushes — same assistant+user commit shape as the turn-end
|
|
337
|
+
// guards, so assistant/tool pairing stays valid and the transcript shows
|
|
338
|
+
// each turn normally (no synthetic user input beyond this mechanism). It
|
|
339
|
+
// also carries the report protocol (the only model-visible channel for the
|
|
340
|
+
// goal-scoped update_goal tool — the tool-schema payload wiring arrives in
|
|
341
|
+
// a later ticket): the model reports each turn's outcome, and a turn with
|
|
342
|
+
// no report counts as `continue` until the ticket-04 evaluator lands.
|
|
343
|
+
export function goalFollowUp(objective) {
|
|
344
|
+
return (`(goal continues: "${objective}" — keep working toward the goal with ` +
|
|
345
|
+
`tool calls, or answer in text when there is nothing left to do. ` +
|
|
346
|
+
`Final text starts the next goal turn automatically; the goal runs ` +
|
|
347
|
+
`until it is paused or cleared. Report the turn outcome with the ` +
|
|
348
|
+
`update_goal tool (status "continue" with the next action, or ` +
|
|
349
|
+
`"complete"/"blocked" with a reason); a turn with no report continues ` +
|
|
350
|
+
`the goal.)`);
|
|
351
|
+
}
|
|
352
|
+
// Pause-with-preservation notice for cancel/budget paths: names the reason,
|
|
353
|
+
// keeps the objective quoted, and points at /goal resume. The goal itself
|
|
354
|
+
// is never cleared here — pausing only flips `active`.
|
|
355
|
+
export function goalPausedNotice(objective, reason) {
|
|
356
|
+
return `(goal paused — "${objective}" preserved ${reason}; resume with /goal resume)`;
|
|
357
|
+
}
|
|
358
|
+
// Judge tail (ticket 04): the evaluator sees the goal text plus the recent
|
|
359
|
+
// turns only — never the full history. 20 messages covers a few turns of
|
|
360
|
+
// tool traffic without bloating the judge POST.
|
|
361
|
+
export const GOAL_JUDGE_TAIL_MESSAGES = 20;
|
|
362
|
+
// Slice the recent transcript tail for the judge (read-only — the caller
|
|
363
|
+
// shares the message objects, and the judge must never mutate them).
|
|
364
|
+
export function recentTurnsForJudge(history) {
|
|
365
|
+
if (!Array.isArray(history))
|
|
366
|
+
return [];
|
|
367
|
+
return history.slice(-GOAL_JUDGE_TAIL_MESSAGES);
|
|
368
|
+
}
|
|
369
|
+
// Strict-but-tolerant judge-verdict parsing (ticket 04): the judge must
|
|
370
|
+
// answer with one JSON verdict object; anything else (prose, empty text, a
|
|
371
|
+
// bad shape, failed validation) counts as unclear → null → the loop pauses
|
|
372
|
+
// instead of looping. Code fences are tolerated (the slice runs from the
|
|
373
|
+
// first `{` to the last `}`); single-object only. An empty `next` on
|
|
374
|
+
// `continue` reads as absent (still a clear verdict — the generic follow-up
|
|
375
|
+
// covers it); everything else validates exactly like update_goal args.
|
|
376
|
+
export function parseGoalJudgeVerdict(text) {
|
|
377
|
+
if (typeof text !== "string")
|
|
378
|
+
return null;
|
|
379
|
+
const start = text.indexOf("{");
|
|
380
|
+
const end = text.lastIndexOf("}");
|
|
381
|
+
const candidate = start >= 0 && end > start ? text.slice(start, end + 1) : text;
|
|
382
|
+
let parsed;
|
|
383
|
+
try {
|
|
384
|
+
parsed = JSON.parse(candidate);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
390
|
+
return null;
|
|
391
|
+
const args = { ...parsed };
|
|
392
|
+
if (args["status"] === "continue" &&
|
|
393
|
+
typeof args["next"] === "string" &&
|
|
394
|
+
args["next"].trim().length === 0) {
|
|
395
|
+
delete args["next"];
|
|
396
|
+
}
|
|
397
|
+
if (validateUpdateGoalArgs(args) !== null)
|
|
398
|
+
return null;
|
|
399
|
+
const disposition = updateGoalDisposition(args);
|
|
400
|
+
// Trim payloads so a padded verdict prints clean (validation already
|
|
401
|
+
// guaranteed the required fields are non-empty).
|
|
402
|
+
if (disposition.status === "continue" && typeof disposition.next === "string") {
|
|
403
|
+
return { status: "continue", next: disposition.next.trim() };
|
|
404
|
+
}
|
|
405
|
+
if (disposition.status === "complete" || disposition.status === "blocked") {
|
|
406
|
+
return { status: disposition.status, reason: disposition.reason.trim() };
|
|
407
|
+
}
|
|
408
|
+
return disposition;
|
|
409
|
+
}
|
|
410
|
+
// Novelty progress guard (ticket 05): the goal measures real forward motion,
|
|
411
|
+
// not motion. Every committed tool result fingerprints to name + stable args
|
|
412
|
+
// + result; an exact repeat (fingerprint already seen) advances nothing,
|
|
413
|
+
// while genuinely new evidence bumps the novel count and resets the stall
|
|
414
|
+
// streak. Error results never count — the error-streak machinery owns those.
|
|
415
|
+
// When the streak reaches GOAL_STALL_REPEATS at a goal turn end, the loop
|
|
416
|
+
// pushes the replan nudge as the next follow-up and resets the epoch —
|
|
417
|
+
// stalling never pauses, clears, or ends the goal. Loop-local only: a whole
|
|
418
|
+
// goal run normally lives inside one runLoopWithChat call, so the seen-set
|
|
419
|
+
// never leaves the turn (a later ticket can expose it if App/telemetry ever
|
|
420
|
+
// needs it).
|
|
421
|
+
// Consecutive non-novel commits before the replan nudge fires: 3. One repeat
|
|
422
|
+
// is often a legit retry (truncation repair, parallel re-fetch) and two can
|
|
423
|
+
// be coincidence; three identical results is a loop. It also mirrors the
|
|
424
|
+
// other turn-end guards (MAX_VERIFY_ROUNDS / MAX_TODO_ROUNDS = 3, error-streak
|
|
425
|
+
// default 3), so every guard agrees on what "sustained" means.
|
|
426
|
+
export const GOAL_STALL_REPEATS = 3;
|
|
427
|
+
// Upper bound on retained fingerprints: runs are budget-bounded, but the
|
|
428
|
+
// default budgets are uncapped, so the set never grows without limit. Past
|
|
429
|
+
// the cap an unseen key still counts as novel (the safe direction — progress
|
|
430
|
+
// over stall) without being stored.
|
|
431
|
+
export const GOAL_PROGRESS_SEEN_CAP = 5000;
|
|
432
|
+
// Fresh per-run progress memory (the loop owns one per runLoopWithChat call).
|
|
433
|
+
export function emptyGoalProgress() {
|
|
434
|
+
return { seen: new Set(), novel: 0, stale: 0 };
|
|
435
|
+
}
|
|
436
|
+
// FNV-1a (32-bit, non-crypto): small, dependency-free, and total — never
|
|
437
|
+
// throws, so accounting can never break the turn.
|
|
438
|
+
function goalFingerprintHash(text) {
|
|
439
|
+
let hash = 0x811c9dc5;
|
|
440
|
+
for (let i = 0; i < text.length; i++) {
|
|
441
|
+
hash ^= text.charCodeAt(i);
|
|
442
|
+
hash = Math.imul(hash, 0x01000193);
|
|
443
|
+
}
|
|
444
|
+
return (hash >>> 0).toString(16);
|
|
445
|
+
}
|
|
446
|
+
// Bounded novelty key: name + stable args (via toolSignature, so key order
|
|
447
|
+
// never aliases) + full result, hashed — the set keeps ~40 chars per entry,
|
|
448
|
+
// never result text. Total: never throws (unstringifiable args fall back
|
|
449
|
+
// inside toolSignature; anything else degrades to a length-only key).
|
|
450
|
+
export function goalProgressFingerprint(name, parsed, result) {
|
|
451
|
+
const tool = typeof name === "string" ? name : "(unknown)";
|
|
452
|
+
const body = typeof result === "string" ? result : "";
|
|
453
|
+
let argsKey;
|
|
454
|
+
try {
|
|
455
|
+
argsKey = toolSignature(tool, parsed ?? {});
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
argsKey = `${tool} {}`;
|
|
459
|
+
}
|
|
460
|
+
return `${tool}#${goalFingerprintHash(`${argsKey}\n${body}`)}:${body.length}`;
|
|
461
|
+
}
|
|
462
|
+
// Record one committed result: errors are ignored (owned by the error-streak
|
|
463
|
+
// machinery — they neither advance nor stall progress); a repeat bumps the
|
|
464
|
+
// streak; anything new bumps the novel count and resets the streak. Returns
|
|
465
|
+
// true when the commit advanced progress. Total: never throws.
|
|
466
|
+
export function noteGoalProgress(state, name, parsed, result, isError) {
|
|
467
|
+
try {
|
|
468
|
+
if (!state || isError)
|
|
469
|
+
return false;
|
|
470
|
+
const key = goalProgressFingerprint(name, parsed, result);
|
|
471
|
+
if (state.seen.has(key)) {
|
|
472
|
+
state.stale += 1;
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
if (state.seen.size < GOAL_PROGRESS_SEEN_CAP)
|
|
476
|
+
state.seen.add(key);
|
|
477
|
+
state.novel += 1;
|
|
478
|
+
state.stale = 0;
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// The stall redirect fires when the streak reaches the threshold (never
|
|
486
|
+
// earlier — a partial streak is still a working run). Total: never throws.
|
|
487
|
+
export function goalStallReached(state) {
|
|
488
|
+
try {
|
|
489
|
+
return (state?.stale ?? 0) >= GOAL_STALL_REPEATS;
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// Reset the stall epoch after the nudge fires (the seen-set stays — continued
|
|
496
|
+
// repeats keep reading as repeats, so a stuck run nudges again instead of
|
|
497
|
+
// going quiet). Total: never throws.
|
|
498
|
+
export function resetGoalStall(state) {
|
|
499
|
+
try {
|
|
500
|
+
if (state)
|
|
501
|
+
state.stale = 0;
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// accounting never breaks the turn
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Replan nudge (ticket 05): the stall-epoch follow-up — same `(…)` notice
|
|
508
|
+
// voice as the other turn-end gates, naming the objective and the repeat
|
|
509
|
+
// count so the redirect is auditable in the transcript. Total: never throws.
|
|
510
|
+
export function goalStallNudge(objective, repeats) {
|
|
511
|
+
try {
|
|
512
|
+
const goal = typeof objective === "string" && objective.length > 0 ? objective : "(unknown)";
|
|
513
|
+
const count = typeof repeats === "number" && Number.isFinite(repeats) && repeats > 0
|
|
514
|
+
? Math.floor(repeats)
|
|
515
|
+
: GOAL_STALL_REPEATS;
|
|
516
|
+
return (`(goal stalled — "${goal}": the last ${count} tool results repeated earlier work ` +
|
|
517
|
+
`with nothing new. Replan: try a different approach, file, or check instead of ` +
|
|
518
|
+
`repeating the same calls. The goal stays active — keep working toward it.)`);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return `(goal stalled — replanning needed; the goal stays active.)`;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// Compacted-summary goal block (ticket 08): the canonical `Goal:` text that
|
|
525
|
+
// rides inside the compaction summary, following the `Touched files:`
|
|
526
|
+
// precedent — appended to the model summary within budget, surfaced verbatim
|
|
527
|
+
// on resume. It carries the objective verbatim (never trimmed — user text),
|
|
528
|
+
// the live state flag (active vs paused — a paused goal still resumes), and
|
|
529
|
+
// the cumulative stats (never reset by compaction), plus the open checklist
|
|
530
|
+
// lines (pending/in_progress only — completed work belongs in the summary
|
|
531
|
+
// prose the instruction already asks the summarizer to preserve). The block
|
|
532
|
+
// is the model's context backstop only: record restore (ticket 07) stays the
|
|
533
|
+
// restore path, so nothing here needs machine-parsing — it just has to read
|
|
534
|
+
// unambiguously. Total: never throws; no goal renders "" (caller appends
|
|
535
|
+
// nothing, keeping non-goal output byte-identical).
|
|
536
|
+
// Upper bounds for the checklist tail: the block rides with the model text
|
|
537
|
+
// (never shrunk by the budget fitter — only the touched-files lists shrink),
|
|
538
|
+
// so it stays bounded on its own. 10 lines mirrors
|
|
539
|
+
// GOAL_UNVERIFIED_MAX_ITEMS; 120 chars keeps one line scannable.
|
|
540
|
+
export const GOAL_COMPACT_TODOS_MAX = 10;
|
|
541
|
+
export const GOAL_COMPACT_TODO_CHARS = 120;
|
|
542
|
+
export function formatGoalForCompact(goal, todos) {
|
|
543
|
+
try {
|
|
544
|
+
if (!goal)
|
|
545
|
+
return "";
|
|
546
|
+
if (typeof goal.objective !== "string" || goal.objective.length === 0)
|
|
547
|
+
return "";
|
|
548
|
+
const lines = [
|
|
549
|
+
`Goal: "${goal.objective}" [${goal.active ? "active" : "paused"}] (${goalStatsText(goal.stats)})`,
|
|
550
|
+
];
|
|
551
|
+
if (Array.isArray(todos)) {
|
|
552
|
+
const open = [];
|
|
553
|
+
for (const t of todos) {
|
|
554
|
+
if (open.length >= GOAL_COMPACT_TODOS_MAX)
|
|
555
|
+
break;
|
|
556
|
+
if (!t || typeof t.content !== "string" || typeof t.status !== "string")
|
|
557
|
+
continue;
|
|
558
|
+
if (t.status === "completed")
|
|
559
|
+
continue;
|
|
560
|
+
const content = t.content.trim();
|
|
561
|
+
if (content.length === 0)
|
|
562
|
+
continue;
|
|
563
|
+
const short = content.length > GOAL_COMPACT_TODO_CHARS
|
|
564
|
+
? `${content.slice(0, GOAL_COMPACT_TODO_CHARS)}…`
|
|
565
|
+
: content;
|
|
566
|
+
open.push(`[${t.status}] ${short}`);
|
|
567
|
+
}
|
|
568
|
+
if (open.length > 0)
|
|
569
|
+
lines.push(`Goal todos: ${open.join("; ")}`);
|
|
570
|
+
}
|
|
571
|
+
return lines.join("\n");
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
return "";
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// Append the goal block to a summary (mirrors appendTouchedFiles — empty
|
|
578
|
+
// renders the summary untouched).
|
|
579
|
+
export function appendGoalBlock(summaryText, goalBlock) {
|
|
580
|
+
if (typeof goalBlock !== "string" || goalBlock.length === 0)
|
|
581
|
+
return summaryText;
|
|
582
|
+
return `${summaryText}\n\n${goalBlock}`;
|
|
583
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Project trust for extensions (ticket 07): project-scope extensions run
|
|
2
|
+
// unsandboxed with full user privileges, so they never execute until the
|
|
3
|
+
// user trusts the project. Global-scope extensions are user-owned
|
|
4
|
+
// (implicitly trusted, like the user's own config); the gate applies to
|
|
5
|
+
// project-scope + explicit paths only.
|
|
6
|
+
//
|
|
7
|
+
// The grant is durable per project directory (asked once): grants live in
|
|
8
|
+
// ~/.atom/trusted-projects.json (a plain JSON array of absolute dir paths,
|
|
9
|
+
// following the auth.json load-never-throws pattern). A decline persists
|
|
10
|
+
// nothing, so the next boot asks again — declining leaves project extensions
|
|
11
|
+
// fully inert for this session with a visible notice, never silently loaded.
|
|
12
|
+
// Session tool trust (/trust in App.tsx) is a separate, in-memory tier for
|
|
13
|
+
// write/edit/bash approval — this file is only about extension loading.
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { atomDir } from "./auth.js";
|
|
17
|
+
export const TRUSTED_PROJECTS_FILENAME = "trusted-projects.json";
|
|
18
|
+
// Canonical trust prompt (single source so App and tests share the exact
|
|
19
|
+
// wording): states plainly that extensions run unsandboxed with full user
|
|
20
|
+
// privileges. The acceptance criterion pins on these words.
|
|
21
|
+
export function projectTrustQuestion(names) {
|
|
22
|
+
const list = names.length > 0 ? ` (${names.join(", ")})` : "";
|
|
23
|
+
return (`This project contains ${names.length} extension(s)${list} that run unsandboxed ` +
|
|
24
|
+
`with your full user privileges — they can read/write your files and run commands as you. ` +
|
|
25
|
+
`Load them?`);
|
|
26
|
+
}
|
|
27
|
+
export function trustedProjectsFilePath(home) {
|
|
28
|
+
return path.join(atomDir(home), TRUSTED_PROJECTS_FILENAME);
|
|
29
|
+
}
|
|
30
|
+
function normalizeDir(dir) {
|
|
31
|
+
return path.resolve(dir);
|
|
32
|
+
}
|
|
33
|
+
// Load granted project dirs; missing/corrupt files yield [] (never throws —
|
|
34
|
+
// a missing grant file is the normal first-run case).
|
|
35
|
+
export function loadTrustedProjects(home) {
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
const p = trustedProjectsFilePath(home);
|
|
39
|
+
if (!existsSync(p))
|
|
40
|
+
return [];
|
|
41
|
+
raw = readFileSync(p, "utf8");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
let data;
|
|
47
|
+
try {
|
|
48
|
+
data = JSON.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(data))
|
|
54
|
+
return [];
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const e of data) {
|
|
57
|
+
if (typeof e === "string" && e.length > 0) {
|
|
58
|
+
const abs = normalizeDir(e);
|
|
59
|
+
if (!out.includes(abs))
|
|
60
|
+
out.push(abs);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
export function isProjectTrusted(dir, home) {
|
|
66
|
+
return loadTrustedProjects(home).includes(normalizeDir(dir));
|
|
67
|
+
}
|
|
68
|
+
function saveTrustedProjects(dirs, home) {
|
|
69
|
+
const dir = atomDir(home);
|
|
70
|
+
mkdirSync(dir, { recursive: true });
|
|
71
|
+
writeFileSync(path.join(dir, TRUSTED_PROJECTS_FILENAME), JSON.stringify([...dirs].sort(), null, 2) + "\n", "utf8");
|
|
72
|
+
}
|
|
73
|
+
// Persist a grant (never throws — disk errors leave trust ungranted, so the
|
|
74
|
+
// next boot asks again rather than assuming consent).
|
|
75
|
+
export function grantProjectTrust(dir, home) {
|
|
76
|
+
try {
|
|
77
|
+
const abs = normalizeDir(dir);
|
|
78
|
+
const current = loadTrustedProjects(home);
|
|
79
|
+
if (current.includes(abs))
|
|
80
|
+
return;
|
|
81
|
+
saveTrustedProjects([...current, abs], home);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// best-effort only; trust stays ungranted
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Remove a grant (never throws).
|
|
88
|
+
export function revokeProjectTrust(dir, home) {
|
|
89
|
+
try {
|
|
90
|
+
const abs = normalizeDir(dir);
|
|
91
|
+
saveTrustedProjects(loadTrustedProjects(home).filter((d) => d !== abs), home);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// best-effort only
|
|
95
|
+
}
|
|
96
|
+
}
|