@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
package/dist/sessions/hooks.js
CHANGED
|
@@ -4,18 +4,23 @@
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { performance } from 'node:perf_hooks';
|
|
6
6
|
import { defaultConfigDir } from '../credentials.js';
|
|
7
|
+
import { noEmit } from '../telemetry/emit.js';
|
|
8
|
+
import { frameWords, HOOK_HARNESS, recallFields } from '../telemetry/fields.js';
|
|
7
9
|
import { cursorRoot } from './cursor.js';
|
|
8
10
|
import { SOURCES, sourceLabel } from './sources.js';
|
|
9
|
-
import {
|
|
11
|
+
import { ACTION_TABLES, actionOf } from './actions.js';
|
|
12
|
+
import { errorCodeOf, signatureOf } from './extract.js';
|
|
10
13
|
import { appendHookCall } from './hook-calls.js';
|
|
11
14
|
import { contextBlock, readIndex } from './hook-index.js';
|
|
12
|
-
import {
|
|
15
|
+
import { bundleNext, frameKeyOf, lastTable, mergePositions, positionsRecall, recoveryDecision, recoveryRecall, signatureRecall, isSignatureRetry } from './recall-next.js';
|
|
16
|
+
import { pushRecall, tailOf } from './recall-push.js';
|
|
17
|
+
import { statedAt, statedOf } from './transcript-tail.js';
|
|
13
18
|
import { toolShapeId } from './recall-reads.js';
|
|
14
19
|
import { makeRedactor } from './redact.js';
|
|
15
20
|
import { errorLine, inputKey, logStep, newContext, runDeposit, stampKey, tryLog } from './steps.js';
|
|
16
21
|
import { deposit, depositSession, readSaved } from './store.js';
|
|
17
22
|
import { recordSurvival } from './survival.js';
|
|
18
|
-
export const HOOK_EVENTS = ['session-start', 'pre-tool', 'post-tool', 'stop', 'nightly'];
|
|
23
|
+
export const HOOK_EVENTS = ['session-start', 'prompt', 'pre-tool', 'post-tool', 'stop', 'nightly'];
|
|
19
24
|
const TOP = 5;
|
|
20
25
|
const MAX_CHARS = 1200;
|
|
21
26
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -30,11 +35,53 @@ const parse = (stdin) => {
|
|
|
30
35
|
};
|
|
31
36
|
const str = (value) => (typeof value === 'string' && value !== '' ? value : undefined);
|
|
32
37
|
const additional = (eventName, context) => `${JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: context } })}\n`;
|
|
33
|
-
|
|
38
|
+
/** What a next-node read needs of the hook's input: the session, the transcript's tail (version, model), and the clock; undefined when the harness gave no session, since the holdback arm is the session's. */
|
|
39
|
+
const nextInputOf = async (runtime, input, now) => {
|
|
40
|
+
const sid = str(input.session_id);
|
|
41
|
+
if (sid === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
const transcript = str(input.transcript_path);
|
|
44
|
+
const tail = transcript === undefined ? '' : await tailOf(transcript);
|
|
45
|
+
const { harnessVersion } = statedOf(tail);
|
|
46
|
+
return { next: { storeDir: runtime.storeDir, sid, tail, platform: process.platform, at: new Date(now()).toISOString() }, asker: { harness: HOOK_HARNESS, harnessVersion } };
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* The split-positions table (Item 3): the kept bundle's positions first, the local pool's second. A `repeat` read (UserPromptSubmit) goes out only when the table differs from
|
|
50
|
+
* the one this session was last sent, shown or held, so a held session and a shown one hear the same number of times.
|
|
51
|
+
*/
|
|
52
|
+
const positionsTable = async (runtime, input, now, index, repeat) => {
|
|
53
|
+
const prepared = await nextInputOf(runtime, input, now);
|
|
54
|
+
if (prepared === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
const bundle = await bundleNext(runtime.storeDir, prepared.asker);
|
|
57
|
+
const positions = mergePositions(bundle?.positions ?? [], index?.positionsByFrame?.[frameKeyOf(prepared.asker)] ?? []);
|
|
58
|
+
if (positions.length === 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
const previous = repeat ? await lastTable(runtime.storeDir, prepared.next.sid) : undefined;
|
|
61
|
+
return positionsRecall(prepared.next, prepared.asker, positions, bundle?.poolVersion ?? index?.at ?? '', previous);
|
|
62
|
+
};
|
|
63
|
+
const sessionStart = async (runtime, input, now) => {
|
|
34
64
|
const cwd = str(input.cwd);
|
|
35
|
-
const index =
|
|
65
|
+
const index = await readIndex(runtime.storeDir);
|
|
36
66
|
const block = index === undefined || cwd === undefined ? undefined : contextBlock(index, { cwd, top: TOP, maxChars: MAX_CHARS });
|
|
37
|
-
|
|
67
|
+
const recall = await positionsTable(runtime, input, now, index, false);
|
|
68
|
+
const model = str(input.model);
|
|
69
|
+
const shown = recall?.arm === 'shown' ? recall.answer.rendered : undefined;
|
|
70
|
+
const text = [block, shown].filter((part) => part !== undefined).join('\n\n');
|
|
71
|
+
return {
|
|
72
|
+
hit: text !== '' || recall !== undefined,
|
|
73
|
+
queried: index !== undefined || recall !== undefined,
|
|
74
|
+
...(text === '' ? {} : { output: additional('SessionStart', text) }),
|
|
75
|
+
...(recall === undefined ? {} : { recall }),
|
|
76
|
+
...(model === undefined ? {} : { model }),
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
/** UserPromptSubmit: the positions table again, only when it changed since this session's last one. */
|
|
80
|
+
const prompt = async (runtime, input, now) => {
|
|
81
|
+
const recall = await positionsTable(runtime, input, now, await readIndex(runtime.storeDir), true);
|
|
82
|
+
if (recall === undefined)
|
|
83
|
+
return { hit: false, queried: false };
|
|
84
|
+
return recall.arm === 'shown' ? { output: additional('UserPromptSubmit', recall.answer.rendered), hit: true, queried: true, recall } : { hit: true, queried: true, recall };
|
|
38
85
|
};
|
|
39
86
|
const inputKeys = (input) => (isRecord(input.tool_input) ? Object.keys(input.tool_input) : []);
|
|
40
87
|
/**
|
|
@@ -52,7 +99,7 @@ const preTool = async (runtime, input, now) => {
|
|
|
52
99
|
const recall = await pushRecall({ storeDir: runtime.storeDir, sid, tool, keys: inputKeys(input), transcript: str(input.transcript_path), platform: process.platform, at: new Date(now()).toISOString(), index });
|
|
53
100
|
if (recall === undefined)
|
|
54
101
|
return { hit: false, queried: false };
|
|
55
|
-
return recall.arm === 'shown' ? { output: additional('PreToolUse', recall.answer.rendered), hit: true, queried: true } : { hit: true, queried: true };
|
|
102
|
+
return recall.arm === 'shown' ? { output: additional('PreToolUse', recall.answer.rendered), hit: true, queried: true, recall } : { hit: true, queried: true, recall };
|
|
56
103
|
};
|
|
57
104
|
/** A failed tool arrives as PostToolUseFailure with the text in `error` (Bash and PowerShell start it with `Exit code N`); a PostToolUse response that starts that way is read too. */
|
|
58
105
|
const failureText = (input) => {
|
|
@@ -62,20 +109,37 @@ const failureText = (input) => {
|
|
|
62
109
|
const response = str(input.tool_response);
|
|
63
110
|
return response !== undefined && /^Exit code \d+/.test(response) ? response : undefined;
|
|
64
111
|
};
|
|
65
|
-
|
|
112
|
+
/**
|
|
113
|
+
* The recovery edge (Item 2): what followed a failure of this action and error class, from the kept bundle for the session's setup and then the local pool, held to A33's
|
|
114
|
+
* holdback like every next-node read. A same-step retry or a local edge under the floor says nothing, signature line included; the signature line answers only a failure no cell covers.
|
|
115
|
+
*/
|
|
116
|
+
const postTool = async (runtime, input, now) => {
|
|
66
117
|
const tool = str(input.tool_name);
|
|
67
118
|
const text = failureText(input);
|
|
68
|
-
|
|
119
|
+
const prepared = tool === undefined || text === undefined ? undefined : await nextInputOf(runtime, input, now);
|
|
120
|
+
if (tool === undefined || text === undefined || prepared === undefined)
|
|
69
121
|
return { hit: false, queried: false };
|
|
70
122
|
const index = await readIndex(runtime.storeDir);
|
|
71
|
-
|
|
123
|
+
const bundle = await bundleNext(runtime.storeDir, prepared.asker);
|
|
124
|
+
if (index === undefined && bundle === undefined)
|
|
72
125
|
return { hit: false, queried: false };
|
|
73
|
-
const
|
|
74
|
-
|
|
126
|
+
const action = actionOf(ACTION_TABLES['claude-code'], tool).action;
|
|
127
|
+
const code = errorCodeOf(text);
|
|
128
|
+
const decision = recoveryDecision(bundle, index === undefined ? undefined : { recovery: index.recoveryByFrame?.[frameKeyOf(prepared.asker)] ?? {}, poolVersion: index.at }, action, code);
|
|
129
|
+
const event = str(input.hook_event_name) ?? 'PostToolUseFailure';
|
|
130
|
+
if (decision.kind === 'serve') {
|
|
131
|
+
const recall = await recoveryRecall(prepared.next, prepared.asker, action, code, decision);
|
|
132
|
+
return recall.arm === 'shown' ? { output: additional(event, recall.answer.rendered), hit: true, queried: true, recall } : { hit: true, queried: true, recall };
|
|
133
|
+
}
|
|
134
|
+
// A same-step retry, a local cell too thin to serve, and a cell whose top is that nothing followed all end the lookup: a cell that exists but says nothing is not a reason to fall back to a signature count.
|
|
135
|
+
if (decision.kind === 'retry' || decision.kind === 'floor' || decision.kind === 'ended')
|
|
75
136
|
return { hit: false, queried: true };
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
137
|
+
const sig = signatureOf(tool, text, makeRedactor(index?.redactDomains ?? []));
|
|
138
|
+
const found = index?.signatures[sig];
|
|
139
|
+
if (index === undefined || found === undefined || isSignatureRetry(tool, found))
|
|
140
|
+
return { hit: false, queried: true };
|
|
141
|
+
const recall = await signatureRecall(prepared.next, prepared.asker, tool, sig, found, index.at);
|
|
142
|
+
return recall.arm === 'shown' ? { output: additional(event, recall.answer.rendered), hit: true, queried: true, recall } : { hit: true, queried: true, recall };
|
|
79
143
|
};
|
|
80
144
|
const stop = async (runtime, input, now) => {
|
|
81
145
|
const transcript = str(input.transcript_path);
|
|
@@ -85,13 +149,13 @@ const stop = async (runtime, input, now) => {
|
|
|
85
149
|
const deposited = await runDeposit(newContext(runtime.storeDir, now, 'stop'), false, inputKey('stop', [transcript], []), async (trace) => ({ value: await depositSession(runtime, transcript, now, trace), in: { transcript: 1 } }));
|
|
86
150
|
return { hit: deposited === 'deposited', queried: false };
|
|
87
151
|
};
|
|
88
|
-
const
|
|
152
|
+
const nightlyRun = async (runtime, now, timing) => {
|
|
89
153
|
// A state.json that cannot be read is a failed deposit run like any other: its error row and the skipped derive row (spec 4.4) are logged.
|
|
90
154
|
const saved = await readSaved(runtime.storeDir).catch((failure) => runDeposit(newContext(runtime.storeDir, now, 'nightly'), true, inputKey('nightly', [], []), () => Promise.reject(failure instanceof Error ? failure : new Error(String(failure)))));
|
|
91
|
-
if (saved.allow.length === 0)
|
|
92
|
-
return { hit: false, queried: false };
|
|
93
155
|
const result = await runDeposit(newContext(runtime.storeDir, now, 'nightly'), true, inputKey('nightly', saved.allow, saved.redactDomains), async (trace) => {
|
|
156
|
+
const parseStart = timing.clock();
|
|
94
157
|
const done = await deposit(runtime, { allow: saved.allow, redactDomains: saved.redactDomains, now }, trace);
|
|
158
|
+
timing.parseMs = timing.clock() - parseStart;
|
|
95
159
|
return { value: done, in: { transcript: done.sessions + done.subagents + done.unchanged } };
|
|
96
160
|
});
|
|
97
161
|
const survival = await recordSurvival(runtime, saved.allow, now);
|
|
@@ -107,12 +171,38 @@ const nightly = async (runtime, now) => {
|
|
|
107
171
|
// A survival read that fails every night is otherwise invisible, for the same reason; it comes last because the run itself did not lose a session.
|
|
108
172
|
return survival === null ? { hit: true, queried: false, error: await errorLine(runtime.storeDir, 'Commit survival not read this run.') } : { hit: true, queried: false };
|
|
109
173
|
};
|
|
174
|
+
/** The nightly run, then (when telemetry is given) the store-health snapshot and the run's cost. Recording happens after the run and cannot change its outcome or its error. */
|
|
175
|
+
const nightly = async (runtime, now, telemetry) => {
|
|
176
|
+
const timing = { clock: telemetry?.now ?? now, parseMs: 0 };
|
|
177
|
+
const started = timing.clock();
|
|
178
|
+
let outcome;
|
|
179
|
+
let failure;
|
|
180
|
+
let failed = false;
|
|
181
|
+
try {
|
|
182
|
+
outcome = await nightlyRun(runtime, now, timing);
|
|
183
|
+
}
|
|
184
|
+
catch (thrown) {
|
|
185
|
+
failure = thrown;
|
|
186
|
+
failed = true;
|
|
187
|
+
}
|
|
188
|
+
if (telemetry !== undefined) {
|
|
189
|
+
// Loaded here so the hooks that run before every tool call never pay for the telemetry modules.
|
|
190
|
+
const { recordRunCost, recordStoreHealth } = await import('../telemetry/health.js');
|
|
191
|
+
await recordStoreHealth(telemetry, runtime);
|
|
192
|
+
recordRunCost(telemetry, { totalMs: timing.clock() - started, parseMs: timing.parseMs, failed: failed || outcome?.error !== undefined });
|
|
193
|
+
}
|
|
194
|
+
if (failed)
|
|
195
|
+
throw failure;
|
|
196
|
+
return outcome ?? { hit: false, queried: false };
|
|
197
|
+
};
|
|
110
198
|
/** What a lookup hook looks up, as its recall key (spec 4.2 names none for a lookup): the cwd for session-start, the failure's signature for post-tool, the call's shape for pre-tool; null when it had nothing to look up. */
|
|
111
199
|
const lookupKey = (event, input) => {
|
|
112
200
|
if (event === 'session-start') {
|
|
113
201
|
const cwd = str(input.cwd);
|
|
114
202
|
return cwd === undefined ? null : stampKey(event, cwd);
|
|
115
203
|
}
|
|
204
|
+
if (event === 'prompt')
|
|
205
|
+
return stampKey(event, 'positions');
|
|
116
206
|
const tool = str(input.tool_name);
|
|
117
207
|
if (event === 'pre-tool')
|
|
118
208
|
return tool === undefined ? null : stampKey(event, toolShapeId(tool, inputKeys(input)));
|
|
@@ -126,31 +216,37 @@ export const hookRuntime = (event, env, platform, home, io) => ({
|
|
|
126
216
|
...(event === 'nightly' ? { cursorRoot: cursorRoot(platform, env, home), sourceRoots: Object.fromEntries(SOURCES.map((source) => [source.name, source.root(platform, env, home)])) } : {}),
|
|
127
217
|
...io,
|
|
128
218
|
});
|
|
219
|
+
/** What the nightly run records with, and nothing for any other hook: the telemetry folder sits beside the session store under the config directory, so `oars telemetry` and the nightly run read and write the same one. The version is read only for the nightly run, so the per-tool hooks never touch the manifest. */
|
|
220
|
+
export const nightlyTelemetry = (event, env, platform, home, version, now) => event === 'nightly' ? { env, telemetry: { dir: join(defaultConfigDir(env, platform, home), 'oars', 'telemetry'), platform }, version: version(), now } : undefined;
|
|
129
221
|
/**
|
|
130
222
|
* Runs one hook. It never fails or blocks the session it runs in: whatever happens, it resolves (the process exits 0), and the
|
|
131
223
|
* hook-call line records the outcome, with the failure's first line when there was one, so the record is evidence, not a claim.
|
|
132
224
|
*/
|
|
133
|
-
export const runHook = async (event, stdin, runtime, now) => {
|
|
225
|
+
export const runHook = async (event, stdin, runtime, now, telemetry, emit = noEmit) => {
|
|
134
226
|
const started = performance.now();
|
|
135
227
|
let session = null;
|
|
136
228
|
let outcome = { hit: false, queried: false };
|
|
137
229
|
let failed = false;
|
|
138
230
|
let failure;
|
|
139
231
|
let key = null;
|
|
232
|
+
let transcript;
|
|
140
233
|
try {
|
|
141
234
|
const input = parse(stdin);
|
|
235
|
+
transcript = str(input.transcript_path);
|
|
142
236
|
key = lookupKey(event, input);
|
|
143
237
|
session = str(input.session_id) ?? null;
|
|
144
238
|
if (event === 'session-start')
|
|
145
|
-
outcome = await sessionStart(runtime, input);
|
|
239
|
+
outcome = await sessionStart(runtime, input, now);
|
|
240
|
+
else if (event === 'prompt')
|
|
241
|
+
outcome = await prompt(runtime, input, now);
|
|
146
242
|
else if (event === 'pre-tool')
|
|
147
243
|
outcome = await preTool(runtime, input, now);
|
|
148
244
|
else if (event === 'post-tool')
|
|
149
|
-
outcome = await postTool(runtime, input);
|
|
245
|
+
outcome = await postTool(runtime, input, now);
|
|
150
246
|
else if (event === 'stop')
|
|
151
247
|
outcome = await stop(runtime, input, now);
|
|
152
248
|
else
|
|
153
|
-
outcome = await nightly(runtime, now);
|
|
249
|
+
outcome = await nightly(runtime, now, telemetry);
|
|
154
250
|
}
|
|
155
251
|
catch (thrown) {
|
|
156
252
|
failure = thrown;
|
|
@@ -166,5 +262,20 @@ export const runHook = async (event, stdin, runtime, now) => {
|
|
|
166
262
|
const report = { in: { query: 1 }, out: failed ? {} : { matches: outcome.hit ? 1 : 0 }, key };
|
|
167
263
|
await tryLog(() => logStep(newContext(runtime.storeDir, now, event), 'recall', failed ? 'error' : 'ok', ms, report, line));
|
|
168
264
|
}
|
|
265
|
+
// After the answer is settled and the local rows are written; it runs before hook-bin prints, at the cost measured in t876 item 5. `emit` never throws. A push states its own frame;
|
|
266
|
+
// every other fire reads the transcript's tail the way a push does, so the version and model are there on a miss, a post-tool, and a stop. (A nightly run has no hook input, so it has no transcript to read.)
|
|
267
|
+
const { recall } = outcome;
|
|
268
|
+
const stated = recall?.frame !== undefined || transcript === undefined ? undefined : await statedAt(transcript);
|
|
269
|
+
emit('hook', {
|
|
270
|
+
name: event,
|
|
271
|
+
latencyMs: ms,
|
|
272
|
+
outcome: failed ? 'error' : recall?.arm === 'held' ? 'held' : outcome.hit ? 'hit' : 'miss',
|
|
273
|
+
bytes: Buffer.byteLength(outcome.output ?? ''),
|
|
274
|
+
// The nightly is the scheduled task, not a harness's hook: it names no harness.
|
|
275
|
+
...frameWords(event === 'nightly' ? undefined : recall?.frame?.harness ?? HOOK_HARNESS, recall?.frame?.harnessVersion ?? stated?.harnessVersion, recall?.frame?.model ?? outcome.model ?? stated?.model),
|
|
276
|
+
});
|
|
277
|
+
// A push is a recall: its read is the family its query names (the `fit` push names none), and its arm is the exposure record, as a count of one.
|
|
278
|
+
if (recall !== undefined)
|
|
279
|
+
emit('recall', recallFields(recall, [recall.query.family ?? 'fit']));
|
|
169
280
|
return outcome.output;
|
|
170
281
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ActionTable, type CanonicalAction } from './actions.js';
|
|
2
|
+
import type { Redactor } from './redact.js';
|
|
2
3
|
import type { PullIdentity, ShellIdentity, StepIdentity, ToolPull } from './schema.js';
|
|
3
4
|
/** A word of a shell command, as the tokenizer read it. */
|
|
4
5
|
export interface WordOfCommand {
|
|
@@ -7,15 +8,19 @@ export interface WordOfCommand {
|
|
|
7
8
|
}
|
|
8
9
|
export declare const NUMBER_FLAG = "-N";
|
|
9
10
|
export declare const isPositional: ({ text, quoted }: WordOfCommand) => boolean;
|
|
10
|
-
/**
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* A pull with its package name as written (the command was redacted before it was read, and the name is checked once more part by part, as a host and a skill-run name are). A package off the public list can be identified by its version, so the version is masked beside it; a stated version of a listed package stays stated.
|
|
13
|
+
* A name that is not one (a URL, a path, a mask) is `<literal>`.
|
|
14
|
+
*/
|
|
15
|
+
export declare const pullIdentity: (pull: ToolPull, redact: Redactor) => PullIdentity;
|
|
12
16
|
/** A shell step whose program is private or unreadable: its flags are its own, so none are kept. */
|
|
13
17
|
export declare const UNREAD_SHELL: ShellIdentity;
|
|
14
|
-
export declare const shellIdentity: (program: string, args: readonly WordOfCommand[], pulls: readonly ToolPull[]) => ShellIdentity;
|
|
18
|
+
export declare const shellIdentity: (program: string, args: readonly WordOfCommand[], pulls: readonly ToolPull[], redact: Redactor) => ShellIdentity;
|
|
15
19
|
/** What a non-shell call's step reads as. `native` comes back masked unless every name it holds is public, since an MCP name spells out its server. */
|
|
16
20
|
export declare const callIdentity: (table: ActionTable, action: CanonicalAction, native: string, shown: string, input: {
|
|
17
21
|
skill: string | undefined;
|
|
18
22
|
url: string | undefined;
|
|
23
|
+
redact: Redactor;
|
|
19
24
|
}) => {
|
|
20
25
|
native: string;
|
|
21
26
|
identity: StepIdentity;
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
// A step's `identity` (recall spec Section 2.2): what the step acted on, by action,
|
|
2
|
-
// `public-vocabulary.ts
|
|
1
|
+
// A step's `identity` (recall spec Section 2.2): what the step acted on, by action. A program, subcommand, flag, MCP server and tool pass through
|
|
2
|
+
// `public-vocabulary.ts` and become `<literal>` when it does not list them; a skill-run name, a host, and a pull name are kept as they are after the
|
|
3
|
+
// operator's redaction (CAPTURE 24), and what leaves the machine is decided at the deposit's one gate and by the pool.
|
|
3
4
|
import { mcpOf } from './actions.js';
|
|
4
|
-
import {
|
|
5
|
+
import { isWireName, isWirePullName, wireName } from './pool-wire.js';
|
|
6
|
+
import { BUNDLING_PROGRAMS, isPublicMcpTool, isRegistryVersion, LITERAL, PRIVATE, PUBLIC_FLAGS, PUBLIC_MCP_SERVERS, PUBLIC_PACKAGES, PUBLIC_PROGRAMS, PUBLIC_SUBCOMMANDS } from './public-vocabulary.js';
|
|
5
7
|
const orLiteral = (name, allowed) => (allowed.has(name) ? name : LITERAL);
|
|
8
|
+
/**
|
|
9
|
+
* A long-tail name (a skill run, a host, a pull) as the store keeps it: the name itself, or `<literal>` when it is not a name or the operator's redaction would change it. The test is the wire's own (`wireName`), so a name stored here is a
|
|
10
|
+
* name the deposit sends, and a list never decides it: the pool does, by deploy. The operator's exclusion terms are the deposit's, since they change after the capture.
|
|
11
|
+
*/
|
|
12
|
+
const keptName = (name, redact, hasShape = isWireName) => {
|
|
13
|
+
const kept = wireName({ redact, terms: [] }, name, hasShape);
|
|
14
|
+
return kept === PRIVATE ? LITERAL : kept;
|
|
15
|
+
};
|
|
6
16
|
const SHORT_CLUSTER = /^-[a-z]{1,4}$/;
|
|
7
17
|
const SHORT_LETTER = /^-[A-Za-z]$/;
|
|
8
18
|
// A short flag with a value joined after a non-letter, as in `-c1-200`: the first capture is the name and the rest is the value, which is dropped.
|
|
@@ -63,33 +73,53 @@ const REGISTRY_OF = {
|
|
|
63
73
|
plugin: 'plugin',
|
|
64
74
|
mcp: 'mcp',
|
|
65
75
|
};
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
76
|
+
/**
|
|
77
|
+
* A pull with its package name as written (the command was redacted before it was read, and the name is checked once more part by part, as a host and a skill-run name are). A package off the public list can be identified by its version, so the version is masked beside it; a stated version of a listed package stays stated.
|
|
78
|
+
* A name that is not one (a URL, a path, a mask) is `<literal>`.
|
|
79
|
+
*/
|
|
80
|
+
export const pullIdentity = (pull, redact) => {
|
|
81
|
+
const name = keptName(pull.name, redact, isWirePullName);
|
|
82
|
+
const showsVersion = PUBLIC_PACKAGES.has(name) && pull.version !== null && isRegistryVersion(pull.version);
|
|
70
83
|
return { registry: REGISTRY_OF[pull.ecosystem], name, version: pull.version === null ? null : showsVersion ? pull.version : LITERAL };
|
|
71
84
|
};
|
|
72
85
|
/** A shell step whose program is private or unreadable: its flags are its own, so none are kept. */
|
|
73
86
|
export const UNREAD_SHELL = { program: LITERAL, sub: null, flags: [] };
|
|
74
|
-
|
|
87
|
+
/** Flags of `git checkout` that take only paths: a checkout with one of them acts on a path, never on a branch. */
|
|
88
|
+
const PATH_ONLY_FLAGS = new Set(['--ours', '--theirs', '--patch', '-p', '--pathspec-from-file']);
|
|
89
|
+
/**
|
|
90
|
+
* The class of what a `git checkout` acts on, from the line alone and never the target's text. A `--` marker or a path-only flag proves a path; a target word the segment drops (the same
|
|
91
|
+
* words `isPositional` refuses: quoted, or holding a slash, a backslash, or `$`) with neither proof could be a branch (`feature/x`) or a path (`src/a.ts`), so it is `word`; no such word is absent.
|
|
92
|
+
*/
|
|
93
|
+
const checkoutTarget = (all) => {
|
|
94
|
+
// Words before `checkout` are the global options of `git` (`-C <dir>`), not what the checkout acts on.
|
|
95
|
+
const args = all.slice(all.findIndex(({ text, quoted }) => !quoted && text === 'checkout') + 1);
|
|
96
|
+
const dashes = args.findIndex(({ text, quoted }) => !quoted && text === '--');
|
|
97
|
+
const flagWords = (dashes < 0 ? args : args.slice(0, dashes)).filter(({ text, quoted }) => !quoted && text.startsWith('-')).map(({ text }) => text.split('=')[0] ?? text);
|
|
98
|
+
if (dashes >= 0 || flagWords.some((flag) => PATH_ONLY_FLAGS.has(flag)))
|
|
99
|
+
return 'path';
|
|
100
|
+
return args.some((word) => (word.quoted || !word.text.startsWith('-')) && !isPositional(word)) ? 'word' : undefined;
|
|
101
|
+
};
|
|
102
|
+
export const shellIdentity = (program, args, pulls, redact) => {
|
|
75
103
|
if (!PUBLIC_PROGRAMS.has(program))
|
|
76
104
|
return UNREAD_SHELL;
|
|
77
|
-
const
|
|
78
|
-
|
|
105
|
+
const sub = subOf(program, args);
|
|
106
|
+
const target = program === 'git' && sub === 'checkout' ? checkoutTarget(args) : undefined;
|
|
107
|
+
const identity = { program, sub, flags: flagsOf(program, args), ...(target === undefined ? {} : { target }) };
|
|
108
|
+
return pulls.length === 0 ? identity : { ...identity, pulls: pulls.map((pull) => pullIdentity(pull, redact)) };
|
|
79
109
|
};
|
|
80
|
-
const hostOf = (url) => {
|
|
110
|
+
const hostOf = (url, redact) => {
|
|
81
111
|
if (url === undefined || url === '')
|
|
82
112
|
return { host: null };
|
|
83
|
-
// The parser drops credentials, the path, and the query, and lower-cases the host; the
|
|
113
|
+
// The parser drops credentials, the path, and the query, and lower-cases the host; the redactor and the name shape then decide, and the pool hashes it on arrival.
|
|
84
114
|
const parsed = URL.canParse(url) ? new URL(url).hostname : '';
|
|
85
|
-
return { host:
|
|
115
|
+
return { host: keptName(parsed, redact) };
|
|
86
116
|
};
|
|
87
117
|
/** What a non-shell call's step reads as. `native` comes back masked unless every name it holds is public, since an MCP name spells out its server. */
|
|
88
118
|
export const callIdentity = (table, action, native, shown, input) => {
|
|
89
119
|
if (action === 'web.fetch' || action === 'web.search')
|
|
90
|
-
return { native: shown, identity: hostOf(input.url) };
|
|
120
|
+
return { native: shown, identity: hostOf(input.url, input.redact) };
|
|
91
121
|
if (action === 'skill.run')
|
|
92
|
-
return { native: shown, identity: { skill:
|
|
122
|
+
return { native: shown, identity: { skill: keptName(input.skill ?? '', input.redact) } };
|
|
93
123
|
const mcp = action === 'mcp.call' ? mcpOf(table, native) : undefined;
|
|
94
124
|
if (mcp === undefined)
|
|
95
125
|
return { native: shown, identity: null };
|
|
@@ -3,6 +3,8 @@ export declare const DEFAULT_RETENTION = 30;
|
|
|
3
3
|
export declare const TASK_NAME = "OarsSessionsNightly";
|
|
4
4
|
/** What the install needs from the machine, so a test can stand in for each. */
|
|
5
5
|
export interface InstallRuntime {
|
|
6
|
+
/** `process.platform`: only `win32` has the nightly task. */
|
|
7
|
+
platform: NodeJS.Platform;
|
|
6
8
|
nodePath: string;
|
|
7
9
|
/** Absolute path of the built `hook-bin.js`. */
|
|
8
10
|
hookBin: string;
|
|
@@ -21,8 +23,9 @@ interface Json {
|
|
|
21
23
|
[key: string]: unknown;
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
|
-
* The post-tool command reads the event first in the hook shell (Git Bash on Windows, where the harness runs hooks)
|
|
25
|
-
* when the failure text carries `Exit code
|
|
26
|
+
* The post-tool command reads the event first in the hook shell (Git Bash on Windows, where the harness runs hooks). For a shell tool it starts node only
|
|
27
|
+
* when the failure text carries `Exit code` (a refusal or an interrupt has none, and a fresh node costs 250 ms to seconds on Windows, the shell builtins a few
|
|
28
|
+
* milliseconds); any other tool's failure goes straight to node, because its text has no such line and its recovery read does not need one.
|
|
26
29
|
*/
|
|
27
30
|
export declare const commandOf: (runtime: InstallRuntime, arg: string) => string;
|
|
28
31
|
export declare const settingsPath: (runtime: InstallRuntime, scope: Scope, file: string | undefined) => string;
|
|
@@ -50,5 +53,18 @@ export declare const hookStatus: (runtime: InstallRuntime, extra: string | undef
|
|
|
50
53
|
export declare const scheduleArgs: (runtime: InstallRuntime, hour: number) => string[];
|
|
51
54
|
export declare const SCHEDULE_HOUR = 3;
|
|
52
55
|
export declare const setSchedule: (runtime: InstallRuntime, mode: "nightly" | "off") => Promise<string>;
|
|
56
|
+
/** True once the operator ran `--schedule off` and has not run `--schedule nightly` since. A record that cannot be read or parsed counts as no opt-out. */
|
|
57
|
+
export declare const scheduleOptedOut: (storeDir: string) => Promise<boolean>;
|
|
58
|
+
/** Records the choice of `--schedule`: `off` writes the opt-out; `nightly` clears it. Without a store the choice has nowhere to live and is not recorded. */
|
|
59
|
+
export declare const recordSchedule: (storeDir: string | undefined, mode: "nightly" | "off") => Promise<void>;
|
|
60
|
+
/** What a plain `hook install` says when the operator turned the nightly task off: it is left alone. */
|
|
61
|
+
export declare const SCHEDULE_LEFT_OFF = "Scheduled task OarsSessionsNightly: left off, as you chose with --schedule off. Turn it back on with: oars sessions hook install --schedule nightly";
|
|
62
|
+
/**
|
|
63
|
+
* What a plain `hook install` does about the nightly task on Windows: register it unless it is already there. Registration is
|
|
64
|
+
* best effort: the hooks are installed by then, so a machine that refuses the task (policy, no Task Scheduler) is told how to retry
|
|
65
|
+
* and is not failed. `install --schedule off` removes the task and records the opt-out (`recordSchedule`), which the caller checks
|
|
66
|
+
* (`scheduleOptedOut`) before calling this; `--schedule nightly` clears it.
|
|
67
|
+
*/
|
|
68
|
+
export declare const ensureSchedule: (runtime: InstallRuntime) => Promise<string>;
|
|
53
69
|
export declare const scheduleStatus: (runtime: InstallRuntime) => Promise<string>;
|
|
54
70
|
export {};
|
package/dist/sessions/install.js
CHANGED
|
@@ -1,27 +1,31 @@
|
|
|
1
1
|
// `oars sessions hook install|uninstall|status`: writes the four Claude Code hook entries into one settings file, merging with what is
|
|
2
2
|
// there. An entry is ours when its command ends with `hook-bin.js" <session-start|pre-tool|post-tool|stop>`, so uninstall removes those entries and nothing else. Also the retention floor
|
|
3
3
|
// (the harness deletes raw transcripts after `cleanupPeriodDays`, 30 when unset) and the Windows nightly task.
|
|
4
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { CliError, ExitCode } from '../errors.js';
|
|
7
|
+
import { ACTION_TABLES } from './actions.js';
|
|
7
8
|
export const RETENTION_FLOOR = 365;
|
|
8
9
|
export const DEFAULT_RETENTION = 30;
|
|
9
10
|
export const TASK_NAME = 'OarsSessionsNightly';
|
|
10
11
|
// The tail of a command this module wrote; anything else in a settings file is the operator's, whatever it is called.
|
|
11
|
-
const OWN_COMMAND = /hook-bin\.js" (?:session-start|pre-tool|post-tool|stop)$/;
|
|
12
|
+
const OWN_COMMAND = /hook-bin\.js" (?:session-start|prompt|pre-tool|post-tool|stop)$/;
|
|
12
13
|
const SPECS = [
|
|
13
14
|
{ event: 'SessionStart', arg: 'session-start', timeout: 5 },
|
|
15
|
+
// The split-positions table again, only when it changed since this session's last one (`hooks.ts`); a prompt with nothing new costs one node start and prints nothing.
|
|
16
|
+
{ event: 'UserPromptSubmit', arg: 'prompt', timeout: 5 },
|
|
14
17
|
// Every tool (no matcher): the push is gated by the index's fit floors and lift, not by the tool's name, so a sharp read on any tool can fire. The cost is one node start per call.
|
|
15
18
|
{ event: 'PreToolUse', arg: 'pre-tool', timeout: 5 },
|
|
16
|
-
// A failed
|
|
17
|
-
{ event: 'PostToolUseFailure', arg: 'post-tool', timeout: 5, matcher: '
|
|
19
|
+
// A failed call arrives as PostToolUseFailure (PostToolUse itself fires on success only); a shell tool's `error` starts "Exit code N". Every tool the action table maps can fail into a recovery read, so the matcher is the table's names and the MCP pattern, not the two shells.
|
|
20
|
+
{ event: 'PostToolUseFailure', arg: 'post-tool', timeout: 5, matcher: `${Object.keys(ACTION_TABLES['claude-code'].rows).filter((name) => !name.startsWith('mcp__')).join('|')}|mcp__.*` },
|
|
18
21
|
// SessionEnd fires once per session; Stop fires after every assistant turn and would re-extract the growing transcript each time.
|
|
19
22
|
{ event: 'SessionEnd', arg: 'stop', timeout: 30 },
|
|
20
23
|
];
|
|
21
24
|
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
22
25
|
/**
|
|
23
|
-
* The post-tool command reads the event first in the hook shell (Git Bash on Windows, where the harness runs hooks)
|
|
24
|
-
* when the failure text carries `Exit code
|
|
26
|
+
* The post-tool command reads the event first in the hook shell (Git Bash on Windows, where the harness runs hooks). For a shell tool it starts node only
|
|
27
|
+
* when the failure text carries `Exit code` (a refusal or an interrupt has none, and a fresh node costs 250 ms to seconds on Windows, the shell builtins a few
|
|
28
|
+
* milliseconds); any other tool's failure goes straight to node, because its text has no such line and its recovery read does not need one.
|
|
25
29
|
*/
|
|
26
30
|
export const commandOf = (runtime, arg) => {
|
|
27
31
|
const launch = `"${runtime.nodePath}" "${runtime.hookBin}" ${arg}`;
|
|
@@ -29,7 +33,7 @@ export const commandOf = (runtime, arg) => {
|
|
|
29
33
|
const present = `[ -f "${runtime.hookBin}" ] || exit 0; `;
|
|
30
34
|
if (arg !== 'post-tool')
|
|
31
35
|
return `${present}${launch}`;
|
|
32
|
-
return `${present}IFS= read -r event; case "$event" in *'Exit code'*) ;; *) exit 0;; esac; { printf '%s\\n' "$event"; cat; } | ${launch}`;
|
|
36
|
+
return `${present}IFS= read -r event; case "$event" in '') exit 0;; *'"tool_name":"Bash"'*|*'"tool_name":"PowerShell"'*) case "$event" in *'Exit code'*) ;; *) exit 0;; esac;; esac; { printf '%s\\n' "$event"; cat; } | ${launch}`;
|
|
33
37
|
};
|
|
34
38
|
const isOwnHook = (hook) => isObject(hook) && typeof hook.command === 'string' && OWN_COMMAND.test(hook.command);
|
|
35
39
|
/** The groups with our entries taken out; a group that held only ours goes, one that held others keeps them. */
|
|
@@ -185,16 +189,57 @@ export const scheduleArgs = (runtime, hour) => [
|
|
|
185
189
|
'/F',
|
|
186
190
|
];
|
|
187
191
|
export const SCHEDULE_HOUR = 3;
|
|
192
|
+
const registered = () => `Scheduled task ${TASK_NAME}: the full deposit and the recurrence refresh run daily at ${String(SCHEDULE_HOUR).padStart(2, '0')}:00.`;
|
|
188
193
|
export const setSchedule = async (runtime, mode) => {
|
|
189
194
|
if (mode === 'nightly') {
|
|
190
195
|
const created = await runtime.schtasks(scheduleArgs(runtime, SCHEDULE_HOUR));
|
|
191
196
|
if (created.code !== 0)
|
|
192
197
|
throw new CliError(`Could not register the scheduled task: ${created.output.trim()}`, ExitCode.UNEXPECTED, 'unexpected');
|
|
193
|
-
return
|
|
198
|
+
return registered();
|
|
194
199
|
}
|
|
195
200
|
const deleted = await runtime.schtasks(['/Delete', '/TN', TASK_NAME, '/F']);
|
|
196
201
|
return deleted.code === 0 ? `Removed the scheduled task ${TASK_NAME}.` : `No scheduled task ${TASK_NAME} to remove.`;
|
|
197
202
|
};
|
|
203
|
+
/** Where `--schedule off` is remembered: a file of its own in the sessions store, because `state.json` is rewritten whole by every deposit. */
|
|
204
|
+
const scheduleRecord = (storeDir) => join(storeDir, 'schedule.json');
|
|
205
|
+
/** True once the operator ran `--schedule off` and has not run `--schedule nightly` since. A record that cannot be read or parsed counts as no opt-out. */
|
|
206
|
+
export const scheduleOptedOut = async (storeDir) => {
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(await readFile(scheduleRecord(storeDir), 'utf8'));
|
|
209
|
+
return typeof parsed === 'object' && parsed !== null && 'schedule' in parsed && parsed.schedule === 'off';
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
/** Records the choice of `--schedule`: `off` writes the opt-out; `nightly` clears it. Without a store the choice has nowhere to live and is not recorded. */
|
|
216
|
+
export const recordSchedule = async (storeDir, mode) => {
|
|
217
|
+
if (storeDir === undefined)
|
|
218
|
+
return;
|
|
219
|
+
if (mode === 'nightly') {
|
|
220
|
+
await rm(scheduleRecord(storeDir), { force: true });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
await mkdir(storeDir, { recursive: true });
|
|
224
|
+
await writeFile(scheduleRecord(storeDir), `${JSON.stringify({ schedule: 'off' })}\n`, { mode: 0o600 });
|
|
225
|
+
};
|
|
226
|
+
/** What a plain `hook install` says when the operator turned the nightly task off: it is left alone. */
|
|
227
|
+
export const SCHEDULE_LEFT_OFF = `Scheduled task ${TASK_NAME}: left off, as you chose with --schedule off. Turn it back on with: oars sessions hook install --schedule nightly`;
|
|
228
|
+
/**
|
|
229
|
+
* What a plain `hook install` does about the nightly task on Windows: register it unless it is already there. Registration is
|
|
230
|
+
* best effort: the hooks are installed by then, so a machine that refuses the task (policy, no Task Scheduler) is told how to retry
|
|
231
|
+
* and is not failed. `install --schedule off` removes the task and records the opt-out (`recordSchedule`), which the caller checks
|
|
232
|
+
* (`scheduleOptedOut`) before calling this; `--schedule nightly` clears it.
|
|
233
|
+
*/
|
|
234
|
+
export const ensureSchedule = async (runtime) => {
|
|
235
|
+
const queried = await runtime.schtasks(['/Query', '/TN', TASK_NAME]);
|
|
236
|
+
if (queried.code === 0)
|
|
237
|
+
return `Scheduled task ${TASK_NAME}: already registered.`;
|
|
238
|
+
const created = await runtime.schtasks(scheduleArgs(runtime, SCHEDULE_HOUR));
|
|
239
|
+
if (created.code !== 0)
|
|
240
|
+
return `Could not register the scheduled task ${TASK_NAME} (${created.output.trim()}); the hooks are installed. Retry with: oars sessions hook install --schedule nightly`;
|
|
241
|
+
return registered();
|
|
242
|
+
};
|
|
198
243
|
export const scheduleStatus = async (runtime) => {
|
|
199
244
|
const queried = await runtime.schtasks(['/Query', '/TN', TASK_NAME]);
|
|
200
245
|
return queried.code === 0 ? `Scheduled task ${TASK_NAME}: registered.` : `Scheduled task ${TASK_NAME}: not registered.`;
|
package/dist/sessions/mcp.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { type Emit } from '../telemetry/emit.js';
|
|
1
2
|
import { type SessionsRuntime } from './store.js';
|
|
2
3
|
export declare const TOOL_NAME = "oars_recall";
|
|
3
4
|
/** Answers one JSON-RPC message; a notification (no `id`) gets no answer. */
|
|
4
|
-
export declare const handleMessage: (runtime: SessionsRuntime, now: () => number, message: unknown) => Promise<object | undefined>;
|
|
5
|
+
export declare const handleMessage: (runtime: SessionsRuntime, now: () => number, message: unknown, emit?: Emit) => Promise<object | undefined>;
|
|
5
6
|
/** Reads requests from `runtime.stdin` until it ends, writing one response line per request to `runtime.stdout`. */
|
|
6
|
-
export declare const serve: (runtime: SessionsRuntime, now: () => number) => Promise<void>;
|
|
7
|
+
export declare const serve: (runtime: SessionsRuntime, now: () => number, emit?: Emit) => Promise<void>;
|
package/dist/sessions/mcp.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
import { appendFile, mkdir } from 'node:fs/promises';
|
|
6
6
|
import { createInterface } from 'node:readline';
|
|
7
7
|
import { servedPool } from './pool.js';
|
|
8
|
+
import { recallPool } from './recall-bundle.js';
|
|
9
|
+
import { noEmit } from '../telemetry/emit.js';
|
|
10
|
+
import { recallFields } from '../telemetry/fields.js';
|
|
8
11
|
import { appendRecall } from './recall-object.js';
|
|
9
12
|
import { formsRefusal, pull, pullRefusal, RECALL_FAMILIES } from './recall-reads.js';
|
|
10
13
|
import { MISS_TEXT } from './recall-render.js';
|
|
@@ -79,7 +82,7 @@ const toQuery = (args) => {
|
|
|
79
82
|
const reply = (id, result) => ({ jsonrpc: '2.0', id, result });
|
|
80
83
|
const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
81
84
|
/** Answers one JSON-RPC message; a notification (no `id`) gets no answer. */
|
|
82
|
-
export const handleMessage = async (runtime, now, message) => {
|
|
85
|
+
export const handleMessage = async (runtime, now, message, emit = noEmit) => {
|
|
83
86
|
if (!isObj(message) || typeof message.method !== 'string')
|
|
84
87
|
return failure(isObj(message) ? message.id : null, -32600, 'Invalid request');
|
|
85
88
|
const { id, method } = message;
|
|
@@ -110,7 +113,7 @@ export const handleMessage = async (runtime, now, message) => {
|
|
|
110
113
|
// The server is not told which session it serves, so `sid` is null: naming it is what a consequence for an MCP pull still needs.
|
|
111
114
|
const store = await loadStore(runtime.storeDir);
|
|
112
115
|
const redactDomains = await readRedactDomains(runtime.storeDir);
|
|
113
|
-
const result = pull(store, query, { sid: null, now: now(), frame: null, redactDomains, current: await
|
|
116
|
+
const result = pull(store, query, { sid: null, now: now(), frame: null, redactDomains, current: await recallPool(runtime.storeDir, store, servedPool) });
|
|
114
117
|
// What an asker typed is class A: the call log masks each string with the redactor `pull` applies to the recall row, before it is serialized, because JSON escaping of a quote or newline hides a secret from the rules.
|
|
115
118
|
const redact = makeRedactor(redactDomains);
|
|
116
119
|
const masked = JSON.stringify(query, (_key, value) => (typeof value === 'string' ? redact(value) : value));
|
|
@@ -121,6 +124,7 @@ export const handleMessage = async (runtime, now, message) => {
|
|
|
121
124
|
await appendFile(paths(runtime.storeDir).calls, `${JSON.stringify(call)}\n`, { mode: 0o600 });
|
|
122
125
|
});
|
|
123
126
|
await tryLog(() => appendRecall(runtime.storeDir, result.recall));
|
|
127
|
+
emit('recall', recallFields(result.recall, result.reads.map((read) => read.family), result.legacy !== undefined));
|
|
124
128
|
// An agent reads this, so it gets the rendered counts and the structured recall, and no signature text.
|
|
125
129
|
const { answer, id: recallId, evidence } = result.recall;
|
|
126
130
|
return reply(id, { content: [{ type: 'text', text: answer.rendered === '' ? MISS_TEXT : answer.rendered }], structuredContent: { recallId, kind: answer.kind, reads: answer.reads, evidence } });
|
|
@@ -133,7 +137,7 @@ export const handleMessage = async (runtime, now, message) => {
|
|
|
133
137
|
return failure(id, -32601, `Method not found: ${method}`);
|
|
134
138
|
};
|
|
135
139
|
/** Reads requests from `runtime.stdin` until it ends, writing one response line per request to `runtime.stdout`. */
|
|
136
|
-
export const serve = async (runtime, now) => {
|
|
140
|
+
export const serve = async (runtime, now, emit = noEmit) => {
|
|
137
141
|
const lines = createInterface({ input: runtime.stdin, crlfDelay: Infinity });
|
|
138
142
|
for await (const line of lines) {
|
|
139
143
|
if (line.trim() === '')
|
|
@@ -146,7 +150,7 @@ export const serve = async (runtime, now) => {
|
|
|
146
150
|
runtime.stdout.write(`${JSON.stringify(failure(null, -32700, 'Parse error'))}\n`);
|
|
147
151
|
continue;
|
|
148
152
|
}
|
|
149
|
-
const answer = await handleMessage(runtime, now, parsed);
|
|
153
|
+
const answer = await handleMessage(runtime, now, parsed, emit);
|
|
150
154
|
if (answer !== undefined)
|
|
151
155
|
runtime.stdout.write(`${JSON.stringify(answer)}\n`);
|
|
152
156
|
}
|
package/dist/sessions/motif.d.ts
CHANGED
|
@@ -15,7 +15,8 @@ export interface WindowStep {
|
|
|
15
15
|
/** A step as a cell counts it. */
|
|
16
16
|
export interface CountedStep extends WindowStep {
|
|
17
17
|
status: ToolStatus;
|
|
18
|
-
|
|
18
|
+
/** Null when the step recorded no cost (a source that never reported one, or a call captured before steps): a cell leaves it out of its sum and coverage, never reading it as 0. */
|
|
19
|
+
tokens: number | null;
|
|
19
20
|
durationMs: number;
|
|
20
21
|
}
|
|
21
22
|
export interface Motif {
|
package/dist/sessions/motif.js
CHANGED
|
@@ -27,8 +27,8 @@ export const shapeMotif = (steps) => motifOf('shape', steps, null);
|
|
|
27
27
|
/** The steps of one call. A record captured before steps existed reads through the same action table capture uses, so old data joins new. */
|
|
28
28
|
export const stepsOfCall = (tool, harness) => {
|
|
29
29
|
if (tool.steps === undefined)
|
|
30
|
-
return [{ action: actionOf(ACTION_TABLES[harness], tool.tool).action, identity: null, status: tool.status, tokens:
|
|
31
|
-
return tool.steps.map((step) => ({ action: step.action, identity: step.identity, status: step.status, tokens: step.cost.tokens
|
|
30
|
+
return [{ action: actionOf(ACTION_TABLES[harness], tool.tool).action, identity: null, status: tool.status, tokens: null, durationMs: tool.durationMs ?? 0 }];
|
|
31
|
+
return tool.steps.map((step) => ({ action: step.action, identity: step.identity, status: step.status, tokens: step.cost.tokens, durationMs: step.cost.durationMs ?? 0 }));
|
|
32
32
|
};
|
|
33
33
|
/** Every window of a line of work, at both levels, shortest first at each position. */
|
|
34
34
|
export const motifsOf = (line) => {
|