@manyoars/oars 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +44 -29
  2. package/dist/cli.d.ts +4 -1
  3. package/dist/cli.js +102 -32
  4. package/dist/commands/login.d.ts +1 -1
  5. package/dist/commands/login.js +1 -1
  6. package/dist/gate/hook-main.js +13 -1
  7. package/dist/gate/hook-process.d.ts +11 -1
  8. package/dist/gate/hook-process.js +50 -2
  9. package/dist/gate/hook-record.d.ts +3 -0
  10. package/dist/gate/hook-record.js +8 -0
  11. package/dist/gate/hook.d.ts +2 -0
  12. package/dist/gate/hook.js +3 -0
  13. package/dist/gate/replay.js +2 -5
  14. package/dist/gate/report.js +1 -1
  15. package/dist/gate/transcripts.js +1 -1
  16. package/dist/hook-bin.js +27 -2
  17. package/dist/issuer.d.ts +7 -9
  18. package/dist/issuer.js +10 -32
  19. package/dist/main.js +3 -0
  20. package/dist/sessions/actions.js +78 -0
  21. package/dist/sessions/allow.d.ts +10 -1
  22. package/dist/sessions/allow.js +29 -9
  23. package/dist/sessions/baseline.d.ts +1 -1
  24. package/dist/sessions/baseline.js +6 -4
  25. package/dist/sessions/call-shape.d.ts +19 -0
  26. package/dist/sessions/call-shape.js +85 -0
  27. package/dist/sessions/cell-increments.d.ts +5 -1
  28. package/dist/sessions/cell-increments.js +84 -50
  29. package/dist/sessions/cells.d.ts +33 -3
  30. package/dist/sessions/cells.js +89 -9
  31. package/dist/sessions/codex.js +93 -27
  32. package/dist/sessions/command.d.ts +11 -1
  33. package/dist/sessions/command.js +64 -18
  34. package/dist/sessions/coverage.d.ts +19 -0
  35. package/dist/sessions/coverage.js +56 -0
  36. package/dist/sessions/cursor.js +2 -2
  37. package/dist/sessions/derive.js +72 -6
  38. package/dist/sessions/exclusion.d.ts +6 -0
  39. package/dist/sessions/exclusion.js +8 -0
  40. package/dist/sessions/extract.d.ts +4 -2
  41. package/dist/sessions/extract.js +58 -26
  42. package/dist/sessions/hook-calls.js +1 -1
  43. package/dist/sessions/hook-index.d.ts +14 -1
  44. package/dist/sessions/hook-index.js +10 -3
  45. package/dist/sessions/hooks.d.ts +5 -1
  46. package/dist/sessions/hooks.js +133 -22
  47. package/dist/sessions/identity.d.ts +8 -3
  48. package/dist/sessions/identity.js +45 -15
  49. package/dist/sessions/install.d.ts +18 -2
  50. package/dist/sessions/install.js +53 -8
  51. package/dist/sessions/mcp.d.ts +3 -2
  52. package/dist/sessions/mcp.js +8 -4
  53. package/dist/sessions/motif.d.ts +2 -1
  54. package/dist/sessions/motif.js +2 -2
  55. package/dist/sessions/opencode.d.ts +33 -0
  56. package/dist/sessions/opencode.js +319 -0
  57. package/dist/sessions/otel.d.ts +60 -0
  58. package/dist/sessions/otel.js +494 -0
  59. package/dist/sessions/package-books.d.ts +48 -0
  60. package/dist/sessions/package-books.js +120 -0
  61. package/dist/sessions/package-resolve.d.ts +2 -0
  62. package/dist/sessions/package-resolve.js +3 -1
  63. package/dist/sessions/pi.d.ts +36 -0
  64. package/dist/sessions/pi.js +353 -0
  65. package/dist/sessions/pool-books.d.ts +21 -0
  66. package/dist/sessions/pool-books.js +75 -0
  67. package/dist/sessions/pool-choices.d.ts +5 -0
  68. package/dist/sessions/pool-choices.js +11 -0
  69. package/dist/sessions/pool-command.d.ts +76 -5
  70. package/dist/sessions/pool-command.js +263 -136
  71. package/dist/sessions/pool-nightly-deps.d.ts +3 -0
  72. package/dist/sessions/pool-nightly-deps.js +11 -0
  73. package/dist/sessions/pool-nightly.d.ts +10 -0
  74. package/dist/sessions/pool-nightly.js +85 -0
  75. package/dist/sessions/pool-outbox.d.ts +15 -2
  76. package/dist/sessions/pool-outbox.js +79 -26
  77. package/dist/sessions/pool-print.d.ts +1 -1
  78. package/dist/sessions/pool-raw.d.ts +27 -0
  79. package/dist/sessions/pool-raw.js +68 -0
  80. package/dist/sessions/pool-replica.d.ts +35 -0
  81. package/dist/sessions/pool-replica.js +91 -0
  82. package/dist/sessions/pool-send-lock.d.ts +20 -0
  83. package/dist/sessions/pool-send-lock.js +87 -0
  84. package/dist/sessions/pool-sent.d.ts +4 -0
  85. package/dist/sessions/pool-sent.js +26 -0
  86. package/dist/sessions/pool-totals.d.ts +10 -0
  87. package/dist/sessions/pool-totals.js +21 -0
  88. package/dist/sessions/pool-wire.d.ts +240 -30
  89. package/dist/sessions/pool-wire.js +695 -215
  90. package/dist/sessions/public-vocabulary.d.ts +2 -3
  91. package/dist/sessions/public-vocabulary.js +2 -9
  92. package/dist/sessions/query.d.ts +16 -4
  93. package/dist/sessions/query.js +32 -10
  94. package/dist/sessions/read-cost.js +14 -3
  95. package/dist/sessions/read-recovery-edge.d.ts +2 -0
  96. package/dist/sessions/read-recovery-edge.js +26 -0
  97. package/dist/sessions/read.d.ts +8 -1
  98. package/dist/sessions/read.js +6 -3
  99. package/dist/sessions/recall-bundle.d.ts +12 -0
  100. package/dist/sessions/recall-bundle.js +33 -0
  101. package/dist/sessions/recall-frames.d.ts +18 -0
  102. package/dist/sessions/recall-frames.js +44 -0
  103. package/dist/sessions/recall-next.d.ts +145 -0
  104. package/dist/sessions/recall-next.js +241 -0
  105. package/dist/sessions/recall-object.d.ts +2 -0
  106. package/dist/sessions/recall-object.js +1 -0
  107. package/dist/sessions/recall-push.d.ts +2 -2
  108. package/dist/sessions/recall-push.js +3 -51
  109. package/dist/sessions/recall-reads.d.ts +7 -0
  110. package/dist/sessions/recall-reads.js +7 -0
  111. package/dist/sessions/recovery-calls.d.ts +21 -0
  112. package/dist/sessions/recovery-calls.js +25 -0
  113. package/dist/sessions/redact.d.ts +11 -0
  114. package/dist/sessions/redact.js +59 -0
  115. package/dist/sessions/schema.d.ts +59 -9
  116. package/dist/sessions/schema.js +22 -3
  117. package/dist/sessions/session-shape.d.ts +6 -3
  118. package/dist/sessions/session-shape.js +19 -6
  119. package/dist/sessions/sources.js +75 -1
  120. package/dist/sessions/store.d.ts +13 -1
  121. package/dist/sessions/store.js +36 -16
  122. package/dist/sessions/survival.d.ts +2 -0
  123. package/dist/sessions/survival.js +2 -2
  124. package/dist/sessions/transcript-tail.d.ts +14 -0
  125. package/dist/sessions/transcript-tail.js +64 -0
  126. package/dist/sessions/vscode-copilot.js +24 -7
  127. package/dist/telemetry/command.d.ts +8 -0
  128. package/dist/telemetry/command.js +85 -0
  129. package/dist/telemetry/consent.d.ts +17 -0
  130. package/dist/telemetry/consent.js +63 -0
  131. package/dist/telemetry/crash.d.ts +8 -0
  132. package/dist/telemetry/crash.js +52 -0
  133. package/dist/telemetry/emit.d.ts +31 -0
  134. package/dist/telemetry/emit.js +42 -0
  135. package/dist/telemetry/fields.d.ts +16 -0
  136. package/dist/telemetry/fields.js +26 -0
  137. package/dist/telemetry/health.d.ts +21 -0
  138. package/dist/telemetry/health.js +108 -0
  139. package/dist/telemetry/hook-emit.d.ts +4 -0
  140. package/dist/telemetry/hook-emit.js +18 -0
  141. package/dist/telemetry/measures.d.ts +32 -0
  142. package/dist/telemetry/measures.js +128 -0
  143. package/dist/telemetry/observe.d.ts +13 -0
  144. package/dist/telemetry/observe.js +51 -0
  145. package/dist/telemetry/read-events.d.ts +9 -0
  146. package/dist/telemetry/read-events.js +17 -0
  147. package/dist/telemetry/record.d.ts +17 -0
  148. package/dist/telemetry/record.js +26 -0
  149. package/dist/telemetry/report.d.ts +77 -0
  150. package/dist/telemetry/report.js +188 -0
  151. package/dist/telemetry/schema.d.ts +40 -0
  152. package/dist/telemetry/schema.js +187 -0
  153. package/dist/telemetry/send-main.d.ts +1 -0
  154. package/dist/telemetry/send-main.js +11 -0
  155. package/dist/telemetry/send.d.ts +36 -0
  156. package/dist/telemetry/send.js +172 -0
  157. package/dist/telemetry/spool.d.ts +17 -0
  158. package/dist/telemetry/spool.js +70 -0
  159. package/dist/telemetry/wire.d.ts +25 -0
  160. package/dist/telemetry/wire.js +76 -0
  161. package/package.json +2 -2
