@vibe-commit/capture 0.8.0 → 0.9.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/README.md +124 -0
- package/dist/agents/claude_code.js +13 -0
- package/dist/agents/claude_code.js.map +1 -1
- package/dist/agents/codex_cli.js +11 -0
- package/dist/agents/codex_cli.js.map +1 -1
- package/dist/agents/cursor.js +7 -1
- package/dist/agents/cursor.js.map +1 -1
- package/dist/agents/registry.d.ts +7 -0
- package/dist/agents/registry.js +9 -0
- package/dist/agents/registry.js.map +1 -1
- package/dist/agents/types.d.ts +32 -0
- package/dist/agents/types.js.map +1 -1
- package/dist/bounded_id.d.ts +29 -0
- package/dist/bounded_id.js +42 -0
- package/dist/bounded_id.js.map +1 -0
- package/dist/codex_folders.d.ts +340 -0
- package/dist/codex_folders.js +1448 -0
- package/dist/codex_folders.js.map +1 -0
- package/dist/commands/backfill.d.ts +29 -7
- package/dist/commands/backfill.js +387 -109
- package/dist/commands/backfill.js.map +1 -1
- package/dist/commands/connect.d.ts +1 -1
- package/dist/commands/connect.js +1 -1
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/connect_picker.js +48 -7
- package/dist/commands/connect_picker.js.map +1 -1
- package/dist/commands/finalize.d.ts +67 -0
- package/dist/commands/finalize.js +450 -114
- package/dist/commands/finalize.js.map +1 -1
- package/dist/commands/scan.d.ts +28 -0
- package/dist/commands/scan.js +11 -0
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/status_machine.js +47 -10
- package/dist/commands/status_machine.js.map +1 -1
- package/dist/consent.d.ts +22 -0
- package/dist/consent.js +38 -1
- package/dist/consent.js.map +1 -1
- package/dist/copy/strings.d.ts +43 -1
- package/dist/copy/strings.js +48 -4
- package/dist/copy/strings.js.map +1 -1
- package/dist/folder_grants.d.ts +44 -0
- package/dist/folder_grants.js +101 -0
- package/dist/folder_grants.js.map +1 -0
- package/dist/git.d.ts +26 -4
- package/dist/git.js +82 -11
- package/dist/git.js.map +1 -1
- package/dist/hooks/entry.d.ts +30 -50
- package/dist/hooks/entry.js +593 -559
- package/dist/hooks/entry.js.map +1 -1
- package/dist/hooks/fire_lock.d.ts +31 -0
- package/dist/hooks/fire_lock.js +120 -12
- package/dist/hooks/fire_lock.js.map +1 -1
- package/dist/machine_state.d.ts +44 -1
- package/dist/machine_state.js +75 -12
- package/dist/machine_state.js.map +1 -1
- package/dist/oauth/discovery.js +2 -1
- package/dist/oauth/discovery.js.map +1 -1
- package/dist/oauth/pkce.js +2 -1
- package/dist/oauth/pkce.js.map +1 -1
- package/dist/paths.d.ts +34 -1
- package/dist/paths.js +62 -12
- package/dist/paths.js.map +1 -1
- package/dist/physical_path.d.ts +46 -0
- package/dist/physical_path.js +151 -0
- package/dist/physical_path.js.map +1 -0
- package/dist/picker.d.ts +52 -3
- package/dist/picker.js +129 -5
- package/dist/picker.js.map +1 -1
- package/dist/picker_checklist.d.ts +9 -1
- package/dist/picker_checklist.js +41 -2
- package/dist/picker_checklist.js.map +1 -1
- package/dist/policy.d.ts +9 -1
- package/dist/policy.js +11 -2
- package/dist/policy.js.map +1 -1
- package/dist/post.d.ts +14 -0
- package/dist/post.js +32 -10
- package/dist/post.js.map +1 -1
- package/dist/project.d.ts +89 -0
- package/dist/project.js +123 -0
- package/dist/project.js.map +1 -1
- package/dist/redact.d.ts +77 -2
- package/dist/redact.js +713 -47
- package/dist/redact.js.map +1 -1
- package/dist/session_targets.d.ts +713 -0
- package/dist/session_targets.js +2602 -0
- package/dist/session_targets.js.map +1 -0
- package/dist/spool.d.ts +12 -0
- package/dist/spool.js +29 -1
- package/dist/spool.js.map +1 -1
- package/dist/spool_union.d.ts +51 -0
- package/dist/spool_union.js +57 -0
- package/dist/spool_union.js.map +1 -0
- package/dist/state.d.ts +5 -0
- package/dist/state.js +19 -1
- package/dist/state.js.map +1 -1
- package/dist/text_tail.d.ts +8 -0
- package/dist/text_tail.js +19 -0
- package/dist/text_tail.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,1448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex record isolation (any-folder capture, part (b)): the EFFECTIVE FOLDER of a Codex rollout record.
|
|
3
|
+
*
|
|
4
|
+
* A Codex record names a folder on very few types (`session_meta` and `turn_context` carry `payload.cwd`, a finished command item carries `item.cwd` as a
|
|
5
|
+
* `file://` URL, `thread_settings_applied` carries `thread_settings.cwd`). Everything else (the user's prompt, the agent's prose, a command's output) runs
|
|
6
|
+
* "somewhere" without saying where, so a copy for repo A of a session that also worked in repo B would carry B's turns. Every record therefore gets an
|
|
7
|
+
* effective folder, told by these rules:
|
|
8
|
+
*
|
|
9
|
+
* 1. a record that carries its OWN folder (`payload.cwd`, `payload.item.cwd`, `payload.thread_settings.cwd`) takes it; an unreadable one is UNKNOWN. Only
|
|
10
|
+
* `session_meta` and `turn_context` CHANGE the turn folder.
|
|
11
|
+
* 2. a CALL takes the folders its tool ran in. The rule is per tool name and DEFAULT DENY: `exec_command` / `shell` / `local_shell_call` (workdir, else the
|
|
12
|
+
* turn folder, plus the files of a patch they carry), `apply_patch` (the files it changes), `exec` (the whole-literal workdirs of the program, the turn
|
|
13
|
+
* folder, and the folders of the sessions it polls), `write_stdin` (the folders of the session it polls), `wait` (the folders of the cell it waits on),
|
|
14
|
+
* `view_image` (its path), `update_plan` (the turn folder). ANY OTHER tool name, in either call form, is UNKNOWN.
|
|
15
|
+
* 3. an OUTPUT takes the folders of its call, matched by `call_id`; an output whose call is not known is UNKNOWN. A process an `exec_command` started and a
|
|
16
|
+
* cell an `exec` program started are named in the output (`Process running with session ID <n>`, `Script running with cell ID <id>`, a `"session_id"`
|
|
17
|
+
* field): the SESSION and CELL maps learned from those lines are how a later `write_stdin` or `wait` is placed.
|
|
18
|
+
* 4. otherwise the TURN FOLDER in force, the latest `session_meta` / `turn_context` cwd at or before the record in the SAME stream.
|
|
19
|
+
*
|
|
20
|
+
* The common failure the rules are built against: a record whose folder cannot actually be read must come out UNKNOWN, never as a KNOWN but wrong folder
|
|
21
|
+
* (usually the turn folder), because UNKNOWN is withheld from a multi-repo copy and a wrong known folder is kept.
|
|
22
|
+
*
|
|
23
|
+
* Pure: no filesystem, no clock. The caller supplies the turn folder, the call table and the two maps, so the resolver's scan (which writes the checkpoint)
|
|
24
|
+
* and the redactor (which reads it) share ONE definition of "where a record ran".
|
|
25
|
+
*/
|
|
26
|
+
import { isAbsolute } from "node:path";
|
|
27
|
+
import { CALL_LOG_BUDGET_BYTES, DIR_OVERHEAD_BYTES, boundedId, boundedName, bytesOf, storedBytes } from "./bounded_id.js";
|
|
28
|
+
import { MAX_PATH_BYTES, joinRaw, pathTooLong } from "./physical_path.js";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
/** The longest `file://` URL that can decode to a path of `MAX_PATH_BYTES`: every byte percent-encoded (3 characters), plus the scheme and a little slack. */
|
|
31
|
+
const FILE_URL_MAX_CHARS = 3 * MAX_PATH_BYTES + 16;
|
|
32
|
+
/** A folder given as a `file://` URL (a finished command item's `cwd`) or as a path, as a plain absolute path; null when it is neither. */
|
|
33
|
+
export function folderFromValue(value) {
|
|
34
|
+
if (typeof value !== "string" || value === "")
|
|
35
|
+
return null;
|
|
36
|
+
// C74 (3): a path longer than any the kernel could have opened is unreadable AT PLACEMENT (the existing unreadable-folder rule): it is never stored.
|
|
37
|
+
// C75 (5): a `file://` URL is bounded BEFORE it is decoded (a percent-encoded byte takes three characters, so a path of 4,096 bytes is at most 3 x 4,096 + 16 characters
|
|
38
|
+
// of URL), and the path bound applies to the DECODED path only: a URL that decodes to a readable path is not made unreadable by its encoding.
|
|
39
|
+
if (value.startsWith("file://")) {
|
|
40
|
+
if (value.length > FILE_URL_MAX_CHARS)
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
const path = fileURLToPath(value);
|
|
44
|
+
return pathTooLong(path) ? null : path;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return pathTooLong(value) || !isAbsolute(value) ? null : value;
|
|
51
|
+
}
|
|
52
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
53
|
+
/**
|
|
54
|
+
* The turn folder this record sets: a `session_meta` or `turn_context` `payload.cwd`. `undefined`: the record sets none (another type, or no `cwd`).
|
|
55
|
+
* `null`: it names one that cannot be read (C49 (6)): the turn folder is UNKNOWN from here until the next readable header.
|
|
56
|
+
*/
|
|
57
|
+
function turnFolderOf(record) {
|
|
58
|
+
if (record.type !== "session_meta" && record.type !== "turn_context")
|
|
59
|
+
return undefined;
|
|
60
|
+
if (!isObject(record.payload) || record.payload.cwd === undefined)
|
|
61
|
+
return undefined;
|
|
62
|
+
return folderFromValue(record.payload.cwd);
|
|
63
|
+
}
|
|
64
|
+
/** The three places a Codex record can carry its own folder: `payload.cwd`, `payload.item.cwd`, `payload.thread_settings.cwd`. */
|
|
65
|
+
function carriersOf(record) {
|
|
66
|
+
if (!isObject(record.payload))
|
|
67
|
+
return [];
|
|
68
|
+
const p = record.payload;
|
|
69
|
+
return [p.cwd, isObject(p.item) ? p.item.cwd : undefined, isObject(p.thread_settings) ? p.thread_settings.cwd : undefined];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The folder a record carries on its own: `payload.cwd`, `payload.item.cwd` or `payload.thread_settings.cwd`, whatever the record's type. `undefined`: it
|
|
73
|
+
* carries none. `null`: it carries one that cannot be read. (C48 (5): `item_started` and `thread_settings_applied` name a folder too.)
|
|
74
|
+
*/
|
|
75
|
+
function ownFolderOf(record) {
|
|
76
|
+
let found;
|
|
77
|
+
for (const carrier of carriersOf(record)) {
|
|
78
|
+
if (carrier === undefined)
|
|
79
|
+
continue;
|
|
80
|
+
const folder = folderFromValue(carrier);
|
|
81
|
+
if (folder === null)
|
|
82
|
+
return null;
|
|
83
|
+
found = found === undefined ? folder : found;
|
|
84
|
+
}
|
|
85
|
+
return found;
|
|
86
|
+
}
|
|
87
|
+
/** EVERY readable folder a record carries on its own (C49 (7): discovery reads folders by this one definition, `file://` decoded). */
|
|
88
|
+
export function ownFoldersOf(record) {
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const carrier of carriersOf(record)) {
|
|
91
|
+
if (carrier === undefined)
|
|
92
|
+
continue;
|
|
93
|
+
const folder = folderFromValue(carrier);
|
|
94
|
+
if (folder !== null && !out.includes(folder))
|
|
95
|
+
out.push(folder);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
const CALL_TYPES = new Set(["function_call", "custom_tool_call", "local_shell_call"]);
|
|
100
|
+
const OUTPUT_TYPES = new Set(["function_call_output", "custom_tool_call_output", "local_shell_call_output"]);
|
|
101
|
+
/** The `call_id` of a call record, or null. */
|
|
102
|
+
function callIdOf(record) {
|
|
103
|
+
if (record.type !== "response_item" || !isObject(record.payload))
|
|
104
|
+
return null;
|
|
105
|
+
const p = record.payload;
|
|
106
|
+
return typeof p.type === "string" && CALL_TYPES.has(p.type) && typeof p.call_id === "string" ? boundedId(p.call_id) : null;
|
|
107
|
+
}
|
|
108
|
+
/** The `call_id` of an output record, or null. */
|
|
109
|
+
function outputIdOf(record) {
|
|
110
|
+
if (record.type !== "response_item" || !isObject(record.payload))
|
|
111
|
+
return null;
|
|
112
|
+
const p = record.payload;
|
|
113
|
+
return typeof p.type === "string" && OUTPUT_TYPES.has(p.type) && typeof p.call_id === "string" ? boundedId(p.call_id) : null;
|
|
114
|
+
}
|
|
115
|
+
/** The tool a call record names (`exec_command`, `exec`, ...); `local_shell_call` has none and is reported as itself. */
|
|
116
|
+
function toolOf(record) {
|
|
117
|
+
if (record.type !== "response_item" || !isObject(record.payload))
|
|
118
|
+
return null;
|
|
119
|
+
const p = record.payload;
|
|
120
|
+
if (p.type === "local_shell_call")
|
|
121
|
+
return "local_shell_call";
|
|
122
|
+
return typeof p.name === "string" ? boundedName(p.name) : "";
|
|
123
|
+
}
|
|
124
|
+
const PATCH_BEGIN = "*** Begin Patch";
|
|
125
|
+
// C65 (3): a header is matched after its leading blanks are trimmed (fail closed: a line the parser may or may not honour is read as a header).
|
|
126
|
+
// C66 (2): every Unicode White_Space character counts as a blank, as Rust's trim() does: [^\S\n\r] covers the rest of the \s class (NBSP, U+2000-200A, U+3000, \v, \f, ...) and U+0085.
|
|
127
|
+
// C67 (1): the path is captured to the end of the line (`(.+)$`) and right-trimmed IN CODE: a lazy `(.+?)\s*$` backtracks quadratically on a header followed by a long run of
|
|
128
|
+
// blanks and one more character (8,000 blanks took 1.6 s, 16,000 took 5.3 s), synchronously, in the scan on every firing and again in delivery.
|
|
129
|
+
// C69 (1): the path runs to the end of the LINE, and Codex splits patch lines on \n only: `.` also stops at \r, U+2028 and U+2029, so `Add File: /r/A/z<U+2028>/../../B/x` named
|
|
130
|
+
// /r/A/z while Codex writes /r/B/x. `([^\n]+)$` takes the whole line (trimEndBlanks drops a CRLF's \r; resolveFrom normalises the `..`), and stays linear.
|
|
131
|
+
// C68 (1): the lead-in must not contain a line terminator: under the `m` flag `^` also matches after U+2028 and U+2029, so a run of them in the lead-in made every position a
|
|
132
|
+
// line start and the loop quadratic. They are not in the class (a header right after one is still read, `^` re-anchors there).
|
|
133
|
+
const PATCH_FILE = /^(?:[^\S\n\r\u2028\u2029]|\u0085)*\*\*\* (?:Add|Update|Delete) File: ([^\n]+)$/gm;
|
|
134
|
+
const PATCH_MOVE = /^(?:[^\S\n\r\u2028\u2029]|\u0085)*\*\*\* Move to: ([^\n]+)$/gm;
|
|
135
|
+
/** Rust's White_Space (what Codex's `trim()` removes): `\s` without U+FEFF, plus U+0085. */
|
|
136
|
+
function isBlank(char) {
|
|
137
|
+
return char !== "\ufeff" && (char === "\u0085" || /\s/.test(char));
|
|
138
|
+
}
|
|
139
|
+
/** `text` without its trailing blanks; one linear pass from the end. */
|
|
140
|
+
function trimEndBlanks(text) {
|
|
141
|
+
let end = text.length;
|
|
142
|
+
while (end > 0 && isBlank(text[end - 1]))
|
|
143
|
+
end -= 1;
|
|
144
|
+
return end === text.length ? text : text.slice(0, end);
|
|
145
|
+
}
|
|
146
|
+
/** The paths an `apply_patch` document's `*** Add/Update/Delete File:` and `*** Move to:` headers name, in header-kind order; `""` for a header whose path is empty once trimmed. */
|
|
147
|
+
function patchHeaderPaths(text) {
|
|
148
|
+
const out = [];
|
|
149
|
+
// (`matchAll` reads a copy of the pattern, so the shared regexes keep `lastIndex` 0.)
|
|
150
|
+
for (const match of text.matchAll(PATCH_FILE))
|
|
151
|
+
out.push(trimEndBlanks(match[1]));
|
|
152
|
+
for (const match of text.matchAll(PATCH_MOVE))
|
|
153
|
+
out.push(trimEndBlanks(match[1]));
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
function resolveFrom(path, base) {
|
|
157
|
+
// C74 (3): ONE guard for every path a record hands to placement (patch headers, `Move to`, every workdir form, `view_image`, a review's files, a program's literals): one
|
|
158
|
+
// longer than any the kernel could have opened (or whose joined form is) is unresolvable here, so it is UNJUDGED_FILE / UNKNOWN and nothing over 4 KB enters the
|
|
159
|
+
// placement, the checkpoint, the tracker, the id maps or the evidence.
|
|
160
|
+
if (pathTooLong(path))
|
|
161
|
+
return null;
|
|
162
|
+
if (isAbsolute(path))
|
|
163
|
+
return path;
|
|
164
|
+
// C70: joined, not normalised: a `..` after a link is only meaningful once the links before it are resolved (`physicalPath`).
|
|
165
|
+
if (base === null)
|
|
166
|
+
return null;
|
|
167
|
+
const joined = joinRaw(base, path);
|
|
168
|
+
return pathTooLong(joined) ? null : joined;
|
|
169
|
+
}
|
|
170
|
+
/** The files an `apply_patch` document names (its `*** Add/Update/Delete File:` and `*** Move to:` headers), resolved against `base`; null when one cannot be. */
|
|
171
|
+
function patchFiles(text, base) {
|
|
172
|
+
const out = [];
|
|
173
|
+
for (const path of patchHeaderPaths(text)) {
|
|
174
|
+
// An empty path (a header with nothing after it) names no file we can resolve: UNKNOWN, never the folder itself.
|
|
175
|
+
const file = path === "" ? null : resolveFrom(path, base);
|
|
176
|
+
if (file === null)
|
|
177
|
+
return null;
|
|
178
|
+
out.push(file);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Every string a shell-form call carries that holds a patch: a top-level string argument, OR a string element of a top-level array argument (the older
|
|
184
|
+
* `command: ["apply_patch", "<patch>"]` form). Nothing deeper is read.
|
|
185
|
+
*/
|
|
186
|
+
function patchTexts(args) {
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const value of Object.values(args)) {
|
|
189
|
+
if (typeof value === "string" && value.includes(PATCH_BEGIN))
|
|
190
|
+
out.push(value);
|
|
191
|
+
else if (Array.isArray(value))
|
|
192
|
+
for (const item of value)
|
|
193
|
+
if (typeof item === "string" && item.includes(PATCH_BEGIN))
|
|
194
|
+
out.push(item);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
/** C57 (a): a `local_shell_call` patch is read from `action.command` ONLY (a string, or a string element of an array); a patch in any other field of the action is unread. */
|
|
199
|
+
function shellPatchTexts(action) {
|
|
200
|
+
return patchTexts({ command: action.command });
|
|
201
|
+
}
|
|
202
|
+
/** A quoted literal that starts at `source[at]` and is the WHOLE value (followed by optional whitespace and then `,` `}` or `)`); null otherwise. */
|
|
203
|
+
function wholeLiteral(source, at) {
|
|
204
|
+
const quote = source[at];
|
|
205
|
+
if (quote !== '"' && quote !== "'")
|
|
206
|
+
return null;
|
|
207
|
+
let i = at + 1;
|
|
208
|
+
while (i < source.length) {
|
|
209
|
+
const c = source[i];
|
|
210
|
+
if (c === "\\") {
|
|
211
|
+
// A single-quoted literal that holds any backslash is not read (its escapes are JavaScript's, not JSON's): UNKNOWN.
|
|
212
|
+
if (quote === "'")
|
|
213
|
+
return null;
|
|
214
|
+
i += 2;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (c === quote)
|
|
218
|
+
break;
|
|
219
|
+
if (c === "\n")
|
|
220
|
+
return null;
|
|
221
|
+
i += 1;
|
|
222
|
+
}
|
|
223
|
+
if (i >= source.length)
|
|
224
|
+
return null;
|
|
225
|
+
const raw = source.slice(at, i + 1);
|
|
226
|
+
let value;
|
|
227
|
+
if (quote === '"') {
|
|
228
|
+
try {
|
|
229
|
+
value = JSON.parse(raw);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
value = raw.slice(1, -1);
|
|
237
|
+
}
|
|
238
|
+
let j = i + 1;
|
|
239
|
+
while (j < source.length && (source[j] === " " || source[j] === "\t" || source[j] === "\n" || source[j] === "\r"))
|
|
240
|
+
j += 1;
|
|
241
|
+
if (source[j] !== "," && source[j] !== "}" && source[j] !== ")")
|
|
242
|
+
return null;
|
|
243
|
+
return { value, end: j };
|
|
244
|
+
}
|
|
245
|
+
/** Every `workdir` of a JavaScript program that drives `tools.exec_command`: each must be a WHOLE string literal, else the program's folders are unknown. */
|
|
246
|
+
function programWorkdirs(source) {
|
|
247
|
+
const mentions = (source.match(/workdir/g) ?? []).length;
|
|
248
|
+
const dirs = [];
|
|
249
|
+
let found = 0;
|
|
250
|
+
for (const match of source.matchAll(/["']?workdir["']?\s*:\s*/g)) {
|
|
251
|
+
const literal = wholeLiteral(source, (match.index ?? 0) + match[0].length);
|
|
252
|
+
if (literal === null)
|
|
253
|
+
return null;
|
|
254
|
+
dirs.push(literal.value);
|
|
255
|
+
found += 1;
|
|
256
|
+
}
|
|
257
|
+
// A `workdir` that is not followed by a whole string literal (a variable, a template, a concatenation) cannot be read.
|
|
258
|
+
return found === mentions ? dirs : null;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The numeric session ids a program polls (`session_id: 7`, `"session_id": 7`); null when any mention is not a WHOLE numeric literal (C49 (3): optional
|
|
262
|
+
* whitespace, then `,` `}` or `)` after the digits, so `session_id: 7 + 1` is unknown).
|
|
263
|
+
*/
|
|
264
|
+
function programSessions(source) {
|
|
265
|
+
const mentions = (source.match(/session_id/g) ?? []).length;
|
|
266
|
+
const ids = [];
|
|
267
|
+
for (const match of source.matchAll(/["']?session_id["']?\s*:\s*(\d+)/g)) {
|
|
268
|
+
let j = (match.index ?? 0) + match[0].length;
|
|
269
|
+
while (j < source.length && (source[j] === " " || source[j] === "\t" || source[j] === "\n" || source[j] === "\r"))
|
|
270
|
+
j += 1;
|
|
271
|
+
if (source[j] !== "," && source[j] !== "}" && source[j] !== ")")
|
|
272
|
+
return null;
|
|
273
|
+
ids.push(boundedId(match[1]));
|
|
274
|
+
}
|
|
275
|
+
return ids.length === mentions ? ids : null;
|
|
276
|
+
}
|
|
277
|
+
/** Every whole-literal `workdir` of a program, skipping the ones that are not (C53: what CAN be read is still kept). */
|
|
278
|
+
function programWorkdirsLoose(source) {
|
|
279
|
+
const dirs = [];
|
|
280
|
+
for (const match of source.matchAll(/["']?workdir["']?\s*:\s*/g)) {
|
|
281
|
+
const literal = wholeLiteral(source, (match.index ?? 0) + match[0].length);
|
|
282
|
+
if (literal !== null)
|
|
283
|
+
dirs.push(literal.value);
|
|
284
|
+
}
|
|
285
|
+
return dirs;
|
|
286
|
+
}
|
|
287
|
+
/** Every polled `session_id` that is a whole numeric literal, skipping the rest. */
|
|
288
|
+
function programSessionsLoose(source) {
|
|
289
|
+
const ids = [];
|
|
290
|
+
for (const match of source.matchAll(/["']?session_id["']?\s*:\s*(\d+)/g)) {
|
|
291
|
+
let j = (match.index ?? 0) + match[0].length;
|
|
292
|
+
while (j < source.length && (source[j] === " " || source[j] === "\t" || source[j] === "\n" || source[j] === "\r"))
|
|
293
|
+
j += 1;
|
|
294
|
+
if (source[j] === "," || source[j] === "}" || source[j] === ")")
|
|
295
|
+
ids.push(boundedId(match[1]));
|
|
296
|
+
}
|
|
297
|
+
return ids;
|
|
298
|
+
}
|
|
299
|
+
/** The files a patch names that CAN be resolved; `skipped` is true when a header could not be (a relative one with no base): the patch is then not fully judged. */
|
|
300
|
+
function patchFilesLoose(text, base) {
|
|
301
|
+
const files = [];
|
|
302
|
+
let skipped = false;
|
|
303
|
+
for (const path of patchHeaderPaths(text)) {
|
|
304
|
+
const file = path === "" ? null : resolveFrom(path, base);
|
|
305
|
+
if (file !== null)
|
|
306
|
+
files.push(file);
|
|
307
|
+
else
|
|
308
|
+
skipped = true;
|
|
309
|
+
}
|
|
310
|
+
return { files, skipped };
|
|
311
|
+
}
|
|
312
|
+
/** Folders as a set where an EMPTY list means "cannot be told" and absorbs the rest. */
|
|
313
|
+
export function unionDirs(a, b) {
|
|
314
|
+
if (a.length === 0 || b.length === 0)
|
|
315
|
+
return [];
|
|
316
|
+
return [...new Set([...a, ...b])];
|
|
317
|
+
}
|
|
318
|
+
/** The item kinds of an `item_completed` event that carry no folder of their own and are MEASURED to run in the turn folder. */
|
|
319
|
+
const TURN_ITEM_KINDS = new Set(["Reasoning", "UserMessage", "AgentMessage", "EnteredReviewMode", "ExitedReviewMode", "Extension"]);
|
|
320
|
+
const TURN_EVENT_KINDS = new Set([
|
|
321
|
+
"agent_message",
|
|
322
|
+
"user_message",
|
|
323
|
+
"token_count",
|
|
324
|
+
"task_started",
|
|
325
|
+
"task_complete",
|
|
326
|
+
"thread_settings_applied",
|
|
327
|
+
"exited_review_mode",
|
|
328
|
+
]);
|
|
329
|
+
/**
|
|
330
|
+
* C48 (10): only a MEASURED record kind takes the turn folder. Everything else is UNKNOWN (kept in a single-repo copy, withheld in a multi-repo one), so a
|
|
331
|
+
* kind Codex adds later, a `compacted` summary of earlier turns (which can describe another repo's work), or an old-format item with no `payload` is never
|
|
332
|
+
* placed by a guess. The kinds C40 (2) withholds from every copy (`world_state`, `additional_tools`, `entered_review_mode`, a user or developer message)
|
|
333
|
+
* need no placing: nothing of theirs is sent.
|
|
334
|
+
*/
|
|
335
|
+
export function takesTurnFolder(record) {
|
|
336
|
+
const type = record.type;
|
|
337
|
+
if (type === undefined)
|
|
338
|
+
return typeof record.id === "string" && typeof record.thread_name === "string" && typeof record.updated_at === "string";
|
|
339
|
+
if (type === "token_usage_record" || type === "world_state")
|
|
340
|
+
return true;
|
|
341
|
+
if (!isObject(record.payload))
|
|
342
|
+
return false;
|
|
343
|
+
const p = record.payload;
|
|
344
|
+
if (type === "response_item")
|
|
345
|
+
return p.type === "message" || p.type === "reasoning" || p.type === "additional_tools" || p.type === "configuration_update";
|
|
346
|
+
if (type !== "event_msg")
|
|
347
|
+
return false;
|
|
348
|
+
if (typeof p.type !== "string")
|
|
349
|
+
return false;
|
|
350
|
+
if (TURN_EVENT_KINDS.has(p.type) || p.type === "entered_review_mode")
|
|
351
|
+
return true;
|
|
352
|
+
return p.type === "item_completed" && isObject(p.item) && typeof p.item.type === "string" && TURN_ITEM_KINDS.has(p.item.type);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* C49 (12): an `exited_review_mode` event and an `item_completed` `ExitedReviewMode` item take the turn folder, and every `absolute_file_path` they hold is
|
|
356
|
+
* a named FILE (judged per copy like a patch's headers). A relative one is resolved against the turn folder.
|
|
357
|
+
*/
|
|
358
|
+
function reviewFiles(record, turn) {
|
|
359
|
+
if (!isObject(record.payload))
|
|
360
|
+
return [];
|
|
361
|
+
const p = record.payload;
|
|
362
|
+
const exited = p.type === "exited_review_mode" || (p.type === "item_completed" && isObject(p.item) && p.item.type === "ExitedReviewMode");
|
|
363
|
+
if (!exited)
|
|
364
|
+
return [];
|
|
365
|
+
const out = new Set();
|
|
366
|
+
// C50 (2): a walk that stopped early (nested past 24 levels, or more than 4,096 paths) cannot vouch for the files it did not reach: the record is UNKNOWN.
|
|
367
|
+
let truncated = false;
|
|
368
|
+
const walk = (node, depth) => {
|
|
369
|
+
if (truncated || node === null || typeof node !== "object")
|
|
370
|
+
return;
|
|
371
|
+
if (depth > 24) {
|
|
372
|
+
truncated = true;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (Array.isArray(node)) {
|
|
376
|
+
for (const item of node)
|
|
377
|
+
walk(item, depth + 1);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
for (const [key, value] of Object.entries(node)) {
|
|
381
|
+
if (key === "absolute_file_path" && typeof value === "string" && value !== "") {
|
|
382
|
+
const file = resolveFrom(value, turn);
|
|
383
|
+
// A relative path with no folder to resolve it against cannot be judged: the walk is unjudged, like a truncated one (C52 (1)).
|
|
384
|
+
if (file === null) {
|
|
385
|
+
truncated = true;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
// The cap bounds the work: the 4,097th distinct path is not added, the walk stops, and the record is withheld whole.
|
|
389
|
+
if (!out.has(file) && out.size >= 4096) {
|
|
390
|
+
truncated = true;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
out.add(file);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
walk(value, depth + 1);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
walk(p, 0);
|
|
401
|
+
return truncated ? null : [...out];
|
|
402
|
+
}
|
|
403
|
+
/** A "file" that lies inside no copy's tree: a record whose named files could not all be judged names it, so it is withheld from every copy. */
|
|
404
|
+
export const UNJUDGED_FILE = "\u0000unjudged";
|
|
405
|
+
// C75 (8): a call whose stored places (`paths` and `files`) alone pass the log budget is UNKNOWN (null), like one that names more than MAX_PLACED_FILES: it is never stored.
|
|
406
|
+
const placed = (paths, files = [], inputFiles = []) => (bytesOf(paths) + bytesOf(files) > CALL_LOG_BUDGET_BYTES ? null : { paths, files, inputFiles });
|
|
407
|
+
/**
|
|
408
|
+
* C62: the most files a PLACED call may name. A call that names more is UNKNOWN (like the readable cap, `readableOf` keeps 64 and marks it unjudged and multi): the
|
|
409
|
+
* checkpoint entry, the tracker, the id maps and the scrub never hold or loop over more than this per call (one 8 MiB patch line can name ~400,000 files).
|
|
410
|
+
*/
|
|
411
|
+
const MAX_PLACED_FILES = 4096;
|
|
412
|
+
/**
|
|
413
|
+
* The places a CALL ran in and the files it names, or null when they cannot be told (DEFAULT DENY: a tool this module has no rule for, a malformed argument
|
|
414
|
+
* document, a `workdir` that is not a string, a relative path with no turn folder, an id that names no known session or cell).
|
|
415
|
+
*/
|
|
416
|
+
export function placeCall(record, ctx) {
|
|
417
|
+
if (record.type !== "response_item" || !isObject(record.payload))
|
|
418
|
+
return null;
|
|
419
|
+
const p = record.payload;
|
|
420
|
+
const turn = ctx.turn;
|
|
421
|
+
if (p.type === "local_shell_call") {
|
|
422
|
+
const action = isObject(p.action) ? p.action : null;
|
|
423
|
+
const dir = action === null ? undefined : action.working_directory;
|
|
424
|
+
let base = turn;
|
|
425
|
+
// C60: ONLY an absent working directory takes the turn folder; `null` is not a string, so it is unreadable (UNKNOWN, and the session is multi in readableOf).
|
|
426
|
+
if (dir !== undefined)
|
|
427
|
+
base = typeof dir === "string" ? resolveFrom(dir, turn) : null;
|
|
428
|
+
if (base === null)
|
|
429
|
+
return null;
|
|
430
|
+
// C49 (5): `action.command` is read like a shell function call's arguments (a string, or a string element of an array, that holds a patch).
|
|
431
|
+
const files = [];
|
|
432
|
+
for (const text of action === null ? [] : shellPatchTexts(action)) {
|
|
433
|
+
const named = patchFiles(text, base);
|
|
434
|
+
if (named === null)
|
|
435
|
+
return null;
|
|
436
|
+
for (const file of named)
|
|
437
|
+
files.push(file);
|
|
438
|
+
}
|
|
439
|
+
const unique = [...new Set(files)];
|
|
440
|
+
if (unique.length > MAX_PLACED_FILES)
|
|
441
|
+
return null;
|
|
442
|
+
// C59 (2): an explicit working directory is an INPUT file (the scrub knows neither `working_directory` nor `command`): the command is withheld when the
|
|
443
|
+
// folder is outside the copy's tree (C48 (11) parity); its output stays the residual.
|
|
444
|
+
return placed([...new Set([base, ...unique])], unique, dir !== undefined ? [base] : []);
|
|
445
|
+
}
|
|
446
|
+
const name = typeof p.name === "string" ? p.name : "";
|
|
447
|
+
if (p.type === "custom_tool_call") {
|
|
448
|
+
if (typeof p.input !== "string")
|
|
449
|
+
return null;
|
|
450
|
+
if (name === "apply_patch") {
|
|
451
|
+
const files = patchFiles(p.input, turn);
|
|
452
|
+
if (files === null)
|
|
453
|
+
return null;
|
|
454
|
+
const unique = [...new Set(files)];
|
|
455
|
+
if (unique.length > MAX_PLACED_FILES)
|
|
456
|
+
return null;
|
|
457
|
+
return unique.length > 0 ? placed([...unique], unique) : turn === null ? null : placed([turn]);
|
|
458
|
+
}
|
|
459
|
+
if (name === "exec")
|
|
460
|
+
return programFolders(p.input, ctx);
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
if (p.type !== "function_call" || typeof p.arguments !== "string")
|
|
464
|
+
return null;
|
|
465
|
+
let args;
|
|
466
|
+
try {
|
|
467
|
+
args = JSON.parse(p.arguments);
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
if (!isObject(args))
|
|
473
|
+
return null;
|
|
474
|
+
switch (name) {
|
|
475
|
+
case "exec_command":
|
|
476
|
+
case "shell":
|
|
477
|
+
case "apply_patch": {
|
|
478
|
+
let base = turn;
|
|
479
|
+
if (args.workdir !== undefined) {
|
|
480
|
+
if (typeof args.workdir !== "string")
|
|
481
|
+
return null;
|
|
482
|
+
base = resolveFrom(args.workdir, turn);
|
|
483
|
+
}
|
|
484
|
+
if (base === null)
|
|
485
|
+
return null;
|
|
486
|
+
const files = [];
|
|
487
|
+
for (const text of patchTexts(args)) {
|
|
488
|
+
const named = patchFiles(text, base);
|
|
489
|
+
if (named === null)
|
|
490
|
+
return null;
|
|
491
|
+
for (const file of named)
|
|
492
|
+
files.push(file);
|
|
493
|
+
}
|
|
494
|
+
const unique = [...new Set(files)];
|
|
495
|
+
if (unique.length > MAX_PLACED_FILES)
|
|
496
|
+
return null;
|
|
497
|
+
// C61 (1): an EXPLICIT working folder is an input file here too (`shell`'s `command` array is a key the scrub does not know): the call's text is withheld
|
|
498
|
+
// when the folder is outside the copy's tree. An absent `workdir` is the turn folder: no input file.
|
|
499
|
+
return placed([...new Set([base, ...unique])], unique, args.workdir !== undefined ? [base] : []);
|
|
500
|
+
}
|
|
501
|
+
case "write_stdin": {
|
|
502
|
+
const id = args.session_id;
|
|
503
|
+
if (typeof id !== "number" && typeof id !== "string")
|
|
504
|
+
return null;
|
|
505
|
+
const place = ctx.session(boundedId(String(id)));
|
|
506
|
+
return place === undefined || place.unknown === true ? null : placed([...place.paths], [...place.files]);
|
|
507
|
+
}
|
|
508
|
+
case "wait": {
|
|
509
|
+
const id = args.cell_id;
|
|
510
|
+
if (typeof id !== "number" && typeof id !== "string")
|
|
511
|
+
return null;
|
|
512
|
+
const place = ctx.cell(boundedId(String(id)));
|
|
513
|
+
return place === undefined || place.unknown === true ? null : placed([...place.paths], [...place.files]);
|
|
514
|
+
}
|
|
515
|
+
case "view_image": {
|
|
516
|
+
if (typeof args.path !== "string")
|
|
517
|
+
return null;
|
|
518
|
+
const file = resolveFrom(args.path, turn);
|
|
519
|
+
return file === null ? null : placed([file], [file]);
|
|
520
|
+
}
|
|
521
|
+
case "update_plan":
|
|
522
|
+
return turn === null ? null : placed([turn]);
|
|
523
|
+
default:
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const MAX_READABLE = 64;
|
|
528
|
+
export function readableOf(record, ctx) {
|
|
529
|
+
const evidence = new Set();
|
|
530
|
+
const files = new Set();
|
|
531
|
+
const inputFiles = new Set();
|
|
532
|
+
// C54: wherever partial information would be lost (a cap, a path that cannot be resolved), the record carries UNJUDGED_FILE and is withheld from every copy.
|
|
533
|
+
let unjudged = false;
|
|
534
|
+
// C56 (10): a folder the call names that cannot be read is an UNRESOLVED candidate (a folder, not a file): the session is multi, the record is NOT unjudged.
|
|
535
|
+
let incomplete = false;
|
|
536
|
+
const addFile = (file) => {
|
|
537
|
+
files.add(file);
|
|
538
|
+
evidence.add(file);
|
|
539
|
+
};
|
|
540
|
+
const done = () => {
|
|
541
|
+
const over = evidence.size > MAX_READABLE || files.size > MAX_READABLE || inputFiles.size > MAX_READABLE;
|
|
542
|
+
const outFiles = [...files].slice(0, MAX_READABLE);
|
|
543
|
+
if (over || unjudged)
|
|
544
|
+
outFiles.push(UNJUDGED_FILE);
|
|
545
|
+
return { evidence: [...evidence].slice(0, MAX_READABLE), files: outFiles, inputFiles: [...inputFiles].slice(0, MAX_READABLE), ...(over || incomplete ? { overflow: true } : {}) };
|
|
546
|
+
};
|
|
547
|
+
if (record.type !== "response_item" || !isObject(record.payload))
|
|
548
|
+
return done();
|
|
549
|
+
const p = record.payload;
|
|
550
|
+
const turn = ctx.turn;
|
|
551
|
+
const name = typeof p.name === "string" ? p.name : "";
|
|
552
|
+
const baseOf = (value) => {
|
|
553
|
+
if (value === undefined)
|
|
554
|
+
return turn;
|
|
555
|
+
if (typeof value !== "string") {
|
|
556
|
+
incomplete = true;
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
const resolved = resolveFrom(value, turn);
|
|
560
|
+
if (resolved === null)
|
|
561
|
+
unjudged = true;
|
|
562
|
+
return resolved;
|
|
563
|
+
};
|
|
564
|
+
const patches = (texts, base) => {
|
|
565
|
+
for (const text of texts) {
|
|
566
|
+
const loose = patchFilesLoose(text, base);
|
|
567
|
+
for (const file of loose.files)
|
|
568
|
+
addFile(file);
|
|
569
|
+
if (loose.skipped)
|
|
570
|
+
unjudged = true;
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
const fromPlace = (place) => {
|
|
574
|
+
if (place === undefined)
|
|
575
|
+
return;
|
|
576
|
+
for (const path of place.paths)
|
|
577
|
+
evidence.add(path);
|
|
578
|
+
for (const path of place.evidence ?? [])
|
|
579
|
+
evidence.add(path);
|
|
580
|
+
for (const file of place.files)
|
|
581
|
+
files.add(file);
|
|
582
|
+
};
|
|
583
|
+
if (p.type === "local_shell_call") {
|
|
584
|
+
const action = isObject(p.action) ? p.action : null;
|
|
585
|
+
const base = baseOf(action === null ? undefined : action.working_directory);
|
|
586
|
+
if (base !== null)
|
|
587
|
+
evidence.add(base);
|
|
588
|
+
if (base !== null && action !== null && action.working_directory !== undefined)
|
|
589
|
+
inputFiles.add(base);
|
|
590
|
+
patches(action === null ? [] : shellPatchTexts(action), base);
|
|
591
|
+
}
|
|
592
|
+
else if (p.type === "custom_tool_call" && typeof p.input === "string") {
|
|
593
|
+
if (name === "apply_patch") {
|
|
594
|
+
patches([p.input], turn);
|
|
595
|
+
}
|
|
596
|
+
else if (name === "exec") {
|
|
597
|
+
if (turn !== null)
|
|
598
|
+
evidence.add(turn);
|
|
599
|
+
// C56 (2): a program that holds a patch marker (a template literal naming files) is source text, not a patch the client can parse. What can be read is
|
|
600
|
+
// evidence here; the program (with its outputs) is unjudged by `callGuard`.
|
|
601
|
+
if (p.input.includes(PATCH_BEGIN))
|
|
602
|
+
patches([p.input], turn);
|
|
603
|
+
// C56 (10): a `workdir` that is not a whole string literal names a folder this module cannot read.
|
|
604
|
+
if (programWorkdirs(p.input) === null)
|
|
605
|
+
incomplete = true;
|
|
606
|
+
for (const literal of programWorkdirsLoose(p.input)) {
|
|
607
|
+
const dir = resolveFrom(literal, turn);
|
|
608
|
+
if (dir !== null) {
|
|
609
|
+
evidence.add(dir);
|
|
610
|
+
inputFiles.add(dir);
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
unjudged = true;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
for (const id of programSessionsLoose(p.input))
|
|
617
|
+
fromPlace(ctx.session(id));
|
|
618
|
+
// C56 (13): a `session_id` that is not a numeric literal may reach ANY process the session knows at this point: the program carries the files of every
|
|
619
|
+
// session entry known here, and is unjudged when one of them is unknown with files (or already unjudged). A session that names no file adds nothing.
|
|
620
|
+
if (programSessions(p.input) === null) {
|
|
621
|
+
for (const place of ctx.allSessions?.() ?? []) {
|
|
622
|
+
for (const file of place.files)
|
|
623
|
+
files.add(file);
|
|
624
|
+
if (place.files.length > 0 && (place.unknown === true || place.files.includes(UNJUDGED_FILE)))
|
|
625
|
+
unjudged = true;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
else if (p.type === "function_call" && typeof p.arguments === "string") {
|
|
631
|
+
let args;
|
|
632
|
+
try {
|
|
633
|
+
args = JSON.parse(p.arguments);
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
return done();
|
|
637
|
+
}
|
|
638
|
+
if (!isObject(args))
|
|
639
|
+
return done();
|
|
640
|
+
if (name === "exec_command" || name === "shell" || name === "apply_patch") {
|
|
641
|
+
const base = baseOf(args.workdir);
|
|
642
|
+
if (base !== null)
|
|
643
|
+
evidence.add(base);
|
|
644
|
+
if (base !== null && args.workdir !== undefined)
|
|
645
|
+
inputFiles.add(base);
|
|
646
|
+
patches(patchTexts(args), base);
|
|
647
|
+
}
|
|
648
|
+
else if (name === "view_image" && typeof args.path === "string") {
|
|
649
|
+
const file = resolveFrom(args.path, turn);
|
|
650
|
+
if (file !== null)
|
|
651
|
+
addFile(file);
|
|
652
|
+
else
|
|
653
|
+
unjudged = true;
|
|
654
|
+
}
|
|
655
|
+
else if (name === "write_stdin" && (typeof args.session_id === "number" || typeof args.session_id === "string")) {
|
|
656
|
+
// C54 (2): a poll inherits what its process named, even when its own placement is unreadable.
|
|
657
|
+
fromPlace(ctx.session(boundedId(String(args.session_id))));
|
|
658
|
+
}
|
|
659
|
+
else if (name === "wait" && (typeof args.cell_id === "number" || typeof args.cell_id === "string")) {
|
|
660
|
+
fromPlace(ctx.cell(boundedId(String(args.cell_id))));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return done();
|
|
664
|
+
}
|
|
665
|
+
/** The places a call ran in (see `placeCall`), or null when they cannot be told. */
|
|
666
|
+
export function callFolders(record, ctx) {
|
|
667
|
+
return placeCall(record, ctx)?.paths ?? null;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* An `exec` program's folders: its whole-literal workdirs, the TURN folder always (a command with no `workdir` runs there), and the folders of every session
|
|
671
|
+
* it polls. A program that holds a patch, a workdir that is not a whole literal, or a session id that is not a numeric literal naming a known session is
|
|
672
|
+
* UNKNOWN. Its workdirs are also `inputFiles`: the program text is withheld when one lies outside the copy's tree.
|
|
673
|
+
*/
|
|
674
|
+
function programFolders(source, ctx) {
|
|
675
|
+
if (source.includes(PATCH_BEGIN))
|
|
676
|
+
return null;
|
|
677
|
+
if (ctx.turn === null)
|
|
678
|
+
return null;
|
|
679
|
+
const workdirs = programWorkdirs(source);
|
|
680
|
+
if (workdirs === null)
|
|
681
|
+
return null;
|
|
682
|
+
const dirs = new Set([ctx.turn]);
|
|
683
|
+
const files = new Set();
|
|
684
|
+
const literal = new Set();
|
|
685
|
+
for (const text of workdirs) {
|
|
686
|
+
const dir = resolveFrom(text, ctx.turn);
|
|
687
|
+
if (dir === null)
|
|
688
|
+
return null;
|
|
689
|
+
dirs.add(dir);
|
|
690
|
+
literal.add(dir);
|
|
691
|
+
}
|
|
692
|
+
const sessions = programSessions(source);
|
|
693
|
+
if (sessions === null)
|
|
694
|
+
return null;
|
|
695
|
+
for (const id of sessions) {
|
|
696
|
+
const place = ctx.session(id);
|
|
697
|
+
if (place === undefined || place.unknown === true)
|
|
698
|
+
return null;
|
|
699
|
+
for (const dir of place.paths)
|
|
700
|
+
dirs.add(dir);
|
|
701
|
+
for (const file of place.files)
|
|
702
|
+
files.add(file);
|
|
703
|
+
}
|
|
704
|
+
// C65 (4): like `placeCall`, a program that names more than MAX_PLACED_FILES places in total (its workdirs plus the folders and files of the sessions it
|
|
705
|
+
// polls, the turn folder counted) is UNKNOWN: nothing downstream (the checkpoint entry, the id maps, the scrub) holds or loops over more.
|
|
706
|
+
if (new Set([...dirs, ...files]).size > MAX_PLACED_FILES)
|
|
707
|
+
return null;
|
|
708
|
+
return placed([...dirs], [...files], [...literal]);
|
|
709
|
+
}
|
|
710
|
+
export function outputTextOf(record) {
|
|
711
|
+
if (!isObject(record.payload))
|
|
712
|
+
return undefined;
|
|
713
|
+
const output = record.payload.output;
|
|
714
|
+
if (typeof output === "string")
|
|
715
|
+
return { header: headerOf(output), all: output };
|
|
716
|
+
if (!Array.isArray(output) || output.length === 0)
|
|
717
|
+
return undefined;
|
|
718
|
+
// C56 (6): a block that is not text (an image) is skipped, not a reason to read nothing: learn from the TEXT blocks, the header from the FIRST of them.
|
|
719
|
+
const texts = [];
|
|
720
|
+
for (const block of output)
|
|
721
|
+
if (isObject(block) && typeof block.text === "string")
|
|
722
|
+
texts.push(block.text);
|
|
723
|
+
if (texts.length === 0)
|
|
724
|
+
return undefined;
|
|
725
|
+
return { header: headerOf(texts[0]), all: texts.join("\n") };
|
|
726
|
+
}
|
|
727
|
+
const OUTPUT_DELIMITER = "\nOutput:\n";
|
|
728
|
+
/** The part of an output that is HEADER: before the first `\nOutput:\n` (all of it when there is none, and then there is no body to be fooled by). */
|
|
729
|
+
function headerOf(text) {
|
|
730
|
+
const at = text.indexOf(OUTPUT_DELIMITER);
|
|
731
|
+
return at === -1 ? text : text.slice(0, at);
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* What an output teaches about the processes and scripts its call started (C48 (1)): from an `exec_command` / `write_stdin` output, the session id in the
|
|
735
|
+
* header line `Process running with session ID <n>` (never from the body, where a printed log could remap an id); from an `exec` program's output, a
|
|
736
|
+
* `"session_id": <n>` field (either JSON escaping, anywhere in the text) and the header line `Script running with cell ID <id>`.
|
|
737
|
+
*/
|
|
738
|
+
export function learnFromOutput(tool, text) {
|
|
739
|
+
const sessions = [];
|
|
740
|
+
const cells = [];
|
|
741
|
+
if (tool === "exec_command" || tool === "write_stdin") {
|
|
742
|
+
for (const match of text.header.matchAll(/^Process running with session ID (\d+)$/gm))
|
|
743
|
+
sessions.push(boundedId(match[1]));
|
|
744
|
+
}
|
|
745
|
+
else if (tool === "exec" || tool === "wait") {
|
|
746
|
+
// C56 (7): a `wait` output prints the `session_id` of a process the script started, like the `exec` output that started it.
|
|
747
|
+
for (const match of text.all.matchAll(/\\?"session_id\\?"\s*:\s*(\d+)/g))
|
|
748
|
+
sessions.push(boundedId(match[1]));
|
|
749
|
+
if (tool === "exec")
|
|
750
|
+
for (const match of text.header.matchAll(/^Script running with cell ID (\S+)$/gm))
|
|
751
|
+
cells.push(boundedId(match[1]));
|
|
752
|
+
}
|
|
753
|
+
return { sessions, cells };
|
|
754
|
+
}
|
|
755
|
+
/** The most entries a session or cell map keeps; the oldest is evicted, and an evicted id is UNKNOWN. */
|
|
756
|
+
export const MAX_ID_MAP = 4096;
|
|
757
|
+
/** C75 (9) / C76: the bytes of freeable path text ONE id map (the session map, or the cell map) may store per stream (`dirs`, `files` and `evidence` of live entries and tombstones). */
|
|
758
|
+
export const ID_MAP_BUDGET_BYTES = CALL_LOG_BUDGET_BYTES;
|
|
759
|
+
/** The most distinct folders one id keeps (C49 (10)); past it the id is UNKNOWN for good. */
|
|
760
|
+
export const MAX_ID_FOLDERS = 64;
|
|
761
|
+
/** The path text an id-map entry stores (what a collapse frees): its folders, files and evidence, with their quoting. */
|
|
762
|
+
function idEntryText(entry) {
|
|
763
|
+
let total = 0;
|
|
764
|
+
for (const learned of entry.dirs)
|
|
765
|
+
total += Buffer.byteLength(learned.dir, "utf8") + DIR_OVERHEAD_BYTES;
|
|
766
|
+
return total + storedBytes(entry.files ?? []) + storedBytes(entry.evidence ?? []);
|
|
767
|
+
}
|
|
768
|
+
/** The tombstone a collapse leaves: it withholds its polls and outputs, and stores one short mark. */
|
|
769
|
+
const isCollapsed = (entry) => entry.tomb === true && entry.dirs.length === 0 && entry.evidence === undefined && entry.files?.length === 1 && entry.files[0] === UNJUDGED_FILE;
|
|
770
|
+
/** The most tombstones (evicted ids) a map keeps; past that the whole map is UNKNOWN from that offset (sticky). */
|
|
771
|
+
export const MAX_ID_TOMBS = 4096;
|
|
772
|
+
/** The map key that holds the "whole map is unknown from `unknownAt`" mark. */
|
|
773
|
+
export const ALL_UNKNOWN = "\u0000all-unknown";
|
|
774
|
+
/**
|
|
775
|
+
* A session or cell map with the checkpoint's bounds: at most `MAX_ID_MAP` live entries; the oldest live entry that is evicted becomes a TOMBSTONE (UNKNOWN
|
|
776
|
+
* for good), at most `MAX_ID_TOMBS` of those; past that the whole map is UNKNOWN from that offset. It is a `Map`, so it persists as an array of entries.
|
|
777
|
+
*/
|
|
778
|
+
export class IdMap extends Map {
|
|
779
|
+
live = 0;
|
|
780
|
+
tombs = 0;
|
|
781
|
+
/**
|
|
782
|
+
* C75 (9) / C76: the FREEABLE text the entries store (the path text of `dirs`, `files` and `evidence` with their quoting), live entries and tombstones alike. The byte budget compares only
|
|
783
|
+
* this: the fixed cost of an entry (its key and field names) is bounded by the count caps (`MAX_ID_MAP` + `MAX_ID_TOMBS`) and is its own term of the record's worst case.
|
|
784
|
+
*/
|
|
785
|
+
text = 0;
|
|
786
|
+
/** C75 (9): the byte budget collapsed an entry since the flag was last taken: evidence was LOST (the session counts as multi from that offset). */
|
|
787
|
+
collapsed = false;
|
|
788
|
+
// C76: the oldest live entry, the oldest tombstone, and the oldest of each that holds freeable text, each found in amortized O(1) (never by walking the map).
|
|
789
|
+
liveOrder = new OrderedKeys();
|
|
790
|
+
tombOrder = new OrderedKeys();
|
|
791
|
+
liveTextOrder = new OrderedKeys();
|
|
792
|
+
tombTextOrder = new OrderedKeys();
|
|
793
|
+
constructor(entries = []) {
|
|
794
|
+
super();
|
|
795
|
+
for (const [id, entry] of entries)
|
|
796
|
+
this.put(id, entry);
|
|
797
|
+
}
|
|
798
|
+
/** Remove an entry, keeping the text count and the order indexes (the live and tombstone counts are kept by the callers). */
|
|
799
|
+
delete(id) {
|
|
800
|
+
const had = this.get(id);
|
|
801
|
+
if (had !== undefined)
|
|
802
|
+
this.text -= idEntryText(had);
|
|
803
|
+
this.liveOrder.delete(id);
|
|
804
|
+
this.tombOrder.delete(id);
|
|
805
|
+
this.liveTextOrder.delete(id);
|
|
806
|
+
this.tombTextOrder.delete(id);
|
|
807
|
+
return super.delete(id);
|
|
808
|
+
}
|
|
809
|
+
/** Set an entry, keeping the live, tombstone and text counts and the order indexes. */
|
|
810
|
+
put(id, entry) {
|
|
811
|
+
const had = this.get(id);
|
|
812
|
+
if (had !== undefined)
|
|
813
|
+
this.text -= idEntryText(had);
|
|
814
|
+
if (id !== ALL_UNKNOWN) {
|
|
815
|
+
if (had !== undefined) {
|
|
816
|
+
if (had.tomb === true)
|
|
817
|
+
this.tombs -= 1;
|
|
818
|
+
else
|
|
819
|
+
this.live -= 1;
|
|
820
|
+
}
|
|
821
|
+
const tomb = entry.tomb === true;
|
|
822
|
+
// An entry keeps its place in the order while it stays the same kind (the Map's own order for a key that is set again).
|
|
823
|
+
(tomb ? this.liveOrder : this.tombOrder).delete(id);
|
|
824
|
+
(tomb ? this.tombOrder : this.liveOrder).add(id);
|
|
825
|
+
(tomb ? this.liveTextOrder : this.tombTextOrder).delete(id);
|
|
826
|
+
const holds = idEntryText(entry) > 0 && !isCollapsed(entry);
|
|
827
|
+
if (holds)
|
|
828
|
+
(tomb ? this.tombTextOrder : this.liveTextOrder).add(id);
|
|
829
|
+
else
|
|
830
|
+
(tomb ? this.tombTextOrder : this.liveTextOrder).delete(id);
|
|
831
|
+
if (tomb)
|
|
832
|
+
this.tombs += 1;
|
|
833
|
+
else
|
|
834
|
+
this.live += 1;
|
|
835
|
+
}
|
|
836
|
+
super.set(id, entry);
|
|
837
|
+
this.text += idEntryText(entry);
|
|
838
|
+
}
|
|
839
|
+
/** Read and clear the "the byte budget collapsed an entry" flag. */
|
|
840
|
+
takeCollapsed() {
|
|
841
|
+
const was = this.collapsed;
|
|
842
|
+
this.collapsed = false;
|
|
843
|
+
return was;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* C75 (9): the map's byte budget. Past `ID_MAP_BUDGET_BYTES` of freeable text the OLDEST entry that stores any is collapsed to a tombstone whose only file is `UNJUDGED_FILE` (its
|
|
847
|
+
* polls and outputs are withheld from every copy), live entries first, then tombstones that kept files. A collapse loses evidence: the caller counts the session as multi (C54 (1)).
|
|
848
|
+
* C76: each step takes the oldest text-bearing entry from an index, so learning one id costs O(1) amortized whatever the map holds.
|
|
849
|
+
*/
|
|
850
|
+
fit(at) {
|
|
851
|
+
let collapsedAny = false;
|
|
852
|
+
while (this.text > ID_MAP_BUDGET_BYTES) {
|
|
853
|
+
const key = this.liveTextOrder.oldest() ?? this.tombTextOrder.oldest();
|
|
854
|
+
// Only the one-line marks of earlier collapses are left (at most one per entry, far under the budget): nothing more to free.
|
|
855
|
+
if (key === undefined)
|
|
856
|
+
break;
|
|
857
|
+
this.collapse(key, at);
|
|
858
|
+
collapsedAny = true;
|
|
859
|
+
}
|
|
860
|
+
// A collapse turns a live entry into a tombstone: the count caps are enforced again.
|
|
861
|
+
if (collapsedAny)
|
|
862
|
+
this.bound(at);
|
|
863
|
+
}
|
|
864
|
+
/** The oldest entry that holds freeable text (a live one first), or undefined. */
|
|
865
|
+
oldestText() {
|
|
866
|
+
return this.liveTextOrder.oldest() ?? this.tombTextOrder.oldest();
|
|
867
|
+
}
|
|
868
|
+
/** Store `id` as a tombstone that withholds its polls and outputs, whatever it held. */
|
|
869
|
+
collapse(id, at) {
|
|
870
|
+
this.put(id, { dirs: [], unknownAt: at, tomb: true, files: [UNJUDGED_FILE] });
|
|
871
|
+
this.collapsed = true;
|
|
872
|
+
}
|
|
873
|
+
/** Enforce the bounds after a learning at offset `at`. */
|
|
874
|
+
bound(at) {
|
|
875
|
+
while (this.live > MAX_ID_MAP) {
|
|
876
|
+
const oldest = this.liveOrder.oldest();
|
|
877
|
+
if (oldest === undefined)
|
|
878
|
+
break;
|
|
879
|
+
const evicted = this.get(oldest);
|
|
880
|
+
this.delete(oldest);
|
|
881
|
+
this.live -= 1;
|
|
882
|
+
// The tombstone keeps the files the evicted entry named (the folders go; the id is unknown): nothing silently dropped.
|
|
883
|
+
const kept = unionCapped(evicted?.files, evicted?.dirs.filter((learned) => learned.file === true).map((learned) => learned.dir));
|
|
884
|
+
this.put(oldest, { dirs: [], unknownAt: at, tomb: true, ...(kept === undefined ? {} : { files: kept }) });
|
|
885
|
+
}
|
|
886
|
+
while (this.tombs > MAX_ID_TOMBS)
|
|
887
|
+
if (this.evictOldestTomb(at) === undefined)
|
|
888
|
+
break;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Forget the OLDEST tombstone: the id is no longer remembered as evicted, so the whole map is marked UNKNOWN from `at` (`ALL_UNKNOWN`) and every lookup past it fails closed.
|
|
892
|
+
* Returns the forgotten [id, entry], or undefined when there is none.
|
|
893
|
+
*/
|
|
894
|
+
evictOldestTomb(at) {
|
|
895
|
+
const oldest = this.tombOrder.oldest();
|
|
896
|
+
if (oldest === undefined)
|
|
897
|
+
return undefined;
|
|
898
|
+
const entry = this.get(oldest);
|
|
899
|
+
this.delete(oldest);
|
|
900
|
+
this.tombs -= 1;
|
|
901
|
+
if (this.get(ALL_UNKNOWN) === undefined)
|
|
902
|
+
super.set(ALL_UNKNOWN, { dirs: [], unknownAt: at });
|
|
903
|
+
return [oldest, entry];
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* C76: keys in the order they were added, with O(1) amortized add, delete and "the oldest". A key that is added again after a delete takes a new place at the end; one that is added
|
|
908
|
+
* while present keeps its place. (A queue with lazy deletion: a stale item is skipped when it reaches the head, and the queue is rebuilt when stale items outnumber live ones.)
|
|
909
|
+
*/
|
|
910
|
+
class OrderedKeys {
|
|
911
|
+
seq = 0;
|
|
912
|
+
at = new Map();
|
|
913
|
+
queue = [];
|
|
914
|
+
head = 0;
|
|
915
|
+
add(key) {
|
|
916
|
+
if (this.at.has(key))
|
|
917
|
+
return;
|
|
918
|
+
this.seq += 1;
|
|
919
|
+
this.at.set(key, this.seq);
|
|
920
|
+
this.queue.push([key, this.seq]);
|
|
921
|
+
if (this.queue.length - this.head > 4 * this.at.size + 1024)
|
|
922
|
+
this.rebuild();
|
|
923
|
+
}
|
|
924
|
+
delete(key) {
|
|
925
|
+
this.at.delete(key);
|
|
926
|
+
}
|
|
927
|
+
/** The oldest key still present, or undefined. */
|
|
928
|
+
oldest() {
|
|
929
|
+
while (this.head < this.queue.length) {
|
|
930
|
+
const [key, seq] = this.queue[this.head];
|
|
931
|
+
if (this.at.get(key) === seq)
|
|
932
|
+
return key;
|
|
933
|
+
this.head += 1;
|
|
934
|
+
}
|
|
935
|
+
if (this.queue.length > 0) {
|
|
936
|
+
this.queue = [];
|
|
937
|
+
this.head = 0;
|
|
938
|
+
}
|
|
939
|
+
return undefined;
|
|
940
|
+
}
|
|
941
|
+
rebuild() {
|
|
942
|
+
this.queue = [...this.at].map(([key, seq]) => [key, seq]);
|
|
943
|
+
this.head = 0;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* C56 (12): the entries of an id map as they are persisted. The tracker bounds the map (live entries, tombstones and the whole-map mark), so the limit below
|
|
948
|
+
* is never reached; if it ever were, the oldest entries would be CUT, and a cut loses their files: the whole map is marked UNKNOWN from the start instead.
|
|
949
|
+
*/
|
|
950
|
+
export function boundedIdEntries(entries) {
|
|
951
|
+
const limit = MAX_ID_MAP + MAX_ID_TOMBS + 1;
|
|
952
|
+
if (entries.length <= limit)
|
|
953
|
+
return entries.map(([id, entry]) => [id, entry]);
|
|
954
|
+
const kept = entries.filter(([id]) => id !== ALL_UNKNOWN).slice(-(limit - 1));
|
|
955
|
+
return [...kept.map(([id, entry]) => [id, entry]), [ALL_UNKNOWN, { dirs: [], unknownAt: 0 }]];
|
|
956
|
+
}
|
|
957
|
+
/** The placement of `id` in `map` at `position` (see `lookupId`), UNKNOWN when the whole map has been marked unknown from before `position`. */
|
|
958
|
+
export function lookupIn(map, id, position) {
|
|
959
|
+
if (map === undefined)
|
|
960
|
+
return undefined;
|
|
961
|
+
const all = map.get(ALL_UNKNOWN);
|
|
962
|
+
// C54: a whole-map mark lost every entry's files: whatever is polled from here on is unjudged (withheld from every copy).
|
|
963
|
+
if (all?.unknownAt !== undefined && (position === undefined || all.unknownAt < position))
|
|
964
|
+
return { paths: [], files: [UNJUDGED_FILE], unknown: true };
|
|
965
|
+
return lookupId(map.get(id), position);
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* C56 (13): the placement of EVERY entry of `map` at `position` that carries something (folders or files), for a program that polls an id it cannot name:
|
|
969
|
+
* it may reach any of them. A whole-map mark that is in force returns the one unknown-and-unjudged place (every entry's files are lost).
|
|
970
|
+
*/
|
|
971
|
+
export function placesIn(map, position) {
|
|
972
|
+
if (map === undefined)
|
|
973
|
+
return [];
|
|
974
|
+
const all = map.get(ALL_UNKNOWN);
|
|
975
|
+
if (all?.unknownAt !== undefined && (position === undefined || all.unknownAt < position))
|
|
976
|
+
return [{ paths: [], files: [UNJUDGED_FILE], unknown: true }];
|
|
977
|
+
const out = [];
|
|
978
|
+
for (const [key, entry] of map) {
|
|
979
|
+
if (key === ALL_UNKNOWN)
|
|
980
|
+
continue;
|
|
981
|
+
const place = lookupId(entry, position);
|
|
982
|
+
if (place !== undefined)
|
|
983
|
+
out.push(place);
|
|
984
|
+
}
|
|
985
|
+
return out;
|
|
986
|
+
}
|
|
987
|
+
/** The placement of an id at `position` (`undefined`: the whole entry), or `undefined` when it is unknown there. */
|
|
988
|
+
export function lookupId(entry, position) {
|
|
989
|
+
if (entry === undefined)
|
|
990
|
+
return undefined;
|
|
991
|
+
const unknown = entry.tomb === true || (entry.unknownAt !== undefined && (position === undefined || entry.unknownAt < position));
|
|
992
|
+
if (unknown) {
|
|
993
|
+
// C54 (3): an UNKNOWN id still carries what it named: its polls and their outputs inherit the files.
|
|
994
|
+
const files = [...new Set([...(entry.files ?? []), ...entry.dirs.filter((learned) => learned.file === true).map((learned) => learned.dir)])];
|
|
995
|
+
const evidence = entry.evidence ?? [];
|
|
996
|
+
return files.length > 0 || evidence.length > 0 ? { paths: [], files, evidence, unknown: true } : undefined;
|
|
997
|
+
}
|
|
998
|
+
const live = position === undefined ? entry.dirs : entry.dirs.filter((learned) => learned.at < position);
|
|
999
|
+
if (live.length === 0)
|
|
1000
|
+
return undefined;
|
|
1001
|
+
// The guard's UNJUDGED mark (learned as a file, C59 (1)) is never a folder.
|
|
1002
|
+
return { paths: live.map((learned) => learned.dir).filter((dir) => dir !== UNJUDGED_FILE), files: live.filter((learned) => learned.file === true).map((learned) => learned.dir) };
|
|
1003
|
+
}
|
|
1004
|
+
/** A set union that never silently slices: past the bound the list gains UNJUDGED_FILE (the record is withheld from every copy). */
|
|
1005
|
+
function unionCapped(a, b) {
|
|
1006
|
+
const all = [...new Set([...(a ?? []), ...(b ?? [])])];
|
|
1007
|
+
if (all.length === 0)
|
|
1008
|
+
return undefined;
|
|
1009
|
+
if (all.length <= MAX_ID_FOLDERS)
|
|
1010
|
+
return all;
|
|
1011
|
+
return [...all.filter((value) => value !== UNJUDGED_FILE).slice(0, MAX_ID_FOLDERS), UNJUDGED_FILE];
|
|
1012
|
+
}
|
|
1013
|
+
function learnInto(map, id, call, at) {
|
|
1014
|
+
const previous = map.get(id);
|
|
1015
|
+
// An evicted id stays UNKNOWN for good: a re-announcement does not bring it back, but what it names is still kept.
|
|
1016
|
+
if (previous?.tomb === true) {
|
|
1017
|
+
const files = unionCapped(previous.files, call.files);
|
|
1018
|
+
map.put(id, { ...previous, ...(files === undefined ? {} : { files }) });
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
if (previous !== undefined) {
|
|
1022
|
+
map.delete(id);
|
|
1023
|
+
map.live -= 1;
|
|
1024
|
+
}
|
|
1025
|
+
const dirs = previous === undefined ? [] : [...previous.dirs];
|
|
1026
|
+
let unknownAt = previous?.unknownAt;
|
|
1027
|
+
let files = previous?.files;
|
|
1028
|
+
let evidence = previous?.evidence;
|
|
1029
|
+
if (call.paths.length === 0) {
|
|
1030
|
+
unknownAt = unknownAt ?? at;
|
|
1031
|
+
// C54 (3): an id learned from an UNKNOWN call keeps that call's readable files and evidence beside `unknownAt`.
|
|
1032
|
+
files = unionCapped(files, call.files);
|
|
1033
|
+
evidence = unionCapped(evidence, call.evidence);
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
// C62: linear in the call's paths. Membership is a Map / Set (never `includes` / `findIndex` per path), and insertion STOPS at MAX_ID_FOLDERS + 1: the list
|
|
1037
|
+
// is never built past the bound it is then cut to.
|
|
1038
|
+
const where = new Map(dirs.map((learned, index) => [learned.dir, index]));
|
|
1039
|
+
const fileSet = new Set(call.files ?? []);
|
|
1040
|
+
const learn = (dir, isFile) => {
|
|
1041
|
+
const at_ = where.get(dir);
|
|
1042
|
+
if (at_ !== undefined) {
|
|
1043
|
+
if (isFile && dirs[at_].file !== true)
|
|
1044
|
+
dirs[at_] = { ...dirs[at_], file: true };
|
|
1045
|
+
return true;
|
|
1046
|
+
}
|
|
1047
|
+
if (dirs.length > MAX_ID_FOLDERS)
|
|
1048
|
+
return false;
|
|
1049
|
+
where.set(dir, dirs.length);
|
|
1050
|
+
dirs.push(isFile ? { dir, at, file: true } : { dir, at });
|
|
1051
|
+
return true;
|
|
1052
|
+
};
|
|
1053
|
+
const pathSet = new Set(call.paths);
|
|
1054
|
+
for (const path of call.paths)
|
|
1055
|
+
if (!learn(path, fileSet.has(path)))
|
|
1056
|
+
break;
|
|
1057
|
+
// C59 (1): a guard result travels everywhere the placement travels. A file of the call that is not one of its paths (the guard's UNJUDGED mark) is learned
|
|
1058
|
+
// too, so a later poll of the id and its output are unjudged.
|
|
1059
|
+
for (const file of fileSet)
|
|
1060
|
+
if (!pathSet.has(file) && !learn(file, true))
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
if (dirs.length > MAX_ID_FOLDERS) {
|
|
1064
|
+
// The folders past the bound are dropped: any file among them goes with them, so the id is unjudged from here on.
|
|
1065
|
+
dirs.length = MAX_ID_FOLDERS;
|
|
1066
|
+
unknownAt = unknownAt ?? at;
|
|
1067
|
+
files = unionCapped(files, [UNJUDGED_FILE]);
|
|
1068
|
+
}
|
|
1069
|
+
const learned = { dirs, ...(unknownAt === undefined ? {} : { unknownAt }), ...(files === undefined ? {} : { files }), ...(evidence === undefined ? {} : { evidence }) };
|
|
1070
|
+
// C75 (9): an entry that alone passes the map's budget is stored as the tombstone the budget would make of it.
|
|
1071
|
+
if (idEntryText(learned) > ID_MAP_BUDGET_BYTES)
|
|
1072
|
+
map.collapse(id, at);
|
|
1073
|
+
else
|
|
1074
|
+
map.put(id, learned);
|
|
1075
|
+
map.bound(at);
|
|
1076
|
+
map.fit(at);
|
|
1077
|
+
}
|
|
1078
|
+
const UNKNOWN = { unknown: true };
|
|
1079
|
+
/** UNKNOWN, carrying whatever named files could still be read (they are judged whether or not the folder is known). */
|
|
1080
|
+
function unknownWith(files, inputFiles = [], evidence = [], overflow = false) {
|
|
1081
|
+
if (files.length === 0 && inputFiles.length === 0 && evidence.length === 0 && !overflow)
|
|
1082
|
+
return UNKNOWN;
|
|
1083
|
+
return {
|
|
1084
|
+
unknown: true,
|
|
1085
|
+
...(files.length > 0 ? { files } : {}),
|
|
1086
|
+
...(inputFiles.length > 0 ? { inputFiles } : {}),
|
|
1087
|
+
...(evidence.length > 0 ? { evidence } : {}),
|
|
1088
|
+
...(overflow ? { overflow: true } : {}),
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* C57: the GUARD stage. It runs FIRST for every record, before any placement, own-folder branch or early return, and placement only ADDS to what it returns
|
|
1093
|
+
* (`addFiles`): a guard cannot be skipped by the shape of the record. Each guard is one function:
|
|
1094
|
+
* - `callGuard` (G1 an unread patch marker, G2 unreadable arguments) for a call;
|
|
1095
|
+
* - `outputGuard` (G3 the call's files, a call that was dropped or may have been lost) for an output;
|
|
1096
|
+
* - `unmeasuredGuard` (G1 for a record kind nobody measured).
|
|
1097
|
+
* The same `callGuard` runs in `CodexTracker.step`, so the checkpoint entry (and with it the call's output) carries the mark too.
|
|
1098
|
+
*/
|
|
1099
|
+
function guardFiles(record, env) {
|
|
1100
|
+
const call = callIdOf(record);
|
|
1101
|
+
if (call !== null)
|
|
1102
|
+
return callGuard(record);
|
|
1103
|
+
const output = outputIdOf(record);
|
|
1104
|
+
if (output !== null)
|
|
1105
|
+
return outputGuard(output, env);
|
|
1106
|
+
return unmeasuredGuard(record);
|
|
1107
|
+
}
|
|
1108
|
+
function addFiles(result, extra) {
|
|
1109
|
+
if (extra.length === 0)
|
|
1110
|
+
return result;
|
|
1111
|
+
return { ...result, files: [...new Set([...(result.files ?? []), ...extra])] };
|
|
1112
|
+
}
|
|
1113
|
+
/** The effective folder(s) of `record` (see the module note). The guard stage first, then the placement. */
|
|
1114
|
+
export function recordFolders(record, env) {
|
|
1115
|
+
return addFiles(placeRecord(record, env), guardFiles(record, env));
|
|
1116
|
+
}
|
|
1117
|
+
function placeRecord(record, env) {
|
|
1118
|
+
// C52: an own folder, readable or not, changes ONLY the placement's turn folder. Named-file judgment and review truncation always run.
|
|
1119
|
+
const own = ownFolderOf(record);
|
|
1120
|
+
const call = callIdOf(record);
|
|
1121
|
+
if (call !== null) {
|
|
1122
|
+
// C49 (11): a call that carries its own folder is placed with it as the turn folder, plus the folder itself.
|
|
1123
|
+
// C54 (4): only an ABSENT own folder inherits the turn folder; an unreadable one (`null`) means the turn is unknown for this call.
|
|
1124
|
+
const callCtx = { turn: own === undefined ? env.turnFolder() : own, session: env.session, cell: env.cell, ...(env.sessionPlaces === undefined ? {} : { allSessions: env.sessionPlaces }) };
|
|
1125
|
+
// C53: a placement that cannot be fully told keeps what could still be read (as evidence and as named files); the verdict stays UNKNOWN.
|
|
1126
|
+
const now = own === null ? null : placeCall(record, callCtx);
|
|
1127
|
+
const partial = now === null ? readableOf(record, callCtx) : null;
|
|
1128
|
+
const entry = env.ownCall?.(call);
|
|
1129
|
+
if (entry !== undefined) {
|
|
1130
|
+
const inputFiles = (now ?? partial)?.inputFiles ?? [];
|
|
1131
|
+
if (entry.paths.length === 0)
|
|
1132
|
+
return unknownWith([...new Set([...(entry.files ?? []), ...(partial?.files ?? [])])], inputFiles, partial?.evidence ?? [], partial?.overflow === true);
|
|
1133
|
+
const files = entry.files ?? [];
|
|
1134
|
+
return { dirs: entry.paths, ...(files.length > 0 ? { files } : {}), ...(inputFiles.length > 0 ? { inputFiles } : {}) };
|
|
1135
|
+
}
|
|
1136
|
+
if (now === null)
|
|
1137
|
+
return unknownWith(partial?.files ?? [], partial?.inputFiles ?? [], partial?.evidence ?? [], partial?.overflow === true);
|
|
1138
|
+
const dirs = own === undefined || own === null ? now.paths : [...new Set([...now.paths, own])];
|
|
1139
|
+
return { dirs, ...(now.files.length > 0 ? { files: now.files } : {}), ...(now.inputFiles.length > 0 ? { inputFiles: now.inputFiles } : {}) };
|
|
1140
|
+
}
|
|
1141
|
+
if (record.type === "session_meta" || record.type === "turn_context")
|
|
1142
|
+
return own !== undefined && own !== null ? { dirs: [own] } : UNKNOWN;
|
|
1143
|
+
// C50 (1): the files a record names (its call's files for an output, a review's) are judged whether or not it carries an own folder.
|
|
1144
|
+
const output = outputIdOf(record);
|
|
1145
|
+
if (output !== null) {
|
|
1146
|
+
// The unjudged mark is the guard's (`outputGuard`); the placement only needs the real files for the folders it reports.
|
|
1147
|
+
const files = env.callFiles(output).filter((file) => file !== UNJUDGED_FILE);
|
|
1148
|
+
if (own === null)
|
|
1149
|
+
return unknownWith(files);
|
|
1150
|
+
if (own !== undefined) {
|
|
1151
|
+
// C57 (c): an output's own folder never REPLACES the folders of its known call: placement only adds. A known call's folders and the output's own are both its places.
|
|
1152
|
+
const dirs = [...new Set([own, ...(env.callFolders(output) ?? []), ...files])];
|
|
1153
|
+
return files.length > 0 ? { dirs, files } : { dirs };
|
|
1154
|
+
}
|
|
1155
|
+
const dirs = env.callFolders(output);
|
|
1156
|
+
// C53: a call whose folders are unknown still carries the files it could read.
|
|
1157
|
+
if (dirs === undefined || dirs.length === 0)
|
|
1158
|
+
return unknownWith(files);
|
|
1159
|
+
return files.length > 0 ? { dirs, files } : { dirs };
|
|
1160
|
+
}
|
|
1161
|
+
if (own === null) {
|
|
1162
|
+
// An unreadable own folder leaves the placement UNKNOWN, but a review's files (or its truncation) are still judged. C55 (2): the turn is unknown for
|
|
1163
|
+
// this record too (C54 (4)), so a relative path in the review has nothing to resolve against and the review is unjudged.
|
|
1164
|
+
const named = reviewFiles(record, null);
|
|
1165
|
+
return unknownWith(named === null ? [UNJUDGED_FILE] : named);
|
|
1166
|
+
}
|
|
1167
|
+
if (own === undefined && !takesTurnFolder(record))
|
|
1168
|
+
return UNKNOWN;
|
|
1169
|
+
const turn = own ?? env.turnFolder();
|
|
1170
|
+
const files = reviewFiles(record, turn);
|
|
1171
|
+
// C50 (2), amended: a truncated walk cannot vouch for the files it did not reach, and a file outside the tree is withheld in EVERY copy: so is the record,
|
|
1172
|
+
// whether or not its folder is known (C52 (1)).
|
|
1173
|
+
if (files === null)
|
|
1174
|
+
return turn === null ? unknownWith([UNJUDGED_FILE]) : { dirs: [turn], files: [UNJUDGED_FILE] };
|
|
1175
|
+
if (turn === null)
|
|
1176
|
+
return unknownWith(files);
|
|
1177
|
+
return files.length > 0 ? { dirs: [turn, ...files], files } : { dirs: [turn] };
|
|
1178
|
+
}
|
|
1179
|
+
function markerCount(text) {
|
|
1180
|
+
let count = 0;
|
|
1181
|
+
for (let at = text.indexOf(PATCH_BEGIN); at !== -1; at = text.indexOf(PATCH_BEGIN, at + PATCH_BEGIN.length))
|
|
1182
|
+
count += 1;
|
|
1183
|
+
return count;
|
|
1184
|
+
}
|
|
1185
|
+
/** The strings the call's own placement READS for patch headers (`patchTexts` of a shell-form call, a custom `apply_patch` input). */
|
|
1186
|
+
function readPatchTexts(p) {
|
|
1187
|
+
if (p.type === "local_shell_call")
|
|
1188
|
+
return isObject(p.action) ? shellPatchTexts(p.action) : [];
|
|
1189
|
+
if (p.type === "custom_tool_call")
|
|
1190
|
+
return p.name === "apply_patch" && typeof p.input === "string" ? [p.input] : [];
|
|
1191
|
+
if (p.type === "function_call" && typeof p.arguments === "string" && (p.name === "exec_command" || p.name === "shell" || p.name === "apply_patch")) {
|
|
1192
|
+
try {
|
|
1193
|
+
const args = JSON.parse(p.arguments);
|
|
1194
|
+
return isObject(args) ? patchTexts(args) : [];
|
|
1195
|
+
}
|
|
1196
|
+
catch {
|
|
1197
|
+
return [];
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return [];
|
|
1201
|
+
}
|
|
1202
|
+
/** G2: arguments no rule can read: a `function_call` whose `arguments` is not a string holding a JSON object, a custom call whose input is not a string, a shell call with no action. */
|
|
1203
|
+
function unreadableArguments(p) {
|
|
1204
|
+
if (p.type === "function_call") {
|
|
1205
|
+
if (typeof p.arguments !== "string")
|
|
1206
|
+
return true;
|
|
1207
|
+
try {
|
|
1208
|
+
return !isObject(JSON.parse(p.arguments));
|
|
1209
|
+
}
|
|
1210
|
+
catch {
|
|
1211
|
+
return true;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (p.type === "custom_tool_call")
|
|
1215
|
+
return typeof p.input !== "string";
|
|
1216
|
+
if (p.type === "local_shell_call")
|
|
1217
|
+
return !isObject(p.action);
|
|
1218
|
+
return false;
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* C57 G1 + G2, for ANY call, placed or not: unreadable arguments, or a patch marker anywhere in the raw payload (arguments of any type serialized, input,
|
|
1222
|
+
* action) that the call's own patch rule did not read, name files nobody judged: the call, and with it its output, is unjudged.
|
|
1223
|
+
*/
|
|
1224
|
+
function callGuard(record) {
|
|
1225
|
+
if (record.type !== "response_item" || !isObject(record.payload))
|
|
1226
|
+
return [];
|
|
1227
|
+
const p = record.payload;
|
|
1228
|
+
if (unreadableArguments(p))
|
|
1229
|
+
return [UNJUDGED_FILE];
|
|
1230
|
+
// C58 (2): an `exec` program that names `apply_patch` ANYWHERE (a call, a comment) may build a patch the marker count cannot see (a JS-escaped or concatenated
|
|
1231
|
+
// marker): it is source text, so it is unjudged. (A fully computed name, `tools["apply" + "_patch"]`, is the stated residual.)
|
|
1232
|
+
if (p.type === "custom_tool_call" && p.name === "exec" && typeof p.input === "string" && p.input.includes("apply_patch"))
|
|
1233
|
+
return [UNJUDGED_FILE];
|
|
1234
|
+
// The total is counted over the payload as the TOOL receives it (the arguments decoded once, like the texts the rule reads): a marker spelled with JSON
|
|
1235
|
+
// escapes in the arguments text (`\u002a** Begin Patch`) is the same marker, and an escaped read one must not offset an unescaped unread one.
|
|
1236
|
+
let received = p;
|
|
1237
|
+
if (p.type === "function_call" && typeof p.arguments === "string")
|
|
1238
|
+
received = { ...p, arguments: JSON.parse(p.arguments) };
|
|
1239
|
+
return markerCount(JSON.stringify(received) ?? "") > markerCount(readPatchTexts(p).join("\n")) ? [UNJUDGED_FILE] : [];
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* C57 G3, for an OUTPUT: the files its call named (a dropped call is `DROPPED_CALL`, which names the unjudged mark), and the mark itself when its call is not
|
|
1243
|
+
* known and may have been lost. Checked before any own-folder branch of the placement.
|
|
1244
|
+
*/
|
|
1245
|
+
function outputGuard(output, env) {
|
|
1246
|
+
const out = [...env.callFiles(output)];
|
|
1247
|
+
if (env.callFolders(output) === undefined && env.callsLost?.() === true)
|
|
1248
|
+
out.push(UNJUDGED_FILE);
|
|
1249
|
+
return out;
|
|
1250
|
+
}
|
|
1251
|
+
/** C57 G1 for a record kind nobody measured (UNKNOWN): a patch it carries names files nobody judged. */
|
|
1252
|
+
function unmeasuredGuard(record) {
|
|
1253
|
+
if (record.type === "session_meta" || record.type === "turn_context" || takesTurnFolder(record))
|
|
1254
|
+
return [];
|
|
1255
|
+
return (JSON.stringify(record) ?? "").includes(PATCH_BEGIN) ? [UNJUDGED_FILE] : [];
|
|
1256
|
+
}
|
|
1257
|
+
/** C65 (2): the call of an output whose call is not known: placed nowhere, naming nothing. */
|
|
1258
|
+
const UNKNOWN_CALL = { tool: "", paths: [] };
|
|
1259
|
+
/** C56 (1): a call whose files were lost (it was evicted from a bounded table, or its entry is gone): its output and the ids it started are unjudged. */
|
|
1260
|
+
export const DROPPED_CALL = { tool: "", paths: [], files: [UNJUDGED_FILE] };
|
|
1261
|
+
/**
|
|
1262
|
+
* C62 (3): a call that opens an id ALREADY KNOWN in its stream (still open, closed, dropped, or in the checkpoint) POISONS it: from that call on the id's entry
|
|
1263
|
+
* carries the unjudged mark and `named`, so the repeated call, every later output with the id and every id learned from such an output are withheld from
|
|
1264
|
+
* every copy. ONE function, shared by the checkpoint fold and `CodexTracker`, so the two views agree.
|
|
1265
|
+
*/
|
|
1266
|
+
export function poisonRepeated(info) {
|
|
1267
|
+
return { ...info, files: [...new Set([...(info.files ?? []), UNJUDGED_FILE])], named: true };
|
|
1268
|
+
}
|
|
1269
|
+
/** The call carried files or evidence (so evicting it loses something). */
|
|
1270
|
+
export function carriesNames(info) {
|
|
1271
|
+
return (info.files?.length ?? 0) > 0 || (info.evidence?.length ?? 0) > 0 || info.named === true;
|
|
1272
|
+
}
|
|
1273
|
+
function mergeLearned(a, b) {
|
|
1274
|
+
return { sessions: [...new Set([...a.sessions, ...b.sessions])], cells: [...new Set([...a.cells, ...b.cells])] };
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* The in-order state of one Codex stream: the turn folder, the session map, the cell map and the call table. The resolver's scan and the delivery's observer
|
|
1278
|
+
* both walk records through it, so the checkpoint and the in-span view are built by the same code. The call table has the CHECKPOINT's bounds: at most
|
|
1279
|
+
* `bounds.open` open calls (the oldest is dropped, and its output is UNKNOWN), calls closed on their output, `bounds.closed` of those kept.
|
|
1280
|
+
*/
|
|
1281
|
+
export class CodexTracker {
|
|
1282
|
+
known;
|
|
1283
|
+
bounds;
|
|
1284
|
+
seen;
|
|
1285
|
+
turn;
|
|
1286
|
+
sessions;
|
|
1287
|
+
cells;
|
|
1288
|
+
open = new Map();
|
|
1289
|
+
closed = new Map();
|
|
1290
|
+
dropped = new Set();
|
|
1291
|
+
/** C56 (1): the evicted calls that carried files or evidence (bounded by `bounds.dropped`): their outputs are UNJUDGED. */
|
|
1292
|
+
droppedNamed = new Set();
|
|
1293
|
+
/** C56 (1): `droppedNamed` overflowed: every output whose call cannot be found is unjudged from here on. */
|
|
1294
|
+
droppedNamedAll = false;
|
|
1295
|
+
constructor(init,
|
|
1296
|
+
/** The calls known from before this tracker started (the persisted table). */
|
|
1297
|
+
known, bounds,
|
|
1298
|
+
/** C62 (3): has the checkpoint seen a call with this id (an entry, or one it dropped)? Exact membership: the sticky all-mark does not count. */
|
|
1299
|
+
seen = () => false) {
|
|
1300
|
+
this.known = known;
|
|
1301
|
+
this.bounds = bounds;
|
|
1302
|
+
this.seen = seen;
|
|
1303
|
+
this.turn = init.turn;
|
|
1304
|
+
this.sessions = new IdMap(init.sessions);
|
|
1305
|
+
this.cells = new IdMap(init.cells);
|
|
1306
|
+
for (const [id, info] of init.openCalls ?? [])
|
|
1307
|
+
this.open.set(id, info);
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* C65 (1): an independent copy of this tracker's state (the delivery observer snapshots it before a read and restores it when the same bytes are read again).
|
|
1311
|
+
* The persisted lookups are shared; every map and set is copied. Id entries and call infos are never mutated in place (`learnInto` and `register` replace
|
|
1312
|
+
* them), so they are shared too.
|
|
1313
|
+
*/
|
|
1314
|
+
clone() {
|
|
1315
|
+
const copy = new CodexTracker({ turn: this.turn, sessions: this.sessions, cells: this.cells, openCalls: this.open }, this.known, this.bounds, this.seen);
|
|
1316
|
+
for (const [id, info] of this.closed)
|
|
1317
|
+
copy.closed.set(id, info);
|
|
1318
|
+
for (const id of this.dropped)
|
|
1319
|
+
copy.dropped.add(id);
|
|
1320
|
+
for (const id of this.droppedNamed)
|
|
1321
|
+
copy.droppedNamed.add(id);
|
|
1322
|
+
copy.droppedNamedAll = this.droppedNamedAll;
|
|
1323
|
+
return copy;
|
|
1324
|
+
}
|
|
1325
|
+
/** A call this tracker has seen, else one the persisted table knows; a dropped one is not known. */
|
|
1326
|
+
callInfo(id) {
|
|
1327
|
+
// C56 (1): an evicted call that carried files or evidence (or any evicted call once that set overflowed) is a call whose files are LOST: unjudged.
|
|
1328
|
+
if (this.droppedNamed.has(id) || (this.droppedNamedAll && this.dropped.has(id)))
|
|
1329
|
+
return DROPPED_CALL;
|
|
1330
|
+
if (this.dropped.has(id))
|
|
1331
|
+
return undefined;
|
|
1332
|
+
const found = this.open.get(id) ?? this.closed.get(id) ?? this.known(id);
|
|
1333
|
+
return found === undefined && this.droppedNamedAll ? DROPPED_CALL : found;
|
|
1334
|
+
}
|
|
1335
|
+
/** An evicted call: remembered as unknown, and (C56 (1)) as unjudged when it carried files or evidence. */
|
|
1336
|
+
evict(id, info) {
|
|
1337
|
+
if (info !== undefined && carriesNames(info)) {
|
|
1338
|
+
this.droppedNamed.add(id);
|
|
1339
|
+
// Bounded like `dropped`; every named id is also in `dropped`, so when this set outgrows its bound `dropped` has too, and that sets the sticky mark below.
|
|
1340
|
+
if (this.droppedNamed.size > this.bounds.dropped)
|
|
1341
|
+
this.droppedNamed.delete(this.droppedNamed.values().next().value);
|
|
1342
|
+
}
|
|
1343
|
+
this.dropped.add(id);
|
|
1344
|
+
if (this.dropped.size > this.bounds.dropped) {
|
|
1345
|
+
this.dropped.delete(this.dropped.values().next().value);
|
|
1346
|
+
// C56 (11): an evicted id was forgotten: from here an output whose call cannot be found may be one of them, so it is unjudged.
|
|
1347
|
+
this.droppedNamedAll = true;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
/** C62 (3): is `id` already known in this stream? */
|
|
1351
|
+
repeated(id) {
|
|
1352
|
+
return this.open.has(id) || this.closed.has(id) || this.dropped.has(id) || this.droppedNamed.has(id) || this.seen(id);
|
|
1353
|
+
}
|
|
1354
|
+
register(id, info) {
|
|
1355
|
+
// The id is live again: it must not stay shadowed by the dropped sets.
|
|
1356
|
+
this.dropped.delete(id);
|
|
1357
|
+
this.droppedNamed.delete(id);
|
|
1358
|
+
this.closed.delete(id);
|
|
1359
|
+
this.open.delete(id);
|
|
1360
|
+
this.open.set(id, info);
|
|
1361
|
+
if (this.open.size > this.bounds.open) {
|
|
1362
|
+
const oldest = this.open.keys().next().value;
|
|
1363
|
+
const evicted = this.open.get(oldest);
|
|
1364
|
+
this.open.delete(oldest);
|
|
1365
|
+
this.evict(oldest, evicted);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
close(id) {
|
|
1369
|
+
const info = this.callInfo(id);
|
|
1370
|
+
if (info === undefined)
|
|
1371
|
+
return undefined;
|
|
1372
|
+
if (this.open.delete(id)) {
|
|
1373
|
+
this.closed.set(id, info);
|
|
1374
|
+
if (this.closed.size > this.bounds.closed) {
|
|
1375
|
+
// C56 (1): a closed call that falls off the table keeps its names too (a second output with its id is unjudged, not placed by nothing).
|
|
1376
|
+
const oldest = this.closed.keys().next().value;
|
|
1377
|
+
const evicted = this.closed.get(oldest);
|
|
1378
|
+
this.closed.delete(oldest);
|
|
1379
|
+
this.evict(oldest, evicted);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
return info;
|
|
1383
|
+
}
|
|
1384
|
+
step(record, at = 0) {
|
|
1385
|
+
const turn = turnFolderOf(record);
|
|
1386
|
+
if (turn !== undefined) {
|
|
1387
|
+
this.turn = turn;
|
|
1388
|
+
return { turnChange: turn, unknown: turn === null };
|
|
1389
|
+
}
|
|
1390
|
+
const own = ownFolderOf(record);
|
|
1391
|
+
const id = callIdOf(record);
|
|
1392
|
+
if (id !== null) {
|
|
1393
|
+
// C49 (11): a call that carries its own folder is registered like any call, placed with it as the turn folder, plus the folder itself.
|
|
1394
|
+
const callCtx = { turn: own === undefined ? this.turn : own, session: (key) => lookupIn(this.sessions, key), cell: (key) => lookupIn(this.cells, key), allSessions: () => placesIn(this.sessions) };
|
|
1395
|
+
const placed_ = own === null ? null : placeCall(record, callCtx);
|
|
1396
|
+
// C53: an unknown placement keeps the files and folders that could still be read (evidence for discovery, named files for judgment).
|
|
1397
|
+
const partial = placed_ === null ? readableOf(record, callCtx) : null;
|
|
1398
|
+
const tool = toolOf(record) ?? "";
|
|
1399
|
+
const paths = placed_ === null ? [] : own === undefined || own === null ? placed_.paths : [...new Set([...placed_.paths, own])];
|
|
1400
|
+
// C57: the guard stage (`callGuard`) runs for every call, placed or not; placement only adds to it.
|
|
1401
|
+
const knownFiles = [...new Set([...(placed_ !== null ? placed_.files : (partial?.files ?? [])), ...callGuard(record)])];
|
|
1402
|
+
const files = knownFiles.length > 0 ? knownFiles : undefined;
|
|
1403
|
+
const evidence = partial !== null && partial.evidence.length > 0 ? partial.evidence : undefined;
|
|
1404
|
+
const base = { tool, paths, ...(files === undefined ? {} : { files }), ...(evidence === undefined ? {} : { evidence }) };
|
|
1405
|
+
// C62 (3): a repeated id is poisoned, before it is registered (the same function the checkpoint fold uses).
|
|
1406
|
+
const info = this.repeated(id) ? poisonRepeated(base) : base;
|
|
1407
|
+
this.register(id, info);
|
|
1408
|
+
return {
|
|
1409
|
+
open: { id, tool, paths, ...(info.files === undefined ? {} : { files: [...info.files] }), ...(evidence === undefined ? {} : { evidence }), ...(partial?.overflow === true ? { overflow: true } : {}) },
|
|
1410
|
+
unknown: placed_ === null,
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
// C52 (2): an output closes its call and teaches the maps WHATEVER folder it carries; the own folder affects only its placement.
|
|
1414
|
+
const closedId = outputIdOf(record);
|
|
1415
|
+
if (closedId !== null) {
|
|
1416
|
+
const found = this.close(closedId);
|
|
1417
|
+
// C65 (2): an output whose call is UNKNOWN is still read by EVERY announcement rule (it has no tool to say which applies), and each id it announces becomes
|
|
1418
|
+
// UNKNOWN from here, with no folders and no files: an earlier, known mapping of that id is not kept.
|
|
1419
|
+
const call = found ?? UNKNOWN_CALL;
|
|
1420
|
+
const text = outputTextOf(record);
|
|
1421
|
+
if (text !== undefined) {
|
|
1422
|
+
// C56 (1) / C63 (1): the tool of a dropped call is lost, and the tool of a POISONED id (a repeated call id) is whichever call the table happened to keep: an
|
|
1423
|
+
// output whose call carries the unjudged mark is read by EVERY announcement rule (the exec_command / write_stdin header, an exec program's session_id
|
|
1424
|
+
// field, the cell header), so a process it started is learned as UNJUDGED whatever tool announced it.
|
|
1425
|
+
const unjudged = call === UNKNOWN_CALL || call === DROPPED_CALL || (call.files ?? []).includes(UNJUDGED_FILE);
|
|
1426
|
+
const learned = unjudged ? mergeLearned(learnFromOutput("exec_command", text), learnFromOutput("exec", text)) : learnFromOutput(call.tool, text);
|
|
1427
|
+
for (const session of learned.sessions)
|
|
1428
|
+
learnInto(this.sessions, session, call, at);
|
|
1429
|
+
for (const cell of learned.cells)
|
|
1430
|
+
learnInto(this.cells, cell, call, at);
|
|
1431
|
+
}
|
|
1432
|
+
// (Both flags are read, then combined: `||` would leave the cell map's flag set for a later offset.)
|
|
1433
|
+
const sessionsCollapsed = this.sessions.takeCollapsed();
|
|
1434
|
+
const cellsCollapsed = this.cells.takeCollapsed();
|
|
1435
|
+
const collapsed = sessionsCollapsed || cellsCollapsed;
|
|
1436
|
+
const mark = collapsed ? { collapsed: true } : {};
|
|
1437
|
+
if (found === undefined)
|
|
1438
|
+
return { close: closedId, unknown: true, ...mark };
|
|
1439
|
+
return { close: closedId, unknown: own === null ? true : own !== undefined ? false : call.paths.length === 0, ...mark };
|
|
1440
|
+
}
|
|
1441
|
+
if (own === null || record.type === "session_meta" || record.type === "turn_context")
|
|
1442
|
+
return { unknown: true };
|
|
1443
|
+
if (own !== undefined)
|
|
1444
|
+
return { unknown: false };
|
|
1445
|
+
return { unknown: this.turn === null || !takesTurnFolder(record) };
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
//# sourceMappingURL=codex_folders.js.map
|