@manyoars/oars 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -29
- package/dist/cli.d.ts +4 -1
- package/dist/cli.js +102 -32
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.js +1 -1
- package/dist/gate/hook-main.js +13 -1
- package/dist/gate/hook-process.d.ts +11 -1
- package/dist/gate/hook-process.js +50 -2
- package/dist/gate/hook-record.d.ts +3 -0
- package/dist/gate/hook-record.js +8 -0
- package/dist/gate/hook.d.ts +2 -0
- package/dist/gate/hook.js +3 -0
- package/dist/gate/replay.js +2 -5
- package/dist/gate/report.js +1 -1
- package/dist/gate/transcripts.js +1 -1
- package/dist/hook-bin.js +27 -2
- package/dist/issuer.d.ts +7 -9
- package/dist/issuer.js +10 -32
- package/dist/main.js +3 -0
- package/dist/sessions/actions.js +78 -0
- package/dist/sessions/allow.d.ts +10 -1
- package/dist/sessions/allow.js +29 -9
- package/dist/sessions/baseline.d.ts +1 -1
- package/dist/sessions/baseline.js +6 -4
- package/dist/sessions/call-shape.d.ts +19 -0
- package/dist/sessions/call-shape.js +85 -0
- package/dist/sessions/cell-increments.d.ts +5 -1
- package/dist/sessions/cell-increments.js +84 -50
- package/dist/sessions/cells.d.ts +33 -3
- package/dist/sessions/cells.js +89 -9
- package/dist/sessions/codex.js +93 -27
- package/dist/sessions/command.d.ts +11 -1
- package/dist/sessions/command.js +64 -18
- package/dist/sessions/coverage.d.ts +19 -0
- package/dist/sessions/coverage.js +56 -0
- package/dist/sessions/cursor.js +2 -2
- package/dist/sessions/derive.js +72 -6
- package/dist/sessions/exclusion.d.ts +6 -0
- package/dist/sessions/exclusion.js +8 -0
- package/dist/sessions/extract.d.ts +4 -2
- package/dist/sessions/extract.js +58 -26
- package/dist/sessions/hook-calls.js +1 -1
- package/dist/sessions/hook-index.d.ts +14 -1
- package/dist/sessions/hook-index.js +10 -3
- package/dist/sessions/hooks.d.ts +5 -1
- package/dist/sessions/hooks.js +133 -22
- package/dist/sessions/identity.d.ts +8 -3
- package/dist/sessions/identity.js +45 -15
- package/dist/sessions/install.d.ts +18 -2
- package/dist/sessions/install.js +53 -8
- package/dist/sessions/mcp.d.ts +3 -2
- package/dist/sessions/mcp.js +8 -4
- package/dist/sessions/motif.d.ts +2 -1
- package/dist/sessions/motif.js +2 -2
- package/dist/sessions/opencode.d.ts +33 -0
- package/dist/sessions/opencode.js +319 -0
- package/dist/sessions/otel.d.ts +60 -0
- package/dist/sessions/otel.js +494 -0
- package/dist/sessions/package-books.d.ts +48 -0
- package/dist/sessions/package-books.js +120 -0
- package/dist/sessions/package-resolve.d.ts +2 -0
- package/dist/sessions/package-resolve.js +3 -1
- package/dist/sessions/pi.d.ts +36 -0
- package/dist/sessions/pi.js +353 -0
- package/dist/sessions/pool-books.d.ts +21 -0
- package/dist/sessions/pool-books.js +75 -0
- package/dist/sessions/pool-choices.d.ts +5 -0
- package/dist/sessions/pool-choices.js +11 -0
- package/dist/sessions/pool-command.d.ts +76 -5
- package/dist/sessions/pool-command.js +263 -136
- package/dist/sessions/pool-nightly-deps.d.ts +3 -0
- package/dist/sessions/pool-nightly-deps.js +11 -0
- package/dist/sessions/pool-nightly.d.ts +10 -0
- package/dist/sessions/pool-nightly.js +85 -0
- package/dist/sessions/pool-outbox.d.ts +15 -2
- package/dist/sessions/pool-outbox.js +79 -26
- package/dist/sessions/pool-print.d.ts +1 -1
- package/dist/sessions/pool-raw.d.ts +27 -0
- package/dist/sessions/pool-raw.js +68 -0
- package/dist/sessions/pool-replica.d.ts +35 -0
- package/dist/sessions/pool-replica.js +91 -0
- package/dist/sessions/pool-send-lock.d.ts +20 -0
- package/dist/sessions/pool-send-lock.js +87 -0
- package/dist/sessions/pool-sent.d.ts +4 -0
- package/dist/sessions/pool-sent.js +26 -0
- package/dist/sessions/pool-totals.d.ts +10 -0
- package/dist/sessions/pool-totals.js +21 -0
- package/dist/sessions/pool-wire.d.ts +240 -30
- package/dist/sessions/pool-wire.js +695 -215
- package/dist/sessions/public-vocabulary.d.ts +2 -3
- package/dist/sessions/public-vocabulary.js +2 -9
- package/dist/sessions/query.d.ts +16 -4
- package/dist/sessions/query.js +32 -10
- package/dist/sessions/read-cost.js +14 -3
- package/dist/sessions/read-recovery-edge.d.ts +2 -0
- package/dist/sessions/read-recovery-edge.js +26 -0
- package/dist/sessions/read.d.ts +8 -1
- package/dist/sessions/read.js +6 -3
- package/dist/sessions/recall-bundle.d.ts +12 -0
- package/dist/sessions/recall-bundle.js +33 -0
- package/dist/sessions/recall-frames.d.ts +18 -0
- package/dist/sessions/recall-frames.js +44 -0
- package/dist/sessions/recall-next.d.ts +145 -0
- package/dist/sessions/recall-next.js +241 -0
- package/dist/sessions/recall-object.d.ts +2 -0
- package/dist/sessions/recall-object.js +1 -0
- package/dist/sessions/recall-push.d.ts +2 -2
- package/dist/sessions/recall-push.js +3 -51
- package/dist/sessions/recall-reads.d.ts +7 -0
- package/dist/sessions/recall-reads.js +7 -0
- package/dist/sessions/recovery-calls.d.ts +21 -0
- package/dist/sessions/recovery-calls.js +25 -0
- package/dist/sessions/redact.d.ts +11 -0
- package/dist/sessions/redact.js +59 -0
- package/dist/sessions/schema.d.ts +59 -9
- package/dist/sessions/schema.js +22 -3
- package/dist/sessions/session-shape.d.ts +6 -3
- package/dist/sessions/session-shape.js +19 -6
- package/dist/sessions/sources.js +75 -1
- package/dist/sessions/store.d.ts +13 -1
- package/dist/sessions/store.js +36 -16
- package/dist/sessions/survival.d.ts +2 -0
- package/dist/sessions/survival.js +2 -2
- package/dist/sessions/transcript-tail.d.ts +14 -0
- package/dist/sessions/transcript-tail.js +64 -0
- package/dist/sessions/vscode-copilot.js +24 -7
- package/dist/telemetry/command.d.ts +8 -0
- package/dist/telemetry/command.js +85 -0
- package/dist/telemetry/consent.d.ts +17 -0
- package/dist/telemetry/consent.js +63 -0
- package/dist/telemetry/crash.d.ts +8 -0
- package/dist/telemetry/crash.js +52 -0
- package/dist/telemetry/emit.d.ts +31 -0
- package/dist/telemetry/emit.js +42 -0
- package/dist/telemetry/fields.d.ts +16 -0
- package/dist/telemetry/fields.js +26 -0
- package/dist/telemetry/health.d.ts +21 -0
- package/dist/telemetry/health.js +108 -0
- package/dist/telemetry/hook-emit.d.ts +4 -0
- package/dist/telemetry/hook-emit.js +18 -0
- package/dist/telemetry/measures.d.ts +32 -0
- package/dist/telemetry/measures.js +128 -0
- package/dist/telemetry/observe.d.ts +13 -0
- package/dist/telemetry/observe.js +51 -0
- package/dist/telemetry/read-events.d.ts +9 -0
- package/dist/telemetry/read-events.js +17 -0
- package/dist/telemetry/record.d.ts +17 -0
- package/dist/telemetry/record.js +26 -0
- package/dist/telemetry/report.d.ts +77 -0
- package/dist/telemetry/report.js +188 -0
- package/dist/telemetry/schema.d.ts +40 -0
- package/dist/telemetry/schema.js +187 -0
- package/dist/telemetry/send-main.d.ts +1 -0
- package/dist/telemetry/send-main.js +11 -0
- package/dist/telemetry/send.d.ts +36 -0
- package/dist/telemetry/send.js +172 -0
- package/dist/telemetry/spool.d.ts +17 -0
- package/dist/telemetry/spool.js +70 -0
- package/dist/telemetry/wire.d.ts +25 -0
- package/dist/telemetry/wire.js +76 -0
- package/package.json +2 -2
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
// What crosses the wire between `oars pool` and the pool server (recall spec Sections 2.5, 4, and 5). A skeleton is one session
|
|
1
|
+
// What crosses the wire between `oars pool` and the pool server (recall spec Sections 2.5, 4, and 5). A skeleton is one session as a closed list of fields, each value redacted first: the records a cell is
|
|
2
2
|
// computed from, with every class A field left out, so any later derivation can run over old data while nothing private leaves. The client builds a skeleton and checks it before
|
|
3
3
|
// sending, and the server parses it again, field by field, because a live URL takes anything from anyone. This file imports nothing from the file system: the Worker bundles it.
|
|
4
4
|
import { ACTION_TABLES, CANONICAL_ACTIONS, mcpOf } from './actions.js';
|
|
5
|
-
import { CUSTOM_AGENT, FRAMEWORKS, isPublicMcpTool, isRegistryVersion, LANGUAGES, LITERAL, PRIVATE, publicEffort,
|
|
5
|
+
import { CUSTOM_AGENT, FRAMEWORKS, isPublicMcpTool, isRegistryVersion, LANGUAGES, LITERAL, PRIVATE, publicEffort, publicVersion, PUBLIC_PACKAGES, projectedAgentType, PUBLIC_FLAGS, REPO_SIZES, TOOL_INPUT_KEYS, VOCAB_VERSION } from './public-vocabulary.js';
|
|
6
|
+
import { ARG_CLASSES, TARGET_CLASSES } from './call-shape.js';
|
|
7
|
+
import { recoveryCallsOf, RECOVERY_CALLS } from './recovery-calls.js';
|
|
6
8
|
import { projectLanguage, projectLanguageShare } from './strata.js';
|
|
7
|
-
import { LANGUAGE_SHARES } from './schema.js';
|
|
8
|
-
|
|
9
|
+
import { ERROR_CODES, LANGUAGE_SHARES, PROMPT_INTERVENTIONS } from './schema.js';
|
|
10
|
+
// Format 3 (step 1 of the wire): a tool carries its `errorCode` and, for a failed call, what followed it (`recoveryCalls`). Formats 1 and 2 are refused by name; nothing is deployed, so nothing is migrated.
|
|
11
|
+
export const SKELETON_FORMAT = 3;
|
|
12
|
+
/**
|
|
13
|
+
* The version of the field list: every field name a batch can hold (`BATCH_FIELDS` in `pool-outbox.ts`). It moves with any field added, so a server told the version knows which names a batch may carry,
|
|
14
|
+
* and a test pins the list to it (`pool-wire-additive.test.ts`). A field is deprecated and left on the list, never removed (conclusion section 17, rule 3).
|
|
15
|
+
*/
|
|
16
|
+
export const FIELD_LIST_VERSION = 8;
|
|
9
17
|
export const BUNDLE_FORMAT = 1;
|
|
10
18
|
/** One constant: the pool serves only the vocabulary version the client holds. */
|
|
11
19
|
export { VOCAB_VERSION };
|
|
@@ -27,6 +35,26 @@ export const bucketOf = (kind, value) => EDGES[kind].filter((edge) => value >= e
|
|
|
27
35
|
export const projectExplore = (stored) => (stored !== undefined && Number.isSafeInteger(stored) && stored >= 0 ? bucketOf('calls', stored) : null);
|
|
28
36
|
/** The lowest value of a bucket, which is what a derivation counts a bucketed value as: it never reads above what the sender held. */
|
|
29
37
|
export const floorOf = (kind, bucket) => EDGES[kind][bucket - 1] ?? 0;
|
|
38
|
+
/** The closed list of how a step came about (`schema.ts` `StepEdge`). */
|
|
39
|
+
export const EDGE_KINDS = ['next', 'spawned', 'retried', 'after-error'];
|
|
40
|
+
/** How many people the operator's organization has (an operator's setting, read locally, never looked up). */
|
|
41
|
+
export const ORG_SIZES = ['solo', '2-10', '11-100', '101-1000', 'over-1000', 'unknown'];
|
|
42
|
+
/** Whether the repository's local git log shows one commit author or several (distinct authors, counted and then dropped). */
|
|
43
|
+
export const TEAMS = ['solo', 'team', 'unknown'];
|
|
44
|
+
/** Whether the repository is public or private (an operator's setting, read locally, never looked up). */
|
|
45
|
+
export const REPO_VISIBILITIES = ['public', 'private', 'unknown'];
|
|
46
|
+
export const NO_COVERAGE = { orgSize: 'unknown', team: 'unknown', repoVisibility: 'unknown' };
|
|
47
|
+
/** A repo key is the store-salted hash a session record holds of its repository's identity: sixteen lowercase hex characters. */
|
|
48
|
+
const REPO_KEY = /^[0-9a-f]{16}$/;
|
|
49
|
+
/** The UTC hour of day, 0 to 23, of a timestamp, whatever offset it was written in; null when there is none or it does not parse. */
|
|
50
|
+
export const hourOf = (ts) => {
|
|
51
|
+
if (ts === null || ts === undefined || ts === '')
|
|
52
|
+
return null;
|
|
53
|
+
const millis = Date.parse(ts);
|
|
54
|
+
return Number.isNaN(millis) ? null : new Date(millis).getUTCHours();
|
|
55
|
+
};
|
|
56
|
+
/** A usage category's `tokens` bucket, or null when the record says the source never recorded it. A record that predates `usageObserved` reads every category as observed. */
|
|
57
|
+
const observedBucket = (value, observed, field) => (observed === undefined || observed.includes(field) ? bucketOf('tokens', value) : null);
|
|
30
58
|
/** W's minimum for a timing print (deposit-weighting spec, layer 5): fewer timed calls than this say too little to tell sessions apart. */
|
|
31
59
|
export const MIN_TIMED_CALLS = 50;
|
|
32
60
|
/** A session has ended once no transcript of it has gained a line for this long (`session_idle_hours`, spec Section 7). */
|
|
@@ -60,65 +88,135 @@ export const sessionsOf = (store, roots) => {
|
|
|
60
88
|
// A subagent whose main transcript the store does not hold is in no group: it cannot be folded, and counting it alone would raise `n`.
|
|
61
89
|
return store.sessions.map((main) => ({ main, transcripts: [main, ...[...(subagents.get(main.session) ?? [])].sort((a, b) => (a.session < b.session ? -1 : 1))].map(recordsOf) }));
|
|
62
90
|
};
|
|
91
|
+
/** The most characters a name may hold on the wire. A longer value is not a name: it counts as redacted. */
|
|
92
|
+
export const NAME_MAX = 128;
|
|
93
|
+
// A name is a word: letters, digits, and a few joiners, with an optional leading `@` for a scope. A mis-parsed command line, a path, an email, or a quoted string fails it. A `/` is not a joiner here: a name holding one is a path (`src/acme/plan.md`).
|
|
94
|
+
const WIRE_NAME = /^@?[A-Za-z0-9_][A-Za-z0-9_.+-]*(?::[A-Za-z0-9_.+-]+)*$/;
|
|
95
|
+
// A pull's package `name` is the one place a `/` belongs: a scope (`@scope/pkg`) or a Go module path (`golang.org/x/tools`).
|
|
96
|
+
const WIRE_PULL_NAME = /^@?[A-Za-z0-9_][A-Za-z0-9_.+-]*(?:[/:][A-Za-z0-9_.+-]+)*$/;
|
|
97
|
+
/** Whether a value has the shape of a name (and so can be told from a command line, a path, or an email) and is within `NAME_MAX`. */
|
|
98
|
+
export const isWireName = (name) => name.length <= NAME_MAX && WIRE_NAME.test(name);
|
|
99
|
+
/** The shape of a pull's package name: a name, or a scope or module path with `/` in it. */
|
|
100
|
+
export const isWirePullName = (name) => name.length <= NAME_MAX && WIRE_PULL_NAME.test(name);
|
|
101
|
+
/** No redaction and no terms: the name shape and the length limit still apply. A local read, which sends nothing, uses it. */
|
|
102
|
+
export const OPEN_GATE = { redact: (text) => text, terms: [] };
|
|
103
|
+
/** The redactor leaves the name and each of its underscore-delimited parts as they are: `_` is a word character to it, so a host inside `mcp__git.acme.internal__run` is invisible to a pass over the whole name. */
|
|
104
|
+
const unchanged = (gate, name) => gate.redact(name) === name && name.split(/_+/).every((part) => gate.redact(part) === part);
|
|
105
|
+
/**
|
|
106
|
+
* A name as the wire holds it: the value itself, or `private` when it counts as redacted. A value counts as redacted when the redactor changes it, it holds an exclusion term, it fails the name shape, or it is over
|
|
107
|
+
* `NAME_MAX` characters, because a secret, an employer's name, or a mis-parsed command is not a name and nothing downstream can take it back. `keys` (t870) sends its names through this same function.
|
|
108
|
+
*/
|
|
109
|
+
export const wireName = (gate, name, hasShape = isWireName) => {
|
|
110
|
+
const lower = name.toLowerCase();
|
|
111
|
+
return hasShape(name) && unchanged(gate, name) && !gate.terms.some((term) => term !== '' && lower.includes(term.toLowerCase())) ? name : PRIVATE;
|
|
112
|
+
};
|
|
113
|
+
const FLAG_DASHES = /^-{1,2}/;
|
|
63
114
|
const SHORT_FLAG = /^-[A-Za-z]$/;
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
115
|
+
// The long form has its own shape, with no `:` (the joiner `WIRE_NAME` allows for `scope:name`), because `--password:hunter2` is a value joined to its flag and no part of it is a name.
|
|
116
|
+
const LONG_FLAG = /^--[A-Za-z0-9][A-Za-z0-9_.+-]*$/;
|
|
117
|
+
/**
|
|
118
|
+
* The shape of a flag name: a long flag (two dashes, then a name with no `:`) or a short one (one dash, one letter), or a single-dash token longer than one letter only when the public flag list holds it (`-NoProfile`, `-Command`). A value joined to its flag
|
|
119
|
+
* fails it: `--token=abc`, `--password:hunter2`, and a short flag with its value attached (`-psecret123`, `-uuser:pass`), which no list holds.
|
|
120
|
+
*/
|
|
121
|
+
const isWireFlag = (flag) => SHORT_FLAG.test(flag) || PUBLIC_FLAGS.has(flag) || (flag.length <= NAME_MAX && LONG_FLAG.test(flag));
|
|
122
|
+
/** A multi-letter single-dash flag no public list holds (`go test -race`, `-psecret123`): the client before t900 dropped it, so this one does too, and an old and a new client key one motif for one command. */
|
|
123
|
+
const isDroppedFlag = (flag) => flag.length > 2 && flag.startsWith('-') && !flag.startsWith('--') && !PUBLIC_FLAGS.has(flag);
|
|
124
|
+
/** A flag name as the wire holds it: the name after the one gate, or `private` when it counts as redacted. Only the name is ever read, never a value. */
|
|
125
|
+
const wireFlag = (gate, flag) => (isWireFlag(flag) && wireName(gate, flag.replace(FLAG_DASHES, '')) !== PRIVATE ? flag : PRIVATE);
|
|
126
|
+
/**
|
|
127
|
+
* A call's key names as the wire holds them (t870): names only, never a value. A public tool (one with a pinned input list) sends the names on its list and counts the rest; any other tool sends a name only when it passes the
|
|
128
|
+
* name gate, and a tool that went as `private` sends none, so a private tool's keys name no private work. `dropped` counts the distinct names not sent.
|
|
129
|
+
*/
|
|
130
|
+
const wireKeys = (stored, native, gate) => {
|
|
131
|
+
const distinct = [...new Set(stored)];
|
|
132
|
+
const listed = native === undefined ? undefined : TOOL_INPUT_KEYS.get(native);
|
|
133
|
+
const kept = native === undefined || native === PRIVATE ? [] : distinct.filter((name) => (listed === undefined ? wireName(gate, name) !== PRIVATE : listed.has(name)));
|
|
134
|
+
return { keys: kept.sort(), dropped: distinct.length - kept.length };
|
|
135
|
+
};
|
|
136
|
+
/** A stored closed-list member, else dropped: a hand-edited record never puts a value off the list on the wire. */
|
|
137
|
+
const memberOf = (list, value) => list.find((entry) => entry === value);
|
|
138
|
+
/** The basename of a program as capture read it: a path never goes on the wire. */
|
|
139
|
+
const basenameOf = (program) => program.replace(/^.*[\\/]/, '');
|
|
67
140
|
/**
|
|
68
|
-
* A projected command as the server's parser reads it
|
|
69
|
-
*
|
|
141
|
+
* A projected command as the server's parser reads it: the program as its basename after the gate (the stored identity reads `<literal>` for a program off the curated list, so the name comes from the call's segments),
|
|
142
|
+
* and the subcommand and flag names the reader gives, each through the same gate and never a flag value. Which of them the reader holds for a program is capture's; the wire adds no list of its own.
|
|
70
143
|
*/
|
|
71
|
-
const wireCommand = ({ program, subcommand, flags }) => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
144
|
+
const wireCommand = ({ program, subcommand, flags }, raw, gate) => {
|
|
145
|
+
const named = program !== PRIVATE ? program : raw;
|
|
146
|
+
if (named === undefined)
|
|
147
|
+
return { program: PRIVATE, sub: null, flags: [] };
|
|
148
|
+
const sent = wireName(gate, basenameOf(named));
|
|
149
|
+
// A private program holds no subcommand or flag: the parser refuses one that does.
|
|
150
|
+
if (sent === PRIVATE)
|
|
151
|
+
return { program: PRIVATE, sub: null, flags: [] };
|
|
152
|
+
return { program: sent, sub: subcommand === null ? null : wireName(gate, subcommand), flags: flags.filter((flag) => !isDroppedFlag(flag)).map((flag) => wireFlag(gate, flag)) };
|
|
153
|
+
};
|
|
154
|
+
/** A pull in the wire's spelling: its package name after the gate. A version beside a name that is not on the public list could identify it, so it goes as `<literal>` (null stays null), the rule capture applies. */
|
|
155
|
+
const wirePull = (pull, gate) => {
|
|
156
|
+
const name = wireName(gate, pull.name, isWirePullName);
|
|
157
|
+
return { ...pull, name, version: !PUBLIC_PACKAGES.has(name) && pull.version !== null ? LITERAL : pull.version };
|
|
158
|
+
};
|
|
159
|
+
/** A non-shell step's identity in the wire's spelling; a shell step's comes from `wireCommand`. `raw` is the harness's own tool name, class A, which an MCP call's masked identity is read back from. */
|
|
160
|
+
const wireIdentity = (identity, harness, raw, gate) => {
|
|
85
161
|
if (identity === null || 'program' in identity)
|
|
86
162
|
return identity;
|
|
87
163
|
if ('server' in identity) {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
164
|
+
const table = ACTION_TABLES[harness];
|
|
165
|
+
const parsed = mcpOf(table, raw);
|
|
166
|
+
const server = wireName(gate, identity.server === LITERAL ? (parsed?.server ?? LITERAL) : identity.server);
|
|
167
|
+
// A tool beside a masked server has no server to belong to, and a harness that does not delimit the server cannot split the tool from it: both go as the literal.
|
|
168
|
+
const name = identity.tool === LITERAL ? parsed?.tool : identity.tool;
|
|
169
|
+
const tool = server === PRIVATE || !table.toolDelimited || name === undefined ? PRIVATE : wireName(gate, name);
|
|
170
|
+
return { server, tool: tool === PRIVATE ? LITERAL : tool };
|
|
93
171
|
}
|
|
94
172
|
if ('skill' in identity)
|
|
95
|
-
return { skill:
|
|
96
|
-
return { host: identity.host === null ? null :
|
|
173
|
+
return { skill: wireName(gate, identity.skill) };
|
|
174
|
+
return { host: identity.host === null ? null : wireName(gate, identity.host) };
|
|
97
175
|
};
|
|
98
176
|
/**
|
|
99
|
-
* A stored tool's steps as the wire holds them: each shell step's identity is replaced by the projected command, and every name
|
|
100
|
-
*
|
|
177
|
+
* A stored tool's steps as the wire holds them: each shell step's identity is replaced by the projected command, and every long-tail name (a native name, MCP server, skill, host, program, or pull name) is
|
|
178
|
+
* sent as it is after the gate, or `private` when it counts as redacted. A `private` program holds no pull. The one place a deposit and a local read take it from.
|
|
101
179
|
*/
|
|
102
|
-
export const projectedSteps = (tool, read, harness) => {
|
|
180
|
+
export const projectedSteps = (tool, read, harness, gate = OPEN_GATE) => {
|
|
103
181
|
const commands = read(tool);
|
|
104
|
-
|
|
105
|
-
|
|
182
|
+
const steps = tool.steps ?? [];
|
|
183
|
+
// `segments` pair with the steps when their counts agree (`projectLiteralStep`): segment `at` is step `at`, and its `run` starts with the program's basename.
|
|
184
|
+
const segments = tool.segments?.length === steps.length ? tool.segments : undefined;
|
|
185
|
+
return steps.map((step, at) => {
|
|
186
|
+
const native = admittedNative(harness, step.native) ? step.native : wireName(gate, step.native === LITERAL ? tool.tool : step.native);
|
|
106
187
|
if (step.action !== 'shell.exec')
|
|
107
|
-
return { ...step, native, identity: wireIdentity(step.identity, harness) };
|
|
108
|
-
const command = wireCommand(commands[at] ?? { program: PRIVATE, subcommand: null, flags: [] });
|
|
188
|
+
return { ...step, native, identity: wireIdentity(step.identity, harness, tool.tool, gate) };
|
|
189
|
+
const command = wireCommand(commands[at] ?? { program: PRIVATE, subcommand: null, flags: [] }, segments?.[at]?.run.split(' ')[0], gate);
|
|
109
190
|
const pulls = step.identity !== null && 'program' in step.identity ? step.identity.pulls : undefined;
|
|
110
|
-
return { ...step, native, identity: pulls === undefined || command.program === PRIVATE ? command : { ...command, pulls: pulls.map(wirePull) } };
|
|
191
|
+
return { ...step, native, identity: pulls === undefined || command.program === PRIVATE ? command : { ...command, pulls: pulls.map((pull) => wirePull(pull, gate)) } };
|
|
111
192
|
});
|
|
112
193
|
};
|
|
194
|
+
/** A failed call's stored `errorCode` as the wire holds it: a member of the closed list, else `other`; null for a call that did not fail and for one captured without a code (the wire does not guess one). */
|
|
195
|
+
const wireErrorCode = ({ status, errorCode }) => (status !== 'error' || errorCode === undefined ? null : ERROR_CODES.has(errorCode) ? errorCode : 'other');
|
|
113
196
|
/** A transcript's calls in the order they ran, which is the order a skeleton holds them in. */
|
|
114
197
|
const byCall = (tools) => [...tools].sort((a, b) => a.n - b.n);
|
|
198
|
+
const placesOf = (tools) => {
|
|
199
|
+
const calls = new Map();
|
|
200
|
+
const steps = new Map();
|
|
201
|
+
for (const [position, { n, steps: held }] of byCall(tools).entries()) {
|
|
202
|
+
calls.set(n, position);
|
|
203
|
+
for (const step of held ?? [])
|
|
204
|
+
steps.set(`${String(n)}/${String(step.seg)}`, steps.size);
|
|
205
|
+
}
|
|
206
|
+
return { calls, steps };
|
|
207
|
+
};
|
|
115
208
|
/** The step of a call that carries the runs it spawned: its first spawn step, else its first step. A call with no step carries none. */
|
|
116
209
|
const carrier = (steps) => Math.max(0, steps.findIndex(({ action }) => action === 'agent.spawn'));
|
|
117
210
|
/**
|
|
118
|
-
* A
|
|
211
|
+
* A model or harness version string as the wire holds it: the string after the gate (`wireName`), null staying null. The pool assigns its class. Capture's closed value (`<literal>`) goes as it is, the form a client before
|
|
212
|
+
* t900 sent, so an old and a new client key one closed frame to one pool cell; the parser accepts it.
|
|
213
|
+
*/
|
|
214
|
+
const wireString = (gate, value) => (value === null || value === LITERAL ? value : wireName(gate, value));
|
|
215
|
+
/**
|
|
216
|
+
* A stored frame with the UTC hour of `ts` beside its day, and its stack's language shares: each stored share is read through its projection, so a share outside the list is dropped and its language stays in `languages`. A stored
|
|
119
217
|
* language outside the pinned list is `other`, and `other` holds the first share a record gives it. A record captured before the strata holds none.
|
|
120
218
|
*/
|
|
121
|
-
const wireFrame = (frame, strata) => {
|
|
219
|
+
const wireFrame = (frame, strata, gate, ts) => {
|
|
122
220
|
const shares = new Map();
|
|
123
221
|
for (const { language, share } of strata?.languages ?? []) {
|
|
124
222
|
const held = projectLanguageShare(share);
|
|
@@ -126,15 +224,45 @@ const wireFrame = (frame, strata) => {
|
|
|
126
224
|
if (held !== null && !shares.has(name))
|
|
127
225
|
shares.set(name, held);
|
|
128
226
|
}
|
|
129
|
-
|
|
227
|
+
const hour = hourOf(ts);
|
|
228
|
+
return { ...frame, harnessVersion: wireString(gate, frame.harnessVersion), model: wireString(gate, frame.model), stack: { ...frame.stack, languageShares: [...shares].map(([language, languageShare]) => ({ language, languageShare })) }, ...(hour === null ? {} : { hour }) };
|
|
229
|
+
};
|
|
230
|
+
/** Each of `names` after the gate (a name that counts as redacted reads `private`), sorted. */
|
|
231
|
+
const listedAll = (gate, names) => [...new Set(names)].map((name) => wireName(gate, name)).sort();
|
|
232
|
+
/**
|
|
233
|
+
* The reach a session's record shows (t867): the servers of the tools its `funnel` offered or loaded and of the MCP tools it called, the skills it called, and the plugins its `setup` names. A record
|
|
234
|
+
* with neither `setup` nor `funnel` is unfilled (a harness that records none, or a capture before version 12) and lists nothing, so the absence is not read as no tools.
|
|
235
|
+
*/
|
|
236
|
+
const reachOf = (session, tools, harness, gate) => {
|
|
237
|
+
if (session.setup === undefined && session.funnel === undefined)
|
|
238
|
+
return { filled: false, mcp: [], skills: [], plugins: [] };
|
|
239
|
+
const table = ACTION_TABLES[harness];
|
|
240
|
+
const servers = new Set();
|
|
241
|
+
const skills = new Set();
|
|
242
|
+
for (const name of [...(session.funnel?.offered ?? []), ...(session.funnel?.loaded ?? [])]) {
|
|
243
|
+
const server = mcpOf(table, name)?.server;
|
|
244
|
+
if (server !== undefined)
|
|
245
|
+
servers.add(server);
|
|
246
|
+
}
|
|
247
|
+
for (const { steps, skill } of tools) {
|
|
248
|
+
if (skill !== undefined)
|
|
249
|
+
skills.add(skill);
|
|
250
|
+
for (const { identity } of steps ?? []) {
|
|
251
|
+
if (identity !== null && 'server' in identity)
|
|
252
|
+
servers.add(identity.server);
|
|
253
|
+
if (identity !== null && 'skill' in identity)
|
|
254
|
+
skills.add(identity.skill);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { filled: true, mcp: listedAll(gate, servers), skills: listedAll(gate, skills), plugins: (session.setup?.plugins ?? []).map(() => PRIVATE) };
|
|
130
258
|
};
|
|
131
|
-
const transcriptOf = ({ session, prompts, tools, root },
|
|
259
|
+
const transcriptOf = ({ session, prompts, tools, root }, spawner, spawned, agentType, read, gate, coverage) => {
|
|
132
260
|
if (session.frame === undefined || root === null)
|
|
133
261
|
return null;
|
|
134
262
|
const harness = session.harness ?? 'claude-code';
|
|
135
263
|
const framedPrompts = [];
|
|
136
264
|
const promptAt = new Map();
|
|
137
|
-
for (const { index, cls, errors, wallSec, usage, frame, strata } of [...prompts].sort((a, b) => a.index - b.index)) {
|
|
265
|
+
for (const { index, cls, errors, wallSec, usage, usageObserved, usageResponses, frame, strata, ts, intervention } of [...prompts].sort((a, b) => a.index - b.index)) {
|
|
138
266
|
if (frame === undefined)
|
|
139
267
|
return null;
|
|
140
268
|
promptAt.set(index, framedPrompts.length);
|
|
@@ -142,41 +270,108 @@ const transcriptOf = ({ session, prompts, tools, root }, spawners, spawned, agen
|
|
|
142
270
|
cls,
|
|
143
271
|
errors: bucketOf('count', errors),
|
|
144
272
|
wallSec: wallSec === null ? null : bucketOf('seconds', wallSec),
|
|
145
|
-
usage: {
|
|
273
|
+
usage: {
|
|
274
|
+
input: observedBucket(usage.input, usageObserved, 'input'),
|
|
275
|
+
cacheWrite: observedBucket(usage.cacheWrite, usageObserved, 'cacheWrite'),
|
|
276
|
+
cacheRead: observedBucket(usage.cacheRead, usageObserved, 'cacheRead'),
|
|
277
|
+
output: observedBucket(usage.output, usageObserved, 'output'),
|
|
278
|
+
thinking: observedBucket(usage.thinking, usageObserved, 'thinking'),
|
|
279
|
+
},
|
|
146
280
|
explore: projectExplore(strata?.explore),
|
|
147
|
-
|
|
281
|
+
...(usageResponses === undefined ? {} : { usageResponses: bucketOf('count', usageResponses) }),
|
|
282
|
+
...(intervention === undefined ? {} : { intervention }),
|
|
283
|
+
frame: wireFrame(frame, strata, gate, ts),
|
|
148
284
|
});
|
|
149
285
|
}
|
|
286
|
+
// The frames the steps ran in: the transcript's own first, then one for each distinct (prompt frame, call model, call harness version) a call shows, so a model that changes in the middle of a prompt
|
|
287
|
+
// reaches the steps that ran on it. A call that recorded neither takes its prompt's frame, or the transcript's before the first prompt.
|
|
288
|
+
const reach = reachOf(session, tools, harness, gate);
|
|
289
|
+
// The repo key and the bands describe the session, not a prompt, so they sit where the reach does. A session in no repository sends no key, and so does one whose record holds anything but a key's shape: the emitter is the privacy control.
|
|
290
|
+
const where = { reach, ...(session.repo !== null && REPO_KEY.test(session.repo) ? { repoKey: session.repo } : {}), ...coverage };
|
|
291
|
+
const transcriptFrame = { ...wireFrame(session.frame, session.strata, gate, session.tsFirst), ...where };
|
|
292
|
+
const frames = [transcriptFrame];
|
|
293
|
+
const frameAtKey = new Map([[JSON.stringify(transcriptFrame), 0]]);
|
|
294
|
+
const frameOfCall = ({ model, harnessVersion }, promptPosition) => {
|
|
295
|
+
const base = promptPosition === null ? undefined : framedPrompts[promptPosition]?.frame;
|
|
296
|
+
const held = {
|
|
297
|
+
...(base ?? transcriptFrame),
|
|
298
|
+
...(model === undefined ? {} : { model: wireString(gate, model) }),
|
|
299
|
+
...(harnessVersion === null ? {} : { harnessVersion: wireString(gate, harnessVersion) }),
|
|
300
|
+
...where,
|
|
301
|
+
};
|
|
302
|
+
const key = JSON.stringify(held);
|
|
303
|
+
const found = frameAtKey.get(key);
|
|
304
|
+
if (found !== undefined)
|
|
305
|
+
return found;
|
|
306
|
+
frames.push(held);
|
|
307
|
+
frameAtKey.set(key, frames.length - 1);
|
|
308
|
+
return frames.length - 1;
|
|
309
|
+
};
|
|
150
310
|
const steppedTools = [];
|
|
311
|
+
const own = placesOf(tools);
|
|
151
312
|
// The exact tokens of every step, by call and step, which the `remainingTokens` buckets below are computed from: a bucket of a sum is not a sum of buckets.
|
|
152
313
|
const exact = [];
|
|
153
314
|
let unrecorded = 0;
|
|
154
|
-
|
|
315
|
+
const ordered = byCall(tools);
|
|
316
|
+
for (const [at, tool] of ordered.entries()) {
|
|
155
317
|
if (tool.steps === undefined)
|
|
156
318
|
return null;
|
|
157
319
|
const { side, prompt, status, durationMs } = tool;
|
|
320
|
+
const errorCode = wireErrorCode(tool);
|
|
321
|
+
const recoveryCalls = recoveryCallsOf(ordered, at);
|
|
158
322
|
// A subagent's call names the prompt that spawned it, which its own transcript holds no record of: such a number takes the next position past the prompts, one per distinct number, so
|
|
159
323
|
// the calls that shared a prompt still share one, and no position reads as a prompt.
|
|
160
324
|
if (prompt !== null && !promptAt.has(prompt))
|
|
161
325
|
promptAt.set(prompt, framedPrompts.length + unrecorded++);
|
|
162
326
|
const promptPosition = prompt === null ? null : (promptAt.get(prompt) ?? null);
|
|
163
|
-
const projected = projectedSteps(tool, read, harness);
|
|
164
|
-
exact.push(projected.map(({ cost }) => cost.tokens
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
327
|
+
const projected = projectedSteps(tool, read, harness, gate);
|
|
328
|
+
exact.push(projected.map(({ cost }) => cost.tokens));
|
|
329
|
+
const frame = frameOfCall(tool, promptPosition);
|
|
330
|
+
const steps = projected.map(({ action, native, identity, status: reported, cost, parent, edges }) => {
|
|
331
|
+
// An edge names a step by its position in the transcript it sits in; a `spawned` edge sits in the spawner, and one with no spawner in the batch goes unsent, since it names nothing here.
|
|
332
|
+
const arriving = edges.flatMap(({ kind, n: from, seg: fromSeg }) => {
|
|
333
|
+
const target = (kind === 'spawned' ? spawner?.places : own)?.steps.get(`${String(from)}/${String(fromSeg)}`);
|
|
334
|
+
return target === undefined ? [] : [{ kind, target }];
|
|
335
|
+
});
|
|
336
|
+
return {
|
|
337
|
+
action,
|
|
338
|
+
native,
|
|
339
|
+
identity,
|
|
340
|
+
status: reported,
|
|
341
|
+
tokens: cost.tokens === null ? null : bucketOf('tokens', cost.tokens),
|
|
342
|
+
ms: cost.durationMs === null ? null : bucketOf('ms', cost.durationMs),
|
|
343
|
+
...(cost.allocated === true ? { allocated: true } : {}),
|
|
344
|
+
remainingTokens: 0,
|
|
345
|
+
agentType,
|
|
346
|
+
parent: parent === null || spawner === null ? null : (spawner.places.calls.get(parent) ?? null),
|
|
347
|
+
frame,
|
|
348
|
+
...(arriving.length === 0 ? {} : { edges: arriving }),
|
|
349
|
+
...(tool.skill === undefined ? {} : { skill: wireName(gate, tool.skill) }),
|
|
350
|
+
};
|
|
351
|
+
});
|
|
352
|
+
const { keys, dropped } = wireKeys(tool.keys, steps[0]?.native, gate);
|
|
353
|
+
const targetClass = memberOf(TARGET_CLASSES, tool.targetClass);
|
|
354
|
+
const stored = tool.argClass;
|
|
355
|
+
const argClass = stored === undefined ? undefined : ARG_CLASSES.filter((kind) => stored.includes(kind));
|
|
356
|
+
steppedTools.push({
|
|
357
|
+
side,
|
|
358
|
+
prompt: promptPosition,
|
|
359
|
+
status,
|
|
360
|
+
durationMs,
|
|
361
|
+
errorCode,
|
|
362
|
+
...(recoveryCalls === null ? {} : { recoveryCalls }),
|
|
363
|
+
...(keys.length === 0 ? {} : { keys }),
|
|
364
|
+
...(dropped === 0 ? {} : { keysDropped: bucketOf('count', dropped) }),
|
|
365
|
+
...(targetClass === undefined ? {} : { targetClass }),
|
|
366
|
+
...(argClass === undefined ? {} : { argClass: argClass.sort() }),
|
|
367
|
+
steps,
|
|
368
|
+
});
|
|
177
369
|
}
|
|
178
370
|
// A step's remaining tokens are those of the steps after it in its own run; a main-run step also counts the subagent runs its later steps spawned (a run belongs to the spawning call's carrier step).
|
|
371
|
+
// `later` sums the steps that reported tokens; `laterKnown` is how many did, so a run whose later steps all reported nothing stays null instead of reading as zero.
|
|
179
372
|
let later = 0;
|
|
373
|
+
let laterSteps = 0;
|
|
374
|
+
let laterKnown = 0;
|
|
180
375
|
for (let call = steppedTools.length - 1; call >= 0; call -= 1) {
|
|
181
376
|
const steps = steppedTools[call]?.steps ?? [];
|
|
182
377
|
const held = carrier(steps);
|
|
@@ -184,25 +379,41 @@ const transcriptOf = ({ session, prompts, tools, root }, spawners, spawned, agen
|
|
|
184
379
|
const step = steps[at];
|
|
185
380
|
if (step === undefined)
|
|
186
381
|
continue;
|
|
187
|
-
step.remainingTokens = bucketOf('tokens', later);
|
|
188
|
-
|
|
382
|
+
step.remainingTokens = laterSteps > 0 && laterKnown === 0 ? null : bucketOf('tokens', later);
|
|
383
|
+
const own = exact[call]?.[at] ?? null;
|
|
384
|
+
const spawnedTokens = at === held ? (spawned.get(call) ?? 0) : 0;
|
|
385
|
+
later += (own ?? 0) + spawnedTokens;
|
|
386
|
+
laterSteps += 1;
|
|
387
|
+
if (own !== null || spawnedTokens > 0)
|
|
388
|
+
laterKnown += 1;
|
|
189
389
|
}
|
|
190
390
|
}
|
|
191
|
-
return { merkleRoot: root.merkleRoot, leaves: root.leaves, harness, frame:
|
|
391
|
+
return { merkleRoot: root.merkleRoot, leaves: root.leaves, harness, frame: transcriptFrame, frames, spawnedIn: spawner?.index ?? null, prompts: framedPrompts, tools: steppedTools };
|
|
192
392
|
};
|
|
193
393
|
/**
|
|
194
394
|
* The skeleton of a session; null when any record of it was captured before steps and frames, or a transcript of it has no root, which holds no closed reading to send. The batch holds the main
|
|
195
395
|
* transcript's run first, then each subagent's as its own run; its `id` is the main session's, and its one `timingPrint` is over the main transcript's calls alone.
|
|
196
396
|
*/
|
|
197
|
-
export const skeletonOf = (records, id, read, print, deltas = []) => {
|
|
397
|
+
export const skeletonOf = (records, id, read, print, deltas = [], gate = OPEN_GATE, coverage = NO_COVERAGE) => {
|
|
198
398
|
const mainTools = byCall(records.transcripts[0]?.tools ?? []);
|
|
199
|
-
|
|
200
|
-
const spawners = new Map(
|
|
201
|
-
|
|
399
|
+
const places = records.transcripts.map(({ tools }) => placesOf(tools));
|
|
400
|
+
const spawners = places[0]?.calls ?? new Map();
|
|
401
|
+
// A subagent's step names the call that spawned it by that call's `n` in the transcript its session record names as `spawnedIn`, which the skeleton holds as a position in the batch: that transcript's, and the call's within it.
|
|
402
|
+
// A record that names none the batch holds, or itself, or lies on a cycle of spawners, sends no link; one whose spawner sits on a cycle it is not on keeps its link.
|
|
403
|
+
const spawnerOf = records.transcripts.map(({ session }, index) => {
|
|
404
|
+
const at = index === 0 || session.spawnedIn === null ? -1 : records.transcripts.findIndex((held) => held.session.session === session.spawnedIn);
|
|
405
|
+
return at === -1 ? null : at;
|
|
406
|
+
});
|
|
407
|
+
const cyclic = (index) => {
|
|
408
|
+
let at = spawnerOf[index] ?? null;
|
|
409
|
+
for (let hop = 0; hop < spawnerOf.length && at !== null && at !== index; hop += 1)
|
|
410
|
+
at = spawnerOf[at] ?? null;
|
|
411
|
+
return at === index;
|
|
412
|
+
};
|
|
202
413
|
// The exact tokens of the runs each main-run call spawned. A run a subagent spawned names a call of that other transcript, so it joins no main-run step.
|
|
203
414
|
const spawned = new Map();
|
|
204
|
-
for (const entry of records.transcripts.
|
|
205
|
-
if (
|
|
415
|
+
for (const [index, entry] of records.transcripts.entries()) {
|
|
416
|
+
if (spawnerOf[index] !== 0)
|
|
206
417
|
continue;
|
|
207
418
|
for (const tool of entry.tools) {
|
|
208
419
|
for (const { cost, parent } of tool.steps ?? []) {
|
|
@@ -214,17 +425,23 @@ export const skeletonOf = (records, id, read, print, deltas = []) => {
|
|
|
214
425
|
}
|
|
215
426
|
const transcripts = [];
|
|
216
427
|
for (const [index, entry] of records.transcripts.entries()) {
|
|
217
|
-
|
|
218
|
-
const
|
|
428
|
+
const at = spawnerOf[index] ?? null;
|
|
429
|
+
const spawnerPlaces = at === null || cyclic(index) ? undefined : places[at];
|
|
430
|
+
const transcript = transcriptOf(entry, at === null || spawnerPlaces === undefined ? null : { index: at, places: spawnerPlaces }, index === 0 ? spawned : new Map(), index === 0 ? null : projectedAgentType(entry.session.agentType ?? CUSTOM_AGENT), read, gate, coverage);
|
|
219
431
|
if (transcript === null)
|
|
220
432
|
return null;
|
|
221
433
|
transcripts.push(transcript);
|
|
222
434
|
}
|
|
223
435
|
const durations = mainTools.flatMap(({ durationMs }) => (durationMs === null ? [] : [durationMs]));
|
|
224
|
-
return { format: SKELETON_FORMAT, id, transcripts, timingPrint: durations.length < MIN_TIMED_CALLS ? null : print(durations), deltas: [...deltas] };
|
|
436
|
+
return { format: SKELETON_FORMAT, vocabVersion: VOCAB_VERSION, fieldsVersion: FIELD_LIST_VERSION, id, transcripts, timingPrint: durations.length < MIN_TIMED_CALLS ? null : print(durations), deltas: [...deltas] };
|
|
225
437
|
};
|
|
226
|
-
/**
|
|
227
|
-
|
|
438
|
+
/**
|
|
439
|
+
* The version bucket of a session that recorded none: Cursor, Gemini, and Grok Build keep no harness version in their local files, and neither does a VS Code Copilot Chat session whose requests carry no `agent.extensionVersion`, so their cells are
|
|
440
|
+
* `<harness>/unknown/<model>`. It is the one non-numeric word a bundle can be asked for, so those harnesses are served the cells they have.
|
|
441
|
+
*/
|
|
442
|
+
export const UNKNOWN_VERSION = 'unknown';
|
|
443
|
+
/** A harness version a bundle can be asked for: public, so the request carries nothing private, or `unknown` for a harness with none on record. The admitted list replaces this shape rule when it ships. */
|
|
444
|
+
export const isBundleVersion = (version) => version === UNKNOWN_VERSION || (version !== LITERAL && publicVersion(version) === version);
|
|
228
445
|
/** Whether `name` is a harness the schema names. */
|
|
229
446
|
export const isHarness = (name) => HARNESSES.some((harness) => harness === name);
|
|
230
447
|
class Refused extends Error {
|
|
@@ -238,16 +455,65 @@ class Refused extends Error {
|
|
|
238
455
|
}
|
|
239
456
|
const isFields = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
240
457
|
const under = (at, key) => (at === '' ? key : `${at}.${key}`);
|
|
241
|
-
/** The
|
|
242
|
-
const
|
|
458
|
+
/** The value a reader gives an enumerated field whose value it does not know (conclusion section 17, rule 3). */
|
|
459
|
+
export const OTHER = 'other';
|
|
460
|
+
/** The one way into the bag: an entry over `MAX_EXTENSION_BYTES` (as JSON, in UTF-8) is refused as too large at its path, whatever kind it is and whatever the deposit's own cap is. */
|
|
461
|
+
const bagged = (held, entry) => {
|
|
462
|
+
if (new TextEncoder().encode(JSON.stringify(entry.value)).length > MAX_EXTENSION_BYTES)
|
|
463
|
+
throw new Refused(entry.at, 'too large');
|
|
464
|
+
held.extensions.push(entry);
|
|
465
|
+
};
|
|
466
|
+
/**
|
|
467
|
+
* A value no vocabulary of this reader holds: counted, kept as sent in the bag, and read as `other`. The bag is the stored form of what the reader does not serve; the client's projection is the gate on what it
|
|
468
|
+
* sends (lead 194), so the pool keeps the value and serves nothing from it.
|
|
469
|
+
*/
|
|
470
|
+
const unknownValue = (held, at, sent) => {
|
|
471
|
+
held.others += 1;
|
|
472
|
+
bagged(held, { at, kind: 'value', value: sent });
|
|
473
|
+
return OTHER;
|
|
474
|
+
};
|
|
475
|
+
/** The fields a client may send a literal beside (`<field>Literal`) when the field itself says `other`: the enumerated fields, whose values a closed list holds. */
|
|
476
|
+
export const ENUMERATED_FIELDS = new Set(['harness', 'harnessVersion', 'model', 'effort', 'platform', 'shell', 'size', 'languages', 'frameworks', 'language', 'languageShare', 'cls', 'status', 'action', 'native', 'agentType', 'program', 'sub', 'flags', 'registry', 'name', 'version', 'server', 'tool', 'skill', 'host', 'deltaKind', 'field', 'metric', 'intervention']);
|
|
477
|
+
/**
|
|
478
|
+
* The fields that may be absent at format 3, by the level of the batch that holds them. An absent one reads as null, or as empty for a list, exactly as a sent null does, so a batch with all of them left out is
|
|
479
|
+
* served from what remains. A field the cells need to be sound (a harness, a status, a bucket of tokens) is never here.
|
|
480
|
+
*/
|
|
481
|
+
export const OPTIONAL = {
|
|
482
|
+
batch: ['timingPrint', 'deltas'],
|
|
483
|
+
frame: ['harnessVersion', 'model', 'effort', 'day', 'shell', 'reach', 'repoKey', 'orgSize', 'team', 'repoVisibility', 'hour'],
|
|
484
|
+
stack: ['size', 'languageShares'],
|
|
485
|
+
prompt: ['wallSec', 'explore', 'usageResponses', 'intervention'],
|
|
486
|
+
tool: ['prompt', 'durationMs', 'errorCode', 'recoveryCalls', 'keys', 'keysDropped', 'targetClass', 'argClass'],
|
|
487
|
+
step: ['tokens', 'ms', 'allocated', 'parent', 'skill', 'edges', 'remainingTokens'],
|
|
488
|
+
transcript: ['spawnedIn'],
|
|
489
|
+
};
|
|
490
|
+
/** The most bytes (as JSON) one field this reader does not know, or one literal, may hold: an enumerated value or a literal never needs more, and a newer client's larger structure belongs in a new format version. The deposit's own cap is no part of this. */
|
|
491
|
+
export const MAX_EXTENSION_BYTES = 8 * 1024;
|
|
492
|
+
const LITERAL_SUFFIX = 'Literal';
|
|
493
|
+
const FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
494
|
+
/**
|
|
495
|
+
* The object at `at`, holding every field of `known` and, of the rest, any of `optional`. A name that is neither is not refused: it goes to the bag as it came (rule 3), so a newer client loses nothing
|
|
496
|
+
* against this reader. The emitter is what keeps a class A field out of a batch, not this check. A shape error still refuses: a name that is no field name, a missing field, a literal that is not text.
|
|
497
|
+
*/
|
|
498
|
+
const fieldsOf = (held, value, at, known, optional = []) => {
|
|
243
499
|
if (!isFields(value))
|
|
244
500
|
throw new Refused(at === '' ? 'skeleton' : at, 'not an object');
|
|
245
|
-
for (const key of Object.keys(value))
|
|
246
|
-
if (!known.includes(key) && !optional.includes(key))
|
|
247
|
-
throw new Refused(under(at, key), 'not a field of a skeleton');
|
|
248
501
|
for (const key of known)
|
|
249
502
|
if (!Object.hasOwn(value, key))
|
|
250
503
|
throw new Refused(under(at, key), 'missing');
|
|
504
|
+
for (const key of Object.keys(value)) {
|
|
505
|
+
if (known.includes(key) || optional.includes(key))
|
|
506
|
+
continue;
|
|
507
|
+
if (!FIELD_NAME.test(key))
|
|
508
|
+
throw new Refused(under(at, key), 'not a field name');
|
|
509
|
+
const entry = value[key];
|
|
510
|
+
const stem = key.endsWith(LITERAL_SUFFIX) ? key.slice(0, -LITERAL_SUFFIX.length) : undefined;
|
|
511
|
+
// A literal sits beside an `other` and nowhere else; beside a value this reader knows it is a field it does not know.
|
|
512
|
+
const literal = stem !== undefined && ENUMERATED_FIELDS.has(stem) && (known.includes(stem) || optional.includes(stem)) && value[stem] === OTHER;
|
|
513
|
+
if (literal)
|
|
514
|
+
textOf(entry, under(at, key));
|
|
515
|
+
bagged(held, { at: under(at, key), kind: literal ? 'literal' : 'field', value: entry });
|
|
516
|
+
}
|
|
251
517
|
return value;
|
|
252
518
|
};
|
|
253
519
|
const listOf = (value, at, item) => {
|
|
@@ -267,48 +533,67 @@ const countOf = (value, at) => {
|
|
|
267
533
|
throw new Refused(at, 'not a count');
|
|
268
534
|
return value;
|
|
269
535
|
};
|
|
270
|
-
|
|
536
|
+
/** The allocation marker is `true` or absent: a depositor cannot say `false`, since an unmarked step is the measured one. */
|
|
537
|
+
const allocatedOf = (value, at) => {
|
|
538
|
+
if (value !== true)
|
|
539
|
+
throw new Refused(at, 'not true');
|
|
540
|
+
return true;
|
|
541
|
+
};
|
|
542
|
+
/** An absent field reads as null: an optional field is absent or null alike. */
|
|
543
|
+
const orNull = (value, at, item) => (value === null || value === undefined ? null : item(value, at));
|
|
544
|
+
/** An absent list reads as empty. */
|
|
545
|
+
const listOrEmpty = (value, at, item) => (value === undefined ? [] : listOf(value, at, item));
|
|
271
546
|
const booleanOf = (value, at) => {
|
|
272
547
|
if (typeof value !== 'boolean')
|
|
273
548
|
throw new Refused(at, 'not a boolean');
|
|
274
549
|
return value;
|
|
275
550
|
};
|
|
276
|
-
/** A value from a closed list; the list narrows the type, so no caller needs a cast. */
|
|
277
|
-
const oneOf = (list, value, at) => {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
throw new Refused(at, 'outside the vocabulary');
|
|
281
|
-
return found;
|
|
551
|
+
/** A value from a closed list, or `other` for a text the list does not hold; the list narrows the type, so no caller needs a cast. A value that is no text is a shape error. */
|
|
552
|
+
const oneOf = (held, list, value, at) => {
|
|
553
|
+
const name = textOf(value, at);
|
|
554
|
+
return list.find((entry) => entry === name) ?? (name === OTHER ? OTHER : unknownValue(held, at, name));
|
|
282
555
|
};
|
|
283
|
-
const inSet = (allowed, value, at) => {
|
|
556
|
+
const inSet = (held, allowed, value, at) => {
|
|
284
557
|
const name = textOf(value, at);
|
|
285
|
-
|
|
286
|
-
throw new Refused(at, 'outside the vocabulary');
|
|
287
|
-
return name;
|
|
558
|
+
return name === PRIVATE || name === OTHER || allowed.has(name) ? name : unknownValue(held, at, name);
|
|
288
559
|
};
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
560
|
+
/** A long-tail name field: `private`, `other`, or a name (the pool assigns its class, `packages/pool/src/field-classes.ts`). A value that is no name is refused: a client that sends one has not run its gate. */
|
|
561
|
+
const nameIn = (value, at, hasShape = isWireName) => {
|
|
562
|
+
const name = textOf(value, at);
|
|
563
|
+
if (name === PRIVATE || name === OTHER || hasShape(name))
|
|
564
|
+
return name;
|
|
565
|
+
throw new Refused(at, 'not a name');
|
|
293
566
|
};
|
|
294
|
-
/**
|
|
295
|
-
const
|
|
296
|
-
if (value === null)
|
|
567
|
+
/** A model or harness version: null, or a long-tail name (`private`, `other`, the older client's literal, or a name). A value that is no name is set aside, as a native name is. */
|
|
568
|
+
const nameOrNull = (held, value, at) => {
|
|
569
|
+
if (value === null || value === undefined)
|
|
297
570
|
return null;
|
|
298
571
|
const name = textOf(value, at);
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
572
|
+
return name === LITERAL ? name : nameValue(held, name, at);
|
|
573
|
+
};
|
|
574
|
+
/** A long-tail name in a field whose value the pool classes: `private`, `other`, or a name; a value that is no name is set aside. */
|
|
575
|
+
const nameValue = (held, name, at) => (name === PRIVATE || name === OTHER || isWireName(name) ? name : unknownValue(held, at, name));
|
|
576
|
+
const exactlyIn = (held, allowed) => (value, at) => {
|
|
577
|
+
const name = textOf(value, at);
|
|
578
|
+
return allowed.has(name) || name === OTHER ? name : unknownValue(held, at, name);
|
|
579
|
+
};
|
|
580
|
+
/** `publicEffort` and its like hand back what is public and the literal for what is not, so only a value they return unchanged is in the vocabulary. */
|
|
581
|
+
const heldTo = (held, isHeld, value, at) => {
|
|
582
|
+
if (value === null || value === undefined)
|
|
583
|
+
return null;
|
|
584
|
+
const name = textOf(value, at);
|
|
585
|
+
return name === OTHER || isHeld(name) === name ? name : unknownValue(held, at, name);
|
|
302
586
|
};
|
|
303
587
|
// `satisfies` checks every entry is a `Harness` but not that every `Harness` is listed; this refuses a list that leaves one out at compile time.
|
|
304
588
|
const allHarnesses = (list) => list;
|
|
305
|
-
export const HARNESSES = allHarnesses(['claude-code', 'cursor', 'codex', 'copilot', 'gemini', 'grok', 'vscode-copilot']);
|
|
589
|
+
export const HARNESSES = allHarnesses(['claude-code', 'cursor', 'codex', 'copilot', 'gemini', 'grok', 'opencode', 'pi', 'vscode-copilot', 'otel']);
|
|
306
590
|
const PROMPT_CLASSES = ['no-tools', 'orchestration', 'research', 'edit-verify', 'edit-only', 'explore-only', 'shell-only', 'mixed'];
|
|
307
591
|
const STATUSES = ['ok', 'error', 'none'];
|
|
308
592
|
const PLATFORMS = new Set(['aix', 'android', 'cygwin', 'darwin', 'freebsd', 'haiku', 'linux', 'netbsd', 'openbsd', 'sunos', 'win32']);
|
|
309
593
|
const SHELLS = new Set(Object.values(ACTION_TABLES).flatMap((table) => Object.values(table.shells)));
|
|
310
594
|
/** The registries a package comes from; a pull may also come from a skill, plugin, or MCP registry. */
|
|
311
595
|
export const PACKAGE_REGISTRIES = ['npm', 'pypi', 'crates', 'go', 'rubygems', 'homebrew'];
|
|
596
|
+
const NO_NAME = new Set();
|
|
312
597
|
const REGISTRIES = [...PACKAGE_REGISTRIES, 'skill', 'plugin', 'mcp'];
|
|
313
598
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
314
599
|
const ID = /^[0-9a-f]{16}$/;
|
|
@@ -321,14 +606,40 @@ const isCalendarDay = (text) => {
|
|
|
321
606
|
};
|
|
322
607
|
/** The languages a share can name: the pinned list and `other`, which `projectLanguage` gives any language off the list. The frame's own `languages` stay closed to the list. */
|
|
323
608
|
const SHARE_LANGUAGES = new Set([...LANGUAGES, 'other']);
|
|
324
|
-
const parseLanguageShare = (value, at) => {
|
|
325
|
-
const raw = fieldsOf(value, at, ['language', 'languageShare']);
|
|
326
|
-
return { language: exactlyIn(SHARE_LANGUAGES)(raw.language, under(at, 'language')), languageShare: oneOf(LANGUAGE_SHARES, raw.languageShare, under(at, 'languageShare')) };
|
|
609
|
+
const parseLanguageShare = (held, value, at) => {
|
|
610
|
+
const raw = fieldsOf(held, value, at, ['language', 'languageShare']);
|
|
611
|
+
return { language: exactlyIn(held, SHARE_LANGUAGES)(raw.language, under(at, 'language')), languageShare: oneOf(held, LANGUAGE_SHARES, raw.languageShare, under(at, 'languageShare')) };
|
|
612
|
+
};
|
|
613
|
+
/** A reach, each list held to its own row; an unfilled reach lists nothing. A plugin has no pinned list, so only the word `private` stands for one. */
|
|
614
|
+
const parseReach = (held, value, at) => {
|
|
615
|
+
const raw = fieldsOf(held, value, at, ['filled', 'mcp', 'skills', 'plugins']);
|
|
616
|
+
const filled = booleanOf(raw.filled, under(at, 'filled'));
|
|
617
|
+
const reach = {
|
|
618
|
+
filled,
|
|
619
|
+
mcp: listOf(raw.mcp, under(at, 'mcp'), (entry, entryAt) => nameIn(entry, entryAt)),
|
|
620
|
+
skills: listOf(raw.skills, under(at, 'skills'), (entry, entryAt) => nameIn(entry, entryAt)),
|
|
621
|
+
plugins: listOf(raw.plugins, under(at, 'plugins'), (entry, entryAt) => inSet(held, NO_NAME, entry, entryAt)),
|
|
622
|
+
};
|
|
623
|
+
if (!filled && reach.mcp.length + reach.skills.length + reach.plugins.length > 0)
|
|
624
|
+
throw new Refused(at, 'an unfilled reach lists nothing');
|
|
625
|
+
return reach;
|
|
626
|
+
};
|
|
627
|
+
/** Sixteen lowercase hex characters and nothing else, so no path or remote text can ride in a repo key. */
|
|
628
|
+
const parseRepoKey = (value, at) => {
|
|
629
|
+
const text = textOf(value, at);
|
|
630
|
+
if (!REPO_KEY.test(text))
|
|
631
|
+
throw new Refused(at, 'outside the vocabulary');
|
|
632
|
+
return text;
|
|
327
633
|
};
|
|
328
|
-
const
|
|
329
|
-
|
|
634
|
+
const parseHour = (value, at) => {
|
|
635
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 23)
|
|
636
|
+
throw new Refused(at, 'not an hour');
|
|
637
|
+
return value;
|
|
638
|
+
};
|
|
639
|
+
const parseFrame = (held, value, at) => {
|
|
640
|
+
const raw = fieldsOf(held, value, at, ['harness', 'platform', 'stack'], OPTIONAL.frame);
|
|
330
641
|
const stackAt = under(at, 'stack');
|
|
331
|
-
const stack = fieldsOf(raw.stack, stackAt, ['languages', 'frameworks',
|
|
642
|
+
const stack = fieldsOf(held, raw.stack, stackAt, ['languages', 'frameworks'], OPTIONAL.stack);
|
|
332
643
|
const day = orNull(raw.day, under(at, 'day'), (entry, entryAt) => {
|
|
333
644
|
const text = textOf(entry, entryAt);
|
|
334
645
|
if (!isCalendarDay(text))
|
|
@@ -336,19 +647,25 @@ const parseFrame = (value, at) => {
|
|
|
336
647
|
return text;
|
|
337
648
|
});
|
|
338
649
|
return {
|
|
339
|
-
harness: oneOf(HARNESSES, raw.harness, under(at, 'harness')),
|
|
340
|
-
harnessVersion:
|
|
341
|
-
model:
|
|
342
|
-
effort: heldTo(publicEffort, raw.effort, under(at, 'effort')),
|
|
650
|
+
harness: oneOf(held, HARNESSES, raw.harness, under(at, 'harness')),
|
|
651
|
+
harnessVersion: nameOrNull(held, raw.harnessVersion, under(at, 'harnessVersion')),
|
|
652
|
+
model: nameOrNull(held, raw.model, under(at, 'model')),
|
|
653
|
+
effort: heldTo(held, publicEffort, raw.effort, under(at, 'effort')),
|
|
343
654
|
day,
|
|
344
|
-
platform: exactlyIn(PLATFORMS)(raw.platform, under(at, 'platform')),
|
|
345
|
-
shell: orNull(raw.shell, under(at, 'shell'), exactlyIn(SHELLS)),
|
|
655
|
+
platform: exactlyIn(held, PLATFORMS)(raw.platform, under(at, 'platform')),
|
|
656
|
+
shell: orNull(raw.shell, under(at, 'shell'), exactlyIn(held, SHELLS)),
|
|
346
657
|
stack: {
|
|
347
|
-
languages: listOf(stack.languages, under(stackAt, 'languages'), exactlyIn(LANGUAGES)),
|
|
348
|
-
frameworks: listOf(stack.frameworks, under(stackAt, 'frameworks'), exactlyIn(FRAMEWORKS)),
|
|
349
|
-
size: orNull(stack.size, under(stackAt, 'size'), (entry, entryAt) => oneOf(REPO_SIZES, entry, entryAt)),
|
|
350
|
-
languageShares:
|
|
658
|
+
languages: listOf(stack.languages, under(stackAt, 'languages'), exactlyIn(held, LANGUAGES)),
|
|
659
|
+
frameworks: listOf(stack.frameworks, under(stackAt, 'frameworks'), exactlyIn(held, FRAMEWORKS)),
|
|
660
|
+
size: orNull(stack.size, under(stackAt, 'size'), (entry, entryAt) => oneOf(held, REPO_SIZES, entry, entryAt)),
|
|
661
|
+
languageShares: listOrEmpty(stack.languageShares, under(stackAt, 'languageShares'), (entry, entryAt) => parseLanguageShare(held, entry, entryAt)),
|
|
351
662
|
},
|
|
663
|
+
...(raw.reach === undefined ? {} : { reach: parseReach(held, raw.reach, under(at, 'reach')) }),
|
|
664
|
+
...(raw.repoKey === undefined ? {} : { repoKey: parseRepoKey(raw.repoKey, under(at, 'repoKey')) }),
|
|
665
|
+
...(raw.orgSize === undefined ? {} : { orgSize: oneOf(held, ORG_SIZES, raw.orgSize, under(at, 'orgSize')) }),
|
|
666
|
+
...(raw.team === undefined ? {} : { team: oneOf(held, TEAMS, raw.team, under(at, 'team')) }),
|
|
667
|
+
...(raw.repoVisibility === undefined ? {} : { repoVisibility: oneOf(held, REPO_VISIBILITIES, raw.repoVisibility, under(at, 'repoVisibility')) }),
|
|
668
|
+
...(raw.hour === undefined ? {} : { hour: parseHour(raw.hour, under(at, 'hour')) }),
|
|
352
669
|
};
|
|
353
670
|
};
|
|
354
671
|
const bucketIn = (kind, value, at) => {
|
|
@@ -356,70 +673,81 @@ const bucketIn = (kind, value, at) => {
|
|
|
356
673
|
throw new Refused(at, 'not a bucket');
|
|
357
674
|
return value;
|
|
358
675
|
};
|
|
359
|
-
const parseUsage = (value, at) => {
|
|
360
|
-
const raw = fieldsOf(value, at, ['input', 'cacheWrite', 'cacheRead', 'output', 'thinking']);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
cacheWrite: bucketIn('tokens', raw.cacheWrite, under(at, 'cacheWrite')),
|
|
364
|
-
cacheRead: bucketIn('tokens', raw.cacheRead, under(at, 'cacheRead')),
|
|
365
|
-
output: bucketIn('tokens', raw.output, under(at, 'output')),
|
|
366
|
-
thinking: bucketIn('tokens', raw.thinking, under(at, 'thinking')),
|
|
367
|
-
};
|
|
676
|
+
const parseUsage = (held, value, at) => {
|
|
677
|
+
const raw = fieldsOf(held, value, at, ['input', 'cacheWrite', 'cacheRead', 'output', 'thinking']);
|
|
678
|
+
const bucket = (field) => orNull(raw[field], under(at, field), (entry, entryAt) => bucketIn('tokens', entry, entryAt));
|
|
679
|
+
return { input: bucket('input'), cacheWrite: bucket('cacheWrite'), cacheRead: bucket('cacheRead'), output: bucket('output'), thinking: bucket('thinking') };
|
|
368
680
|
};
|
|
369
|
-
const parsePull = (value, at) => {
|
|
370
|
-
const raw = fieldsOf(value, at, ['registry', 'name', 'version']);
|
|
371
|
-
const name =
|
|
681
|
+
const parsePull = (held, value, at) => {
|
|
682
|
+
const raw = fieldsOf(held, value, at, ['registry', 'name', 'version']);
|
|
683
|
+
const name = nameIn(raw.name, under(at, 'name'), isWirePullName);
|
|
372
684
|
const version = orNull(raw.version, under(at, 'version'), (entry, entryAt) => {
|
|
373
685
|
const text = textOf(entry, entryAt);
|
|
374
|
-
if (text !== LITERAL && !isRegistryVersion(text))
|
|
375
|
-
throw new Refused(entryAt, 'outside the vocabulary');
|
|
376
686
|
// A version beside a masked name could identify it: capture masks the two together.
|
|
377
|
-
if (name
|
|
378
|
-
throw new Refused(entryAt, 'a
|
|
379
|
-
return text;
|
|
687
|
+
if (!PUBLIC_PACKAGES.has(name) && text !== LITERAL)
|
|
688
|
+
throw new Refused(entryAt, 'a name off the public list holds no version');
|
|
689
|
+
return text === LITERAL || text === OTHER || isRegistryVersion(text) ? text : unknownValue(held, entryAt, text);
|
|
380
690
|
});
|
|
381
|
-
return { registry: oneOf(REGISTRIES, raw.registry, under(at, 'registry')), name, version };
|
|
691
|
+
return { registry: oneOf(held, REGISTRIES, raw.registry, under(at, 'registry')), name, version };
|
|
382
692
|
};
|
|
383
|
-
const parseFlag = (value, at) => {
|
|
693
|
+
const parseFlag = (held, value, at) => {
|
|
384
694
|
const flag = textOf(value, at);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
695
|
+
return flag === PRIVATE || flag === OTHER || isWireFlag(flag) ? flag : unknownValue(held, at, flag);
|
|
696
|
+
};
|
|
697
|
+
/** What a reader sets aside whole: a part of a step it cannot read against a vocabulary. Its place is noted and its content kept as sent, as for any value it does not know (see `unknownValue`). */
|
|
698
|
+
const setAside = (held, at, sent) => {
|
|
699
|
+
bagged(held, { at, kind: 'value', value: sent });
|
|
388
700
|
};
|
|
389
|
-
const parseIdentity = (harness, action, value, at) => {
|
|
701
|
+
const parseIdentity = (held, harness, action, value, at) => {
|
|
390
702
|
if (value === null)
|
|
391
703
|
return null;
|
|
704
|
+
// An action this reader does not know holds an identity it cannot read: the step goes without one and the place of what it held is noted.
|
|
705
|
+
if (action === OTHER) {
|
|
706
|
+
setAside(held, at, value);
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
392
709
|
if (action === 'shell.exec') {
|
|
393
|
-
const raw = fieldsOf(value, at, ['program', 'sub', 'flags'], ['pulls']);
|
|
394
|
-
// A program is a
|
|
710
|
+
const raw = fieldsOf(held, value, at, ['program', 'sub', 'flags'], ['pulls']);
|
|
711
|
+
// A program is a name, `private`, or `other`; only `other` holds no subcommand, flag, or pull here.
|
|
395
712
|
const program = textOf(raw.program, under(at, 'program'));
|
|
396
|
-
|
|
397
|
-
|
|
713
|
+
nameIn(program, under(at, 'program'));
|
|
714
|
+
// The subcommands, flags, and pulls of a program this reader does not know cannot be read against its lists: it takes none, and notes what it left.
|
|
715
|
+
if (program === OTHER) {
|
|
716
|
+
for (const key of ['sub', 'flags', 'pulls'])
|
|
717
|
+
if (raw[key] !== undefined && !(raw[key] === null || (Array.isArray(raw[key]) && raw[key].length === 0)))
|
|
718
|
+
setAside(held, under(at, key), raw[key]);
|
|
719
|
+
return { program: OTHER, sub: null, flags: [] };
|
|
720
|
+
}
|
|
398
721
|
if (program === PRIVATE) {
|
|
399
722
|
if (!(Array.isArray(raw.flags) && raw.flags.length === 0))
|
|
400
723
|
throw new Refused(under(at, 'flags'), 'a private program holds no flags');
|
|
401
724
|
if (raw.pulls !== undefined)
|
|
402
725
|
throw new Refused(under(at, 'pulls'), 'a private program holds no pulls');
|
|
403
726
|
}
|
|
404
|
-
|
|
405
|
-
const sub = orNull(raw.sub, under(at, 'sub'),
|
|
406
|
-
const flags = listOf(raw.flags, under(at, 'flags'), parseFlag);
|
|
407
|
-
return raw.pulls === undefined ? { program, sub, flags } : { program, sub, flags, pulls: listOf(raw.pulls, under(at, 'pulls'), parsePull) };
|
|
727
|
+
// A private program holds no subcommand, so one it sends is set aside, as its flags are refused.
|
|
728
|
+
const sub = orNull(raw.sub, under(at, 'sub'), (entry, entryAt) => (program === PRIVATE ? unknownValue(held, entryAt, textOf(entry, entryAt)) : nameValue(held, textOf(entry, entryAt), entryAt)));
|
|
729
|
+
const flags = listOf(raw.flags, under(at, 'flags'), (entry, entryAt) => parseFlag(held, entry, entryAt));
|
|
730
|
+
return raw.pulls === undefined ? { program, sub, flags } : { program, sub, flags, pulls: listOf(raw.pulls, under(at, 'pulls'), (entry, entryAt) => parsePull(held, entry, entryAt)) };
|
|
408
731
|
}
|
|
409
732
|
if (action === 'mcp.call') {
|
|
410
|
-
const raw = fieldsOf(value, at, ['server', 'tool']);
|
|
411
|
-
const server =
|
|
733
|
+
const raw = fieldsOf(held, value, at, ['server', 'tool']);
|
|
734
|
+
const server = nameIn(raw.server, under(at, 'server'));
|
|
412
735
|
const tool = textOf(raw.tool, under(at, 'tool'));
|
|
413
736
|
// The same rule capture applies (`callIdentity`), so a name it would have masked is refused here rather than stored.
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
737
|
+
if (harness === OTHER) {
|
|
738
|
+
// The name crosses as the literal, and what the client sent is kept in the bag.
|
|
739
|
+
if (tool !== OTHER && tool !== LITERAL)
|
|
740
|
+
setAside(held, under(at, 'tool'), tool);
|
|
741
|
+
return { server, tool: tool === OTHER ? tool : LITERAL };
|
|
742
|
+
}
|
|
743
|
+
const admitted = tool === LITERAL || tool === OTHER || (server !== PRIVATE && ACTION_TABLES[harness].toolDelimited && (isPublicMcpTool(server, tool) || isWireName(tool)));
|
|
744
|
+
return { server, tool: admitted ? tool : unknownValue(held, under(at, 'tool'), tool) };
|
|
417
745
|
}
|
|
418
746
|
if (action === 'skill.run')
|
|
419
|
-
return { skill:
|
|
747
|
+
return { skill: nameIn(fieldsOf(held, value, at, ['skill']).skill, under(at, 'skill')) };
|
|
420
748
|
if (action === 'web.fetch' || action === 'web.search') {
|
|
421
|
-
const { host } = fieldsOf(value, at, ['host']);
|
|
422
|
-
return { host: orNull(host, under(at, 'host'), (entry, entryAt) =>
|
|
749
|
+
const { host } = fieldsOf(held, value, at, ['host']);
|
|
750
|
+
return { host: orNull(host, under(at, 'host'), (entry, entryAt) => nameIn(entry, entryAt)) };
|
|
423
751
|
}
|
|
424
752
|
throw new Refused(at, 'this action has no identity');
|
|
425
753
|
};
|
|
@@ -427,59 +755,119 @@ const parseIdentity = (harness, action, value, at) => {
|
|
|
427
755
|
const admittedNative = (harness, native) => {
|
|
428
756
|
if (native === PRIVATE)
|
|
429
757
|
return true;
|
|
758
|
+
if (harness === OTHER)
|
|
759
|
+
return false;
|
|
430
760
|
const table = ACTION_TABLES[harness];
|
|
431
761
|
if (Object.hasOwn(table.rows, native))
|
|
432
762
|
return true;
|
|
433
763
|
const mcp = mcpOf(table, native);
|
|
434
764
|
return mcp !== undefined && table.toolDelimited && isPublicMcpTool(mcp.server, mcp.tool);
|
|
435
765
|
};
|
|
436
|
-
const parseNative = (harness, value, at) => {
|
|
766
|
+
const parseNative = (held, harness, value, at) => {
|
|
437
767
|
const native = textOf(value, at);
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
768
|
+
// The tools of a harness this reader does not know are off any table it holds, so they cross as `private`, the way capture masks a name its table lacks.
|
|
769
|
+
if (harness === OTHER)
|
|
770
|
+
return native === OTHER || isWireName(native) ? native : unknownValue(held, at, native);
|
|
771
|
+
return native === OTHER || admittedNative(harness, native) || isWireName(native) ? native : unknownValue(held, at, native);
|
|
441
772
|
};
|
|
442
773
|
/** A subagent type as the projection leaves it: a built-in of a harness, or `custom`; a custom agent's own name is private and never admitted. */
|
|
443
|
-
const parseAgentType = (value, at) => {
|
|
774
|
+
const parseAgentType = (held, value, at) => {
|
|
444
775
|
const name = textOf(value, at);
|
|
445
|
-
|
|
446
|
-
throw new Refused(at, 'outside the vocabulary');
|
|
447
|
-
return name;
|
|
776
|
+
return name === OTHER || projectedAgentType(name) === name ? name : unknownValue(held, at, name);
|
|
448
777
|
};
|
|
449
|
-
const
|
|
450
|
-
const raw = fieldsOf(value, at, ['
|
|
451
|
-
|
|
778
|
+
const parseEdge = (held, value, at) => {
|
|
779
|
+
const raw = fieldsOf(held, value, at, ['kind', 'target']);
|
|
780
|
+
return { kind: oneOf(held, EDGE_KINDS, raw.kind, under(at, 'kind')), target: countOf(raw.target, under(at, 'target')) };
|
|
781
|
+
};
|
|
782
|
+
const parseStep = (held, harness, value, at) => {
|
|
783
|
+
const raw = fieldsOf(held, value, at, ['action', 'native', 'identity', 'status', 'agentType', 'frame'], OPTIONAL.step);
|
|
784
|
+
const action = oneOf(held, CANONICAL_ACTIONS, raw.action, under(at, 'action'));
|
|
452
785
|
return {
|
|
453
786
|
action,
|
|
454
|
-
native: parseNative(harness, raw.native, under(at, 'native')),
|
|
455
|
-
identity: parseIdentity(harness, action, raw.identity, under(at, 'identity')),
|
|
456
|
-
status: oneOf(STATUSES, raw.status, under(at, 'status')),
|
|
787
|
+
native: parseNative(held, harness, raw.native, under(at, 'native')),
|
|
788
|
+
identity: parseIdentity(held, harness, action, raw.identity, under(at, 'identity')),
|
|
789
|
+
status: oneOf(held, STATUSES, raw.status, under(at, 'status')),
|
|
457
790
|
tokens: orNull(raw.tokens, under(at, 'tokens'), (entry, entryAt) => bucketIn('tokens', entry, entryAt)),
|
|
458
791
|
ms: orNull(raw.ms, under(at, 'ms'), (entry, entryAt) => bucketIn('ms', entry, entryAt)),
|
|
459
|
-
|
|
460
|
-
|
|
792
|
+
...(raw.allocated === undefined ? {} : { allocated: allocatedOf(raw.allocated, under(at, 'allocated')) }),
|
|
793
|
+
remainingTokens: orNull(raw.remainingTokens, under(at, 'remainingTokens'), (entry, entryAt) => bucketIn('tokens', entry, entryAt)),
|
|
794
|
+
agentType: orNull(raw.agentType, under(at, 'agentType'), (entry, entryAt) => parseAgentType(held, entry, entryAt)),
|
|
461
795
|
parent: orNull(raw.parent, under(at, 'parent'), countOf),
|
|
796
|
+
frame: countOf(raw.frame, under(at, 'frame')),
|
|
797
|
+
...(raw.edges === undefined ? {} : { edges: listOf(raw.edges, under(at, 'edges'), (entry, entryAt) => parseEdge(held, entry, entryAt)) }),
|
|
798
|
+
...(raw.skill === undefined ? {} : { skill: nameIn(raw.skill, under(at, 'skill')) }),
|
|
462
799
|
};
|
|
463
800
|
};
|
|
464
|
-
|
|
465
|
-
|
|
801
|
+
/** A call's `errorCode`: absent or null is none; otherwise a member of the closed list (a value off it reads as `other` and goes to the bag), and only on a call that did not succeed or sit out. */
|
|
802
|
+
const parseErrorCode = (held, status, value, at) => {
|
|
803
|
+
if (value === undefined || value === null)
|
|
804
|
+
return null;
|
|
805
|
+
const code = exactlyIn(held, ERROR_CODES)(value, at);
|
|
806
|
+
if (status === 'ok' || status === 'none')
|
|
807
|
+
throw new Refused(at, 'only a failed call has one');
|
|
808
|
+
return code;
|
|
809
|
+
};
|
|
810
|
+
/** A call's `recoveryCalls`: absent is none; otherwise at most 5 actions (a value off the list reads as `other`) and a flag, and only on a call that did not succeed or sit out; the transcript parse then refuses one on a call whose status is not `error`. */
|
|
811
|
+
const parseRecovery = (held, status, value, at) => {
|
|
812
|
+
if (value === undefined)
|
|
813
|
+
return undefined;
|
|
814
|
+
if (status === 'ok' || status === 'none')
|
|
815
|
+
throw new Refused(at, 'only a failed call has one');
|
|
816
|
+
const raw = fieldsOf(held, value, at, ['next', 'editFollowed']);
|
|
817
|
+
const next = listOf(raw.next, under(at, 'next'), (entry, entryAt) => oneOf(held, CANONICAL_ACTIONS, entry, entryAt));
|
|
818
|
+
if (next.length > RECOVERY_CALLS)
|
|
819
|
+
throw new Refused(under(at, 'next'), `more than ${String(RECOVERY_CALLS)} calls`);
|
|
820
|
+
return { next, editFollowed: booleanOf(raw.editFollowed, under(at, 'editFollowed')) };
|
|
821
|
+
};
|
|
822
|
+
/**
|
|
823
|
+
* A call's `keys`: absent is none. A tool with a pinned input list sends names on it, and a name off it reads as `other` and goes to the bag; any other tool's names must have the shape of a name. Key names
|
|
824
|
+
* only, so a value that is no name shape is not a key.
|
|
825
|
+
*/
|
|
826
|
+
const parseKeys = (held, native, value, at) => {
|
|
827
|
+
if (value === undefined)
|
|
828
|
+
return undefined;
|
|
829
|
+
const listed = native === undefined ? undefined : TOOL_INPUT_KEYS.get(native);
|
|
830
|
+
return listOf(value, at, (entry, entryAt) => {
|
|
831
|
+
const name = textOf(entry, entryAt);
|
|
832
|
+
return (listed === undefined ? isWireName(name) : listed.has(name)) ? name : unknownValue(held, entryAt, name);
|
|
833
|
+
});
|
|
834
|
+
};
|
|
835
|
+
const parseTool = (held, harness, value, at) => {
|
|
836
|
+
const raw = fieldsOf(held, value, at, ['side', 'status', 'steps'], OPTIONAL.tool);
|
|
837
|
+
const status = oneOf(held, STATUSES, raw.status, under(at, 'status'));
|
|
838
|
+
const errorCode = parseErrorCode(held, status, raw.errorCode, under(at, 'errorCode'));
|
|
839
|
+
const recoveryCalls = parseRecovery(held, status, raw.recoveryCalls, under(at, 'recoveryCalls'));
|
|
840
|
+
const steps = listOf(raw.steps, under(at, 'steps'), (entry, entryAt) => parseStep(held, harness, entry, entryAt));
|
|
841
|
+
const keys = parseKeys(held, steps[0]?.native, raw.keys, under(at, 'keys'));
|
|
842
|
+
const keysDropped = raw.keysDropped === undefined ? undefined : bucketIn('count', raw.keysDropped, under(at, 'keysDropped'));
|
|
843
|
+
const targetClass = raw.targetClass === undefined ? undefined : oneOf(held, TARGET_CLASSES, raw.targetClass, under(at, 'targetClass'));
|
|
844
|
+
const argClass = raw.argClass === undefined ? undefined : listOf(raw.argClass, under(at, 'argClass'), (entry, entryAt) => oneOf(held, ARG_CLASSES, entry, entryAt));
|
|
466
845
|
return {
|
|
467
846
|
side: booleanOf(raw.side, under(at, 'side')),
|
|
468
847
|
prompt: orNull(raw.prompt, under(at, 'prompt'), countOf),
|
|
469
|
-
status
|
|
848
|
+
status,
|
|
470
849
|
durationMs: orNull(raw.durationMs, under(at, 'durationMs'), countOf),
|
|
471
|
-
|
|
850
|
+
errorCode,
|
|
851
|
+
...(recoveryCalls === undefined ? {} : { recoveryCalls }),
|
|
852
|
+
...(keys === undefined ? {} : { keys }),
|
|
853
|
+
...(keysDropped === undefined ? {} : { keysDropped }),
|
|
854
|
+
...(targetClass === undefined ? {} : { targetClass }),
|
|
855
|
+
...(argClass === undefined ? {} : { argClass }),
|
|
856
|
+
steps,
|
|
472
857
|
};
|
|
473
858
|
};
|
|
474
|
-
const parsePrompt = (value, at) => {
|
|
475
|
-
const raw = fieldsOf(value, at, ['cls', 'errors', '
|
|
859
|
+
const parsePrompt = (held, value, at) => {
|
|
860
|
+
const raw = fieldsOf(held, value, at, ['cls', 'errors', 'usage', 'frame'], OPTIONAL.prompt);
|
|
476
861
|
return {
|
|
477
|
-
cls: oneOf(PROMPT_CLASSES, raw.cls, under(at, 'cls')),
|
|
862
|
+
cls: oneOf(held, PROMPT_CLASSES, raw.cls, under(at, 'cls')),
|
|
478
863
|
errors: bucketIn('count', raw.errors, under(at, 'errors')),
|
|
479
864
|
wallSec: orNull(raw.wallSec, under(at, 'wallSec'), (entry, entryAt) => bucketIn('seconds', entry, entryAt)),
|
|
480
|
-
usage: parseUsage(raw.usage, under(at, 'usage')),
|
|
865
|
+
usage: parseUsage(held, raw.usage, under(at, 'usage')),
|
|
481
866
|
explore: orNull(raw.explore, under(at, 'explore'), (entry, entryAt) => bucketIn('calls', entry, entryAt)),
|
|
482
|
-
|
|
867
|
+
...(raw.usageResponses === undefined ? {} : { usageResponses: bucketIn('count', raw.usageResponses, under(at, 'usageResponses')) }),
|
|
868
|
+
// A sent null reads as an absent field, the way every optional field does.
|
|
869
|
+
...(raw.intervention === undefined || raw.intervention === null ? {} : { intervention: oneOf(held, PROMPT_INTERVENTIONS, raw.intervention, under(at, 'intervention')) }),
|
|
870
|
+
frame: parseFrame(held, raw.frame, under(at, 'frame')),
|
|
483
871
|
};
|
|
484
872
|
};
|
|
485
873
|
const ROOT = /^[0-9a-f]{64}$/;
|
|
@@ -490,17 +878,24 @@ const parseRoot = (value, at) => {
|
|
|
490
878
|
throw new Refused(at, 'not a SHA-256');
|
|
491
879
|
return root;
|
|
492
880
|
};
|
|
493
|
-
const parseTranscript = (value, at) => {
|
|
494
|
-
const raw = fieldsOf(value, at, ['merkleRoot', 'leaves', 'harness', 'frame', 'prompts', 'tools']);
|
|
495
|
-
const harness = oneOf(HARNESSES, raw.harness, under(at, 'harness'));
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
881
|
+
const parseTranscript = (held, value, at) => {
|
|
882
|
+
const raw = fieldsOf(held, value, at, ['merkleRoot', 'leaves', 'harness', 'frame', 'frames', 'prompts', 'tools'], OPTIONAL.transcript);
|
|
883
|
+
const harness = oneOf(held, HARNESSES, raw.harness, under(at, 'harness'));
|
|
884
|
+
const merkleRoot = parseRoot(raw.merkleRoot, under(at, 'merkleRoot'));
|
|
885
|
+
const leaves = countOf(raw.leaves, under(at, 'leaves'));
|
|
886
|
+
// Read in the order the fields are met, so the bag holds what was set aside in that order.
|
|
887
|
+
const frame = parseFrame(held, raw.frame, under(at, 'frame'));
|
|
888
|
+
const frames = listOf(raw.frames, under(at, 'frames'), (entry, entryAt) => parseFrame(held, entry, entryAt));
|
|
889
|
+
// Absent reads as null: a client that names no spawner sends none.
|
|
890
|
+
const spawnedIn = orNull(raw.spawnedIn, under(at, 'spawnedIn'), countOf);
|
|
891
|
+
const prompts = listOf(raw.prompts, under(at, 'prompts'), (entry, entryAt) => parsePrompt(held, entry, entryAt));
|
|
892
|
+
const tools = listOf(raw.tools, under(at, 'tools'), (entry, entryAt) => parseTool(held, harness, entry, entryAt));
|
|
893
|
+
// What followed a call is read off the calls themselves, so a list that disagrees with them is a wrong deposit, not a new reading.
|
|
894
|
+
tools.forEach((tool, index) => {
|
|
895
|
+
if (tool.recoveryCalls !== undefined && JSON.stringify(tool.recoveryCalls) !== JSON.stringify(recoveryCallsOf(tools, index)))
|
|
896
|
+
throw new Refused(`${at}.tools[${String(index)}].recoveryCalls`, 'not what followed the call');
|
|
897
|
+
});
|
|
898
|
+
return { merkleRoot, leaves, harness, frame, frames, spawnedIn, prompts, tools };
|
|
504
899
|
};
|
|
505
900
|
const DELTA_FIELDS = ['plugin', 'package', 'permissionMode', 'effort'];
|
|
506
901
|
/** The kinds each field can take: a plugin comes or goes, a package only goes (an addition is adoption, which `pool` reads from the calls), and a mode or an effort changes. */
|
|
@@ -508,7 +903,8 @@ const KINDS_OF = { plugin: ['added', 'removed'], package: ['removed'], permissio
|
|
|
508
903
|
/** The one metric each field's change is measured in, and the bucket edges that metric is held to. */
|
|
509
904
|
const METRIC_OF = { plugin: 'callsPerSession', package: 'callsPerSession', permissionMode: 'callsPerSession', effort: 'thinking' };
|
|
510
905
|
const METRIC_EDGES = { callsPerSession: 'count', thinking: 'tokens' };
|
|
511
|
-
const
|
|
906
|
+
const ALL_KINDS = ['added', 'removed', 'changed'];
|
|
907
|
+
const ALL_METRICS = ['callsPerSession', 'thinking'];
|
|
512
908
|
const nullOnly = (value, at) => {
|
|
513
909
|
if (value !== null)
|
|
514
910
|
throw new Refused(at, 'outside the vocabulary');
|
|
@@ -518,32 +914,47 @@ const nullOnly = (value, at) => {
|
|
|
518
914
|
* The entry of a delta, held to its field's own row. A plugin has no registry to pass, so only the word `private` stands for one; a package is a public name with its registry, or `private` with none; a mode
|
|
519
915
|
* and an effort are named by their field and carry no name. The change is a signed bucket of the field's metric.
|
|
520
916
|
*/
|
|
521
|
-
const parseDelta = (value, at) => {
|
|
522
|
-
const raw = fieldsOf(value, at, ['deltaKind', 'field', 'name', 'registry', 'metric', 'change']);
|
|
523
|
-
const field = oneOf(DELTA_FIELDS, raw.field, under(at, 'field'));
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
const
|
|
527
|
-
const
|
|
917
|
+
const parseDelta = (held, value, at) => {
|
|
918
|
+
const raw = fieldsOf(held, value, at, ['deltaKind', 'field', 'name', 'registry', 'metric', 'change']);
|
|
919
|
+
const field = oneOf(held, DELTA_FIELDS, raw.field, under(at, 'field'));
|
|
920
|
+
// A field this reader does not know has no row to hold its kind, metric, name, or registry to: the first two read against every kind and metric, and the last two are kept as sent.
|
|
921
|
+
const kinds = field === OTHER ? ALL_KINDS : KINDS_OF[field];
|
|
922
|
+
const deltaKind = oneOf(held, kinds, raw.deltaKind, under(at, 'deltaKind'));
|
|
923
|
+
const metric = oneOf(held, field === OTHER ? ALL_METRICS : [METRIC_OF[field]], raw.metric, under(at, 'metric'));
|
|
924
|
+
const named = field === 'plugin' || field === 'package';
|
|
925
|
+
if (field === OTHER) {
|
|
926
|
+
for (const key of ['name', 'registry'])
|
|
927
|
+
if (raw[key] !== null)
|
|
928
|
+
setAside(held, under(at, key), raw[key]);
|
|
929
|
+
}
|
|
930
|
+
const name = named ? inSet(held, field === 'package' ? PUBLIC_PACKAGES : NO_NAME, raw.name, under(at, 'name')) : field === OTHER ? null : nullOnly(raw.name, under(at, 'name'));
|
|
931
|
+
const registry = field === 'package' && name !== PRIVATE ? oneOf(held, PACKAGE_REGISTRIES, raw.registry, under(at, 'registry')) : field === OTHER ? null : nullOnly(raw.registry, under(at, 'registry'));
|
|
528
932
|
const change = raw.change;
|
|
529
|
-
|
|
933
|
+
const limit = metric === OTHER ? Math.max(...ALL_METRICS.map((kind) => EDGES[METRIC_EDGES[kind]].length)) : EDGES[METRIC_EDGES[metric]].length;
|
|
934
|
+
if (typeof change !== 'number' || !Number.isInteger(change) || Math.abs(change) > limit)
|
|
530
935
|
throw new Refused(under(at, 'change'), 'not a signed bucket');
|
|
531
936
|
return { deltaKind, field, name, registry, metric, change };
|
|
532
937
|
};
|
|
533
|
-
|
|
938
|
+
const VOCAB_TEXT = /^[0-9A-Za-z._-]{1,32}$/;
|
|
939
|
+
/** The skeleton in `value`, rebuilt from the fields read, with the bag of what was set aside; else the first field that is a shape error, and why. */
|
|
534
940
|
export const parseSkeleton = (value) => {
|
|
941
|
+
const held = { extensions: [], others: 0 };
|
|
535
942
|
try {
|
|
536
|
-
// The
|
|
537
|
-
if (isFields(value) && value.format ===
|
|
538
|
-
throw new Refused('format',
|
|
539
|
-
const raw = fieldsOf(value, '', ['format', '
|
|
943
|
+
// The earlier forms are named, since their fields would otherwise read as a list of strangers; any other format is a form this reader does not know.
|
|
944
|
+
if (isFields(value) && typeof value.format === 'number' && value.format < SKELETON_FORMAT)
|
|
945
|
+
throw new Refused('format', `the format ${String(value.format)} form is no longer read; send format ${String(SKELETON_FORMAT)}`);
|
|
946
|
+
const raw = fieldsOf(held, value, '', ['format', 'vocabVersion', 'fieldsVersion', 'id', 'transcripts'], OPTIONAL.batch);
|
|
540
947
|
if (raw.format !== SKELETON_FORMAT)
|
|
541
948
|
throw new Refused('format', 'not a format this version reads');
|
|
949
|
+
const vocabVersion = textOf(raw.vocabVersion, 'vocabVersion');
|
|
950
|
+
if (!VOCAB_TEXT.test(vocabVersion))
|
|
951
|
+
throw new Refused('vocabVersion', 'not a version');
|
|
952
|
+
const fieldsVersion = countOf(raw.fieldsVersion, 'fieldsVersion');
|
|
542
953
|
const id = textOf(raw.id, 'id');
|
|
543
954
|
if (!ID.test(id))
|
|
544
955
|
throw new Refused('id', 'not a hash');
|
|
545
|
-
const deltas =
|
|
546
|
-
const transcripts = listOf(raw.transcripts, 'transcripts', parseTranscript);
|
|
956
|
+
const deltas = listOrEmpty(raw.deltas, 'deltas', (entry, entryAt) => parseDelta(held, entry, entryAt));
|
|
957
|
+
const transcripts = listOf(raw.transcripts, 'transcripts', (entry, entryAt) => parseTranscript(held, entry, entryAt));
|
|
547
958
|
if (transcripts.length === 0)
|
|
548
959
|
throw new Refused('transcripts', 'empty');
|
|
549
960
|
// A counting batch is what lifts the fetch cap, so a deposit has to hold something to derive from.
|
|
@@ -569,20 +980,62 @@ export const parseSkeleton = (value) => {
|
|
|
569
980
|
});
|
|
570
981
|
});
|
|
571
982
|
// Positions are the only ordinals on the wire, so each must name something the batch holds.
|
|
572
|
-
|
|
573
|
-
transcripts.forEach(({
|
|
983
|
+
// A spawner is another transcript of the batch whose own spawners lead to a run with none, so the batch is a forest and the main run is its first root.
|
|
984
|
+
transcripts.forEach(({ spawnedIn }, index) => {
|
|
985
|
+
if (spawnedIn === null)
|
|
986
|
+
return;
|
|
987
|
+
const spawnedAt = `transcripts[${String(index)}].spawnedIn`;
|
|
988
|
+
if (index === 0)
|
|
989
|
+
throw new Refused(spawnedAt, 'the main run has no spawner');
|
|
990
|
+
if (spawnedIn >= transcripts.length || spawnedIn === index)
|
|
991
|
+
throw new Refused(spawnedAt, 'no such transcript');
|
|
992
|
+
let above = spawnedIn;
|
|
993
|
+
for (let hop = 0; hop < transcripts.length && above !== null; hop += 1)
|
|
994
|
+
above = transcripts[above]?.spawnedIn ?? null;
|
|
995
|
+
if (above !== null)
|
|
996
|
+
throw new Refused(spawnedAt, 'a spawner cycle');
|
|
997
|
+
});
|
|
998
|
+
const stepsOf = transcripts.map(({ tools }) => tools.reduce((all, { steps }) => all + steps.length, 0));
|
|
999
|
+
transcripts.forEach(({ prompts, tools, frames, spawnedIn }, index) => {
|
|
1000
|
+
if (frames.length === 0)
|
|
1001
|
+
throw new Refused(`transcripts[${String(index)}].frames`, 'empty');
|
|
1002
|
+
// The call and prompt of every step in the transcript's order, which an edge's target is a position in.
|
|
1003
|
+
const placed = tools.flatMap(({ prompt, steps }) => steps.map(() => ({ prompt })));
|
|
1004
|
+
let before = 0;
|
|
574
1005
|
tools.forEach(({ prompt, steps }, call) => {
|
|
575
1006
|
const callAt = `transcripts[${String(index)}].tools[${String(call)}]`;
|
|
576
1007
|
// Past the prompts it names a prompt with no record, numbered in order of appearance: no more of those than calls.
|
|
577
1008
|
if (prompt !== null && prompt >= prompts.length + tools.length)
|
|
578
1009
|
throw new Refused(`${callAt}.prompt`, 'no such prompt position');
|
|
579
|
-
steps.forEach(({ parent }, position) => {
|
|
580
|
-
|
|
581
|
-
|
|
1010
|
+
steps.forEach(({ parent, frame, edges }, position) => {
|
|
1011
|
+
const stepAt = `${callAt}.steps[${String(position)}]`;
|
|
1012
|
+
if (parent !== null && (spawnedIn === null || parent >= (transcripts[spawnedIn]?.tools.length ?? 0)))
|
|
1013
|
+
throw new Refused(`${stepAt}.parent`, 'no such call in the spawning transcript');
|
|
1014
|
+
if (frame >= frames.length)
|
|
1015
|
+
throw new Refused(`${stepAt}.frame`, 'no such frame position');
|
|
1016
|
+
const here = before + position;
|
|
1017
|
+
(edges ?? []).forEach(({ kind, target }, edge) => {
|
|
1018
|
+
const edgeAt = `${stepAt}.edges[${String(edge)}]`;
|
|
1019
|
+
if (kind === OTHER)
|
|
1020
|
+
return;
|
|
1021
|
+
if (kind === 'spawned') {
|
|
1022
|
+
if (spawnedIn === null)
|
|
1023
|
+
throw new Refused(`${edgeAt}.kind`, 'a spawned edge needs a spawning transcript');
|
|
1024
|
+
if (target >= (stepsOf[spawnedIn] ?? 0))
|
|
1025
|
+
throw new Refused(`${edgeAt}.target`, 'no such step in the spawning transcript');
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
// The other kinds name an earlier step of the same transcript; a next stays inside one prompt.
|
|
1029
|
+
if (target >= here)
|
|
1030
|
+
throw new Refused(`${edgeAt}.target`, 'not an earlier step of this transcript');
|
|
1031
|
+
if (kind === 'next' && placed[target]?.prompt !== prompt)
|
|
1032
|
+
throw new Refused(`${edgeAt}.target`, 'a next edge crosses a prompt');
|
|
1033
|
+
});
|
|
582
1034
|
});
|
|
1035
|
+
before += steps.length;
|
|
583
1036
|
});
|
|
584
1037
|
});
|
|
585
|
-
return { ok: true, skeleton: { format: SKELETON_FORMAT, id, transcripts, timingPrint, deltas } };
|
|
1038
|
+
return { ok: true, skeleton: { format: SKELETON_FORMAT, vocabVersion, fieldsVersion, id, transcripts, timingPrint, deltas }, extensions: held.extensions, others: held.others };
|
|
586
1039
|
}
|
|
587
1040
|
catch (error) {
|
|
588
1041
|
if (error instanceof Refused)
|
|
@@ -591,7 +1044,34 @@ export const parseSkeleton = (value) => {
|
|
|
591
1044
|
}
|
|
592
1045
|
};
|
|
593
1046
|
const isSignedBundle = (value) => isFields(value) && typeof value.payload === 'string' && typeof value.signature === 'string';
|
|
594
|
-
const
|
|
1047
|
+
const isFloor = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 1;
|
|
1048
|
+
/** The floors a bundle states, or none: a client prints what is here, so a value that is not a whole number of at least 1 is refused and not shown, while a family this client does not know is carried (in `--json`) and not printed, since `floorSentence` prints only the families this client knows (additive, section 17 rule 3). */
|
|
1049
|
+
const isFloors = (value) => isFields(value) && isFloor(value.public) && isFloor(value.disclosure) && (value.families === undefined || (isFields(value.families) && Object.values(value.families).every(isFloor)));
|
|
1050
|
+
const isSourced = (value) => isFields(value) && ((value.state === 'value' && typeof value.value === 'number') || (value.state === 'no source yet' && typeof value.reason === 'string'));
|
|
1051
|
+
const isCount = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
1052
|
+
/** The set of four a bundle may carry: every field the `books` read prints is present and of its type, with its format, vocabulary version, and the two served lists checked as well, so a signed bundle with a malformed block reads as not a bundle, not as a crash in the printer. */
|
|
1053
|
+
const isBooks = (value) => isFields(value) &&
|
|
1054
|
+
isFloor(value.floor) &&
|
|
1055
|
+
value.format === BUNDLE_FORMAT &&
|
|
1056
|
+
typeof value.vocabVersion === 'string' &&
|
|
1057
|
+
isFields(value.asOf) &&
|
|
1058
|
+
(value.asOf.day === null || typeof value.asOf.day === 'string') &&
|
|
1059
|
+
isFields(value.served) &&
|
|
1060
|
+
isCount(value.served.total) &&
|
|
1061
|
+
Array.isArray(value.served.byWeek) &&
|
|
1062
|
+
Array.isArray(value.served.byTuple) &&
|
|
1063
|
+
isCount(value.backlog) &&
|
|
1064
|
+
isSourced(value.panel) &&
|
|
1065
|
+
(value.panelDay === null || typeof value.panelDay === 'string') &&
|
|
1066
|
+
isFields(value.demand) &&
|
|
1067
|
+
isSourced(value.demand.comparisonReadOwners) &&
|
|
1068
|
+
isSourced(value.demand.followOnReads) &&
|
|
1069
|
+
isFields(value.goal) &&
|
|
1070
|
+
isSourced(value.goal.readYield) &&
|
|
1071
|
+
isSourced(value.goal.costPerServedCell) &&
|
|
1072
|
+
isSourced(value.goal.yieldPerServedCell);
|
|
1073
|
+
// A bundle states the floor it applied (`floor`), and a client cannot tell which floor a bundle without one applied, so a format 1 bundle with no `floor` is refused as not a bundle this version reads.
|
|
1074
|
+
const isBundle = (value) => isFields(value) && value.format === BUNDLE_FORMAT && typeof value.harness === 'string' && isHarness(value.harness) && typeof value.harnessVersion === 'string' && typeof value.vocabVersion === 'string' && typeof value.floor === 'number' && isFields(value.asOf) && Array.isArray(value.cells) && Array.isArray(value.motifs) && Array.isArray(value.reads) && Array.isArray(value.strata) && (value.floors === undefined || isFloors(value.floors)) && (value.books === undefined || isBooks(value.books));
|
|
595
1075
|
const signatureHolds = async ({ payload, signature }, key) => {
|
|
596
1076
|
try {
|
|
597
1077
|
const verifier = await crypto.subtle.importKey('jwk', { kty: key.kty, crv: key.crv, x: key.x }, 'Ed25519', false, ['verify']);
|