@@ -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 { signatureOf } from './extract.js';
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 { pushRecall } from './recall-push.js';
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
- const sessionStart = async (runtime, input) => {
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 = cwd === undefined ? undefined : await readIndex(runtime.storeDir);
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
- return block === undefined ? { hit: false, queried: index !== undefined } : { output: additional('SessionStart', block), hit: true, queried: true };
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
- const postTool = async (runtime, input) => {
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
- if (tool === undefined || text === undefined)
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
- if (index === undefined)
123
+ const bundle = await bundleNext(runtime.storeDir, prepared.asker);
124
+ if (index === undefined && bundle === undefined)
72
125
  return { hit: false, queried: false };
73
- const found = index.signatures[signatureOf(tool, text, makeRedactor(index.redactDomains))];
74
- if (found === undefined)
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 followed = found.followed.map(([next]) => next).join(', ');
77
- const context = `oars recall: seen in ${String(found.sessions)} sessions; what followed: ${followed === '' ? 'nothing recorded' : followed}`;
78
- return { output: additional(str(input.hook_event_name) ?? 'PostToolUseFailure', context), hit: true, queried: true };
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 nightly = async (runtime, now) => {
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
- /** A pull in public names. A private package's version can still identify it, so it is masked with the name; a stated version stays stated. */
11
- export declare const pullIdentity: (pull: ToolPull) => PullIdentity;
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, in public names only. Every name passes through
2
- // `public-vocabulary.ts`, so what may leave the machine is decided in that one file; anything it does not list becomes `<literal>`.
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 { BUNDLING_PROGRAMS, isPublicMcpTool, isRegistryVersion, LITERAL, PUBLIC_FLAGS, PUBLIC_HOSTS, PUBLIC_MCP_SERVERS, PUBLIC_PACKAGES, PUBLIC_PROGRAMS, PUBLIC_SKILLS, PUBLIC_SUBCOMMANDS } from './public-vocabulary.js';
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
- /** A pull in public names. A private package's version can still identify it, so it is masked with the name; a stated version stays stated. */
67
- export const pullIdentity = (pull) => {
68
- const name = orLiteral(pull.name, PUBLIC_PACKAGES);
69
- const showsVersion = name !== LITERAL && pull.version !== null && isRegistryVersion(pull.version);
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
- export const shellIdentity = (program, args, pulls) => {
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 identity = { program, sub: subOf(program, args), flags: flagsOf(program, args) };
78
- return pulls.length === 0 ? identity : { ...identity, pulls: pulls.map(pullIdentity) };
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 list then decides.
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: orLiteral(parsed, PUBLIC_HOSTS) };
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: orLiteral(input.skill ?? '', PUBLIC_SKILLS) } };
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) and starts node only
25
- * when the failure text carries `Exit code`: a fresh node costs 250 ms to seconds on Windows, the shell builtins a few milliseconds.
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 {};
@@ -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 Bash call arrives as PostToolUseFailure with `error` = "Exit code N ...": PostToolUse itself fires on success only.
17
- { event: 'PostToolUseFailure', arg: 'post-tool', timeout: 5, matcher: 'Bash|PowerShell' },
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) and starts node only
24
- * when the failure text carries `Exit code`: a fresh node costs 250 ms to seconds on Windows, the shell builtins a few milliseconds.
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 `Scheduled task ${TASK_NAME}: the full deposit and the recurrence refresh run daily at ${String(SCHEDULE_HOUR).padStart(2, '0')}:00.`;
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.`;
@@ -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>;
@@ -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 servedPool(runtime.storeDir, store) });
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
  }
@@ -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
- tokens: number;
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 {
@@ -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: 0, durationMs: tool.durationMs ?? 0 }];
31
- return tool.steps.map((step) => ({ action: step.action, identity: step.identity, status: step.status, tokens: step.cost.tokens ?? 0, durationMs: step.cost.durationMs ?? 0 }));
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) => {