@manyoars/oars 0.4.0 → 0.5.1
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 +82 -32
- package/dist/bin.js +3 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +206 -20
- package/dist/club.d.ts +19 -0
- package/dist/club.js +132 -0
- package/dist/gate/hook-fill-main.js +1 -1
- package/dist/gate/hook-fill.d.ts +1 -1
- package/dist/gate/hook-fill.js +1 -1
- package/dist/gate/hook-main.js +7 -5
- package/dist/gate/hook.d.ts +12 -1
- package/dist/gate/hook.js +38 -7
- package/dist/hook-bin.js +23 -4
- package/dist/issuer.d.ts +1 -1
- package/dist/issuer.js +2 -2
- package/dist/main.js +5 -0
- package/dist/node-floor.d.ts +10 -0
- package/dist/node-floor.js +23 -0
- package/dist/oars-hook.d.ts +2 -0
- package/dist/oars-hook.js +7 -0
- package/dist/sessions/allow.d.ts +1 -0
- package/dist/sessions/allow.js +1 -1
- package/dist/sessions/call-shape.d.ts +111 -0
- package/dist/sessions/call-shape.js +464 -0
- package/dist/sessions/candidates.d.ts +65 -0
- package/dist/sessions/candidates.js +236 -0
- package/dist/sessions/card-command.d.ts +24 -0
- package/dist/sessions/card-command.js +71 -0
- package/dist/sessions/card-font.d.ts +6 -0
- package/dist/sessions/card-font.js +36 -0
- package/dist/sessions/card-line.d.ts +30 -0
- package/dist/sessions/card-line.js +105 -0
- package/dist/sessions/card-render.d.ts +15 -0
- package/dist/sessions/card-render.js +146 -0
- package/dist/sessions/card.d.ts +59 -0
- package/dist/sessions/card.js +107 -0
- package/dist/sessions/cell-increments.d.ts +14 -3
- package/dist/sessions/cell-increments.js +79 -12
- package/dist/sessions/cells.d.ts +42 -4
- package/dist/sessions/cells.js +72 -18
- package/dist/sessions/codex-hook-bin.d.ts +1 -0
- package/dist/sessions/codex-hook-bin.js +24 -0
- package/dist/sessions/codex-hook-install.d.ts +21 -0
- package/dist/sessions/codex-hook-install.js +47 -0
- package/dist/sessions/codex-hook-view.d.ts +20 -0
- package/dist/sessions/codex-hook-view.js +20 -0
- package/dist/sessions/codex-hook.d.ts +77 -0
- package/dist/sessions/codex-hook.js +255 -0
- package/dist/sessions/codex.d.ts +9 -0
- package/dist/sessions/codex.js +104 -18
- package/dist/sessions/command.d.ts +17 -2
- package/dist/sessions/command.js +145 -33
- package/dist/sessions/copilot.d.ts +3 -0
- package/dist/sessions/copilot.js +12 -4
- package/dist/sessions/derive.js +2 -1
- package/dist/sessions/empty-state.d.ts +17 -0
- package/dist/sessions/empty-state.js +38 -0
- package/dist/sessions/exclusion.js +48 -3
- package/dist/sessions/extract.d.ts +16 -3
- package/dist/sessions/extract.js +281 -24
- package/dist/sessions/gemini.d.ts +2 -0
- package/dist/sessions/gemini.js +29 -2
- package/dist/sessions/harness-files.d.ts +27 -0
- package/dist/sessions/harness-files.js +109 -0
- package/dist/sessions/head-only.d.ts +7 -0
- package/dist/sessions/head-only.js +21 -0
- package/dist/sessions/hook-calls.js +4 -3
- package/dist/sessions/hooks.d.ts +1 -1
- package/dist/sessions/hooks.js +6 -2
- package/dist/sessions/identity.d.ts +6 -0
- package/dist/sessions/injected.d.ts +13 -0
- package/dist/sessions/injected.js +212 -0
- package/dist/sessions/install.d.ts +31 -20
- package/dist/sessions/install.js +80 -82
- package/dist/sessions/load-status.d.ts +41 -0
- package/dist/sessions/load-status.js +156 -0
- package/dist/sessions/machine-child-deps.d.ts +5 -0
- package/dist/sessions/machine-child-deps.js +83 -0
- package/dist/sessions/machine-child.d.ts +43 -0
- package/dist/sessions/machine-child.js +120 -0
- package/dist/sessions/machine-credential.d.ts +10 -0
- package/dist/sessions/machine-credential.js +20 -0
- package/dist/sessions/machine-enroll.d.ts +47 -0
- package/dist/sessions/machine-enroll.js +230 -0
- package/dist/sessions/machine-hold.d.ts +49 -0
- package/dist/sessions/machine-hold.js +88 -0
- package/dist/sessions/machine-ids.d.ts +6 -0
- package/dist/sessions/machine-ids.js +9 -0
- package/dist/sessions/machine-notice.d.ts +21 -0
- package/dist/sessions/machine-notice.js +89 -0
- package/dist/sessions/machine-pull.d.ts +40 -0
- package/dist/sessions/machine-pull.js +220 -0
- package/dist/sessions/machine-repo.d.ts +7 -0
- package/dist/sessions/machine-repo.js +30 -0
- package/dist/sessions/machine-scan-cache.d.ts +12 -0
- package/dist/sessions/machine-scan-cache.js +47 -0
- package/dist/sessions/machine-send.d.ts +12 -0
- package/dist/sessions/machine-send.js +156 -0
- package/dist/sessions/machine-stop.d.ts +22 -0
- package/dist/sessions/machine-stop.js +53 -0
- package/dist/sessions/machine.d.ts +41 -0
- package/dist/sessions/machine.js +89 -0
- package/dist/sessions/opencode.d.ts +2 -0
- package/dist/sessions/opencode.js +6 -0
- package/dist/sessions/otel.d.ts +21 -0
- package/dist/sessions/otel.js +109 -38
- package/dist/sessions/owner-only.d.ts +7 -0
- package/dist/sessions/owner-only.js +34 -0
- package/dist/sessions/pi.d.ts +13 -1
- package/dist/sessions/pi.js +27 -13
- package/dist/sessions/pool-books.d.ts +1 -1
- package/dist/sessions/pool-books.js +1 -1
- package/dist/sessions/pool-choices.d.ts +4 -2
- package/dist/sessions/pool-choices.js +4 -2
- package/dist/sessions/pool-command.d.ts +46 -6
- package/dist/sessions/pool-command.js +577 -133
- package/dist/sessions/pool-delta.d.ts +7 -0
- package/dist/sessions/pool-delta.js +28 -5
- package/dist/sessions/pool-due.d.ts +43 -0
- package/dist/sessions/pool-due.js +113 -0
- package/dist/sessions/pool-nightly.d.ts +9 -1
- package/dist/sessions/pool-nightly.js +65 -21
- package/dist/sessions/pool-outbox.d.ts +51 -5
- package/dist/sessions/pool-outbox.js +217 -24
- package/dist/sessions/pool-print.d.ts +1 -1
- package/dist/sessions/pool-raw.js +4 -3
- package/dist/sessions/pool-replica.d.ts +4 -4
- package/dist/sessions/pool-replica.js +26 -7
- package/dist/sessions/pool-send-lock.js +1 -1
- package/dist/sessions/pool-sent.d.ts +2 -0
- package/dist/sessions/pool-sent.js +21 -4
- package/dist/sessions/pool-wire.d.ts +172 -12
- package/dist/sessions/pool-wire.js +284 -30
- package/dist/sessions/pool.js +4 -2
- package/dist/sessions/public-vocabulary.d.ts +5 -0
- package/dist/sessions/public-vocabulary.js +16 -0
- package/dist/sessions/read-call-shape.js +12 -6
- package/dist/sessions/read-fit.d.ts +5 -0
- package/dist/sessions/read-fit.js +23 -2
- package/dist/sessions/read.d.ts +1 -1
- package/dist/sessions/recall-consequence.d.ts +2 -2
- package/dist/sessions/recall-consequence.js +14 -9
- package/dist/sessions/recall-object.d.ts +9 -2
- package/dist/sessions/receipt.d.ts +8 -0
- package/dist/sessions/receipt.js +78 -0
- package/dist/sessions/recovery-calls.d.ts +3 -2
- package/dist/sessions/recovery-calls.js +9 -3
- package/dist/sessions/redact.d.ts +2 -2
- package/dist/sessions/redact.js +116 -14
- package/dist/sessions/reversion.d.ts +8 -0
- package/dist/sessions/reversion.js +114 -0
- package/dist/sessions/schema.d.ts +78 -4
- package/dist/sessions/schema.js +31 -1
- package/dist/sessions/session-shape.d.ts +2 -1
- package/dist/sessions/session-shape.js +15 -0
- package/dist/sessions/sources.d.ts +9 -0
- package/dist/sessions/sources.js +16 -5
- package/dist/sessions/steps.js +3 -2
- package/dist/sessions/store.d.ts +41 -2
- package/dist/sessions/store.js +68 -16
- package/dist/sessions/try-demo.d.ts +8 -0
- package/dist/sessions/try-demo.js +90 -0
- package/dist/sessions/try.d.ts +13 -0
- package/dist/sessions/try.js +50 -0
- package/dist/sessions/vscode-copilot.d.ts +4 -0
- package/dist/sessions/vscode-copilot.js +25 -18
- package/dist/telemetry/command.js +12 -6
- package/dist/telemetry/consent.d.ts +9 -3
- package/dist/telemetry/consent.js +25 -9
- package/dist/telemetry/fields.d.ts +11 -0
- package/dist/telemetry/fields.js +56 -0
- package/dist/telemetry/measures.d.ts +13 -0
- package/dist/telemetry/measures.js +30 -0
- package/dist/telemetry/observe.d.ts +15 -2
- package/dist/telemetry/observe.js +34 -7
- package/dist/telemetry/region.d.ts +6 -0
- package/dist/telemetry/region.js +28 -0
- package/dist/telemetry/report.js +1 -1
- package/dist/telemetry/schema.d.ts +3 -0
- package/dist/telemetry/schema.js +11 -1
- package/dist/telemetry/send.d.ts +5 -2
- package/dist/telemetry/send.js +15 -7
- package/dist/telemetry/words.d.ts +6 -0
- package/dist/telemetry/words.js +6 -0
- package/package.json +23 -4
package/dist/sessions/extract.js
CHANGED
|
@@ -7,8 +7,8 @@ import { posix } from 'node:path';
|
|
|
7
7
|
import { ACTION_TABLES, actionOf, shellOf } from './actions.js';
|
|
8
8
|
import { exploreCount, strataOf } from './strata.js';
|
|
9
9
|
import { closedFrame, frameIdOf, repoFilesFromIndex, repoSizeOf, stackOf, utcDay } from './frame.js';
|
|
10
|
-
import { argClassOf, targetClassOf } from './call-shape.js';
|
|
11
|
-
import { LITERAL } from './public-vocabulary.js';
|
|
10
|
+
import { argClassOf, argFeaturesOf, argNamesOf, argValuesOf, changeKindOf, diffShapeOf, targetClassOf, templatesOf, variableOf } from './call-shape.js';
|
|
11
|
+
import { CLOSED_ARG_VALUES, LITERAL } from './public-vocabulary.js';
|
|
12
12
|
import { linkSteps } from './graph.js';
|
|
13
13
|
import { callIdentity, shellIdentity, UNREAD_SHELL } from './identity.js';
|
|
14
14
|
import { manifestDelta, manifestFileOf, manifestNames, packagesOf } from './package-resolve.js';
|
|
@@ -94,6 +94,23 @@ export const isErrorResult = (block, text) => {
|
|
|
94
94
|
const exit = EXIT_RE.exec(text.trim());
|
|
95
95
|
return exit !== null && exit[1] !== '0';
|
|
96
96
|
};
|
|
97
|
+
/** The first 32 hex digits of the SHA-256 of a text's exact UTF-8 bytes: plain, not keyed, so the same text matches across sessions and accounts, and a line ending or a trailing space is a difference. */
|
|
98
|
+
const textDigest = (text) => createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 32);
|
|
99
|
+
/**
|
|
100
|
+
* The digests of an edit call's text, from the input: an Edit has one old and new pair, a MultiEdit one per edit in order, a Write a new digest only (its old text is not in the call, so no old digest is held, not the digest of an
|
|
101
|
+
* empty string). Any other tool, and an edit whose input holds no text for the side it needs, has none: a malformed entry of a MultiEdit is skipped.
|
|
102
|
+
*/
|
|
103
|
+
const editDigestsOf = (tool, input) => {
|
|
104
|
+
if (tool === 'Write') {
|
|
105
|
+
const content = str(input.content);
|
|
106
|
+
return content === undefined ? [] : [{ new: textDigest(content) }];
|
|
107
|
+
}
|
|
108
|
+
const edits = tool === 'MultiEdit' && Array.isArray(input.edits) ? input.edits.filter(isObj) : tool === 'Edit' ? [input] : [];
|
|
109
|
+
return edits.flatMap((edit) => {
|
|
110
|
+
const [old, replacement] = [str(edit.old_string), str(edit.new_string)];
|
|
111
|
+
return old === undefined || replacement === undefined ? [] : [{ old: textDigest(old), new: textDigest(replacement) }];
|
|
112
|
+
});
|
|
113
|
+
};
|
|
97
114
|
/** What an Edit, MultiEdit, or Write shows of a manifest: the names it adds and removes (an edit), or lists (a write). Undefined when it shows none. */
|
|
98
115
|
const manifestOfInput = (file, tool, input, redact) => {
|
|
99
116
|
if (tool === 'Write') {
|
|
@@ -222,6 +239,28 @@ const readUsage = (message) => {
|
|
|
222
239
|
observed: reported.filter(([, value]) => value !== undefined).map(([field]) => field),
|
|
223
240
|
};
|
|
224
241
|
};
|
|
242
|
+
/** The usage categories a line reported, as the fields of a `MessageUsage`; a category the source left out is not written. */
|
|
243
|
+
const reportedUsage = (line) => {
|
|
244
|
+
const fields = {};
|
|
245
|
+
for (const field of USAGE_FIELDS)
|
|
246
|
+
if (line.observed.includes(field))
|
|
247
|
+
fields[field] = line.usage[field];
|
|
248
|
+
return fields;
|
|
249
|
+
};
|
|
250
|
+
const planWindowOf = (raw) => {
|
|
251
|
+
if (!isObj(raw))
|
|
252
|
+
return undefined;
|
|
253
|
+
const usedPercent = num(raw.usedPercent);
|
|
254
|
+
const windowMinutes = num(raw.windowMinutes);
|
|
255
|
+
return usedPercent === undefined ? undefined : { usedPercent, ...(windowMinutes === undefined ? {} : { windowMinutes }) };
|
|
256
|
+
};
|
|
257
|
+
/** The plan share a Codex line carries (`rateLimits`, written by `rolloutLines`), or undefined when it carries none. */
|
|
258
|
+
const planOf = (line) => {
|
|
259
|
+
const limits = isObj(line.rateLimits) ? line.rateLimits : undefined;
|
|
260
|
+
const primary = planWindowOf(limits?.primary);
|
|
261
|
+
const secondary = planWindowOf(limits?.secondary);
|
|
262
|
+
return primary === undefined && secondary === undefined ? undefined : { ...(primary === undefined ? {} : { primary }), ...(secondary === undefined ? {} : { secondary }) };
|
|
263
|
+
};
|
|
225
264
|
const toSeconds = (timestamp) => {
|
|
226
265
|
if (timestamp === undefined)
|
|
227
266
|
return undefined;
|
|
@@ -289,7 +328,7 @@ const hasToolResult = (content) => Array.isArray(content) && content.some((block
|
|
|
289
328
|
const hasImage = (content) => Array.isArray(content) && content.some((block) => isObj(block) && block.type === 'image');
|
|
290
329
|
// The tags a local command (a slash command the harness ran itself) leaves as user lines. A model's answer to a command makes it a prompt, and that case never reaches
|
|
291
330
|
// the run: a tag listed here that is still on a line there was not answered.
|
|
292
|
-
const LOCAL_COMMAND_TAGS = new Set(['command-name', 'command-message', 'local-command-stdout', 'local-command-stderr', 'local-command-caveat']);
|
|
331
|
+
export const LOCAL_COMMAND_TAGS = new Set(['command-name', 'command-message', 'local-command-stdout', 'local-command-stderr', 'local-command-caveat']);
|
|
293
332
|
const firstTextBlock = (content) => {
|
|
294
333
|
if (typeof content === 'string')
|
|
295
334
|
return content;
|
|
@@ -545,22 +584,106 @@ const VERSIONED = /^(@?[^@:\s]+)@(?!\d+\.\d+\.\d+\.\d+$)(v?\d+(?:\.(?:\d+|x|\*))
|
|
|
545
584
|
const redactCommand = (command, redact) => redact(command.replace(/[^\s"'`]+/g, (word) => word.replace(VERSIONED, `$1${VERSION_AT}$2`))).replaceAll(VERSION_AT, '@');
|
|
546
585
|
const MASK = /^<(?:secret|email|org-domain)>/;
|
|
547
586
|
const ONLY_MASKS = /^(?:<(?:secret|email|org-domain)>)+$/;
|
|
548
|
-
|
|
587
|
+
/** Where the single-quoted text that opens at `open` ends: the closing quote, or the end of the text. Bash's ANSI-C string (`$'it\'s'`) honors a backslash, so its `\'` is inside it; any other single-quoted text ends at the next `'`. */
|
|
588
|
+
const singleQuotedEnd = (text, open, ansiC = true) => {
|
|
589
|
+
const ansi = ansiC && text.charAt(open - 1) === '$';
|
|
590
|
+
let end = open + 1;
|
|
591
|
+
while (end < text.length && text.charAt(end) !== "'")
|
|
592
|
+
end += ansi && text.charAt(end) === '\\' ? 2 : 1;
|
|
593
|
+
return Math.min(end, text.length);
|
|
594
|
+
};
|
|
595
|
+
/** What closes each opener that can hold quotes of its own inside a double-quoted piece: a command substitution (`$(`, a backtick) or a parameter expansion (`${x:-"a b"}`). */
|
|
596
|
+
const CLOSERS = new Map([
|
|
597
|
+
['$(', ')'],
|
|
598
|
+
['${', '}'],
|
|
599
|
+
['`', '`'],
|
|
600
|
+
]);
|
|
601
|
+
/**
|
|
602
|
+
* Where the substitution or expansion that began just before `from`, closed by `first`, ends: past its closing character, or -1 when it never closes (PowerShell's backtick is an escape, not an opener, and a torn command has no end).
|
|
603
|
+
* Whatever quoting it holds is its own: a quote inside it never ends the double-quoted piece that carries it, so a quote cannot move the reader's state. An explicit stack keeps a crafted command that nests without end from
|
|
604
|
+
* overflowing the call stack, and `budget` (characters left to scan across one read) keeps one that opens thousands of them from costing a scan to the end of the text each.
|
|
605
|
+
*/
|
|
606
|
+
const substitutionEnd = (text, from, first, budget, powershell) => {
|
|
607
|
+
const stack = [first];
|
|
608
|
+
let at = from;
|
|
609
|
+
// PowerShell's escape is the backtick, and a backslash is an ordinary character there.
|
|
610
|
+
const escape = powershell ? '`' : '\\';
|
|
611
|
+
// Inside a double-quoted piece or an expansion a single quote and a bare parenthesis are text; inside a substitution they quote and group.
|
|
612
|
+
while (at < text.length && stack.length > 0 && budget.left > 0) {
|
|
613
|
+
budget.left -= 1;
|
|
614
|
+
const char = text.charAt(at);
|
|
615
|
+
const top = stack.at(-1);
|
|
616
|
+
const opener = CLOSERS.get(char === '$' ? char + text.charAt(at + 1) : char);
|
|
617
|
+
at += 1;
|
|
618
|
+
if (char === escape)
|
|
619
|
+
at += 1;
|
|
620
|
+
else if (char === top)
|
|
621
|
+
stack.pop();
|
|
622
|
+
else if (opener !== undefined) {
|
|
623
|
+
stack.push(opener);
|
|
624
|
+
at += char === '$' ? 1 : 0;
|
|
625
|
+
}
|
|
626
|
+
else if (char === '"')
|
|
627
|
+
stack.push('"');
|
|
628
|
+
else if (char === "'" && (top === ')' || top === '`'))
|
|
629
|
+
at = singleQuotedEnd(text, at - 1, !powershell) + 1;
|
|
630
|
+
else if (char === '(' && (top === ')' || top === '`'))
|
|
631
|
+
stack.push(')');
|
|
632
|
+
}
|
|
633
|
+
return stack.length === 0 ? at : -1;
|
|
634
|
+
};
|
|
635
|
+
/** What an unquoted word holds before its first quote when it is an assignment or a joined long flag: `API_REGION=` and `--region=`, with whatever unquoted text follows the `=`. */
|
|
636
|
+
const JOINED_PREFIX = /^(?:[A-Za-z_][A-Za-z0-9_]*|--[A-Za-z][A-Za-z0-9_-]*)=/;
|
|
637
|
+
/**
|
|
638
|
+
* The command's opaque point: its first single quote, heredoc marker, typographic quote (U+2018 to U+201E), block comment opener (`<#`), or stop-parsing token (`--%`). An unexpanded `$name` can only come from literal text, and
|
|
639
|
+
* these are where literal text begins in either shell, so a word that starts before this point is read as the shell reads it, and every word at or after it is opaque: it holds no variable and no flag value, whatever the reader made
|
|
640
|
+
* of the quotes before it. The point does not depend on the dialect, since a harness may run a PowerShell command and not say so (Codex, a nested `pwsh -c`); a PowerShell form in a POSIX command is rare, and the point only sends less.
|
|
641
|
+
*/
|
|
642
|
+
const OPAQUE_POINT = /['‘-„]|<<|<#|--%/;
|
|
643
|
+
/**
|
|
644
|
+
* `opaqueFrom` is where that point sits in `text` (-1 for none, 0 for a text that is opaque throughout, as a word an opaque word holds is); left out, it is the first point of the text. A substitution opened inside double quotes
|
|
645
|
+
* lowers it to where it opens, since its end is read by a scan that can close early: every word that starts after it is opaque, as is every word that starts after an unquoted backtick or an escaped double quote, which count only in a POSIX shell (PowerShell's backtick is its escape).
|
|
646
|
+
*/
|
|
647
|
+
const tokenize = (text, opaqueFrom, dialect = 'posix') => {
|
|
648
|
+
let opaque = opaqueFrom ?? text.search(OPAQUE_POINT);
|
|
549
649
|
const tokens = [];
|
|
650
|
+
// PowerShell's escape is the backtick, a backslash is an ordinary character in and out of quotes, `$'` is no ANSI-C string, and a backtick opens no substitution.
|
|
651
|
+
const powershell = dialect === 'powershell';
|
|
652
|
+
const escape = powershell ? '`' : '\\';
|
|
550
653
|
let word = '';
|
|
551
654
|
let quoted = false;
|
|
655
|
+
// The word's raw source starts with an unquoted `NAME=` or `--flag=` before its first quote: the prefix is as written, whatever the quote makes of the rest.
|
|
656
|
+
let joined = false;
|
|
552
657
|
let started = false;
|
|
553
658
|
let redirected = false;
|
|
659
|
+
let at = 0;
|
|
660
|
+
// Where the word's raw source starts in `text`: a word that is exactly one variable reference carries that variable, and any other word carries none, whatever its quotes and `$` marks hold.
|
|
661
|
+
let start = -1;
|
|
662
|
+
const budget = { left: 16 * text.length + 1024 };
|
|
554
663
|
const flush = () => {
|
|
555
664
|
if (started && redirected)
|
|
556
665
|
redirected = false;
|
|
557
666
|
else if (started && !quoted && (word === '{' || word === '}'))
|
|
558
667
|
tokens.push(word === '{' ? 'open' : 'close');
|
|
559
|
-
else if (started)
|
|
560
|
-
|
|
668
|
+
else if (started) {
|
|
669
|
+
const held = { text: word, quoted };
|
|
670
|
+
if (opaque >= 0 && start >= opaque)
|
|
671
|
+
held.opaque = true;
|
|
672
|
+
else {
|
|
673
|
+
const variable = variableOf(text.slice(start, at));
|
|
674
|
+
if (variable !== undefined)
|
|
675
|
+
held.variable = variable;
|
|
676
|
+
// A single quote or a substitution after the `=` is an opaque point inside the word (the value is literal text there), so only a plain double-quoted value is read.
|
|
677
|
+
if (joined && (opaque < 0 || opaque >= at))
|
|
678
|
+
held.joined = true;
|
|
679
|
+
}
|
|
680
|
+
tokens.push(held);
|
|
681
|
+
}
|
|
561
682
|
word = '';
|
|
562
683
|
quoted = false;
|
|
684
|
+
joined = false;
|
|
563
685
|
started = false;
|
|
686
|
+
start = -1;
|
|
564
687
|
};
|
|
565
688
|
const op = (token) => {
|
|
566
689
|
flush();
|
|
@@ -571,13 +694,17 @@ const tokenize = (text) => {
|
|
|
571
694
|
return;
|
|
572
695
|
tokens.push(token);
|
|
573
696
|
};
|
|
574
|
-
let at = 0;
|
|
575
697
|
while (at < text.length) {
|
|
576
698
|
const char = text.charAt(at);
|
|
577
699
|
const next = text.charAt(at + 1);
|
|
700
|
+
const here = at;
|
|
701
|
+
if ((char === "'" || char === '"') && !quoted && JOINED_PREFIX.test(word))
|
|
702
|
+
joined = true;
|
|
703
|
+
// An unquoted backtick opens a substitution whose end the reader does not scan for: an escaped quote inside it reads as a bare one, so every word that starts after it is opaque, as after a substitution inside double quotes.
|
|
704
|
+
if (!powershell && char === '`')
|
|
705
|
+
opaque = opaque < 0 ? at : Math.min(opaque, at);
|
|
578
706
|
if (char === "'") {
|
|
579
|
-
const
|
|
580
|
-
const stop = end < 0 ? text.length : end;
|
|
707
|
+
const stop = singleQuotedEnd(text, at, !powershell);
|
|
581
708
|
word += text.slice(at + 1, stop);
|
|
582
709
|
quoted = true;
|
|
583
710
|
started = true;
|
|
@@ -588,23 +715,49 @@ const tokenize = (text) => {
|
|
|
588
715
|
started = true;
|
|
589
716
|
at += 1;
|
|
590
717
|
while (at < text.length && text.charAt(at) !== '"') {
|
|
591
|
-
|
|
718
|
+
const inner = text.charAt(at);
|
|
719
|
+
const after = text.charAt(at + 1);
|
|
720
|
+
const opener = inner === '$' ? inner + after : inner;
|
|
721
|
+
const closer = powershell && inner === '`' ? undefined : CLOSERS.get(opener);
|
|
722
|
+
if (closer === ')' || closer === '`')
|
|
723
|
+
opaque = opaque < 0 ? at : Math.min(opaque, at);
|
|
724
|
+
const end = closer === undefined ? -1 : substitutionEnd(text, at + opener.length, closer, budget, powershell);
|
|
725
|
+
if (inner === escape && (after === escape || after === '$' || after === '`')) {
|
|
726
|
+
// A backslash pair is one escaped backslash, so the second never escapes the quote after it; an escaped dollar or backtick opens nothing.
|
|
727
|
+
word += inner + after;
|
|
592
728
|
at += 2;
|
|
593
|
-
|
|
729
|
+
}
|
|
730
|
+
else if (inner === escape && after === '\n') {
|
|
731
|
+
at += 2;
|
|
732
|
+
}
|
|
733
|
+
else if (inner === escape && after === '"') {
|
|
734
|
+
// POSIX reads the escaped quote as data, but the same text under PowerShell (a Codex call is stored as `Bash` whatever shell ran it) ends the string there, so every word after it may be read with the quote state inverted.
|
|
735
|
+
if (!powershell)
|
|
736
|
+
opaque = opaque < 0 ? at : Math.min(opaque, at);
|
|
594
737
|
word += '"';
|
|
595
738
|
at += 2;
|
|
596
739
|
}
|
|
740
|
+
else if (end >= 0) {
|
|
741
|
+
// A command substitution stays whole in this word: a quote inside it is its own, and never closes this piece. One that never closes is read as the text it is.
|
|
742
|
+
word += text.slice(at, end);
|
|
743
|
+
at = end;
|
|
744
|
+
}
|
|
597
745
|
else {
|
|
598
|
-
word +=
|
|
746
|
+
word += inner;
|
|
599
747
|
at += 1;
|
|
600
748
|
}
|
|
601
749
|
}
|
|
602
750
|
at += 1;
|
|
603
751
|
}
|
|
604
|
-
else if (
|
|
752
|
+
else if (!powershell && char === '\\' && next === '\\') {
|
|
753
|
+
word += '\\\\';
|
|
754
|
+
started = true;
|
|
755
|
+
at += 2;
|
|
756
|
+
}
|
|
757
|
+
else if ((char === escape || char === '`') && next === '\n') {
|
|
605
758
|
at += 2;
|
|
606
759
|
}
|
|
607
|
-
else if (char ===
|
|
760
|
+
else if (char === escape && next !== '' && (powershell || /[\s&|;()<>'"]/.test(next))) {
|
|
608
761
|
word += next;
|
|
609
762
|
started = true;
|
|
610
763
|
at += 2;
|
|
@@ -668,6 +821,8 @@ const tokenize = (text) => {
|
|
|
668
821
|
started = true;
|
|
669
822
|
at += 1;
|
|
670
823
|
}
|
|
824
|
+
if (started && start < 0)
|
|
825
|
+
start = here;
|
|
671
826
|
}
|
|
672
827
|
flush();
|
|
673
828
|
return tokens;
|
|
@@ -701,7 +856,7 @@ const afterPrelude = (words) => {
|
|
|
701
856
|
const word = words[at];
|
|
702
857
|
if (word === undefined)
|
|
703
858
|
return [];
|
|
704
|
-
if (word.quoted)
|
|
859
|
+
if (word.quoted && word.joined !== true)
|
|
705
860
|
return words.slice(at);
|
|
706
861
|
const name = programOf(word) ?? word.text;
|
|
707
862
|
if (BLOCK_WORDS.has(word.text.toLowerCase()) || ASSIGNMENT.test(word.text))
|
|
@@ -749,7 +904,8 @@ const shellArgument = (program, args) => {
|
|
|
749
904
|
const first = flag < 0 ? undefined : args[flag + 1];
|
|
750
905
|
if (first === undefined)
|
|
751
906
|
return null;
|
|
752
|
-
|
|
907
|
+
// The argument's own text is read for its own first quote or marker, unless the word is opaque already: then nothing in it is read.
|
|
908
|
+
return JOINING_SHELLS.has(program) && !first.quoted ? args.slice(flag + 1) : tokenize(first.text, first.opaque === true ? 0 : undefined);
|
|
753
909
|
};
|
|
754
910
|
/** How many shells deep a command is read. Real commands nest two or three; the cap keeps a torn or hostile one from exhausting the stack. */
|
|
755
911
|
const SHELL_DEPTH_MAX = 8;
|
|
@@ -769,10 +925,11 @@ const readCommands = (tokens, depth = 0) => {
|
|
|
769
925
|
const owned = new Map();
|
|
770
926
|
const close = () => {
|
|
771
927
|
const real = afterPrelude(words);
|
|
928
|
+
const before = words.slice(0, words.length - real.length);
|
|
772
929
|
words = [];
|
|
773
930
|
const [head, ...args] = real;
|
|
774
931
|
const program = head === undefined ? null : programOf(head);
|
|
775
|
-
if (program === null)
|
|
932
|
+
if (program === null || head === undefined)
|
|
776
933
|
return;
|
|
777
934
|
const previous = commands.at(-1);
|
|
778
935
|
if (previous !== undefined)
|
|
@@ -791,7 +948,7 @@ const readCommands = (tokens, depth = 0) => {
|
|
|
791
948
|
});
|
|
792
949
|
owned.clear();
|
|
793
950
|
pending = [];
|
|
794
|
-
commands.push({ program, args, read: true, link: 'end', hidden: false });
|
|
951
|
+
commands.push({ program, args, before, head, read: true, link: 'end', hidden: false });
|
|
795
952
|
};
|
|
796
953
|
for (const token of tokens) {
|
|
797
954
|
if (typeof token === 'string') {
|
|
@@ -1085,17 +1242,73 @@ const isReadBack = (commands, from, scope) => {
|
|
|
1085
1242
|
}
|
|
1086
1243
|
return false;
|
|
1087
1244
|
};
|
|
1245
|
+
const RESTORE_VALUE_FLAGS = new Set(['-s', '--source', '--pathspec-from-file']);
|
|
1246
|
+
const STAGED_FLAG = /^(?:--staged|-[a-zA-Z]*S[a-zA-Z]*)$/;
|
|
1247
|
+
const WORKTREE_FLAG = /^(?:--worktree|-[a-zA-Z]*W[a-zA-Z]*)$/;
|
|
1248
|
+
const PATCH_FLAG = /^(?:--patch|-[a-zA-Z]*p[a-zA-Z]*)$/;
|
|
1249
|
+
/** The words a `git restore` or `git checkout --` takes as paths to the working file. `git restore --staged` alone restores the index, and `--patch` restores part of a file, so neither names a whole file; `git checkout` with no `--` may name a branch. */
|
|
1250
|
+
const restoredBy = (git) => {
|
|
1251
|
+
const words = git.args;
|
|
1252
|
+
if (git.sub === 'checkout') {
|
|
1253
|
+
const dashes = words.findIndex((word) => !word.quoted && word.text === '--');
|
|
1254
|
+
// `git checkout --patch` takes part of a file, as `git restore --patch` does, so it names no whole file.
|
|
1255
|
+
if (dashes < 0 || words.slice(0, dashes).some((word) => !word.quoted && PATCH_FLAG.test(word.text)))
|
|
1256
|
+
return [];
|
|
1257
|
+
return words.slice(dashes + 1).map((word) => word.text);
|
|
1258
|
+
}
|
|
1259
|
+
if (git.sub !== 'restore')
|
|
1260
|
+
return [];
|
|
1261
|
+
const paths = [];
|
|
1262
|
+
let staged = false;
|
|
1263
|
+
let worktree = false;
|
|
1264
|
+
for (let at = 0; at < words.length; at += 1) {
|
|
1265
|
+
const text = words[at]?.text ?? '';
|
|
1266
|
+
if (text === '--')
|
|
1267
|
+
return staged && !worktree ? [] : [...paths, ...words.slice(at + 1).map((word) => word.text)];
|
|
1268
|
+
if (PATCH_FLAG.test(text))
|
|
1269
|
+
return [];
|
|
1270
|
+
if (RESTORE_VALUE_FLAGS.has(text))
|
|
1271
|
+
at += 1;
|
|
1272
|
+
else if (text.startsWith('-')) {
|
|
1273
|
+
staged ||= STAGED_FLAG.test(text);
|
|
1274
|
+
worktree ||= WORKTREE_FLAG.test(text);
|
|
1275
|
+
}
|
|
1276
|
+
else
|
|
1277
|
+
paths.push(text);
|
|
1278
|
+
}
|
|
1279
|
+
return staged && !worktree ? [] : paths;
|
|
1280
|
+
};
|
|
1281
|
+
/**
|
|
1282
|
+
* The paths the call's `git restore` and `git checkout --` commands name, for a command whose status the call reports and that runs in the call's folder: after a `cd`, or under a repository flag (`-C`), a relative path means another folder.
|
|
1283
|
+
* A word that holds a variable is no path.
|
|
1284
|
+
*/
|
|
1285
|
+
const restoredPaths = (commands) => {
|
|
1286
|
+
const folderAt = commands.findIndex((command) => CHANGES_FOLDER.has(command.program));
|
|
1287
|
+
return (folderAt < 0 ? commands : commands.slice(0, folderAt)).flatMap((command) => {
|
|
1288
|
+
const git = command.read ? gitCall(command) : null;
|
|
1289
|
+
return git === null || git.scope !== '' ? [] : restoredBy(git).filter((path) => path !== '' && !path.includes('$'));
|
|
1290
|
+
});
|
|
1291
|
+
};
|
|
1088
1292
|
const commitPlanOf = (commands) => commands.flatMap((command, at) => {
|
|
1089
1293
|
const git = gitCall(command);
|
|
1090
1294
|
if (git === null || git.sub !== 'commit' || git.args.some((word) => !word.quoted && NOT_A_COMMIT.test(word.text)))
|
|
1091
1295
|
return [];
|
|
1092
1296
|
return [{ quiet: git.args.some((word) => !word.quoted && QUIET.test(word.text)), readBack: isReadBack(commands, at, git.scope) }];
|
|
1093
1297
|
});
|
|
1094
|
-
/** Every simple command of a shell call, in order, after the bodies are dropped and the command is redacted. `
|
|
1095
|
-
export const shellCommands = (command, redact
|
|
1298
|
+
/** Every simple command of a shell call, in order, after the bodies are dropped and the command is redacted. Capture passes the dialect of the tool; `gate/checks.ts:524` and `projection.ts:95` call it with the POSIX default. */
|
|
1299
|
+
export const shellCommands = (command, redact, dialect = 'posix') => {
|
|
1300
|
+
const whole = command.replace(/\r\n/g, '\n');
|
|
1301
|
+
const kept = dropBodies(whole);
|
|
1302
|
+
const text = redactCommand(kept, redact);
|
|
1303
|
+
// The command's opaque point (`tokenize`) is read on the whole command. Dropping a body or redacting can remove it and leave a later one in its place, and the words between would read as before it: when the first point
|
|
1304
|
+
// moved or a point is gone, the whole text is opaque.
|
|
1305
|
+
const points = (held) => held.match(new RegExp(OPAQUE_POINT.source, 'g'))?.length ?? 0;
|
|
1306
|
+
const moved = kept.search(OPAQUE_POINT) !== whole.search(OPAQUE_POINT) || points(text) !== points(kept);
|
|
1307
|
+
return readCommands(tokenize(text, moved ? 0 : undefined, dialect));
|
|
1308
|
+
};
|
|
1096
1309
|
/** A shell call's segments, pulls, and the commits it holds, from the command alone. The command is redacted before anything is kept. */
|
|
1097
|
-
export const readShell = (command, redact) => {
|
|
1098
|
-
const commands = shellCommands(command, redact);
|
|
1310
|
+
export const readShell = (command, redact, dialect = 'posix') => {
|
|
1311
|
+
const commands = shellCommands(command, redact, dialect);
|
|
1099
1312
|
const kept = commands.slice(-SEGMENTS_PER_CALL_MAX);
|
|
1100
1313
|
const pulls = new Map();
|
|
1101
1314
|
for (const { program, args } of commands) {
|
|
@@ -1103,10 +1316,12 @@ export const readShell = (command, redact) => {
|
|
|
1103
1316
|
pulls.set(JSON.stringify(pull), pull);
|
|
1104
1317
|
}
|
|
1105
1318
|
return {
|
|
1319
|
+
words: commands.map(({ before, head, args }) => ({ before, head, args })),
|
|
1106
1320
|
segments: kept.map((held) => ({ run: runOf(held), read: held.read })),
|
|
1107
1321
|
steps: kept.map((held) => ({ identity: shellIdentity(held.program, held.args, pullsOfCommand(held.program, held.args).filter(present), redact), read: held.read, run: runOf(held) })),
|
|
1108
1322
|
pulls: [...pulls.values()],
|
|
1109
1323
|
commitPlan: commitPlanOf(commands),
|
|
1324
|
+
restores: restoredPaths(commands),
|
|
1110
1325
|
};
|
|
1111
1326
|
};
|
|
1112
1327
|
const COMMIT_LINE = /^\[[^\]\n]* ([0-9a-f]{7,40})\]/gm;
|
|
@@ -1330,6 +1545,8 @@ export const captureSession = (input) => {
|
|
|
1330
1545
|
const skeletonLines = [];
|
|
1331
1546
|
const toolById = new Map();
|
|
1332
1547
|
const callById = new Map();
|
|
1548
|
+
// The last call of each tool in each prompt, so the next one states what changed. Its raw input is held only while this transcript is read.
|
|
1549
|
+
const lastCall = new Map();
|
|
1333
1550
|
// Packages named so far in this file, by package and version, so a call's `first` mark is true once per version.
|
|
1334
1551
|
const seenPackages = new Set();
|
|
1335
1552
|
// A Read of a manifest shows its names in the result, so the call waits here for it, by tool_use id.
|
|
@@ -1348,6 +1565,9 @@ export const captureSession = (input) => {
|
|
|
1348
1565
|
const callsPerResponse = new Map();
|
|
1349
1566
|
const outputPerResponse = new Map();
|
|
1350
1567
|
const seenResponses = new Set();
|
|
1568
|
+
/** The distinct messages that reported usage, by message key, and the tool-use ids each made (kept apart so a call on a line before the first usage line is still tied). */
|
|
1569
|
+
const messageGroups = new Map();
|
|
1570
|
+
const callsOfMessage = new Map();
|
|
1351
1571
|
let promptFingerprint = null;
|
|
1352
1572
|
let cur;
|
|
1353
1573
|
// The setup, funnel, and trust shape. A subagent's run is the parent's, so it has no run of its own alone.
|
|
@@ -1482,6 +1702,13 @@ export const captureSession = (input) => {
|
|
|
1482
1702
|
session.userTurns += 1;
|
|
1483
1703
|
}
|
|
1484
1704
|
}
|
|
1705
|
+
// A message is the unit usage belongs to: the same id on every content-block line is one message, and a replayed line is the same one again.
|
|
1706
|
+
// Only Claude Code and Codex report per-message counts; another harness's totals are not a message's, and a synthetic line is the harness speaking.
|
|
1707
|
+
const messageKey = (harness === 'claude-code' || harness === 'codex') && messageModel !== '<synthetic>' && type === 'assistant' && role === 'assistant' ? str(message.id) || str(o.requestId) || `line ${String(position)}` : undefined;
|
|
1708
|
+
if (messageKey !== undefined && lineUsage !== undefined && !messageGroups.has(messageKey)) {
|
|
1709
|
+
const plan = planOf(o);
|
|
1710
|
+
messageGroups.set(messageKey, { id: str(message.id) || str(o.requestId) || null, calls: [], ...(side ? { side: true } : {}), ...reportedUsage(lineUsage), ...(plan === undefined ? {} : { plan }) });
|
|
1711
|
+
}
|
|
1485
1712
|
if (type === 'assistant' && role === 'assistant') {
|
|
1486
1713
|
session.assistantTurns += 1;
|
|
1487
1714
|
if (!side && lineUsage !== undefined && firstOfResponse) {
|
|
@@ -1619,7 +1846,7 @@ export const captureSession = (input) => {
|
|
|
1619
1846
|
else if (SHELL.has(name))
|
|
1620
1847
|
session.bashCmds += 1;
|
|
1621
1848
|
const aimed = targetOf(name, toolInput, lineCwd, salt, redact, lineRoot);
|
|
1622
|
-
const shell = SHELL.has(name) || named.action === 'shell.exec' ? readShell(str(toolInput.command) ?? '', redact) : undefined;
|
|
1849
|
+
const shell = SHELL.has(name) || named.action === 'shell.exec' ? readShell(str(toolInput.command) ?? '', redact, shellName === 'powershell' ? 'powershell' : 'posix') : undefined;
|
|
1623
1850
|
for (const held of shell?.steps ?? [])
|
|
1624
1851
|
framePrograms.add(held.identity.program);
|
|
1625
1852
|
drafts.push(draftsOf(actions, named, native, shell, aimed.target, toolInput, redact));
|
|
@@ -1634,6 +1861,22 @@ export const captureSession = (input) => {
|
|
|
1634
1861
|
// Who made the call, from its own line: the model is not the prompt's majority, and a line inside a skill's run names the skill (the first line after the run does not).
|
|
1635
1862
|
const callModel = requested !== undefined && requested !== '' ? requested : realMessageModel;
|
|
1636
1863
|
const callSkill = truthy(o.attributionSkill) ? String(o.attributionSkill) : undefined;
|
|
1864
|
+
// Depth reads below the repository root, else the working folder, as the target's hash does; a relative value is taken from the working folder first.
|
|
1865
|
+
const argFeatures = argFeaturesOf(native, toolInput, lineRoot ?? lineCwd, lineCwd);
|
|
1866
|
+
const argValues = argValuesOf(native, toolInput, CLOSED_ARG_VALUES);
|
|
1867
|
+
// A name is stored as the line's text is: masked at capture, so a credential-shaped folder or file name never reaches the store. The mask has no name shape, so the wire still drops it and counts it.
|
|
1868
|
+
const names = argNamesOf(native, toolInput, lineRoot ?? lineCwd, lineCwd, shell?.words ?? [], CLOSED_ARG_VALUES, shell?.restores ?? []).map(({ kind, name }) => ({ kind, name: redact(name) }));
|
|
1869
|
+
const digests = editDigestsOf(name, toolInput);
|
|
1870
|
+
// A restored path is hashed as an edit's target is, so the two compare; the same call's folder and repository anchor a relative one.
|
|
1871
|
+
const restores = [...new Set((shell?.restores ?? []).map((path) => hashPath(path, lineCwd, lineRoot, salt).hash))];
|
|
1872
|
+
// A template keeps a program, a subcommand, and flags as written and every literal as a slot; its kept words are masked as a name is, and the wire gates each one again.
|
|
1873
|
+
const templates = templatesOf(shell?.words ?? [], redact);
|
|
1874
|
+
// An edit is stored as the shapes of its change from a closed list, read from the old and new text and never kept.
|
|
1875
|
+
const diffShape = diffShapeOf(name, toolInput);
|
|
1876
|
+
const compared = { input: toolInput, target: aimed.target, features: argFeatures, values: argValues };
|
|
1877
|
+
const lastKey = `${String(cur?.index ?? null)}\n${String(side)}\n${stored}`;
|
|
1878
|
+
const before = lastCall.get(lastKey);
|
|
1879
|
+
lastCall.set(lastKey, compared);
|
|
1637
1880
|
const call = {
|
|
1638
1881
|
kind: 'tool',
|
|
1639
1882
|
cv: CAPTURE_VERSION,
|
|
@@ -1652,6 +1895,14 @@ export const captureSession = (input) => {
|
|
|
1652
1895
|
// The class reads the key the target is hashed on (relative to the repository or the cwd), never the absolute path: the folders above a repository say nothing about the file.
|
|
1653
1896
|
targetClass: targetClassOf(aimed.key, aimed.base),
|
|
1654
1897
|
argClass: argClassOf(toolInput),
|
|
1898
|
+
argFeatures,
|
|
1899
|
+
...(argValues.length === 0 ? {} : { argValues }),
|
|
1900
|
+
...(names.length === 0 ? {} : { names }),
|
|
1901
|
+
...(digests.length === 0 ? {} : { digests }),
|
|
1902
|
+
...(restores.length === 0 ? {} : { restores }),
|
|
1903
|
+
...(templates.length === 0 ? {} : { templates }),
|
|
1904
|
+
...(diffShape.length === 0 ? {} : { diffShape }),
|
|
1905
|
+
...(before === undefined ? {} : { changeKind: changeKindOf(before, compared) }),
|
|
1655
1906
|
durationMs: null,
|
|
1656
1907
|
toolUseId: id ?? null,
|
|
1657
1908
|
harnessVersion: version ?? null,
|
|
@@ -1665,6 +1916,8 @@ export const captureSession = (input) => {
|
|
|
1665
1916
|
...(manifest === undefined ? {} : { manifest }),
|
|
1666
1917
|
};
|
|
1667
1918
|
tools.push(call);
|
|
1919
|
+
if (id !== undefined && messageKey !== undefined)
|
|
1920
|
+
callsOfMessage.set(messageKey, [...(callsOfMessage.get(messageKey) ?? []), id]);
|
|
1668
1921
|
if (id !== undefined)
|
|
1669
1922
|
callById.set(id, { call, at: lineSeconds, plan: shell?.commitPlan ?? [] });
|
|
1670
1923
|
}
|
|
@@ -1718,7 +1971,7 @@ export const captureSession = (input) => {
|
|
|
1718
1971
|
const kept = [...files].sort().slice(0, FILES_PER_SESSION_MAX);
|
|
1719
1972
|
session.files = kept;
|
|
1720
1973
|
session.fileHashes = [...new Set(kept.flatMap((path) => [...(fileHashes.get(path) ?? [])]))].sort();
|
|
1721
|
-
session.repo = sessionRoot === null ? null : sha256(`${salt}\nrepo:${identityOf(sessionRoot)}`).slice(0, 16);
|
|
1974
|
+
session.repo = sessionRoot === null ? null : sha256(`${input.repoSalt ?? salt}\nrepo:${identityOf(sessionRoot)}`).slice(0, 16);
|
|
1722
1975
|
session.urls = [...urls].sort();
|
|
1723
1976
|
session.searches = [...searches].sort();
|
|
1724
1977
|
const repoFiles = input.repoFiles ?? repoFilesFromIndex;
|
|
@@ -1769,6 +2022,10 @@ export const captureSession = (input) => {
|
|
|
1769
2022
|
frameId,
|
|
1770
2023
|
}));
|
|
1771
2024
|
}
|
|
2025
|
+
for (const [key, group] of messageGroups)
|
|
2026
|
+
group.calls = callsOfMessage.get(key) ?? [];
|
|
2027
|
+
if (messageGroups.size > 0)
|
|
2028
|
+
session.messageUsage = [...messageGroups.values()];
|
|
1772
2029
|
// The strata: the file's evidence once, and each prompt's own `explore` from one pass over the calls. The size is the one the frame holds, so a re-capture that carries `repoSize` keeps `repoFiles` too.
|
|
1773
2030
|
const packageNames = tools.flatMap((call) => [...(call.packages ?? []).map((pkg) => pkg.package), ...(call.manifest?.seen ?? []), ...(call.manifest?.added ?? [])]);
|
|
1774
2031
|
const fileStrata = strataOf({ paths: framePaths, packages: packageNames, size });
|
|
@@ -23,6 +23,8 @@ export interface GeminiStore {
|
|
|
23
23
|
transcript: (session: GeminiSession) => Promise<string>;
|
|
24
24
|
/** Sessions skipped because their id is not a safe file name (it names a skeleton), counted so the skip is never silent. */
|
|
25
25
|
rejected: number;
|
|
26
|
+
/** Files dropped as holding nothing that held a record of a shape the reader does not know (a renamed message type or content key). Read only by `sessions verify`; selection is unchanged. */
|
|
27
|
+
shapeMoved: number;
|
|
26
28
|
/** Items that could not be inspected, reported once each and never fatal; the reason names no path. */
|
|
27
29
|
unreadable: {
|
|
28
30
|
file: string;
|
package/dist/sessions/gemini.js
CHANGED
|
@@ -474,6 +474,28 @@ const holdsNothing = (text, id) => {
|
|
|
474
474
|
return false;
|
|
475
475
|
}
|
|
476
476
|
};
|
|
477
|
+
/** The message types the CLI writes (chatRecordingService.ts): a prompt, a response, and the notices that carry no turn. */
|
|
478
|
+
const KNOWN_MESSAGE_TYPES = new Set(['user', 'gemini', 'info', 'warning', 'error']);
|
|
479
|
+
/** True when a prompt carries parts and none of them is a `text`, a `functionResponse`, or an `inlineData` or `fileData` (an image-only prompt): the parts' key moved. */
|
|
480
|
+
const holdsUnreadParts = (message) => {
|
|
481
|
+
const parts = partsOf(message.content);
|
|
482
|
+
return message.type === 'user' && parts.length > 0 && !parts.some((part) => typeof part.text === 'string' || 'functionResponse' in part || 'inlineData' in part || 'fileData' in part);
|
|
483
|
+
};
|
|
484
|
+
/**
|
|
485
|
+
* True when a chat dropped as holding nothing held records in a shape the reader does not know, not a launch and quit. Four shapes: a folded message of a type outside the known ones,
|
|
486
|
+
* or a prompt or response with no `content`; a prompt whose parts carry no `text`, `functionResponse`, `inlineData`, or `fileData`; a line that is an object but not a head, `$set`, `$patch`, `$rewindTo`, or a
|
|
487
|
+
* message with a string `id` (`foldChat` discards it); and a legacy `.json` head with no `messages` list (a line-format head carries none).
|
|
488
|
+
*/
|
|
489
|
+
const holdsMovedShape = (text, file) => {
|
|
490
|
+
const whole = wholeObject(text);
|
|
491
|
+
if (whole !== undefined && extname(file) === '.json' && !Array.isArray(whole.messages))
|
|
492
|
+
return true;
|
|
493
|
+
const records = whole === undefined ? lineTexts(text).map(parse) : [whole];
|
|
494
|
+
const unplaced = records.some((record) => isObj(record) && str(record.sessionId) === undefined && str(record.id) === undefined && !isObj(record.$set) && !isObj(record.$patch) && typeof record.$rewindTo !== 'string');
|
|
495
|
+
if (unplaced)
|
|
496
|
+
return true;
|
|
497
|
+
return foldChat(text).messages.some((message) => typeof message.type !== 'string' || !KNOWN_MESSAGE_TYPES.has(message.type) || ((message.type === 'user' || message.type === 'gemini') && !('content' in message)) || holdsUnreadParts(message));
|
|
498
|
+
};
|
|
477
499
|
/** The project roots a runtime folder's registry knows (`projects.json`, config/projectRegistry.ts:15-22): its keys. A missing or torn registry names none. */
|
|
478
500
|
const registered = async (runtime) => {
|
|
479
501
|
const registry = parse(await readFile(join(runtime, 'projects.json'), 'utf8').catch(() => ''));
|
|
@@ -497,11 +519,12 @@ export const openGeminiStore = async (root) => {
|
|
|
497
519
|
if (bases.length === 0) {
|
|
498
520
|
if (unreadable.length === 0)
|
|
499
521
|
return undefined;
|
|
500
|
-
return { sessions: [], transcript: () => Promise.reject(new Error('no sessions')), rejected: 0, unreadable };
|
|
522
|
+
return { sessions: [], transcript: () => Promise.reject(new Error('no sessions')), rejected: 0, shapeMoved: 0, unreadable };
|
|
501
523
|
}
|
|
502
524
|
// A file is a candidate under its session id.
|
|
503
525
|
const found = new Map();
|
|
504
526
|
let rejected = 0;
|
|
527
|
+
let shapeMoved = 0;
|
|
505
528
|
const list = async (dir) => {
|
|
506
529
|
try {
|
|
507
530
|
return (await readdir(dir, { withFileTypes: true })).map((entry) => ({ name: entry.name, directory: entry.isDirectory() })).sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -530,8 +553,11 @@ export const openGeminiStore = async (root) => {
|
|
|
530
553
|
return;
|
|
531
554
|
}
|
|
532
555
|
// Every new chat appends a head (geminiChat.ts:453), so a launch and quit, or a /clear, leaves a file with nothing in it: no session, and not an unreadable one.
|
|
533
|
-
if (holdsNothing(text, named))
|
|
556
|
+
if (holdsNothing(text, named)) {
|
|
557
|
+
if (holdsMovedShape(text, file))
|
|
558
|
+
shapeMoved += 1;
|
|
534
559
|
return;
|
|
560
|
+
}
|
|
535
561
|
const session = { id, folder, file, size: info.size, mtimeMs: Math.floor(info.mtimeMs), parent };
|
|
536
562
|
// A `.json` file migrated to `.jsonl` can leave both, with one name; the newer one is the session. Files of other names under one id are other chats (below).
|
|
537
563
|
const key = `${id}/${name}`;
|
|
@@ -603,6 +629,7 @@ export const openGeminiStore = async (root) => {
|
|
|
603
629
|
return {
|
|
604
630
|
sessions,
|
|
605
631
|
rejected,
|
|
632
|
+
shapeMoved,
|
|
606
633
|
unreadable,
|
|
607
634
|
transcript: async (session) => {
|
|
608
635
|
const raw = await readFile(session.file, 'utf8');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type SessionsRuntime } from './store.js';
|
|
2
|
+
export interface FileCounts {
|
|
3
|
+
/** Files the reader selected and opened: `yielded + unreadable`, always. */
|
|
4
|
+
seen: number;
|
|
5
|
+
/** Seen files that gave at least one record (a user or assistant turn). */
|
|
6
|
+
yielded: number;
|
|
7
|
+
/** Seen files the reader could not read, or that gave no record from a form it does not know. */
|
|
8
|
+
unreadable: number;
|
|
9
|
+
}
|
|
10
|
+
/** What the docs and the tests quote: where each kind of file goes, so `seen` is the sum of the outcomes. */
|
|
11
|
+
export declare const FILE_COUNT_RULES: string;
|
|
12
|
+
export type FileOutcome = 'yielded' | 'unreadable' | 'empty';
|
|
13
|
+
/** `records` is undefined when the read itself failed; `headOnly` is the reader's word that a file with no turn holds only head records. */
|
|
14
|
+
export declare const outcomeOf: (file: {
|
|
15
|
+
size: number;
|
|
16
|
+
records: number | undefined;
|
|
17
|
+
headOnly?: boolean;
|
|
18
|
+
}) => FileOutcome;
|
|
19
|
+
export declare const tally: (outcomes: readonly FileOutcome[]) => FileCounts;
|
|
20
|
+
export interface HarnessFiles {
|
|
21
|
+
[harness: string]: FileCounts;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The counts for every harness the store reads that has files on this machine, and that the saved allowlist lets a deposit read. A harness with nothing
|
|
25
|
+
* to read has no entry. Read-only: it opens each store as the deposit does, reads each transcript, and writes nothing.
|
|
26
|
+
*/
|
|
27
|
+
export declare const harnessFileCounts: (runtime: SessionsRuntime) => Promise<HarnessFiles>;
|