@visual-z/xeno 0.8.2 → 0.8.3
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 +42 -0
- package/package.json +1 -1
- package/src/apps/shell.ts +13 -3
- package/src/apps/startup.ts +4 -4
- package/src/apps/ui/app.ts +32 -14
- package/src/apps/ui/project.ts +1 -1
- package/src/apps/ui/standalone.ts +4 -2
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/activity-status.ts +17 -2
- package/src/core/command-contract.ts +58 -16
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- package/src/core/event-validation.ts +19 -3
- package/src/core/events.ts +38 -2
- package/src/core/evidence.ts +149 -7
- package/src/core/export.ts +2 -0
- package/src/core/external-effects.ts +488 -0
- package/src/core/handoff.ts +24 -3
- package/src/core/history-query.ts +1 -1
- package/src/core/history.ts +124 -20
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +60 -0
- package/src/core/prompt-policy.ts +4 -2
- package/src/core/secrets.ts +188 -0
- package/src/core/stop-report.ts +25 -3
- package/src/core/tool-failure.ts +11 -3
- package/src/core/transcript.ts +3 -0
- package/src/core/work-state.ts +7 -2
- package/src/core/working-set.ts +34 -6
- package/src/runtime/connection-reuse.ts +40 -0
- package/src/runtime/exec-sessions.ts +66 -18
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +98 -14
- package/src/runtime/open-image.ts +42 -0
- package/src/runtime/openai-responses.ts +15 -7
- package/src/runtime/openai.ts +15 -7
- package/src/runtime/permission-resources.ts +11 -4
- package/src/runtime/pi-provider.ts +34 -9
- package/src/runtime/provider.ts +19 -8
- package/src/runtime/retry.ts +152 -9
- package/src/runtime/secrets.ts +79 -0
- package/src/runtime/session-control.ts +9 -1
- package/src/runtime/stream-idle.ts +80 -0
- package/src/runtime/tools.ts +97 -27
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +1 -1
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/tui/ui/image-size.ts +6 -2
- package/src/tui/ui/items.ts +3 -1
- package/src/tui/ui/pictures.ts +14 -1
package/src/core/history.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** D20: bounded carried state, with deterministic pages rebuilt from the log. */
|
|
2
|
-
import { emitHandoff, type Handoff } from "./handoff.ts";
|
|
2
|
+
import { emitHandoff, type Handoff, type UserTurnRecord } from "./handoff.ts";
|
|
3
3
|
import { estimateTokens } from "./tokens.ts";
|
|
4
|
-
import { recoveryWorkingSet } from "./working-set.ts";
|
|
4
|
+
import { recoveryWorkingSet, recoveryWorkingSetV3 } from "./working-set.ts";
|
|
5
5
|
import { recoveryInstructions } from "./prompt-policy.ts";
|
|
6
6
|
import type { RecoveryNodes } from "./recovery-sources.ts";
|
|
7
7
|
|
|
@@ -65,20 +65,19 @@ function suffixes(h: Handoff, offsets: Record<HistorySection, number>): Handoff
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (size(full) <= budget) return full;
|
|
72
|
-
if (full.predecessor === null) throw new Error("history requires an immutable source");
|
|
73
|
-
// An absent new section must not change legacy projections or frozen replay.
|
|
74
|
-
const sections = HISTORY_SECTIONS.filter(section => (section !== "state.work_items" || full.state.work_items !== undefined) &&
|
|
68
|
+
/** Sections a handoff actually has; an absent new section must not change legacy projections or frozen replay. */
|
|
69
|
+
function pageableSections(full: Handoff): HistorySection[] {
|
|
70
|
+
return HISTORY_SECTIONS.filter(section => (section !== "state.work_items" || full.state.work_items !== undefined) &&
|
|
75
71
|
(section !== "evidence_links" || full.evidence_links !== undefined) &&
|
|
76
72
|
(section !== "acceptance.criteria" || full.acceptance !== undefined) &&
|
|
77
73
|
(section !== "acceptance.targets" || full.acceptance?.targets !== undefined) &&
|
|
78
74
|
(section !== "loaded_skills" || full.loaded_skills !== undefined) &&
|
|
79
75
|
(section !== "processes" || full.processes !== undefined));
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The v2 shape carrying `items.length - offset` newest items of every pageable section. */
|
|
79
|
+
function carriedAt(full: Handoff, sections: readonly HistorySection[], offsets: Record<HistorySection, number>): Handoff {
|
|
80
|
+
return {
|
|
82
81
|
...suffixes(full, offsets),
|
|
83
82
|
handoff_version: 2,
|
|
84
83
|
history: {
|
|
@@ -87,22 +86,42 @@ export function boundHandoff(full: Handoff, budget = DEFAULT_HANDOFF_TOKENS): Ha
|
|
|
87
86
|
total: historyItems(full, s).length, offset: offsets[s],
|
|
88
87
|
}])),
|
|
89
88
|
},
|
|
90
|
-
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const handoffSize = (h: Handoff): number => estimateTokens.count([], emitHandoff(h));
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `priority` sections grow to completion, newest first, before any other
|
|
96
|
+
* section gets an item; the rest then share what is left round-robin. The
|
|
97
|
+
* default (none) is the original D20 order, which recorded cuts replay.
|
|
98
|
+
*/
|
|
99
|
+
export function boundHandoff(full: Handoff, budget = DEFAULT_HANDOFF_TOKENS, priority: readonly HistorySection[] = []): Handoff {
|
|
100
|
+
if (!Number.isFinite(budget) || budget <= 0) throw new RangeError("handoff budget must be positive");
|
|
101
|
+
const size = handoffSize;
|
|
102
|
+
if (size(full) <= budget) return full;
|
|
103
|
+
if (full.predecessor === null) throw new Error("history requires an immutable source");
|
|
104
|
+
const sections = pageableSections(full);
|
|
105
|
+
const offsets = Object.fromEntries(sections.map((s) => [s, historyItems(full, s).length])) as Record<HistorySection, number>;
|
|
106
|
+
const build = (): Handoff => carriedAt(full, sections, offsets);
|
|
91
107
|
let carried = build();
|
|
92
108
|
// The objective stays verbatim (D2). An oversized objective is an explicit
|
|
93
109
|
// input problem; repeatedly cutting it would create an infinite cut loop.
|
|
94
110
|
if (size(carried) > budget) throw new RangeError("handoff scalar fields exceed budget; shorten the objective or frozen input");
|
|
95
|
-
const
|
|
111
|
+
const grow = (section: HistorySection): boolean => {
|
|
112
|
+
if (offsets[section] === 0) return false;
|
|
113
|
+
offsets[section]--;
|
|
114
|
+
const candidate = build();
|
|
115
|
+
if (size(candidate) <= budget) { carried = candidate; return true; }
|
|
116
|
+
offsets[section]++;
|
|
117
|
+
return false;
|
|
118
|
+
};
|
|
119
|
+
for (const section of priority) if (sections.includes(section)) while (grow(section)) { /* newest first until full or out of room */ }
|
|
120
|
+
const active = new Set<HistorySection>(sections.filter(section => !priority.includes(section)));
|
|
96
121
|
// Round-robin suffix growth prevents one long section starving every other
|
|
97
122
|
// category. Each retained range is contiguous and independently pageable.
|
|
98
123
|
while (active.size > 0) {
|
|
99
|
-
for (const section of active)
|
|
100
|
-
if (offsets[section] === 0) { active.delete(section); continue; }
|
|
101
|
-
offsets[section]--;
|
|
102
|
-
const candidate = build();
|
|
103
|
-
if (size(candidate) <= budget) carried = candidate;
|
|
104
|
-
else { offsets[section]++; active.delete(section); }
|
|
105
|
-
}
|
|
124
|
+
for (const section of active) if (!grow(section)) active.delete(section);
|
|
106
125
|
}
|
|
107
126
|
return carried;
|
|
108
127
|
}
|
|
@@ -131,3 +150,88 @@ export function boundRecoveryHandoff(full: Handoff, nodes: RecoveryNodes, budget
|
|
|
131
150
|
const inline = tools?.includes("history") && carried.user_turns_truncated === undefined && (carried.history?.sections["user_turns"]?.offset ?? 0) === 0;
|
|
132
151
|
return inline && carried.working_set ? { ...carried, working_set: { ...carried.working_set, recovery: recoveryInstructions(tools!, true) } } : carried;
|
|
133
152
|
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* D8: join carried turns to their original user events to name attached images
|
|
156
|
+
* by digest. Turns and events are both the active ancestry in log order; any
|
|
157
|
+
* disagreement leaves the turns undecorated rather than guessing a pairing.
|
|
158
|
+
*/
|
|
159
|
+
export function userTurnImages(turns: readonly UserTurnRecord[], nodes: RecoveryNodes): readonly UserTurnRecord[] {
|
|
160
|
+
const users = nodes.flatMap(part => part.events.flatMap(event => event.t === "user" ? [event] : []));
|
|
161
|
+
if (users.length !== turns.length || users.some((event, i) => event.at !== turns[i]!.at || event.text !== turns[i]!.text)) return turns;
|
|
162
|
+
return turns.map((turn, i) => {
|
|
163
|
+
const event = users[i]!;
|
|
164
|
+
return event.images?.length ? { ...turn, event: event.id,
|
|
165
|
+
images: event.images.map(({ sha256, name, width, height }) => ({ sha256, name, width, height })) } : turn;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* v3 carries one copy of each work item's prose. `state.in_flight` and
|
|
171
|
+
* `state.blocked` are compatible projections of identified work (O.2), so a
|
|
172
|
+
* leading run of items the carried `state.work_items` or working set already
|
|
173
|
+
* hold moves into the existing page range, which `validateHandoff` accepts for
|
|
174
|
+
* continuation; D20 arrays stay contiguous suffixes. A working-set entry whose
|
|
175
|
+
* prose the carried `state.work_items` holds keeps only its id and status, since
|
|
176
|
+
* `state.work_items` is the canonical identified work. Every change only
|
|
177
|
+
* removes bytes, so the budget still holds.
|
|
178
|
+
*/
|
|
179
|
+
function oneCopyOfWork(h: Handoff): Handoff {
|
|
180
|
+
const items = new Map((h.state.work_items ?? []).map(item => [item.id, item]));
|
|
181
|
+
const viewed = new Map((h.working_set?.work ?? []).map(entry => [entry.id, entry]));
|
|
182
|
+
const history = h.history && { ...h.history, sections: { ...h.history.sections } };
|
|
183
|
+
type Prose = { status: string; what?: string; next_action?: string; reason?: string };
|
|
184
|
+
const holders = (id: string | undefined): Prose[] => {
|
|
185
|
+
const found: Prose[] = [], item = id === undefined ? undefined : items.get(id), entry = id === undefined ? undefined : viewed.get(id);
|
|
186
|
+
if (item) found.push(item);
|
|
187
|
+
if (entry) found.push(entry);
|
|
188
|
+
return found;
|
|
189
|
+
};
|
|
190
|
+
const page = <T extends { work_id?: string }>(section: "state.in_flight" | "state.blocked", list: readonly T[], same: (item: T, holder: Prose) => boolean): readonly T[] => {
|
|
191
|
+
const range = history?.sections[section];
|
|
192
|
+
let skip = 0;
|
|
193
|
+
while (range && skip < list.length && holders(list[skip]!.work_id).some(holder => same(list[skip]!, holder))) skip++;
|
|
194
|
+
if (skip === 0) return list;
|
|
195
|
+
history!.sections[section] = { total: range!.total, offset: range!.offset + skip };
|
|
196
|
+
return list.slice(skip);
|
|
197
|
+
};
|
|
198
|
+
const in_flight = page("state.in_flight", h.state.in_flight, (item, holder) => holder.status === "active" && holder.what === item.what && holder.next_action === item.next_action);
|
|
199
|
+
const blocked = page("state.blocked", h.state.blocked, (item, holder) => holder.status === "blocked" && holder.what === item.what && holder.reason === item.reason);
|
|
200
|
+
const view = h.working_set && { ...h.working_set, work: h.working_set.work.map(entry => {
|
|
201
|
+
const item = items.get(entry.id);
|
|
202
|
+
if (!item || item.what !== entry.what || item.next_action !== entry.next_action || item.reason !== entry.reason) return entry;
|
|
203
|
+
const { what: _what, next_action: _next, reason: _reason, ...rest } = entry;
|
|
204
|
+
return rest;
|
|
205
|
+
}) };
|
|
206
|
+
return { ...h, ...(view ? { working_set: view } : {}), ...(history ? { history } : {}), state: { ...h.state, in_flight, blocked } };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const USERS_FIRST: readonly HistorySection[] = ["user_turns"];
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Working set v3. User turns are the smallest, highest-value carried content
|
|
213
|
+
* (Codex compaction likewise keeps user messages newest first, verbatim, under
|
|
214
|
+
* its own cap), so they are reserved before the working set and before every
|
|
215
|
+
* other page; only turns that alone exceed the budget are cut, newest kept.
|
|
216
|
+
* While every turn is inline, user references would only point at text already
|
|
217
|
+
* present, so the view leaves them out. The view keeps v2's 40% allocation
|
|
218
|
+
* assumption, measured inside the handoff it is carried in.
|
|
219
|
+
*/
|
|
220
|
+
export function boundRecoveryHandoffV3(full: Handoff, nodes: RecoveryNodes, budget = DEFAULT_HANDOFF_TOKENS, tools?: readonly string[]): Handoff {
|
|
221
|
+
if (!Number.isFinite(budget) || budget <= 0) throw new RangeError("handoff budget must be positive");
|
|
222
|
+
const base: Handoff = { ...full, user_turns: userTurnImages(full.user_turns, nodes) };
|
|
223
|
+
const plain = (): Handoff => oneCopyOfWork(boundHandoff(base, budget, USERS_FIRST));
|
|
224
|
+
if (base.predecessor === null) return plain();
|
|
225
|
+
const sections = pageableSections(base);
|
|
226
|
+
const empty = Object.fromEntries(sections.map((s) => [s, historyItems(base, s).length])) as Record<HistorySection, number>;
|
|
227
|
+
const withUsers = carriedAt(base, sections, { ...empty, user_turns: 0 });
|
|
228
|
+
const usersInline = base.user_turns_truncated === undefined && handoffSize(withUsers) <= budget;
|
|
229
|
+
const skeleton = usersInline ? withUsers : carriedAt(base, sections, empty);
|
|
230
|
+
const cap = Math.min(budget, handoffSize(skeleton) + budget * .4);
|
|
231
|
+
const recovery = recoveryInstructions(tools ?? ["history"], usersInline, base.user_turns.some(turn => turn.images !== undefined));
|
|
232
|
+
const view = recoveryWorkingSetV3(base, nodes, { users: !usersInline, recovery,
|
|
233
|
+
fits: candidate => handoffSize({ ...skeleton, working_set: candidate }) <= cap });
|
|
234
|
+
if (!view) return plain();
|
|
235
|
+
try { return oneCopyOfWork(boundHandoff({ ...base, working_set: view }, budget, USERS_FIRST)); }
|
|
236
|
+
catch (error) { if (error instanceof RangeError) return plain(); throw error; }
|
|
237
|
+
}
|
|
@@ -85,6 +85,30 @@ export function headTailSelection(
|
|
|
85
85
|
visible: [...(headEnd ? [[0, headEnd] as [number, number]] : []), ...(kept ? [[total - kept, total] as [number, number]] : [])] };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The text a terminal would finally show for output that redraws lines with
|
|
90
|
+
* `\r` (progress bars). Within each `\n`-separated line, a carriage return
|
|
91
|
+
* moves back to column 0 and the following characters overwrite, one code
|
|
92
|
+
* point each; characters not overwritten stay. `\r\n` is thus a plain line
|
|
93
|
+
* break. Other control sequences (`ESC[K` and the like) are left as they are.
|
|
94
|
+
*
|
|
95
|
+
* A view only: the archived bytes keep every frame (D8). Without this a 6.3KB
|
|
96
|
+
* brew download of 48 frames reached the model whole (carriage-return-view
|
|
97
|
+
* card), though a terminal showed one line.
|
|
98
|
+
*/
|
|
99
|
+
export function foldCarriageReturns(text: string): string {
|
|
100
|
+
if (!text.includes("\r")) return text;
|
|
101
|
+
return text.split("\n").map(line => {
|
|
102
|
+
if (!line.includes("\r")) return line;
|
|
103
|
+
let screen: string[] = [];
|
|
104
|
+
for (const frame of line.split("\r")) {
|
|
105
|
+
const chars = Array.from(frame);
|
|
106
|
+
screen = chars.concat(screen.slice(chars.length));
|
|
107
|
+
}
|
|
108
|
+
return screen.join("");
|
|
109
|
+
}).join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
88
112
|
/** Signals a shell reports as 128+n; the common ones a model can act on. */
|
|
89
113
|
const SIGNALS: Readonly<Record<number, string>> = {
|
|
90
114
|
1: "SIGHUP", 2: "SIGINT", 6: "SIGABRT", 9: "SIGKILL", 11: "SIGSEGV", 13: "SIGPIPE", 15: "SIGTERM",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Existing paths that a missing one probably meant.
|
|
3
|
+
*
|
|
4
|
+
* Models guess paths, and most guesses in a reviewed session (flotil: 11 of
|
|
5
|
+
* 11 ENOENT) had the right file name in the wrong directory or a near
|
|
6
|
+
* spelling. OpenCode's read tool suggests up to three entries of the same
|
|
7
|
+
* directory whose name contains, or is contained in, the requested one
|
|
8
|
+
* (`packages/opencode/src/tool/read.ts`); that misses a wrong directory, so
|
|
9
|
+
* this ranks the whole workspace listing, file name first.
|
|
10
|
+
*
|
|
11
|
+
* Pure and deterministic: the result depends on the set of candidates, never
|
|
12
|
+
* their order, so the same failure always names the same paths.
|
|
13
|
+
*/
|
|
14
|
+
import { editDistance } from "./text-normalize.ts";
|
|
15
|
+
|
|
16
|
+
/** OpenCode's count: enough to cover a wrong directory, short enough to read at a glance. */
|
|
17
|
+
export const PATH_SUGGESTION_LIMIT = 3;
|
|
18
|
+
|
|
19
|
+
/** Containment of a shorter name is too weak a signal below this length (assumed: "a" is in everything). */
|
|
20
|
+
const MIN_CONTAINED = 3;
|
|
21
|
+
|
|
22
|
+
const baseName = (path: string) => path.slice(path.lastIndexOf("/") + 1);
|
|
23
|
+
const stem = (name: string) => { const dot = name.lastIndexOf("."); return dot > 0 ? name.slice(0, dot) : name; };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rank tier of `name` against the wanted file name, or null when unrelated.
|
|
27
|
+
* 0: same name; 1: same name without extension; 2: one name contains the
|
|
28
|
+
* other; 3: a near spelling (edit distance within a quarter of the length).
|
|
29
|
+
*/
|
|
30
|
+
function tier(name: string, wanted: string, distance: number): number | null {
|
|
31
|
+
if (name === wanted) return 0;
|
|
32
|
+
if (stem(name) === stem(wanted) && stem(name).length > 0) return 1;
|
|
33
|
+
const shorter = name.length < wanted.length ? name : wanted, longer = shorter === name ? wanted : name;
|
|
34
|
+
if (shorter.length >= MIN_CONTAINED && longer.includes(shorter)) return 2;
|
|
35
|
+
if (distance <= Math.max(1, Math.floor(wanted.length / 4))) return 3;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Up to `limit` workspace-relative paths from `candidates` closest to the
|
|
41
|
+
* missing `target`, best first. Paths use "/" separators; comparison ignores
|
|
42
|
+
* case. The target itself is never suggested.
|
|
43
|
+
*/
|
|
44
|
+
export function nearestPaths(target: string, candidates: readonly string[], limit = PATH_SUGGESTION_LIMIT): string[] {
|
|
45
|
+
const wantedPath = target.replace(/^\.\//, "").toLowerCase();
|
|
46
|
+
const wanted = baseName(wantedPath);
|
|
47
|
+
if (wanted.length === 0 || limit <= 0) return [];
|
|
48
|
+
const ranked: { path: string; tier: number; name: number; whole: number }[] = [];
|
|
49
|
+
for (const path of new Set(candidates)) {
|
|
50
|
+
const lower = path.toLowerCase();
|
|
51
|
+
if (lower === wantedPath) continue;
|
|
52
|
+
const name = baseName(lower), distance = editDistance(name, wanted);
|
|
53
|
+
const rank = tier(name, wanted, distance);
|
|
54
|
+
if (rank === null) continue;
|
|
55
|
+
ranked.push({ path, tier: rank, name: distance, whole: editDistance(lower, wantedPath) });
|
|
56
|
+
}
|
|
57
|
+
ranked.sort((a, b) => a.tier - b.tier || a.name - b.name || a.whole - b.whole || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
58
|
+
return ranked.slice(0, limit).map(item => item.path);
|
|
59
|
+
}
|
package/src/core/permissions.ts
CHANGED
|
@@ -162,6 +162,66 @@ export function decideWithSessionRules(
|
|
|
162
162
|
return decide([...remembered, ...configured], request);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Where a gate outcome came from, for the durable approval record
|
|
167
|
+
* (feat-approval-events). `auto` is an allow by a rule with no selector --
|
|
168
|
+
* what `--yolo` compiles to -- so "no approval was required" is derived from
|
|
169
|
+
* the rule list itself rather than from a side flag. `none` means no rule
|
|
170
|
+
* matched and the empty-list default allowed: no policy was consulted.
|
|
171
|
+
*/
|
|
172
|
+
export type DecisionSource = "session_rule" | "config" | "auto" | "none";
|
|
173
|
+
export interface DecisionExplanation {
|
|
174
|
+
readonly action: PermissionAction;
|
|
175
|
+
readonly source: DecisionSource;
|
|
176
|
+
readonly rule?: PermissionRule;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Mirrors decide() exactly, but keeps the index of the rule that produced the action. */
|
|
180
|
+
function explainList(rules: readonly PermissionRule[], request: PermissionRequest): { action: PermissionAction; index?: number } {
|
|
181
|
+
if (request.paths && request.paths.length > 1) {
|
|
182
|
+
const each = request.paths.map(path => explainList(rules, { ...request, paths: [path] }));
|
|
183
|
+
const actions = each.map(item => item.action);
|
|
184
|
+
const action: PermissionAction = actions.includes("deny") ? "deny" : actions.includes("ask") ? "ask" : "allow";
|
|
185
|
+
return each.find(item => item.action === action)!;
|
|
186
|
+
}
|
|
187
|
+
const index = rules.findIndex(rule => ruleMatches(rule, request));
|
|
188
|
+
return index < 0 ? { action: "allow" } : { action: rules[index]!.action, index };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** decideWithSessionRules plus the rule that decided; the action is always identical (property-tested). */
|
|
192
|
+
export function explainDecision(
|
|
193
|
+
configured: readonly PermissionRule[], remembered: readonly PermissionRule[], request: PermissionRequest,
|
|
194
|
+
): DecisionExplanation {
|
|
195
|
+
const ceiling = explainList(configured, request);
|
|
196
|
+
if (ceiling.action === "deny") return { action: "deny", source: "config", rule: configured[ceiling.index!]! };
|
|
197
|
+
const combined = [...remembered, ...configured];
|
|
198
|
+
const found = explainList(combined, request);
|
|
199
|
+
if (found.index === undefined) return { action: found.action, source: "none" };
|
|
200
|
+
const rule = combined[found.index]!;
|
|
201
|
+
const unconditional = rule.tool === undefined && rule.command === undefined && rule.commandPrefix === undefined &&
|
|
202
|
+
rule.path === undefined && rule.pathGlob === undefined;
|
|
203
|
+
return { action: found.action, rule,
|
|
204
|
+
source: found.index < remembered.length ? "session_rule" : unconditional && rule.action === "allow" ? "auto" : "config" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A call needs a decision when the product default would not silently allow
|
|
209
|
+
* it, or when the effective policy did not. Calls outside both -- confined
|
|
210
|
+
* reads and edits under the default -- had nobody's judgement to record.
|
|
211
|
+
*/
|
|
212
|
+
export function approvalNeeded(request: PermissionRequest, effective: PermissionAction): boolean {
|
|
213
|
+
return effective !== "allow" || decide(defaultRules(), request) !== "allow";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Stable one-line rule text for the log and the export; field order is fixed. */
|
|
217
|
+
export function describeRule(rule: PermissionRule): string {
|
|
218
|
+
return [rule.action, rule.tool ?? "*",
|
|
219
|
+
...(rule.command !== undefined ? [`command=${JSON.stringify(rule.command)}`] : []),
|
|
220
|
+
...(rule.commandPrefix !== undefined ? [`commandPrefix=${JSON.stringify(rule.commandPrefix.join(" "))}`] : []),
|
|
221
|
+
...(rule.path !== undefined ? [`path=${JSON.stringify(rule.path)}`] : []),
|
|
222
|
+
...(rule.pathGlob !== undefined ? [`pathGlob=${JSON.stringify(rule.pathGlob)}`] : [])].join(" ");
|
|
223
|
+
}
|
|
224
|
+
|
|
165
225
|
/** Lexical normalization is pure; the store adds canonical symlink resources. */
|
|
166
226
|
export function normalizePermissionPath(path: string): string {
|
|
167
227
|
const absolute = path.startsWith("/");
|
|
@@ -29,10 +29,12 @@ export function toolDescription(description: string, guidance: readonly ToolGuid
|
|
|
29
29
|
* that returns what it already has -- or an empty page after a model switch
|
|
30
30
|
* on a fresh session (Horizyn mv3nsske-x50u, fix-recovery-inline-users).
|
|
31
31
|
*/
|
|
32
|
-
export function recoveryInstructions(tools: readonly string[], usersInline = false): string {
|
|
32
|
+
export function recoveryInstructions(tools: readonly string[], usersInline = false, images = false): string {
|
|
33
33
|
return (tools.includes("history")
|
|
34
34
|
? (usersInline ? "User messages are verbatim in user_turns. Other history(node=source.node,recovery=...) pages: failures, environment, next_actions; " : "Read original user messages before continuing: history(node=source.node,recovery=users,offset=0). Other pages: failures, environment, next_actions; ") +
|
|
35
|
-
"event=ID reads one original. history(work_id=ID) recovers work. "
|
|
35
|
+
"event=ID reads one original. history(work_id=ID) recovers work. " +
|
|
36
|
+
// Working set v3: the turn's image references are carried, the pixels are not.
|
|
37
|
+
(images ? "A user turn's images are not attached here; history(event=ID) with its event reattaches them. " : "")
|
|
36
38
|
: "Original user messages and evidence remain at the source references. The original-source recovery capability is unavailable; state any resulting evidence gap instead of guessing. ") +
|
|
37
39
|
"Abandoned messages are cancelled; annotations are not user instructions. Observations are historical: recheck resource, host and current permissions before acting.";
|
|
38
40
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential redaction at the log boundary (S.9).
|
|
3
|
+
*
|
|
4
|
+
* The log is the only truth and every model request is a projection of it, so
|
|
5
|
+
* a credential that reaches the log is resent on every later request and
|
|
6
|
+
* survives every cut through `objective` and `user_turns` (D2). Masking at
|
|
7
|
+
* projection time would leave the original in an append-only file forever.
|
|
8
|
+
* Redaction therefore happens before an event is written: the secret is
|
|
9
|
+
* replaced by a placeholder, and the value lives in a separate per-session
|
|
10
|
+
* vault that no projection reads.
|
|
11
|
+
*
|
|
12
|
+
* The placeholder is an exact inverse: tools expand it back to the value when
|
|
13
|
+
* they run (OpenHands' SecretRegistry does the same with secret names), and
|
|
14
|
+
* any output that echoes a known value is masked again. A false positive
|
|
15
|
+
* therefore costs readability, never correctness -- an edit whose old_string
|
|
16
|
+
* holds a placeholder still matches the file.
|
|
17
|
+
*
|
|
18
|
+
* Only high-confidence shapes are detected (gitleaks prefix rules plus
|
|
19
|
+
* "secret-named key = value"); entropy detection is deliberately absent because
|
|
20
|
+
* its false positives on hashes and code are irreversible in a live view.
|
|
21
|
+
* A bare password has no shape, so it is recognised only by context: a short
|
|
22
|
+
* token the user sends right after the assistant asked for a credential.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface SecretEntry {
|
|
26
|
+
/** The placeholder's number: `[[xeno-secret-<n>]]`. */
|
|
27
|
+
n: number;
|
|
28
|
+
value: string;
|
|
29
|
+
/** Which rule found it, for the audit trail; never the value. */
|
|
30
|
+
rule: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type Vault = readonly SecretEntry[];
|
|
34
|
+
|
|
35
|
+
export const placeholder = (n: number): string => `[[xeno-secret-${n}]]`;
|
|
36
|
+
const PLACEHOLDER = /\[\[xeno-secret-(\d+)\]\]/g;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A known value shorter than this is too likely to occur by accident ("admin",
|
|
40
|
+
* "123456") to mask everywhere it appears. Assumed, not measured; Codex's
|
|
41
|
+
* key-assignment rule uses the same 8.
|
|
42
|
+
*/
|
|
43
|
+
export const MIN_SECRET_LENGTH = 8;
|
|
44
|
+
|
|
45
|
+
interface Rule { id: string; pattern: RegExp; /** Capture group holding the secret; 0 is the whole match. */ group: number }
|
|
46
|
+
|
|
47
|
+
// Prefix rules follow gitleaks' default config (b58d3f10) and Gemini CLI's
|
|
48
|
+
// environmentSanitization.ts; tskey- is added for Tailscale, which gitleaks lacks.
|
|
49
|
+
const RULES: readonly Rule[] = [
|
|
50
|
+
{ id: "pem-private-key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g, group: 0 },
|
|
51
|
+
{ id: "anthropic-key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, group: 0 },
|
|
52
|
+
{ id: "openai-key", pattern: /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}/g, group: 0 },
|
|
53
|
+
{ id: "github-token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g, group: 0 },
|
|
54
|
+
{ id: "gitlab-token", pattern: /\bglpat-[A-Za-z0-9_-]{20,}/g, group: 0 },
|
|
55
|
+
{ id: "slack-token", pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g, group: 0 },
|
|
56
|
+
{ id: "aws-access-key", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, group: 0 },
|
|
57
|
+
{ id: "google-api-key", pattern: /\bAIza[0-9A-Za-z_-]{35}/g, group: 0 },
|
|
58
|
+
{ id: "stripe-key", pattern: /\b[sr]k_live_[0-9A-Za-z]{24,}/g, group: 0 },
|
|
59
|
+
// tskey-<kind>-<id with a digit>-<secret>; documentation like "tskey-auth-reusable-" has no digit in the id.
|
|
60
|
+
{ id: "tailscale-key", pattern: /\btskey-[a-z]+-[A-Za-z0-9]*\d[A-Za-z0-9]*-[A-Za-z0-9]{10,}/g, group: 0 },
|
|
61
|
+
{ id: "npm-token", pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, group: 0 },
|
|
62
|
+
{ id: "jwt", pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, group: 0 },
|
|
63
|
+
{ id: "bearer-token", pattern: /\bBearer\s+([A-Za-z0-9._~+/=-]{20,})/g, group: 1 },
|
|
64
|
+
{ id: "url-password", pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:([^\s/@]{3,})@/gi, group: 1 },
|
|
65
|
+
// A secret-named key assigned a literal. The value class excludes quotes,
|
|
66
|
+
// brackets and parentheses so `token = getToken()` and `password: string`
|
|
67
|
+
// stay code; requiring a digit or 16+ characters keeps `secret: undefined`.
|
|
68
|
+
{ id: "secret-assignment", group: 1,
|
|
69
|
+
// Up to three prefix words, so access_token, HUILIANYI_REFRESH_TOKEN and db-password count.
|
|
70
|
+
pattern: /\b(?:[a-z0-9]+[_-]){0,3}(?:api[_-]?key|access[_-]?key|secret(?:[_-]?key)?|client[_-]?secret|token|password|passwd|pwd)["']?\s*[:=]\s*["']?([^\s"'`(){}[\]<>;,]{8,})/gi },
|
|
71
|
+
{ id: "secret-assignment-zh", group: 1, pattern: /(?:密码|口令|密钥|令牌)\s*(?:是|为|[::=])\s*["'“]?([^\s"'”,。,;;]{4,})/g },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const plausibleAssigned = (value: string): boolean =>
|
|
75
|
+
!/^(?:\$|process\.|os\.|env\.|self\.|this\.)/.test(value) && !value.startsWith("[[xeno-secret-") &&
|
|
76
|
+
// A pure number after a token-named key is a count (input_tokens=12000000), not a credential.
|
|
77
|
+
!/^\d+$/.test(value) &&
|
|
78
|
+
// Credentials carry digits; a digit-free value is a path, a member access or a placeholder word
|
|
79
|
+
// (replayed 2026-10-12 sessions: "internal/server/…", "auth.AccessToken"). A long mixed-case
|
|
80
|
+
// value without separators is the one digit-free exception.
|
|
81
|
+
(/\d/.test(value) && /[A-Za-z]/.test(value) || value.length >= 20 && /[A-Z]/.test(value) && /[a-z]/.test(value) && !/[./]/.test(value));
|
|
82
|
+
|
|
83
|
+
/** Every secret-shaped span in `text`, with the rule that found it. Overlaps resolve to the earliest, longest. */
|
|
84
|
+
export function findSecrets(text: string): { start: number; end: number; rule: string }[] {
|
|
85
|
+
const spans: { start: number; end: number; rule: string }[] = [];
|
|
86
|
+
for (const rule of RULES) {
|
|
87
|
+
for (const match of text.matchAll(rule.pattern)) {
|
|
88
|
+
const value = match[rule.group];
|
|
89
|
+
if (value === undefined || match.index === undefined) continue;
|
|
90
|
+
if (rule.id === "secret-assignment" && !plausibleAssigned(value)) continue;
|
|
91
|
+
const start = match.index + match[0].lastIndexOf(value);
|
|
92
|
+
spans.push({ start, end: start + value.length, rule: rule.id });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
spans.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
96
|
+
const kept: typeof spans = [];
|
|
97
|
+
for (const span of spans) if (kept.length === 0 || span.start >= kept.at(-1)!.end) kept.push(span);
|
|
98
|
+
return kept;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Replaces known values and newly found secrets with placeholders. Returns the
|
|
103
|
+
* vault entries it had to add; the caller persists them before the text is
|
|
104
|
+
* written anywhere, so a placeholder in the log always has a value to expand to.
|
|
105
|
+
*/
|
|
106
|
+
export function redactText(text: string, vault: Vault, candidates: readonly { value: string; rule: string }[] = []): { text: string; added: SecretEntry[] } {
|
|
107
|
+
if (text.length === 0) return { text, added: [] };
|
|
108
|
+
const added: SecretEntry[] = [];
|
|
109
|
+
let next = vault.reduce((max, entry) => Math.max(max, entry.n), 0) + 1;
|
|
110
|
+
// Candidates (secret-named environment values) join the vault only when they
|
|
111
|
+
// actually appear, so a placeholder number is never spent on a value no event holds.
|
|
112
|
+
const known: SecretEntry[] = [...vault];
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
if (candidate.value.length < MIN_SECRET_LENGTH || !text.includes(candidate.value) || known.some(entry => entry.value === candidate.value)) continue;
|
|
115
|
+
const entry = { n: next++, value: candidate.value, rule: candidate.rule };
|
|
116
|
+
known.push(entry); added.push(entry);
|
|
117
|
+
}
|
|
118
|
+
let out = maskKnown(text, known);
|
|
119
|
+
const spans = findSecrets(out);
|
|
120
|
+
if (spans.length === 0) return { text: out, added };
|
|
121
|
+
let result = "", at = 0;
|
|
122
|
+
for (const span of spans) {
|
|
123
|
+
const value = out.slice(span.start, span.end);
|
|
124
|
+
const existing = known.find(entry => entry.value === value);
|
|
125
|
+
const entry = existing ?? { n: next++, value, rule: span.rule };
|
|
126
|
+
if (!existing) { known.push(entry); added.push(entry); }
|
|
127
|
+
result += out.slice(at, span.start) + placeholder(entry.n);
|
|
128
|
+
at = span.end;
|
|
129
|
+
}
|
|
130
|
+
// Values found in this pass may occur again where a neighbouring match split
|
|
131
|
+
// them (two tokens with no delimiter); a second known-value pass closes that.
|
|
132
|
+
return { text: maskKnown(result + out.slice(at), known), added };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Longest first, so a value containing another is masked whole. A short value is masked only where a rule found it. */
|
|
136
|
+
function maskKnown(text: string, known: readonly SecretEntry[]): string {
|
|
137
|
+
let out = text;
|
|
138
|
+
for (const entry of known.filter(entry => entry.value.length >= MIN_SECRET_LENGTH).sort((a, b) => b.value.length - a.value.length)) {
|
|
139
|
+
if (out.includes(entry.value)) out = out.split(entry.value).join(placeholder(entry.n));
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The inverse of redactText for tool inputs. An unknown placeholder is left as written. */
|
|
145
|
+
export function expandText(text: string, vault: Vault): string {
|
|
146
|
+
if (!text.includes("[[xeno-secret-")) return text;
|
|
147
|
+
return text.replace(PLACEHOLDER, (whole, n: string) => vault.find(entry => entry.n === Number(n))?.value ?? whole);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ASKED_FOR_CREDENTIAL = /password|passcode|passphrase|credential|api[ _-]?key|access[ _-]?token|\btoken\b|密码|口令|密钥|凭据|凭证|令牌|验证码/i;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A bare password has no shape; what identifies it is the exchange. A message
|
|
154
|
+
* that is a single token with mixed character classes, sent right after the
|
|
155
|
+
* assistant asked for a credential, is that credential (the 2026-10-12 ticket
|
|
156
|
+
* session: the assistant asked for a login password, the user replied with it).
|
|
157
|
+
*/
|
|
158
|
+
export function isSolicitedSecret(userText: string, previousAssistantText: string | undefined): boolean {
|
|
159
|
+
const value = userText.trim();
|
|
160
|
+
if (previousAssistantText === undefined || !ASKED_FOR_CREDENTIAL.test(previousAssistantText)) return false;
|
|
161
|
+
if (!/^\S{6,128}$/.test(value) || value.startsWith("[[xeno-secret-")) return false;
|
|
162
|
+
// Letters with digits, or three character classes: "dsm612566" and "Pa$$word"
|
|
163
|
+
// qualify, an identifier like "OPENAI_API_KEY" or a branch name does not.
|
|
164
|
+
const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter(pattern => pattern.test(value)).length;
|
|
165
|
+
const lettersAndDigits = /[A-Za-z]/.test(value) && /\d/.test(value);
|
|
166
|
+
return (lettersAndDigits || classes >= 3) && !/^[a-z]+:\/\//i.test(value) && !/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Applies `redact` to every string in a JSON value, preserving its shape. */
|
|
170
|
+
export function mapStrings(value: unknown, redact: (text: string) => string, skip: (key: string) => boolean = () => false): unknown {
|
|
171
|
+
if (typeof value === "string") return redact(value);
|
|
172
|
+
if (Array.isArray(value)) return value.map(item => mapStrings(item, redact, skip));
|
|
173
|
+
if (value !== null && typeof value === "object") {
|
|
174
|
+
const out: Record<string, unknown> = {};
|
|
175
|
+
for (const [key, item] of Object.entries(value)) out[key] = skip(key) ? item : mapStrings(item, redact, skip);
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Environment variables whose names say they hold a secret; their values are masked wherever they appear. */
|
|
182
|
+
export function secretEnvironmentValues(env: Readonly<Record<string, string | undefined>>): { value: string; rule: string }[] {
|
|
183
|
+
return Object.entries(env).flatMap(([name, value]) =>
|
|
184
|
+
value !== undefined && value.length >= MIN_SECRET_LENGTH && !value.startsWith("/") &&
|
|
185
|
+
/(?:^|_)(?:API_?KEY|KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIALS?|AUTH)(?:_|$)/i.test(name) &&
|
|
186
|
+
!/(?:^|_)(?:PATH|DIR|FILE|URL|HOST|PORT|USER|NAME|ID|MODE|TYPE)$/i.test(name)
|
|
187
|
+
? [{ value, rule: `env:${name}` }] : []).sort((a, b) => a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0);
|
|
188
|
+
}
|
package/src/core/stop-report.ts
CHANGED
|
@@ -8,7 +8,11 @@ export function stopReport(events: readonly LogEvent[], requests: number, limit:
|
|
|
8
8
|
const scoped = currentRequestEvents(events);
|
|
9
9
|
const current = scoped.length > 0 ? scoped : events;
|
|
10
10
|
const results = current.filter(event => event.t === "tool_result");
|
|
11
|
-
|
|
11
|
+
// Q.22 keeps ok:false on a recorded-but-unverified claim so the model does
|
|
12
|
+
// not read "recorded" as "done"; to a person reading why work stopped it is
|
|
13
|
+
// an unproven statement, not a broken tool.
|
|
14
|
+
const unverified = results.filter(unverifiedClaim);
|
|
15
|
+
const failures = results.filter(event => !event.ok && !unverifiedClaim(event));
|
|
12
16
|
const exits = results.filter(event => event.effect?.kind === "command" && event.effect.exit === 0).length;
|
|
13
17
|
const ids = new Set<number>();
|
|
14
18
|
for (const event of current) {
|
|
@@ -17,8 +21,8 @@ export function stopReport(events: readonly LogEvent[], requests: number, limit:
|
|
|
17
21
|
}
|
|
18
22
|
const running = [...ids].filter(id => latestProcessReceipt([{ node: 0, events: current }], id)?.receipt.running === true);
|
|
19
23
|
return [
|
|
20
|
-
|
|
21
|
-
`Recorded evidence: ${results.length} tool receipts; ${exits} successful command exits; ${failures.length} failed tool receipts. Task completion is not verified by this stop.`,
|
|
24
|
+
requestLine(exitLine(cause), requests, limit),
|
|
25
|
+
`Recorded evidence: ${results.length} tool receipts; ${exits} successful command exits; ${failures.length} failed tool receipts${unverified.length ? `; ${unverified.length} unverified claims` : ""}. Task completion is not verified by this stop.`,
|
|
22
26
|
...(failures.length ? [`Failed tools: ${failedTools(current, failures)}.`] : []),
|
|
23
27
|
...(finalReplyGiven(current) ? ["A final reply was given before this stop; read it above — this stop does not mark it wrong."] : []),
|
|
24
28
|
...(running.length ? [`Last observed running: ${running.slice(0, 3).join(", ")}${running.length > 3 ? ` (+${running.length - 3})` : ""}; inspect the process receipt before replaying a command.`] : []),
|
|
@@ -26,6 +30,16 @@ export function stopReport(events: readonly LogEvent[], requests: number, limit:
|
|
|
26
30
|
].join("\n");
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
/**
|
|
34
|
+
* S.6 removed the default cap. A bare "(80 model requests)" in the same
|
|
35
|
+
* brackets as "(3/7 model requests)" read as a ceiling that was hit
|
|
36
|
+
* (fix-stop-report-request-count), so an uncapped run says it is a count.
|
|
37
|
+
*/
|
|
38
|
+
function requestLine(cause: string, requests: number, limit: number): string {
|
|
39
|
+
if (limit !== Infinity) return `Stopped: ${cause} (${requests}/${limit} model requests).`;
|
|
40
|
+
return `Stopped: ${cause}. This request sent ${requests} model request${requests === 1 ? "" : "s"}; no request cap is set.`;
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
/**
|
|
30
44
|
* The user reads this line; no command looks a receipt up by id, so ids told
|
|
31
45
|
* them nothing (fix-stop-report-readable). Tool names say where it went wrong.
|
|
@@ -39,6 +53,14 @@ function failedTools(events: readonly LogEvent[], failures: readonly Extract<Log
|
|
|
39
53
|
return ranked.slice(0, 3).map(([name, count]) => `${name} ×${count}`).join(", ") + (ranked.length > 3 ? ` (+${ranked.length - 3} more)` : "");
|
|
40
54
|
}
|
|
41
55
|
|
|
56
|
+
function unverifiedClaim(event: Extract<LogEvent, { t: "tool_result" }>): boolean {
|
|
57
|
+
if (event.ok || !event.content.startsWith("{")) return false;
|
|
58
|
+
try {
|
|
59
|
+
const body = JSON.parse(event.content) as { recorded?: unknown; verification?: unknown };
|
|
60
|
+
return body.recorded === true && body.verification === "unverified";
|
|
61
|
+
} catch { return false; }
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
/** A stop after the model already answered must not read as "the task failed". */
|
|
43
65
|
function finalReplyGiven(events: readonly LogEvent[]): boolean {
|
|
44
66
|
return events.some(event => event.t === "assistant" && !event.blocks.some(block => block.type === "tool_use") &&
|
package/src/core/tool-failure.ts
CHANGED
|
@@ -27,10 +27,18 @@ const guidance: Record<ToolDiagnostic["code"], string> = {
|
|
|
27
27
|
* policy change is required") would send the model to ask for a setting that
|
|
28
28
|
* does not exist -- which is what a real session did before this text.
|
|
29
29
|
*/
|
|
30
|
-
|
|
30
|
+
/*
|
|
31
|
+
* The diagnostic code stays permission_denied because codes are a closed set
|
|
32
|
+
* validated on every logged tool_result (event-validation.ts); a new code
|
|
33
|
+
* would change the session format. A model still read that code as "wait for
|
|
34
|
+
* approval" and stopped (Horizyn mv3nsske), so the text has to say outright
|
|
35
|
+
* that approval is neither needed nor able to help.
|
|
36
|
+
*/
|
|
37
|
+
const STATE_PATH_RULE = "This path is repository or xeno session state, which edit tools never change. " +
|
|
38
|
+
"This is not a permission problem and no setting changes this: no user approval allows it, so do not wait for one. ";
|
|
39
|
+
export const STATE_PATH_GUIDANCE = STATE_PATH_RULE +
|
|
31
40
|
"To work in an isolated checkout, use the worktree tool instead of creating one under .git or .xeno.";
|
|
32
|
-
const STATE_PATH_GUIDANCE_WITHOUT_WORKTREE =
|
|
33
|
-
"Edit files outside .git and .xeno.";
|
|
41
|
+
const STATE_PATH_GUIDANCE_WITHOUT_WORKTREE = STATE_PATH_RULE + "Edit files in a directory outside .git and .xeno.";
|
|
34
42
|
|
|
35
43
|
export function failureDiagnostic(code: ToolDiagnostic["code"], message: string, facts: FailureFacts,
|
|
36
44
|
extra: Pick<ToolDiagnostic, "recover" | "example" | "next"> = {}): ToolDiagnostic {
|
package/src/core/transcript.ts
CHANGED
|
@@ -81,6 +81,9 @@ export function projectTranscript(events: readonly LogEvent[], entryTypes: Reado
|
|
|
81
81
|
case "goal": items.push({ ...identity, kind: "notice", text: event.action === "set"
|
|
82
82
|
? `Goal set · ${event.accepted.length} accepted${event.rejected.length ? `, ${event.rejected.length} rejected` : ""} · ${event.objective}`
|
|
83
83
|
: event.reason === "met" ? "Goal met · every accepted criterion has current evidence" : "Goal cleared" }); break;
|
|
84
|
+
// Approvals answer "why was this allowed" in /export and history; the person
|
|
85
|
+
// already saw the prompt, and a notice per auto-allowed command would bury the chat.
|
|
86
|
+
case "approval":
|
|
84
87
|
case "checkpoint": case "workspace_observation":
|
|
85
88
|
case "guidance": case "claim": case "decision": case "pitfall": case "blocked": case "question": case "next_action": case "work_status": case "work_owner": case "work_dependencies": case "evidence_link":
|
|
86
89
|
// These remain inspectable via /state and /export; they are not chat messages.
|