@manyoars/oars 0.4.1 → 0.5.2
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 +79 -29
- package/dist/bin.js +3 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +207 -21
- package/dist/club.d.ts +19 -0
- package/dist/club.js +132 -0
- package/dist/gate/hook-fill-main.js +1 -1
- package/dist/gate/hook-fill.d.ts +1 -1
- package/dist/gate/hook-fill.js +1 -1
- package/dist/gate/hook-main.js +7 -5
- package/dist/gate/hook.d.ts +12 -1
- package/dist/gate/hook.js +38 -7
- package/dist/hook-bin.js +23 -4
- package/dist/main.js +5 -0
- package/dist/node-floor.d.ts +10 -0
- package/dist/node-floor.js +23 -0
- package/dist/oars-hook.d.ts +2 -0
- package/dist/oars-hook.js +7 -0
- package/dist/sessions/allow.d.ts +1 -0
- package/dist/sessions/allow.js +1 -1
- package/dist/sessions/call-shape.d.ts +111 -0
- package/dist/sessions/call-shape.js +464 -0
- package/dist/sessions/candidates.d.ts +65 -0
- package/dist/sessions/candidates.js +236 -0
- package/dist/sessions/card-command.d.ts +24 -0
- package/dist/sessions/card-command.js +71 -0
- package/dist/sessions/card-font.d.ts +6 -0
- package/dist/sessions/card-font.js +36 -0
- package/dist/sessions/card-line.d.ts +30 -0
- package/dist/sessions/card-line.js +105 -0
- package/dist/sessions/card-render.d.ts +15 -0
- package/dist/sessions/card-render.js +146 -0
- package/dist/sessions/card.d.ts +59 -0
- package/dist/sessions/card.js +107 -0
- package/dist/sessions/cell-increments.d.ts +14 -3
- package/dist/sessions/cell-increments.js +79 -12
- package/dist/sessions/cells.d.ts +42 -4
- package/dist/sessions/cells.js +72 -18
- package/dist/sessions/codex-hook-bin.d.ts +1 -0
- package/dist/sessions/codex-hook-bin.js +24 -0
- package/dist/sessions/codex-hook-install.d.ts +21 -0
- package/dist/sessions/codex-hook-install.js +47 -0
- package/dist/sessions/codex-hook-view.d.ts +20 -0
- package/dist/sessions/codex-hook-view.js +20 -0
- package/dist/sessions/codex-hook.d.ts +77 -0
- package/dist/sessions/codex-hook.js +255 -0
- package/dist/sessions/codex.d.ts +9 -0
- package/dist/sessions/codex.js +104 -18
- package/dist/sessions/command.d.ts +17 -2
- package/dist/sessions/command.js +145 -33
- package/dist/sessions/copilot.d.ts +3 -0
- package/dist/sessions/copilot.js +12 -4
- package/dist/sessions/derive.js +2 -1
- package/dist/sessions/empty-state.d.ts +17 -0
- package/dist/sessions/empty-state.js +38 -0
- package/dist/sessions/exclusion.js +48 -3
- package/dist/sessions/extract.d.ts +16 -3
- package/dist/sessions/extract.js +281 -24
- package/dist/sessions/gemini.d.ts +2 -0
- package/dist/sessions/gemini.js +29 -2
- package/dist/sessions/harness-files.d.ts +27 -0
- package/dist/sessions/harness-files.js +109 -0
- package/dist/sessions/head-only.d.ts +7 -0
- package/dist/sessions/head-only.js +21 -0
- package/dist/sessions/hook-calls.js +4 -3
- package/dist/sessions/hooks.d.ts +1 -1
- package/dist/sessions/hooks.js +6 -2
- package/dist/sessions/identity.d.ts +6 -0
- package/dist/sessions/injected.d.ts +13 -0
- package/dist/sessions/injected.js +260 -0
- package/dist/sessions/install.d.ts +31 -20
- package/dist/sessions/install.js +80 -82
- package/dist/sessions/load-status.d.ts +41 -0
- package/dist/sessions/load-status.js +156 -0
- package/dist/sessions/machine-child-deps.d.ts +5 -0
- package/dist/sessions/machine-child-deps.js +83 -0
- package/dist/sessions/machine-child.d.ts +43 -0
- package/dist/sessions/machine-child.js +120 -0
- package/dist/sessions/machine-credential.d.ts +10 -0
- package/dist/sessions/machine-credential.js +20 -0
- package/dist/sessions/machine-enroll.d.ts +47 -0
- package/dist/sessions/machine-enroll.js +230 -0
- package/dist/sessions/machine-hold.d.ts +49 -0
- package/dist/sessions/machine-hold.js +88 -0
- package/dist/sessions/machine-ids.d.ts +6 -0
- package/dist/sessions/machine-ids.js +9 -0
- package/dist/sessions/machine-notice.d.ts +21 -0
- package/dist/sessions/machine-notice.js +89 -0
- package/dist/sessions/machine-pull.d.ts +40 -0
- package/dist/sessions/machine-pull.js +220 -0
- package/dist/sessions/machine-repo.d.ts +7 -0
- package/dist/sessions/machine-repo.js +30 -0
- package/dist/sessions/machine-scan-cache.d.ts +12 -0
- package/dist/sessions/machine-scan-cache.js +47 -0
- package/dist/sessions/machine-send.d.ts +12 -0
- package/dist/sessions/machine-send.js +156 -0
- package/dist/sessions/machine-stop.d.ts +22 -0
- package/dist/sessions/machine-stop.js +53 -0
- package/dist/sessions/machine.d.ts +41 -0
- package/dist/sessions/machine.js +89 -0
- package/dist/sessions/opencode.d.ts +2 -0
- package/dist/sessions/opencode.js +6 -0
- package/dist/sessions/otel.d.ts +21 -0
- package/dist/sessions/otel.js +109 -38
- package/dist/sessions/owner-only.d.ts +7 -0
- package/dist/sessions/owner-only.js +34 -0
- package/dist/sessions/pi.d.ts +13 -1
- package/dist/sessions/pi.js +27 -13
- package/dist/sessions/pool-books.d.ts +1 -1
- package/dist/sessions/pool-books.js +1 -1
- package/dist/sessions/pool-choices.d.ts +4 -2
- package/dist/sessions/pool-choices.js +4 -2
- package/dist/sessions/pool-command.d.ts +46 -6
- package/dist/sessions/pool-command.js +613 -136
- package/dist/sessions/pool-delta.d.ts +7 -0
- package/dist/sessions/pool-delta.js +28 -5
- package/dist/sessions/pool-due.d.ts +43 -0
- package/dist/sessions/pool-due.js +113 -0
- package/dist/sessions/pool-nightly.d.ts +9 -1
- package/dist/sessions/pool-nightly.js +65 -21
- package/dist/sessions/pool-outbox.d.ts +51 -5
- package/dist/sessions/pool-outbox.js +217 -24
- package/dist/sessions/pool-print.d.ts +1 -1
- package/dist/sessions/pool-raw.js +4 -3
- package/dist/sessions/pool-replica.d.ts +4 -4
- package/dist/sessions/pool-replica.js +26 -7
- package/dist/sessions/pool-send-lock.js +1 -1
- package/dist/sessions/pool-sent.d.ts +2 -0
- package/dist/sessions/pool-sent.js +21 -4
- package/dist/sessions/pool-wire.d.ts +176 -15
- package/dist/sessions/pool-wire.js +297 -33
- package/dist/sessions/pool.js +4 -2
- package/dist/sessions/public-vocabulary.d.ts +5 -0
- package/dist/sessions/public-vocabulary.js +16 -0
- package/dist/sessions/read-call-shape.js +12 -6
- package/dist/sessions/read-fit.d.ts +5 -0
- package/dist/sessions/read-fit.js +23 -2
- package/dist/sessions/read-interaction.js +1 -1
- package/dist/sessions/read-lead-lag.js +1 -1
- package/dist/sessions/read-readable.js +1 -1
- package/dist/sessions/read.d.ts +1 -1
- package/dist/sessions/recall-consequence.d.ts +2 -2
- package/dist/sessions/recall-consequence.js +14 -9
- package/dist/sessions/recall-object.d.ts +9 -2
- package/dist/sessions/receipt.d.ts +8 -0
- package/dist/sessions/receipt.js +78 -0
- package/dist/sessions/recovery-calls.d.ts +3 -2
- package/dist/sessions/recovery-calls.js +9 -3
- package/dist/sessions/redact.d.ts +11 -2
- package/dist/sessions/redact.js +201 -23
- package/dist/sessions/repo-root.d.ts +7 -0
- package/dist/sessions/repo-root.js +17 -13
- package/dist/sessions/reversion.d.ts +8 -0
- package/dist/sessions/reversion.js +114 -0
- package/dist/sessions/schema.d.ts +78 -4
- package/dist/sessions/schema.js +31 -1
- package/dist/sessions/session-shape.d.ts +2 -1
- package/dist/sessions/session-shape.js +15 -0
- package/dist/sessions/sources.d.ts +9 -0
- package/dist/sessions/sources.js +16 -5
- package/dist/sessions/steps.js +3 -2
- package/dist/sessions/store.d.ts +41 -2
- package/dist/sessions/store.js +68 -16
- package/dist/sessions/try-demo.d.ts +8 -0
- package/dist/sessions/try-demo.js +90 -0
- package/dist/sessions/try.d.ts +13 -0
- package/dist/sessions/try.js +50 -0
- package/dist/sessions/vscode-copilot.d.ts +4 -0
- package/dist/sessions/vscode-copilot.js +25 -18
- package/dist/telemetry/command.js +12 -6
- package/dist/telemetry/consent.d.ts +9 -3
- package/dist/telemetry/consent.js +25 -9
- package/dist/telemetry/fields.d.ts +11 -0
- package/dist/telemetry/fields.js +56 -0
- package/dist/telemetry/measures.d.ts +13 -0
- package/dist/telemetry/measures.js +30 -0
- package/dist/telemetry/observe.d.ts +15 -2
- package/dist/telemetry/observe.js +34 -7
- package/dist/telemetry/region.d.ts +6 -0
- package/dist/telemetry/region.js +28 -0
- package/dist/telemetry/report.js +1 -1
- package/dist/telemetry/schema.d.ts +3 -0
- package/dist/telemetry/schema.js +11 -1
- package/dist/telemetry/send.d.ts +5 -2
- package/dist/telemetry/send.js +15 -7
- package/dist/telemetry/words.d.ts +6 -0
- package/dist/telemetry/words.js +6 -0
- package/package.json +24 -5
|
@@ -2,18 +2,38 @@
|
|
|
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, 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';
|
|
5
|
+
import { CLOSED_ARG_VALUES, 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, CHANGE_KINDS, DIFF_SHAPES, DIRECTORY_KINDS, EXTENSION_CLASSES, NAME_KINDS, PATH_DEPTHS, PATTERN_FEATURES, TARGET_CLASSES, TEMPLATE_SLOTS, VALUE_SIZES } from './call-shape.js';
|
|
7
7
|
import { recoveryCallsOf, RECOVERY_CALLS } from './recovery-calls.js';
|
|
8
8
|
import { projectLanguage, projectLanguageShare } from './strata.js';
|
|
9
9
|
import { ERROR_CODES, LANGUAGE_SHARES, PROMPT_INTERVENTIONS } from './schema.js';
|
|
10
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
|
-
|
|
11
|
+
// Format 4 (layer 1 of a call's parameters): a tool may carry `argFeatures`, `argValues`, and `changeKind`. The pool reads formats 3 and 4 (`READ_FORMATS`): a format 3 batch is read as before, and one of those
|
|
12
|
+
// three names in it is a field format 3 does not know, so it goes to the bag and is served from nowhere. The table is "Formats on open" in docs/pool.md.
|
|
13
|
+
// Format 5 (layer 2 of a call's parameters): a tool may carry `names`, the file, directory, and variable names its values held. The pool reads formats 3, 4, and 5, and a name field in a batch of an older format goes to the bag the same way.
|
|
14
|
+
// Format 6 (exposure): a batch may carry `recommendations`, one entry for each recommendation the session was shown or held back from, so the pool can tell shown from held. A batch with none says nothing, and the pool reads that as unknown, never as unexposed.
|
|
15
|
+
// Format 7 (edit digests): a tool may carry `digests`, the digest of the old and new text of an Edit, MultiEdit, or Write call, so an edit that restores earlier content can be told from one that does not. A `git restore` names its path the way an edit
|
|
16
|
+
// does, in `names`, so it needs no field of its own. The pool reads formats 3 to 7, and a recommendations or digests list in an older batch goes to the bag the same way.
|
|
17
|
+
export const SKELETON_FORMAT = 7;
|
|
18
|
+
/** The formats the parser reads, oldest first: the client builds only the last. */
|
|
19
|
+
export const READ_FORMATS = [3, 4, 5, 6, SKELETON_FORMAT];
|
|
20
|
+
/** The tool fields format 4 added: a format 3 batch that holds one is holding a field it does not define. */
|
|
21
|
+
export const FORMAT_4_TOOL_FIELDS = ['argFeatures', 'argValues', 'changeKind'];
|
|
22
|
+
/** The tool fields format 5 added: a batch of format 4 or 3 that holds one is holding a field it does not define. */
|
|
23
|
+
export const FORMAT_5_TOOL_FIELDS = ['names', 'namesDropped', 'templates', 'templatesDropped', 'diffShape'];
|
|
24
|
+
/** The batch fields format 6 added: a batch of an older format that holds one is holding a field it does not define. */
|
|
25
|
+
export const FORMAT_6_BATCH_FIELDS = ['recommendations'];
|
|
26
|
+
/** The format that added exposure. A batch of an older format holds no `recommendations`, and its failed calls are checked with the rule they were built under. */
|
|
27
|
+
export const EXPOSURE_FORMAT = 6;
|
|
28
|
+
/** The format that added edit digests. */
|
|
29
|
+
export const DIGEST_FORMAT = 7;
|
|
30
|
+
/** The tool fields format 7 added: a batch of format 6 or older that holds one is holding a field it does not define. */
|
|
31
|
+
export const FORMAT_7_TOOL_FIELDS = ['digests'];
|
|
12
32
|
/**
|
|
13
33
|
* 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
34
|
* 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
35
|
*/
|
|
16
|
-
export const FIELD_LIST_VERSION =
|
|
36
|
+
export const FIELD_LIST_VERSION = 14;
|
|
17
37
|
export const BUNDLE_FORMAT = 1;
|
|
18
38
|
/** One constant: the pool serves only the vocabulary version the client holds. */
|
|
19
39
|
export { VOCAB_VERSION };
|
|
@@ -43,6 +63,10 @@ export const ORG_SIZES = ['solo', '2-10', '11-100', '101-1000', 'over-1000', 'un
|
|
|
43
63
|
export const TEAMS = ['solo', 'team', 'unknown'];
|
|
44
64
|
/** Whether the repository is public or private (an operator's setting, read locally, never looked up). */
|
|
45
65
|
export const REPO_VISIBILITIES = ['public', 'private', 'unknown'];
|
|
66
|
+
/** Where a session ran, as a trusted machine states it for a batch it admitted: the five host classes an enrollment knows (`HOST_CLASSES` in `machine.ts`, not imported: the pool bundles this file). */
|
|
67
|
+
export const CLAIMED_HOST_CLASSES = ['local', 'vps', 'cloud', 'ci', 'hosted'];
|
|
68
|
+
/** What started the session, as an enrollment declares it. */
|
|
69
|
+
export const CLAIMED_LAUNCHERS = ['interactive', 'orchestrated', 'scheduled', 'ci', 'hosted-run', 'unknown'];
|
|
46
70
|
export const NO_COVERAGE = { orgSize: 'unknown', team: 'unknown', repoVisibility: 'unknown' };
|
|
47
71
|
/** A repo key is the store-salted hash a session record holds of its repository's identity: sixteen lowercase hex characters. */
|
|
48
72
|
const REPO_KEY = /^[0-9a-f]{16}$/;
|
|
@@ -53,6 +77,8 @@ export const hourOf = (ts) => {
|
|
|
53
77
|
const millis = Date.parse(ts);
|
|
54
78
|
return Number.isNaN(millis) ? null : new Date(millis).getUTCHours();
|
|
55
79
|
};
|
|
80
|
+
/** Whether a recommendation reached the asker (`shown`) or was withheld by the holdback (`held`). The local row's arm, as the closed list holds it. */
|
|
81
|
+
export const RECOMMENDATION_ARMS = ['shown', 'held'];
|
|
56
82
|
/** 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
83
|
const observedBucket = (value, observed, field) => (observed === undefined || observed.includes(field) ? bucketOf('tokens', value) : null);
|
|
58
84
|
/** W's minimum for a timing print (deposit-weighting spec, layer 5): fewer timed calls than this say too little to tell sessions apart. */
|
|
@@ -60,12 +86,24 @@ export const MIN_TIMED_CALLS = 50;
|
|
|
60
86
|
/** A session has ended once no transcript of it has gained a line for this long (`session_idle_hours`, spec Section 7). */
|
|
61
87
|
export const SESSION_IDLE_HOURS = 6;
|
|
62
88
|
const HOUR_MS = 60 * 60 * 1000;
|
|
89
|
+
/** No session leaves this machine sooner than this after it ends, whatever `session_idle_hours` is: time for `oars sessions deposit --deny` or a delete to act on it first. Today the longer idle window governs; this is the floor under it. */
|
|
90
|
+
export const HOLD_WINDOW_MS = HOUR_MS;
|
|
63
91
|
/**
|
|
64
|
-
* Whether a session's batch may be built at `now`: no transcript of it, main or subagent, has gained a line for `SESSION_IDLE_HOURS`. A transcript
|
|
65
|
-
*
|
|
66
|
-
*
|
|
92
|
+
* Whether a session's batch may be built at `now`: no transcript of it, main or subagent, has gained a line for `SESSION_IDLE_HOURS`. A transcript was last active at its own last line (`tsLast`, the
|
|
93
|
+
* session's clock, not ours), or at its capture (`rev`, epoch milliseconds) when it has none, so a store captured a minute ago from days-old transcripts holds sessions that ended days ago. A transcript can
|
|
94
|
+
* also grow after the last capture, so `written` (the newest write to any raw file of the session, 0 when none is known) counts when it is later than every capture: a write at or before the capture is the
|
|
95
|
+
* file arriving, not the session going on. A last line later than the capture is a bad clock, and the capture caps it. A person's build (`sender` false) takes the ended session at once. The sender, which builds and sends in one run, also waits `HOLD_WINDOW_MS` after the newest capture, so a session oars is seeing for the first time (a days-old transcript of a new harness) leaves no sooner than an hour later, time for `--deny` or a delete. A subagent that died without an end marker holds its session back at most that long.
|
|
67
96
|
*/
|
|
68
|
-
export const hasEnded = (records, now, written
|
|
97
|
+
export const hasEnded = (records, now, written, idleMs = SESSION_IDLE_HOURS * HOUR_MS, sender = false) => {
|
|
98
|
+
const lastLines = records.transcripts.map(({ session }) => {
|
|
99
|
+
const ts = session.tsLast === null ? Number.NaN : Date.parse(session.tsLast);
|
|
100
|
+
return Number.isNaN(ts) ? session.rev : Math.min(ts, session.rev);
|
|
101
|
+
});
|
|
102
|
+
const captured = Math.max(...records.transcripts.map(({ session }) => session.rev));
|
|
103
|
+
if (sender && now - captured < HOLD_WINDOW_MS)
|
|
104
|
+
return false;
|
|
105
|
+
return now - Math.max(written > captured ? written : 0, ...lastLines) >= Math.max(idleMs, HOLD_WINDOW_MS);
|
|
106
|
+
};
|
|
69
107
|
const groupBy = (items, keyOf) => {
|
|
70
108
|
const groups = new Map();
|
|
71
109
|
for (const item of items) {
|
|
@@ -102,14 +140,16 @@ export const isWirePullName = (name) => name.length <= NAME_MAX && WIRE_PULL_NAM
|
|
|
102
140
|
export const OPEN_GATE = { redact: (text) => text, terms: [] };
|
|
103
141
|
/** 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
142
|
const unchanged = (gate, name) => gate.redact(name) === name && name.split(/_+/).every((part) => gate.redact(part) === part);
|
|
143
|
+
/** Whether a name clears the one gate: it has the name shape, the redactor leaves it as it is, and it holds none of the exclusion terms. A name token that is really the word `private` passes; only this says a name was refused. */
|
|
144
|
+
const passesGate = (gate, name, hasShape) => {
|
|
145
|
+
const lower = name.toLowerCase();
|
|
146
|
+
return hasShape(name) && unchanged(gate, name) && !gate.terms.some((term) => term !== '' && lower.includes(term.toLowerCase()));
|
|
147
|
+
};
|
|
105
148
|
/**
|
|
106
149
|
* 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
150
|
* `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
151
|
*/
|
|
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
|
-
};
|
|
152
|
+
export const wireName = (gate, name, hasShape = isWireName) => (passesGate(gate, name, hasShape) ? name : PRIVATE);
|
|
113
153
|
const FLAG_DASHES = /^-{1,2}/;
|
|
114
154
|
const SHORT_FLAG = /^-[A-Za-z]$/;
|
|
115
155
|
// 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.
|
|
@@ -127,7 +167,7 @@ const wireFlag = (gate, flag) => (isWireFlag(flag) && wireName(gate, flag.replac
|
|
|
127
167
|
* 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
168
|
* 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
169
|
*/
|
|
130
|
-
const wireKeys = (stored, native, gate) => {
|
|
170
|
+
export const wireKeys = (stored, native, gate) => {
|
|
131
171
|
const distinct = [...new Set(stored)];
|
|
132
172
|
const listed = native === undefined ? undefined : TOOL_INPUT_KEYS.get(native);
|
|
133
173
|
const kept = native === undefined || native === PRIVATE ? [] : distinct.filter((name) => (listed === undefined ? wireName(gate, name) !== PRIVATE : listed.has(name)));
|
|
@@ -135,6 +175,65 @@ const wireKeys = (stored, native, gate) => {
|
|
|
135
175
|
};
|
|
136
176
|
/** A stored closed-list member, else dropped: a hand-edited record never puts a value off the list on the wire. */
|
|
137
177
|
const memberOf = (list, value) => list.find((entry) => entry === value);
|
|
178
|
+
/** Whether a closed value is one the wire may carry for a tool's key: a boolean's two words, or a member of the tool's list. */
|
|
179
|
+
export const isClosedValue = (native, key, value) => value === 'true' || value === 'false' || (native !== undefined && CLOSED_ARG_VALUES.get(native)?.get(key)?.has(value) === true);
|
|
180
|
+
/**
|
|
181
|
+
* A call's value features as the wire sends them (layer 1): only a sent key's, and only closed-list members, so a hand-edited record never puts a value on the wire. An entry whose size is off its list is dropped whole, a
|
|
182
|
+
* path or pattern field off its list is left out, and a pattern holding no listed feature sends none.
|
|
183
|
+
*/
|
|
184
|
+
export const wireFeatures = (stored, sent) => (stored ?? []).flatMap((feature) => {
|
|
185
|
+
const valueSize = memberOf(VALUE_SIZES, feature.valueSize);
|
|
186
|
+
if (!sent.has(feature.key) || valueSize === undefined)
|
|
187
|
+
return [];
|
|
188
|
+
const pathDepth = memberOf(PATH_DEPTHS, feature.pathDepth);
|
|
189
|
+
const extension = memberOf(EXTENSION_CLASSES, feature.extension);
|
|
190
|
+
const dirKind = memberOf(DIRECTORY_KINDS, feature.dirKind);
|
|
191
|
+
const patternFeatures = PATTERN_FEATURES.filter((entry) => feature.patternFeatures?.includes(entry) === true);
|
|
192
|
+
return [{ key: feature.key, valueSize, ...(pathDepth === undefined ? {} : { pathDepth }), ...(extension === undefined ? {} : { extension }), ...(dirKind === undefined ? {} : { dirKind }), ...(patternFeatures.length === 0 ? {} : { patternFeatures }) }];
|
|
193
|
+
});
|
|
194
|
+
/** A call's closed values as the wire sends them (layer 1): only a sent key's, and only a value `isClosedValue` admits. */
|
|
195
|
+
export const wireValues = (stored, sent, native) => (stored ?? []).filter(({ key, enumValue }) => sent.has(key) && isClosedValue(native, key, enumValue)).map(({ key, enumValue }) => ({ key, enumValue }));
|
|
196
|
+
/** A call's change kinds as the wire sends them: the closed-list members, in the list's order. */
|
|
197
|
+
export const wireChange = (stored) => CHANGE_KINDS.filter((kind) => stored?.includes(kind) === true);
|
|
198
|
+
/** A call's diff shapes as the wire sends them: the closed-list members, in the list's order. */
|
|
199
|
+
export const wireDiffShape = (stored) => DIFF_SHAPES.filter((shape) => stored?.includes(shape) === true);
|
|
200
|
+
/**
|
|
201
|
+
* A call's name tokens as the wire sends them (layer 2): every stored token whose kind is on the closed list, through the one gate (`wireName`): a token the operator's exclusion terms or the redactor's secret shapes match, or that has no name shape,
|
|
202
|
+
* is dropped and counted in `dropped`. No floor, no threshold: a name is sent as it is.
|
|
203
|
+
*/
|
|
204
|
+
export const wireNames = (stored, gate) => {
|
|
205
|
+
const known = (stored ?? []).filter(({ kind }) => NAME_KINDS.includes(kind));
|
|
206
|
+
const names = known.filter(({ name }) => passesGate(gate, name, isWireName));
|
|
207
|
+
return { names, dropped: known.length - names.length };
|
|
208
|
+
};
|
|
209
|
+
/** A digest as capture writes it: the first 32 hex digits of a SHA-256, lowercase. */
|
|
210
|
+
const DIGEST = /^[0-9a-f]{32}$/;
|
|
211
|
+
/** A call's edit digests as the wire sends them: a pair whose digest is not that shape is dropped whole, so a corrupt store row costs the pair and never the deposit. */
|
|
212
|
+
export const wireDigests = (stored) => (stored ?? []).filter((pair) => DIGEST.test(pair.new) && (pair.old === undefined || DIGEST.test(pair.old))).map((pair) => (pair.old === undefined ? { new: pair.new } : { old: pair.old, new: pair.new }));
|
|
213
|
+
const TEMPLATE_SLOT_LIST = TEMPLATE_SLOTS;
|
|
214
|
+
/**
|
|
215
|
+
* Whether every word of a template is one the wire may carry: a slot of `TEMPLATE_SLOTS`, the `--` marker, a flag with a slot joined to it, a flag, or a name (the program or subcommand), the last two as the caller judges them. A template is a single line of words
|
|
216
|
+
* set apart by one space, since no name or flag holds a space.
|
|
217
|
+
*/
|
|
218
|
+
const templateHolds = (template, name, flag) => template.split(' ').every((word) => {
|
|
219
|
+
if (TEMPLATE_SLOT_LIST.includes(word) || word === '--')
|
|
220
|
+
return true;
|
|
221
|
+
if (!word.startsWith('-'))
|
|
222
|
+
return name(word);
|
|
223
|
+
const joined = word.indexOf('=');
|
|
224
|
+
return joined < 0 ? flag(word) : flag(word.slice(0, joined)) && TEMPLATE_SLOT_LIST.includes(word.slice(joined + 1));
|
|
225
|
+
});
|
|
226
|
+
/** Whether a received template has the shape the wire sends: the pool's check, which holds no operator terms and no redactor. */
|
|
227
|
+
export const isWireTemplate = (template) => templateHolds(template, isWireName, isWireFlag);
|
|
228
|
+
/**
|
|
229
|
+
* A call's command templates as the wire sends them (layer 3): every stored template whose kept words (program, subcommand, flags) all clear the one gate, as a name or a flag does. A template with a word the redactor changes, an exclusion term matches, or that
|
|
230
|
+
* has no name shape is dropped whole and counted in `dropped`. No floor, no threshold: a template is sent as it is, and a literal is only ever a slot.
|
|
231
|
+
*/
|
|
232
|
+
export const wireTemplates = (stored, gate) => {
|
|
233
|
+
const known = stored ?? [];
|
|
234
|
+
const templates = known.filter((template) => templateHolds(template, (word) => wireName(gate, word) !== PRIVATE, (flag) => wireFlag(gate, flag) !== PRIVATE));
|
|
235
|
+
return { templates, dropped: known.length - templates.length };
|
|
236
|
+
};
|
|
138
237
|
/** The basename of a program as capture read it: a path never goes on the wire. */
|
|
139
238
|
const basenameOf = (program) => program.replace(/^.*[\\/]/, '');
|
|
140
239
|
/**
|
|
@@ -353,6 +452,14 @@ const transcriptOf = ({ session, prompts, tools, root }, spawner, spawned, agent
|
|
|
353
452
|
const targetClass = memberOf(TARGET_CLASSES, tool.targetClass);
|
|
354
453
|
const stored = tool.argClass;
|
|
355
454
|
const argClass = stored === undefined ? undefined : ARG_CLASSES.filter((kind) => stored.includes(kind));
|
|
455
|
+
const sent = new Set(keys);
|
|
456
|
+
const argFeatures = wireFeatures(tool.argFeatures, sent);
|
|
457
|
+
const argValues = wireValues(tool.argValues, sent, steps[0]?.native);
|
|
458
|
+
const changeKind = wireChange(tool.changeKind);
|
|
459
|
+
const { names, dropped: namesDropped } = wireNames(tool.names, gate);
|
|
460
|
+
const digests = wireDigests(tool.digests);
|
|
461
|
+
const { templates, dropped: templatesDropped } = wireTemplates(tool.templates, gate);
|
|
462
|
+
const diffShape = wireDiffShape(tool.diffShape);
|
|
356
463
|
steppedTools.push({
|
|
357
464
|
side,
|
|
358
465
|
prompt: promptPosition,
|
|
@@ -364,6 +471,15 @@ const transcriptOf = ({ session, prompts, tools, root }, spawner, spawned, agent
|
|
|
364
471
|
...(dropped === 0 ? {} : { keysDropped: bucketOf('count', dropped) }),
|
|
365
472
|
...(targetClass === undefined ? {} : { targetClass }),
|
|
366
473
|
...(argClass === undefined ? {} : { argClass: argClass.sort() }),
|
|
474
|
+
...(argFeatures.length === 0 ? {} : { argFeatures }),
|
|
475
|
+
...(argValues.length === 0 ? {} : { argValues }),
|
|
476
|
+
...(changeKind.length === 0 ? {} : { changeKind }),
|
|
477
|
+
...(names.length === 0 ? {} : { names }),
|
|
478
|
+
...(namesDropped === 0 ? {} : { namesDropped: bucketOf('count', namesDropped) }),
|
|
479
|
+
...(digests.length === 0 ? {} : { digests }),
|
|
480
|
+
...(templates.length === 0 ? {} : { templates }),
|
|
481
|
+
...(templatesDropped === 0 ? {} : { templatesDropped: bucketOf('count', templatesDropped) }),
|
|
482
|
+
...(diffShape.length === 0 ? {} : { diffShape }),
|
|
367
483
|
steps,
|
|
368
484
|
});
|
|
369
485
|
}
|
|
@@ -390,11 +506,18 @@ const transcriptOf = ({ session, prompts, tools, root }, spawner, spawned, agent
|
|
|
390
506
|
}
|
|
391
507
|
return { merkleRoot: root.merkleRoot, leaves: root.leaves, harness, frame: transcriptFrame, frames, spawnedIn: spawner?.index ?? null, prompts: framedPrompts, tools: steppedTools };
|
|
392
508
|
};
|
|
509
|
+
/**
|
|
510
|
+
* The recommendations of one session, from the local rows after `deriveConsequences` has given each its window. Only a row that showed or withheld something is carried: a miss recommended nothing, so it is neither shown nor held, and
|
|
511
|
+
* a row with no window has no calls to place. They go in window order, then by id, so one store builds one batch.
|
|
512
|
+
*/
|
|
513
|
+
export const recommendationsOf = (recalls, session) => recalls
|
|
514
|
+
.flatMap(({ sid, id, arm, poolVersion, answer, consequence }) => (sid === session && answer.kind === 'hit' && consequence !== undefined ? [{ id, arm, poolVersion, from: consequence.window.from, to: consequence.window.to }] : []))
|
|
515
|
+
.sort((a, b) => a.from - b.from || a.to - b.to || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
393
516
|
/**
|
|
394
517
|
* 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
|
|
395
518
|
* 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.
|
|
396
519
|
*/
|
|
397
|
-
export const skeletonOf = (records, id, read, print, deltas = [], gate = OPEN_GATE, coverage = NO_COVERAGE) => {
|
|
520
|
+
export const skeletonOf = (records, id, read, print, deltas = [], gate = OPEN_GATE, coverage = NO_COVERAGE, recommendations = []) => {
|
|
398
521
|
const mainTools = byCall(records.transcripts[0]?.tools ?? []);
|
|
399
522
|
const places = records.transcripts.map(({ tools }) => placesOf(tools));
|
|
400
523
|
const spawners = places[0]?.calls ?? new Map();
|
|
@@ -433,7 +556,7 @@ export const skeletonOf = (records, id, read, print, deltas = [], gate = OPEN_GA
|
|
|
433
556
|
transcripts.push(transcript);
|
|
434
557
|
}
|
|
435
558
|
const durations = mainTools.flatMap(({ durationMs }) => (durationMs === null ? [] : [durationMs]));
|
|
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] };
|
|
559
|
+
return { format: SKELETON_FORMAT, vocabVersion: VOCAB_VERSION, fieldsVersion: FIELD_LIST_VERSION, id, transcripts, timingPrint: durations.length < MIN_TIMED_CALLS ? null : print(durations), deltas: [...deltas], ...(recommendations.length === 0 ? {} : { recommendations: recommendations.map((entry) => ({ ...entry })) }) };
|
|
437
560
|
};
|
|
438
561
|
/**
|
|
439
562
|
* 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
|
|
@@ -475,18 +598,20 @@ const unknownValue = (held, at, sent) => {
|
|
|
475
598
|
/** 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
599
|
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
600
|
/**
|
|
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
|
|
601
|
+
* The fields that may be absent at formats 3, 4, and 5 (a format 4 batch reads `OPTIONAL.tool` less `FORMAT_5_TOOL_FIELDS`, and a format 3 batch also less `FORMAT_4_TOOL_FIELDS`), 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
602
|
* served from what remains. A field the cells need to be sound (a harness, a status, a bucket of tokens) is never here.
|
|
480
603
|
*/
|
|
481
604
|
export const OPTIONAL = {
|
|
482
|
-
batch: ['timingPrint', 'deltas'],
|
|
605
|
+
batch: ['timingPrint', 'deltas', 'claimed', 'recommendations'],
|
|
483
606
|
frame: ['harnessVersion', 'model', 'effort', 'day', 'shell', 'reach', 'repoKey', 'orgSize', 'team', 'repoVisibility', 'hour'],
|
|
484
607
|
stack: ['size', 'languageShares'],
|
|
485
608
|
prompt: ['wallSec', 'explore', 'usageResponses', 'intervention'],
|
|
486
|
-
tool: ['prompt', 'durationMs', 'errorCode', 'recoveryCalls', 'keys', 'keysDropped', 'targetClass', 'argClass'],
|
|
609
|
+
tool: ['prompt', 'durationMs', 'errorCode', 'recoveryCalls', 'keys', 'keysDropped', 'targetClass', 'argClass', 'argFeatures', 'argValues', 'changeKind', 'names', 'namesDropped', 'templates', 'templatesDropped', 'diffShape', 'digests'],
|
|
487
610
|
step: ['tokens', 'ms', 'allocated', 'parent', 'skill', 'edges', 'remainingTokens'],
|
|
488
611
|
transcript: ['spawnedIn'],
|
|
489
612
|
};
|
|
613
|
+
/** The fields of a value feature that only a path or a pattern holds: absent, never null, since a feature of another kind of value has no such field. */
|
|
614
|
+
const FEATURE_OPTIONAL = ['pathDepth', 'extension', 'dirKind', 'patternFeatures'];
|
|
490
615
|
/** 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
616
|
export const MAX_EXTENSION_BYTES = 8 * 1024;
|
|
492
617
|
const LITERAL_SUFFIX = 'Literal';
|
|
@@ -589,8 +714,8 @@ const allHarnesses = (list) => list;
|
|
|
589
714
|
export const HARNESSES = allHarnesses(['claude-code', 'cursor', 'codex', 'copilot', 'gemini', 'grok', 'opencode', 'pi', 'vscode-copilot', 'otel']);
|
|
590
715
|
const PROMPT_CLASSES = ['no-tools', 'orchestration', 'research', 'edit-verify', 'edit-only', 'explore-only', 'shell-only', 'mixed'];
|
|
591
716
|
const STATUSES = ['ok', 'error', 'none'];
|
|
592
|
-
const PLATFORMS = new Set(['aix', 'android', 'cygwin', 'darwin', 'freebsd', 'haiku', 'linux', 'netbsd', 'openbsd', 'sunos', 'win32']);
|
|
593
|
-
const SHELLS = new Set(Object.values(ACTION_TABLES).flatMap((table) => Object.values(table.shells)));
|
|
717
|
+
export const PLATFORMS = new Set(['aix', 'android', 'cygwin', 'darwin', 'freebsd', 'haiku', 'linux', 'netbsd', 'openbsd', 'sunos', 'win32']);
|
|
718
|
+
export const SHELLS = new Set(Object.values(ACTION_TABLES).flatMap((table) => Object.values(table.shells)));
|
|
594
719
|
/** The registries a package comes from; a pull may also come from a skill, plugin, or MCP registry. */
|
|
595
720
|
export const PACKAGE_REGISTRIES = ['npm', 'pypi', 'crates', 'go', 'rubygems', 'homebrew'];
|
|
596
721
|
const NO_NAME = new Set();
|
|
@@ -624,6 +749,11 @@ const parseReach = (held, value, at) => {
|
|
|
624
749
|
throw new Refused(at, 'an unfilled reach lists nothing');
|
|
625
750
|
return reach;
|
|
626
751
|
};
|
|
752
|
+
/** A claim, each member held to its closed list. */
|
|
753
|
+
const parseClaimed = (held, value, at) => {
|
|
754
|
+
const raw = fieldsOf(held, value, at, ['hostClass', 'launcher']);
|
|
755
|
+
return { hostClass: oneOf(held, CLAIMED_HOST_CLASSES, raw.hostClass, under(at, 'hostClass')), launcher: oneOf(held, CLAIMED_LAUNCHERS, raw.launcher, under(at, 'launcher')) };
|
|
756
|
+
};
|
|
627
757
|
/** Sixteen lowercase hex characters and nothing else, so no path or remote text can ride in a repo key. */
|
|
628
758
|
const parseRepoKey = (value, at) => {
|
|
629
759
|
const text = textOf(value, at);
|
|
@@ -752,7 +882,7 @@ const parseIdentity = (held, harness, action, value, at) => {
|
|
|
752
882
|
throw new Refused(at, 'this action has no identity');
|
|
753
883
|
};
|
|
754
884
|
/** Whether the harness's table names this native, so it can cross as itself; anything else (including the literal) crosses as `private`. */
|
|
755
|
-
const admittedNative = (harness, native) => {
|
|
885
|
+
export const admittedNative = (harness, native) => {
|
|
756
886
|
if (native === PRIVATE)
|
|
757
887
|
return true;
|
|
758
888
|
if (harness === OTHER)
|
|
@@ -832,8 +962,69 @@ const parseKeys = (held, native, value, at) => {
|
|
|
832
962
|
return (listed === undefined ? isWireName(name) : listed.has(name)) ? name : unknownValue(held, entryAt, name);
|
|
833
963
|
});
|
|
834
964
|
};
|
|
965
|
+
/** A sent key's entry names a key of the call's own `keys`, so a feature or a value never names a key the call did not send. */
|
|
966
|
+
const sentKey = (held, keys, value, at) => {
|
|
967
|
+
const key = textOf(value, at);
|
|
968
|
+
const index = keys.sent.indexOf(key);
|
|
969
|
+
if (index < 0)
|
|
970
|
+
throw new Refused(at, 'not a key the call sent');
|
|
971
|
+
return keys.read[index] === OTHER ? unknownValue(held, at, key) : key;
|
|
972
|
+
};
|
|
973
|
+
const parseFeature = (held, keys, value, at) => {
|
|
974
|
+
const raw = fieldsOf(held, value, at, ['key', 'valueSize'], FEATURE_OPTIONAL);
|
|
975
|
+
const pathDepth = raw.pathDepth === undefined ? undefined : oneOf(held, PATH_DEPTHS, raw.pathDepth, under(at, 'pathDepth'));
|
|
976
|
+
const extension = raw.extension === undefined ? undefined : oneOf(held, EXTENSION_CLASSES, raw.extension, under(at, 'extension'));
|
|
977
|
+
const dirKind = raw.dirKind === undefined ? undefined : oneOf(held, DIRECTORY_KINDS, raw.dirKind, under(at, 'dirKind'));
|
|
978
|
+
const patternFeatures = raw.patternFeatures === undefined ? undefined : listOf(raw.patternFeatures, under(at, 'patternFeatures'), (entry, entryAt) => oneOf(held, PATTERN_FEATURES, entry, entryAt));
|
|
979
|
+
return {
|
|
980
|
+
key: sentKey(held, keys, raw.key, under(at, 'key')),
|
|
981
|
+
valueSize: oneOf(held, VALUE_SIZES, raw.valueSize, under(at, 'valueSize')),
|
|
982
|
+
...(pathDepth === undefined ? {} : { pathDepth }),
|
|
983
|
+
...(extension === undefined ? {} : { extension }),
|
|
984
|
+
...(dirKind === undefined ? {} : { dirKind }),
|
|
985
|
+
...(patternFeatures === undefined ? {} : { patternFeatures }),
|
|
986
|
+
};
|
|
987
|
+
};
|
|
988
|
+
/** A closed value this reader does not hold for the key reads as `other` and goes to the bag, as any value off a list does. */
|
|
989
|
+
const parseValue = (held, keys, native, value, at) => {
|
|
990
|
+
const raw = fieldsOf(held, value, at, ['key', 'enumValue']);
|
|
991
|
+
const key = sentKey(held, keys, raw.key, under(at, 'key'));
|
|
992
|
+
const enumValue = textOf(raw.enumValue, under(at, 'enumValue'));
|
|
993
|
+
return { key, enumValue: key !== OTHER && isClosedValue(native, key, enumValue) ? enumValue : unknownValue(held, under(at, 'enumValue'), enumValue) };
|
|
994
|
+
};
|
|
995
|
+
/** The tool fields a format 5 or 6 batch may hold: format 7's are fields it does not know. */
|
|
996
|
+
const FORMAT_6_TOOL = OPTIONAL.tool.filter((name) => !FORMAT_7_TOOL_FIELDS.some((added) => added === name));
|
|
997
|
+
/** The tool fields a format 4 batch may hold: format 5's are fields it does not know. */
|
|
998
|
+
const FORMAT_4_TOOL = FORMAT_6_TOOL.filter((name) => !FORMAT_5_TOOL_FIELDS.some((added) => added === name));
|
|
999
|
+
/** The tool fields a format 3 batch may hold: format 4's three are fields it does not know too. */
|
|
1000
|
+
const FORMAT_3_TOOL = FORMAT_4_TOOL.filter((name) => !FORMAT_4_TOOL_FIELDS.some((added) => added === name));
|
|
1001
|
+
/** A name token: its kind off the closed list, and a name that has the shape of one. Either off its list or shape reads as `other` and goes to the bag, as any value off a list does. */
|
|
1002
|
+
const parseName = (held, value, at) => {
|
|
1003
|
+
const raw = fieldsOf(held, value, at, ['kind', 'name']);
|
|
1004
|
+
const name = textOf(raw.name, under(at, 'name'));
|
|
1005
|
+
return { kind: oneOf(held, NAME_KINDS, raw.kind, under(at, 'kind')), name: isWireName(name) ? name : unknownValue(held, under(at, 'name'), name) };
|
|
1006
|
+
};
|
|
1007
|
+
/** A digest is the shape capture writes, or the batch is refused: unlike a name, no newer client's value is a digest of another shape, and the field holds only what a hash can. */
|
|
1008
|
+
const digestOf = (value, at) => {
|
|
1009
|
+
const digest = textOf(value, at);
|
|
1010
|
+
if (!DIGEST.test(digest))
|
|
1011
|
+
throw new Refused(at, 'not a digest');
|
|
1012
|
+
return digest;
|
|
1013
|
+
};
|
|
1014
|
+
const parseDigest = (held, value, at) => {
|
|
1015
|
+
const raw = fieldsOf(held, value, at, ['new'], ['old']);
|
|
1016
|
+
const old = raw.old === undefined ? undefined : digestOf(raw.old, under(at, 'old'));
|
|
1017
|
+
return { ...(old === undefined ? {} : { old }), new: digestOf(raw.new, under(at, 'new')) };
|
|
1018
|
+
};
|
|
1019
|
+
const parseTemplate = (held, value, at) => {
|
|
1020
|
+
const template = textOf(value, at);
|
|
1021
|
+
return isWireTemplate(template) ? template : unknownValue(held, at, template);
|
|
1022
|
+
};
|
|
835
1023
|
const parseTool = (held, harness, value, at) => {
|
|
836
|
-
const
|
|
1024
|
+
const layered = held.format >= 4;
|
|
1025
|
+
const named = held.format >= 5;
|
|
1026
|
+
const digested = held.format >= DIGEST_FORMAT;
|
|
1027
|
+
const raw = fieldsOf(held, value, at, ['side', 'status', 'steps'], digested ? OPTIONAL.tool : named ? FORMAT_6_TOOL : layered ? FORMAT_4_TOOL : FORMAT_3_TOOL);
|
|
837
1028
|
const status = oneOf(held, STATUSES, raw.status, under(at, 'status'));
|
|
838
1029
|
const errorCode = parseErrorCode(held, status, raw.errorCode, under(at, 'errorCode'));
|
|
839
1030
|
const recoveryCalls = parseRecovery(held, status, raw.recoveryCalls, under(at, 'recoveryCalls'));
|
|
@@ -842,6 +1033,16 @@ const parseTool = (held, harness, value, at) => {
|
|
|
842
1033
|
const keysDropped = raw.keysDropped === undefined ? undefined : bucketIn('count', raw.keysDropped, under(at, 'keysDropped'));
|
|
843
1034
|
const targetClass = raw.targetClass === undefined ? undefined : oneOf(held, TARGET_CLASSES, raw.targetClass, under(at, 'targetClass'));
|
|
844
1035
|
const argClass = raw.argClass === undefined ? undefined : listOf(raw.argClass, under(at, 'argClass'), (entry, entryAt) => oneOf(held, ARG_CLASSES, entry, entryAt));
|
|
1036
|
+
const sentKeys = { sent: Array.isArray(raw.keys) ? raw.keys : [], read: keys ?? [] };
|
|
1037
|
+
const argFeatures = !layered || raw.argFeatures === undefined ? undefined : listOf(raw.argFeatures, under(at, 'argFeatures'), (entry, entryAt) => parseFeature(held, sentKeys, entry, entryAt));
|
|
1038
|
+
const argValues = !layered || raw.argValues === undefined ? undefined : listOf(raw.argValues, under(at, 'argValues'), (entry, entryAt) => parseValue(held, sentKeys, steps[0]?.native, entry, entryAt));
|
|
1039
|
+
const changeKind = !layered || raw.changeKind === undefined ? undefined : listOf(raw.changeKind, under(at, 'changeKind'), (entry, entryAt) => oneOf(held, CHANGE_KINDS, entry, entryAt));
|
|
1040
|
+
const names = !named || raw.names === undefined ? undefined : listOf(raw.names, under(at, 'names'), (entry, entryAt) => parseName(held, entry, entryAt));
|
|
1041
|
+
const namesDropped = !named || raw.namesDropped === undefined ? undefined : bucketIn('count', raw.namesDropped, under(at, 'namesDropped'));
|
|
1042
|
+
const digests = !digested || raw.digests === undefined ? undefined : listOf(raw.digests, under(at, 'digests'), (entry, entryAt) => parseDigest(held, entry, entryAt));
|
|
1043
|
+
const templates = !named || raw.templates === undefined ? undefined : listOf(raw.templates, under(at, 'templates'), (entry, entryAt) => parseTemplate(held, entry, entryAt));
|
|
1044
|
+
const templatesDropped = !named || raw.templatesDropped === undefined ? undefined : bucketIn('count', raw.templatesDropped, under(at, 'templatesDropped'));
|
|
1045
|
+
const diffShape = !named || raw.diffShape === undefined ? undefined : listOf(raw.diffShape, under(at, 'diffShape'), (entry, entryAt) => oneOf(held, DIFF_SHAPES, entry, entryAt));
|
|
845
1046
|
return {
|
|
846
1047
|
side: booleanOf(raw.side, under(at, 'side')),
|
|
847
1048
|
prompt: orNull(raw.prompt, under(at, 'prompt'), countOf),
|
|
@@ -853,6 +1054,15 @@ const parseTool = (held, harness, value, at) => {
|
|
|
853
1054
|
...(keysDropped === undefined ? {} : { keysDropped }),
|
|
854
1055
|
...(targetClass === undefined ? {} : { targetClass }),
|
|
855
1056
|
...(argClass === undefined ? {} : { argClass }),
|
|
1057
|
+
...(argFeatures === undefined ? {} : { argFeatures }),
|
|
1058
|
+
...(argValues === undefined ? {} : { argValues }),
|
|
1059
|
+
...(changeKind === undefined ? {} : { changeKind }),
|
|
1060
|
+
...(names === undefined ? {} : { names }),
|
|
1061
|
+
...(namesDropped === undefined ? {} : { namesDropped }),
|
|
1062
|
+
...(digests === undefined ? {} : { digests }),
|
|
1063
|
+
...(templates === undefined ? {} : { templates }),
|
|
1064
|
+
...(templatesDropped === undefined ? {} : { templatesDropped }),
|
|
1065
|
+
...(diffShape === undefined ? {} : { diffShape }),
|
|
856
1066
|
steps,
|
|
857
1067
|
};
|
|
858
1068
|
};
|
|
@@ -890,9 +1100,9 @@ const parseTranscript = (held, value, at) => {
|
|
|
890
1100
|
const spawnedIn = orNull(raw.spawnedIn, under(at, 'spawnedIn'), countOf);
|
|
891
1101
|
const prompts = listOf(raw.prompts, under(at, 'prompts'), (entry, entryAt) => parsePrompt(held, entry, entryAt));
|
|
892
1102
|
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.
|
|
1103
|
+
// 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. A batch of a format before 6 was built when a failed edit still counted, so it is checked with that rule.
|
|
894
1104
|
tools.forEach((tool, index) => {
|
|
895
|
-
if (tool.recoveryCalls !== undefined && JSON.stringify(tool.recoveryCalls) !== JSON.stringify(recoveryCallsOf(tools, index)))
|
|
1105
|
+
if (tool.recoveryCalls !== undefined && JSON.stringify(tool.recoveryCalls) !== JSON.stringify(recoveryCallsOf(tools, index, held.format < EXPOSURE_FORMAT)))
|
|
896
1106
|
throw new Refused(`${at}.tools[${String(index)}].recoveryCalls`, 'not what followed the call');
|
|
897
1107
|
});
|
|
898
1108
|
return { merkleRoot, leaves, harness, frame, frames, spawnedIn, prompts, tools };
|
|
@@ -936,16 +1146,39 @@ const parseDelta = (held, value, at) => {
|
|
|
936
1146
|
return { deltaKind, field, name, registry, metric, change };
|
|
937
1147
|
};
|
|
938
1148
|
const VOCAB_TEXT = /^[0-9A-Za-z._-]{1,32}$/;
|
|
1149
|
+
/** The deposit time of the pool a recommendation answered from: an instant, or a version word. */
|
|
1150
|
+
const POOL_VERSION = /^[0-9A-Za-z:.+_-]{1,40}$/;
|
|
1151
|
+
/** The batch fields a batch of format 5 or older may hold: format 6's are fields it does not know. */
|
|
1152
|
+
const FORMAT_5_BATCH = OPTIONAL.batch.filter((name) => !FORMAT_6_BATCH_FIELDS.some((added) => added === name));
|
|
1153
|
+
/** One recommendation: a hash id, its arm off the closed list, the version it answered from, and a window that is two counts in order. Past-the-calls is checked once the transcripts are known. */
|
|
1154
|
+
const parseRecommendation = (held, value, at) => {
|
|
1155
|
+
const raw = fieldsOf(held, value, at, ['id', 'arm', 'poolVersion', 'from', 'to']);
|
|
1156
|
+
const id = textOf(raw.id, under(at, 'id'));
|
|
1157
|
+
if (!ID.test(id))
|
|
1158
|
+
throw new Refused(under(at, 'id'), 'not a hash');
|
|
1159
|
+
const poolVersion = textOf(raw.poolVersion, under(at, 'poolVersion'));
|
|
1160
|
+
if (!POOL_VERSION.test(poolVersion))
|
|
1161
|
+
throw new Refused(under(at, 'poolVersion'), 'not a version');
|
|
1162
|
+
const from = countOf(raw.from, under(at, 'from'));
|
|
1163
|
+
const to = countOf(raw.to, under(at, 'to'));
|
|
1164
|
+
if (to < from)
|
|
1165
|
+
throw new Refused(under(at, 'to'), 'ends before it starts');
|
|
1166
|
+
return { id, arm: oneOf(held, RECOMMENDATION_ARMS, raw.arm, under(at, 'arm')), poolVersion, from, to };
|
|
1167
|
+
};
|
|
939
1168
|
/** 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. */
|
|
940
1169
|
export const parseSkeleton = (value) => {
|
|
941
|
-
const held = { extensions: [], others: 0 };
|
|
1170
|
+
const held = { extensions: [], others: 0, format: SKELETON_FORMAT };
|
|
942
1171
|
try {
|
|
943
1172
|
// 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 <
|
|
1173
|
+
if (isFields(value) && typeof value.format === 'number' && value.format < READ_FORMATS[0])
|
|
945
1174
|
throw new Refused('format', `the format ${String(value.format)} form is no longer read; send format ${String(SKELETON_FORMAT)}`);
|
|
946
|
-
|
|
947
|
-
|
|
1175
|
+
// The format is read first, since which batch fields it defines depends on it.
|
|
1176
|
+
const declared = isFields(value) ? READ_FORMATS.find((known) => known === value.format) : undefined;
|
|
1177
|
+
const raw = fieldsOf(held, value, '', ['format', 'vocabVersion', 'fieldsVersion', 'id', 'transcripts'], declared !== undefined && declared < EXPOSURE_FORMAT ? FORMAT_5_BATCH : OPTIONAL.batch);
|
|
1178
|
+
const format = READ_FORMATS.find((known) => known === raw.format);
|
|
1179
|
+
if (format === undefined)
|
|
948
1180
|
throw new Refused('format', 'not a format this version reads');
|
|
1181
|
+
held.format = format;
|
|
949
1182
|
const vocabVersion = textOf(raw.vocabVersion, 'vocabVersion');
|
|
950
1183
|
if (!VOCAB_TEXT.test(vocabVersion))
|
|
951
1184
|
throw new Refused('vocabVersion', 'not a version');
|
|
@@ -957,6 +1190,13 @@ export const parseSkeleton = (value) => {
|
|
|
957
1190
|
const transcripts = listOf(raw.transcripts, 'transcripts', (entry, entryAt) => parseTranscript(held, entry, entryAt));
|
|
958
1191
|
if (transcripts.length === 0)
|
|
959
1192
|
throw new Refused('transcripts', 'empty');
|
|
1193
|
+
const recommendations = format < EXPOSURE_FORMAT ? [] : listOrEmpty(raw.recommendations, 'recommendations', (entry, entryAt) => parseRecommendation(held, entry, entryAt));
|
|
1194
|
+
// Positions are the only ordinals on the wire, so a window must lie within the main run's own calls: the ones that are not sidechain, which is the line the windows were counted on.
|
|
1195
|
+
const mainCalls = (transcripts[0]?.tools ?? []).filter(({ side }) => !side).length;
|
|
1196
|
+
recommendations.forEach(({ to }, index) => {
|
|
1197
|
+
if (to > mainCalls)
|
|
1198
|
+
throw new Refused(`recommendations[${String(index)}].to`, 'past the main run\'s calls');
|
|
1199
|
+
});
|
|
960
1200
|
// A counting batch is what lifts the fetch cap, so a deposit has to hold something to derive from.
|
|
961
1201
|
if (transcripts.every(({ prompts, tools }) => prompts.length === 0 && tools.length === 0))
|
|
962
1202
|
throw new Refused('transcripts', 'holds no prompt and no tool');
|
|
@@ -1035,7 +1275,8 @@ export const parseSkeleton = (value) => {
|
|
|
1035
1275
|
before += steps.length;
|
|
1036
1276
|
});
|
|
1037
1277
|
});
|
|
1038
|
-
|
|
1278
|
+
const claimed = raw.claimed === undefined ? {} : { claimed: parseClaimed(held, raw.claimed, 'claimed') };
|
|
1279
|
+
return { ok: true, skeleton: { format, vocabVersion, fieldsVersion, id, transcripts, timingPrint, deltas, ...claimed, ...(recommendations.length === 0 ? {} : { recommendations }) }, extensions: held.extensions, others: held.others };
|
|
1039
1280
|
}
|
|
1040
1281
|
catch (error) {
|
|
1041
1282
|
if (error instanceof Refused)
|
|
@@ -1043,10 +1284,21 @@ export const parseSkeleton = (value) => {
|
|
|
1043
1284
|
throw error;
|
|
1044
1285
|
}
|
|
1045
1286
|
};
|
|
1046
|
-
|
|
1287
|
+
/** The key id of a bundle that names none, and of a sign-in file written before key ids: the pool's default `BUNDLE_KEY_ID`. */
|
|
1288
|
+
export const LEGACY_KEY_ID = 'k1';
|
|
1289
|
+
/** Key ids this release no longer trusts: the way a leaked signing key is rotated out (docs/pool.md, "Rotating the bundle signing key"). A bundle signed under one is refused even when its key is pinned. */
|
|
1290
|
+
export const RETIRED_KEY_IDS = new Set();
|
|
1291
|
+
/**
|
|
1292
|
+
* Public keys this release of oars ships, by key id: the only way a key id the login did not pin becomes trusted, because a server at a pool's URL must not be able to introduce a key by naming a new id (S17). Empty until the pool's first rotation
|
|
1293
|
+
* (docs/pool.md, "Rotating the bundle signing key"); a bundle is checked against the keys pinned at login and these, a shipped key winning under a shared id, so a server cannot shadow it with a key of its own.
|
|
1294
|
+
*/
|
|
1295
|
+
export const RELEASE_KEYS = Object.freeze({});
|
|
1296
|
+
/** The first key id a login offers that the server's entry does not hold and no release ships, or none: such an id could only have come from the server. */
|
|
1297
|
+
export const unpinnedKid = (held, offered, released = RELEASE_KEYS) => Object.keys(offered).find((kid) => !Object.hasOwn(held, kid) && !Object.hasOwn(released, kid));
|
|
1298
|
+
const isSignedBundle = (value) => isFields(value) && typeof value.payload === 'string' && typeof value.signature === 'string' && (value.kid === undefined || typeof value.kid === 'string');
|
|
1047
1299
|
const isFloor = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 1;
|
|
1048
1300
|
/** 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)));
|
|
1301
|
+
const isFloors = (value) => isFields(value) && isFloor(value.public) && isFloor(value.disclosure) && (value.clear === undefined || isFloor(value.clear)) && (value.families === undefined || (isFields(value.families) && Object.values(value.families).every(isFloor)));
|
|
1050
1302
|
const isSourced = (value) => isFields(value) && ((value.state === 'value' && typeof value.value === 'number') || (value.state === 'no source yet' && typeof value.reason === 'string'));
|
|
1051
1303
|
const isCount = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
1052
1304
|
/** 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. */
|
|
@@ -1081,12 +1333,24 @@ const signatureHolds = async ({ payload, signature }, key) => {
|
|
|
1081
1333
|
return false;
|
|
1082
1334
|
}
|
|
1083
1335
|
};
|
|
1336
|
+
/** A key id as the pool sends them: letters, digits, dot, dash, and underscore, 1 to 32 characters. */
|
|
1337
|
+
export const isKeyId = (kid) => /^[A-Za-z0-9._-]{1,32}$/.test(kid);
|
|
1338
|
+
/** A key id for a message: the id as the pool sends them (letters, digits, dot, dash, underscore), and a stand-in for anything else, since the pool or a man in the middle chose it. */
|
|
1339
|
+
const keyIdName = (kid) => (isKeyId(kid) ? kid : '(unreadable key id)');
|
|
1084
1340
|
/** The bundle, only when the pinned key signed these bytes for the harness and version that were asked for: another harness's or version's bundle, under a good signature, is still the wrong answer. */
|
|
1085
|
-
export const verifyBundle = async (signed,
|
|
1341
|
+
export const verifyBundle = async (signed, keys, harness, harnessVersion, retired = RETIRED_KEY_IDS, released = RELEASE_KEYS) => {
|
|
1086
1342
|
if (!isSignedBundle(signed))
|
|
1087
1343
|
return { ok: false, reason: 'not a signed bundle' };
|
|
1344
|
+
const kid = signed.kid ?? LEGACY_KEY_ID;
|
|
1345
|
+
const named = keyIdName(kid);
|
|
1346
|
+
if (retired.has(kid))
|
|
1347
|
+
return { ok: false, reason: `this bundle is signed with key ${named}, which this release of oars no longer trusts. Upgrade with npm install -g @manyoars/oars` };
|
|
1348
|
+
const trusted = { ...keys, ...released };
|
|
1349
|
+
const key = Object.hasOwn(trusted, kid) ? trusted[kid] : undefined;
|
|
1350
|
+
if (key === undefined)
|
|
1351
|
+
return { ok: false, reason: `this bundle is signed with key ${named}, which is not pinned for this server and not shipped in this release of oars. Upgrade with npm install -g @manyoars/oars` };
|
|
1088
1352
|
if (!(await signatureHolds(signed, key)))
|
|
1089
|
-
return { ok: false, reason:
|
|
1353
|
+
return { ok: false, reason: `the signature does not match the key ${named} pinned at login` };
|
|
1090
1354
|
const bundle = JSON.parse(signed.payload);
|
|
1091
1355
|
if (!isBundle(bundle))
|
|
1092
1356
|
return { ok: false, reason: 'not a signed bundle this version reads' };
|
package/dist/sessions/pool.js
CHANGED
|
@@ -6,6 +6,7 @@ import { basename, dirname, join } from 'node:path';
|
|
|
6
6
|
import { CliError, ExitCode } from '../errors.js';
|
|
7
7
|
import { cellsOf, projectedStore } from './cell-increments.js';
|
|
8
8
|
import { poolOf, serializeCells, UNKNOWN } from './cells.js';
|
|
9
|
+
import { LOAD_STATUS_FILE, loadStatusLines } from './load-status.js';
|
|
9
10
|
import { shellReader } from './projection.js';
|
|
10
11
|
import { contentHash, FIRST_ORDER_FAMILIES, frozenRead, makeRead, orderResolver } from './read.js';
|
|
11
12
|
import { deriveConsequences, recallGraphOf } from './recall-consequence.js';
|
|
@@ -14,7 +15,7 @@ import { deriveAdoption } from './read-adoption.js';
|
|
|
14
15
|
import { deriveCallShape } from './read-call-shape.js';
|
|
15
16
|
import { deriveChange } from './read-change.js';
|
|
16
17
|
import { deriveCost } from './read-cost.js';
|
|
17
|
-
import { deriveFit } from './read-fit.js';
|
|
18
|
+
import { deriveFit, deriveFitShape } from './read-fit.js';
|
|
18
19
|
import { deriveLive } from './read-live.js';
|
|
19
20
|
import { deriveMethod } from './read-method.js';
|
|
20
21
|
import { deriveNextStep } from './read-next-step.js';
|
|
@@ -30,7 +31,7 @@ import { writeAtomic } from './store.js';
|
|
|
30
31
|
// Built on call.
|
|
31
32
|
const derivers = () => ({
|
|
32
33
|
recovery: deriveRecovery,
|
|
33
|
-
fit: (input) => [...deriveFit(input), ...deriveCallShape(input)],
|
|
34
|
+
fit: (input) => [...deriveFit(input), ...deriveFitShape(input), ...deriveCallShape(input)],
|
|
34
35
|
cost: deriveCost,
|
|
35
36
|
change: deriveChange,
|
|
36
37
|
live: deriveLive,
|
|
@@ -100,6 +101,7 @@ export const refreshPool = async (storeDir, store) => {
|
|
|
100
101
|
const pool = poolOf(set, store.errors);
|
|
101
102
|
const files = poolFiles(storeDir);
|
|
102
103
|
await writeAtomic(files.consequences, recalls.flatMap(({ id, n, consequence }) => (consequence === undefined ? [] : [`${JSON.stringify({ id, n, consequence })}\n`])).join(''));
|
|
104
|
+
await writeAtomic(join(storeDir, LOAD_STATUS_FILE), await loadStatusLines(storeDir, store.sessions));
|
|
103
105
|
await writeAtomic(files.cells, serializeCells(set.cells));
|
|
104
106
|
await writeAtomic(files.motifs, set.motifs.map((motif) => `${JSON.stringify(motif)}\n`).join(''));
|
|
105
107
|
const file = files.reads(pool.version);
|
|
@@ -60,6 +60,9 @@ export declare const HARNESS_TOOLS: ReadonlyMap<string, ReadonlySet<string>>;
|
|
|
60
60
|
* is pinned yet, so an admitted plugin agent is not on this list and pools as `custom` (spec Section 2).
|
|
61
61
|
*/
|
|
62
62
|
export declare const HARNESS_AGENT_TYPES: ReadonlyMap<string, ReadonlySet<string>>;
|
|
63
|
+
export declare const CLOSED_ARG_VALUES: ReadonlyMap<string, ReadonlyMap<string, ReadonlySet<string>>>;
|
|
64
|
+
/** The keys of `CLOSED_ROWS` whose list is not exhaustive: a custom agent's name or a full model id is off it and has no `enum` cell, so the key's on-list values do not sum to the calls that send it. */
|
|
65
|
+
export declare const OPEN_LIST_KEYS: ReadonlySet<string>;
|
|
63
66
|
/** What a subagent type pools as when it is not a built-in: a custom agent's name is private. */
|
|
64
67
|
export declare const CUSTOM_AGENT = "custom";
|
|
65
68
|
/** A subagent type as it pools: a built-in of a harness as itself, anything else as `custom`. */
|
|
@@ -70,3 +73,5 @@ export declare const projectedAgentType: (agentType: string) => string;
|
|
|
70
73
|
* the name gate and counts the rest in `keysDropped`.
|
|
71
74
|
*/
|
|
72
75
|
export declare const TOOL_INPUT_KEYS: ReadonlyMap<string, ReadonlySet<string>>;
|
|
76
|
+
/** Every key name a public tool's pinned list holds: a value feature or closed value keyed on one of these names nothing private, so the pool serves its cell at the clear floor. */
|
|
77
|
+
export declare const PUBLIC_INPUT_KEYS: ReadonlySet<string>;
|