@manyoars/oars 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -209,9 +209,9 @@ Replay is local and read-only on your transcripts; it writes `crossings.jsonl` a
209
209
 
210
210
  `oars pool` uses the Many Oars pool, `https://pool.manyoars.com`, which is live (deposits there are closed until its covenant is published), or a pool server you name with `--server <url>` or `OARS_POOL_URL`. Every other command works without it.
211
211
 
212
- - `oars pool login --provider github|google` asks the pool to start the provider's device flow, prints the user code and link, and polls the pool until you approve; with `--verbose`, every request prints before it is sent. The pool holds the OAuth client and its secret, so the CLI needs no client ID or secret, and the provider's tokens never reach this machine.
212
+ - `oars pool login --provider github|google` asks the pool to start the provider's device flow, prints the user code and link, and polls the pool until you approve (a failed poll is retried with a longer wait until the code expires; the output names the exclusion-terms file when there is none, and the first `pool deposit` needs it); with `--verbose`, every request prints before it is sent. The pool holds the OAuth client and its secret, so the CLI needs no client ID or secret, and the provider's tokens never reach this machine.
213
213
  - `oars pool logout` revokes this machine's credential at the pool, then removes the local sign-in. If the pool cannot be reached it still removes the local sign-in and says the credential was not revoked there.
214
- - `oars pool deposit` writes a batch of session skeletons (a closed list of fields, each value redacted first; a host is hashed by the pool on arrival) to an outbox for each session that passes your folder allowlist and the exclusion check, and stops: no session data is sent. `--send <hash>` sends one batch that `outbox` printed and that is unchanged on disk, once this account has accepted the pool covenant (a person types the phrase the covenant screen states, at a terminal; `--accept-covenant <version>` only names the version expected and accepts nothing; the screen then asks "Are you in the EU, the EEA, or the UK? (yes/no)" and sends the answer, and `--in-eu-eea-uk <yes|no>` answers it without asking; while the pool is closed to those regions a yes, or a request from them, is refused with `region_closed` and nothing is accepted); before that it reads the covenant, sends no batch, and exits 3 with `covenant_not_accepted`. A hash that was never shown, or a batch that changed, is refused before any request. The detached sender is the other sender: after the pool says your account accepted its covenant, it sends new batches, and raw copies only if you said yes to the raw prompt (`oars pool raw --consent yes`, answered by typing the phrase `I consent to sending raw copies` at a terminal; a yes also tells the pool, and `--consent no` tells it to stop accepting raw copies, whatever a later request states (a machine that held a yes and is signed out exits nonzero: the pool was not told), and the pool's nightly job then erases the raw copies it already holds, starting that night; a large account can take more than one night). With no sign-in, no acceptance, or an unreachable server it sends nothing.
214
+ - `oars pool deposit` writes a batch of session skeletons (a closed list of fields, each value redacted first; a host is hashed by the pool on arrival) to an outbox for each session that passes your folder allowlist and the exclusion check and whose last line is over 6 hours old, and stops: no session data is sent. `--send <hash>` sends one batch that `outbox` printed and that is unchanged on disk, once this account has accepted the pool covenant (a person types the phrase the covenant screen states, at a terminal; `--accept-covenant <version>` only names the version expected and accepts nothing; the screen then asks "Are you in the EU, the EEA, or the UK? (yes/no)" and sends the answer, and `--in-eu-eea-uk <yes|no>` answers it without asking; while the pool is closed to those regions a yes, or a request from them, is refused with `region_closed` and nothing is accepted); before that it reads the covenant, sends no batch, and exits 3 with `covenant_not_accepted`. A hash that was never shown, or a batch that changed, is refused before any request. The detached sender is the other sender: after the pool says your account accepted its covenant, it sends new batches, and raw copies only if you said yes to the raw prompt (`oars pool raw --consent yes`, answered by typing the phrase `I consent to sending raw copies` at a terminal; a yes also tells the pool, and `--consent no` tells it to stop accepting raw copies, whatever a later request states (a machine that held a yes and is signed out exits nonzero: the pool was not told), and the pool's nightly job then erases the raw copies it already holds, starting that night; a large account can take more than one night). With no sign-in, no acceptance, or an unreachable server it sends nothing.
215
215
  - `deposit`, `outbox`, `export`, and `fetch` need `oars pool login` first; without it they exit 3 and name the server.
216
216
  - `oars pool coverage [--org-size <band>] [--repo-visibility public|private|unknown]` sets the two coverage bands only you can state, read locally and never from the network; an unset band goes on the wire as `unknown`.
217
217
  - `oars pool export --out <folder> [--raw]` saves a copy of what the pool holds about your account (GDPR Articles 15(3) and 20), as JSON, one file a page, readable by you only. It covers your account row (with your raw consent record), when your sign-ins end (never the credential), your covenant acceptances, your machines (without their keys, with the day of each one's last deposit and its key days), your repository keys, your deposit hours, your deposits as they now read (with the machine, host class, and launcher each came from, and the host class and launcher the batch claimed), your raw ledger, your staged deposit parts, your erasure audit rows, and your telemetry days. `--raw` also saves each raw transcript you deposited that has not been erased, checked against its hash. It needs `oars pool login`, writes nothing to the pool, prints what it saved, and exits nonzero naming any page or object that failed; a rerun overwrites the same files. Totals the pool derives from many accounts are not yours to export and are not in it.
package/dist/cli.js CHANGED
@@ -151,7 +151,7 @@ const buildProgram = (deps, observed) => {
151
151
  const period = flag(options, 'year') ? 'year' : flag(options, 'month') ? 'month' : 'week';
152
152
  emit(deps, flag(options, 'json'), await sessionsCard(sessionDeps(deps), { period, text: flag(options, 'text'), out: text(options, 'out'), noCost: options.cost === false }, deps.env));
153
153
  });
154
- const sessions = program.command('sessions').description("deposit and recall your own coding-agent session logs, locally (these commands make no model call and no network call, except `gold arms`, which asks your own Claude subscription; telemetry is separate: oars telemetry)");
154
+ const sessions = program.command('sessions').description("deposit and recall your own coding-agent session logs, locally; try it on made-up sessions: oars sessions try (these commands make no model call and no network call, except `gold arms`, which asks your own Claude subscription; telemetry is separate: oars telemetry)");
155
155
  sessions
156
156
  .command('deposit')
157
157
  .description('read session logs into the local observation log, from every folder unless --allow names some or --deny keeps some out; idempotent. Subagent transcripts under each session (<project>/<session>/subagents/) are deposited with their parent, and each raw transcript is hard-linked or copied into the oars archive beside the store')
@@ -1,7 +1,7 @@
1
1
  import type { Source } from './sources.js';
2
2
  import type { DepositResult, SessionsRuntime } from './store.js';
3
3
  /** `recall` over a store with no sessions in it. */
4
- export declare const EMPTY_RECALL = "Your store is empty, so there is nothing to recall. See it work on made-up sessions with `oars sessions try`, or fill it with `oars sessions deposit`, which reads this machine's coding-agent session logs into a local store and archives a copy of each transcript.";
4
+ export declare const EMPTY_RECALL = "Your store is empty, so there is nothing to recall. See it work on made-up sessions with `oars sessions try`, or fill it with `oars sessions deposit`, which reads this machine's coding-agent session logs into a local store and archives a copy of each transcript. It reads your own session history, not made-up sessions.";
5
5
  /** `books` over a store with no sessions in it; `what` is "prompts" or "pulls". */
6
6
  export declare const emptyBooks: (what: string) => string;
7
7
  /**
@@ -1,7 +1,7 @@
1
1
  // What `deposit`, `recall`, and `books` say when there is nothing to show, so a first run reads as "nothing found here" and not as a broken tool.
2
2
  import { compileAllow, isDenial } from './allow.js';
3
3
  /** The two commands that get someone out of an empty store: watch it work on made-up sessions first with `sessions try`, or fill it from their own sessions with `sessions deposit`. */
4
- const WAYS_OUT = "See it work on made-up sessions with `oars sessions try`, or fill it with `oars sessions deposit`, which reads this machine's coding-agent session logs into a local store and archives a copy of each transcript.";
4
+ const WAYS_OUT = "See it work on made-up sessions with `oars sessions try`, or fill it with `oars sessions deposit`, which reads this machine's coding-agent session logs into a local store and archives a copy of each transcript. It reads your own session history, not made-up sessions.";
5
5
  /** `recall` over a store with no sessions in it. */
6
6
  export const EMPTY_RECALL = `Your store is empty, so there is nothing to recall. ${WAYS_OUT}`;
7
7
  /** `books` over a store with no sessions in it; `what` is "prompts" or "pulls". */
@@ -76,6 +76,31 @@ const mapContent = (content, text, result, definition) => {
76
76
  });
77
77
  return items.some((item, at) => item !== content[at]) ? items : content;
78
78
  };
79
+ // Harness text a request blob of the system role carries, by what it opens with or names. The whole block is marked, under the kind (and so the hash) its
80
+ // attachment record gets, because it is the same text. First match wins. A system text with none of these forms is left to the other rules.
81
+ const SYSTEM_FORMS = [
82
+ [/\bSessionStart(?::[\w-]+)? hook\b|\bhook additional context\b/, 'hook-additional-context'],
83
+ [/following skills are available for use with the Skill tool/i, 'skill-listing'],
84
+ [/Available agent types/i, 'agent-listing-delta'],
85
+ [/^\s*#{1,3} MCP Server Instructions\b|following MCP servers have provided instructions/m, 'mcp-instructions-delta'],
86
+ [/^\s*#{1,3} Environment\s*$|^\s*You have been invoked in the following environment/m, 'environment'],
87
+ [/Contents of [^\n]*?[\\/]\.claude[\\/]rules[\\/][^\n]*\.md\b|Contents of [^\n]*?(?:CLAUDE|AGENTS|MEMORY)(?:\.local)?\.md\b/, 'instructions'],
88
+ ];
89
+ // A memory file: an instruction file, the memory index, a note under a `memory` folder, or a rules file.
90
+ const MEMORY_PATH = /(?:^|[\\/])(?:CLAUDE|AGENTS|MEMORY)(?:\.local)?\.md$|[\\/]memory[\\/][^\\/]+\.md$|[\\/]\.claude[\\/]rules[\\/][^\\/]+\.md$/i;
91
+ const MEMORY_MENTION = /(?:CLAUDE|AGENTS|MEMORY)(?:\.local)?\.md\b|[\\/]memory[\\/][^\s"'`\\/]+\.md\b/i;
92
+ const CHANGED = /changed on disk|was modified/i;
93
+ /** The kind of harness text a system-role string is, or undefined: one of the forms above, or a note that a memory file changed. */
94
+ const systemKindOf = (text) => {
95
+ const form = SYSTEM_FORMS.find(([shape]) => shape.test(text));
96
+ if (form !== undefined)
97
+ return form[1];
98
+ return MEMORY_MENTION.test(text) && CHANGED.test(text) ? 'memory-file' : undefined;
99
+ };
100
+ const systemText = (key) => (text) => {
101
+ const kind = text.trim() === '' || MARKER.test(text.trim()) ? undefined : systemKindOf(text);
102
+ return kind === undefined ? text : markerOf(key, kind, text);
103
+ };
79
104
  const messageRule = (key, position, known) => {
80
105
  const whole = (text) => (text.trim() === '' || MARKER.test(text.trim()) ? text : markerOf(key, 'meta', text));
81
106
  const leading = (text) => {
@@ -91,13 +116,25 @@ const messageRule = (key, position, known) => {
91
116
  return content === message['content'] || content === undefined ? message : { ...message, content };
92
117
  };
93
118
  };
94
- const withMessage = (line, field, rule) => {
119
+ const withMessage = (line, field, rule, role = 'user') => {
95
120
  const message = line[field];
96
- if (!isObject(message) || message['role'] !== 'user')
121
+ if (!isObject(message) || message['role'] !== role)
97
122
  return line;
98
123
  const next = rule(message);
99
124
  return next === message ? line : { ...line, [field]: next };
100
125
  };
126
+ /** The rule for a request blob of the system role: the harness's own forms first, then the text other lines repeat. */
127
+ const systemMessage = (key, known) => {
128
+ const mark = systemText(key);
129
+ const text = (value) => {
130
+ const next = mark(value);
131
+ return next !== value || !known.has(normalize(value)) ? next : markerOf(key, 'meta', value);
132
+ };
133
+ return (message) => {
134
+ const content = mapContent(message['content'], text, text, (tool) => tool);
135
+ return content === message['content'] || content === undefined ? message : { ...message, content };
136
+ };
137
+ };
101
138
  const withField = (line, field, change) => {
102
139
  const value = line[field];
103
140
  if (value === undefined)
@@ -168,6 +205,8 @@ export const injectedRule = (key, known = new Set()) => {
168
205
  const userMessage = messageRule(key, 'typed', known);
169
206
  const metaMessage = messageRule(key, 'meta', known);
170
207
  const blobMessage = messageRule(key, 'blob', known);
208
+ const systemBlob = systemMessage(key, known);
209
+ const memory = blocksIn(key, 'memory-file');
171
210
  // A meta line's `origin` says where the injected text came from; its `body` and `handback` are that text again, while `kind` stays readable.
172
211
  const originText = (origin) => {
173
212
  if (!isObject(origin))
@@ -179,10 +218,16 @@ export const injectedRule = (key, known = new Set()) => {
179
218
  case 'user':
180
219
  return line['isMeta'] === true ? withField(withMessage(line, 'message', metaMessage), 'origin', originText) : withMessage(line, 'message', userMessage);
181
220
  case 'api-request-blob':
182
- return withMessage(line, 'message', blobMessage);
221
+ return withMessage(withMessage(line, 'message', blobMessage), 'message', systemBlob, 'system');
183
222
  case 'attachment': {
184
223
  const attachment = line['attachment'];
185
224
  const kind = isObject(attachment) ? attachment['type'] : undefined;
225
+ // A memory file the model edited or that changed on disk: the snippet (and the message it was shown in) is the file's text.
226
+ const file = isObject(attachment) ? attachment['filename'] : undefined;
227
+ if (kind === 'edited_text_file' && typeof file === 'string' && MEMORY_PATH.test(file)) {
228
+ const snippet = (value) => (isObject(value) ? withField(value, 'snippet', (text) => mapStrings(text, 'snippet', memory)) : value);
229
+ return withField(withField(line, 'attachment', snippet), 'rendered', (value) => mapStrings(value, 'rendered', memory));
230
+ }
186
231
  if (typeof kind !== 'string' || !INJECTED_ATTACHMENTS.has(kind))
187
232
  return line;
188
233
  // The record repeats its text as the message the model was shown (`rendered`), so that is the same block again.
@@ -197,9 +242,12 @@ export const injectedRule = (key, known = new Set()) => {
197
242
  });
198
243
  case 'system':
199
244
  // The hook output a stop-hook summary carries: what a hook printed, its error text, and the command line it ran.
200
- return line['subtype'] === 'stop_hook_summary'
201
- ? HOOK_SUMMARY_FIELDS.reduce((acc, name) => withField(acc, name, (value) => mapStrings(value, name, blocksIn(key, 'hook-summary'))), line)
202
- : line;
245
+ if (line['subtype'] === 'stop_hook_summary')
246
+ return HOOK_SUMMARY_FIELDS.reduce((acc, name) => withField(acc, name, (value) => mapStrings(value, name, blocksIn(key, 'hook-summary'))), line);
247
+ // Any other system text is the harness speaking (a changed memory file, a notice), except the echo of a command the operator ran: that is operator output, not an instruction.
248
+ if (line['subtype'] === 'local_command')
249
+ return line;
250
+ return withField(line, 'content', (value) => (typeof value === 'string' ? systemText(key)(value) : value));
203
251
  case 'session_meta':
204
252
  case 'turn_context':
205
253
  return withField(line, 'payload', (payload) => (isObject(payload) ? codexFields(key, payload) : payload));
@@ -47,6 +47,8 @@ const DEFAULT_INTERVAL_SEC = 5;
47
47
  const DEFAULT_EXPIRY_SEC = 900;
48
48
  /** RFC 8628: a `slow_down` adds five seconds to every poll after it. */
49
49
  const SLOW_DOWN_MS = 5000;
50
+ /** The longest wait between polls after failed ones. */
51
+ const MAX_BACKOFF_MS = 60_000;
50
52
  export { isObject, loadSignIns, textIn };
51
53
  const usage = (message, step) => new CliError(message, ExitCode.USAGE, step);
52
54
  /** The Many Oars pool, used when neither `--server` nor `OARS_POOL_URL` names another. */
@@ -156,9 +158,30 @@ ${proofLines(account, provedAt)}`;
156
158
  /** Polls the pool until the person approves, and returns the pool's answer with the credential. Time is counted in the waits asked for, so a machine that slept through the code still gets one poll that says it expired. */
157
159
  const awaitCredential = async (deps, server, started, show) => {
158
160
  let { intervalMs } = started;
159
- for (let waited = 0; waited < started.expiresMs; waited += intervalMs) {
160
- await deps.sleep(intervalMs);
161
- const reply = await ask(deps, LOGIN, server, '/v1/login/poll', { body: { handle: started.handle }, show });
161
+ let failures = 0;
162
+ let lastFailure;
163
+ for (let waited = 0; waited < started.expiresMs;) {
164
+ // A failed poll (a network error or a 5xx) is the provider or the network having a bad minute, not the person's answer: wait longer each time, up to the cap, and ask again until the code's own expiry.
165
+ const wait = Math.min(intervalMs * 2 ** failures, Math.max(intervalMs, MAX_BACKOFF_MS));
166
+ await deps.sleep(wait);
167
+ waited += wait;
168
+ // `ask` rejects only with the unreachable failure of `send`.
169
+ const [settled] = await Promise.allSettled([ask(deps, LOGIN, server, '/v1/login/poll', { body: { handle: started.handle }, show })]);
170
+ // A rejection that is not the unreachable failure is a bug, not a bad minute: it ends the login as itself.
171
+ if (settled.status === 'rejected') {
172
+ if (!(settled.reason instanceof CliError) || settled.reason.exitCode !== ExitCode.UNREACHABLE)
173
+ throw settled.reason;
174
+ lastFailure = settled.reason;
175
+ }
176
+ const reply = settled.status === 'fulfilled' ? settled.value : undefined;
177
+ if (reply !== undefined && reply.status >= 500)
178
+ lastFailure = refusal(LOGIN, reply);
179
+ if (reply === undefined || reply.status >= 500) {
180
+ failures += 1;
181
+ continue;
182
+ }
183
+ failures = 0;
184
+ lastFailure = undefined;
162
185
  if (reply.status !== 200 || !isObject(reply.body))
163
186
  throw closedProof(reply) ?? refusal(LOGIN, reply);
164
187
  const status = textIn(reply.body, 'status');
@@ -175,6 +198,9 @@ const awaitCredential = async (deps, server, started, show) => {
175
198
  else if (status !== 'pending')
176
199
  throw new CliError('The pool answered a sign-in poll with a state this client does not know.', ExitCode.UNEXPECTED, LOGIN);
177
200
  }
201
+ // Out of time with the last poll still failing: the failure is the truth (exit 8), not an expiry nobody saw.
202
+ if (lastFailure !== undefined)
203
+ throw lastFailure;
178
204
  const title = 'The sign-in code expired. Run oars pool login again.';
179
205
  throw new CliError(title, ExitCode.AUTH, LOGIN, { code: 'EXPIRED_TOKEN', title });
180
206
  };
@@ -223,7 +249,11 @@ export const poolLogin = async (deps, options) => {
223
249
  const pinned = { ...signIn, signingKeys: known === undefined ? signIn.signingKeys : held };
224
250
  await mkdirOwnerOnly(storeDir);
225
251
  await writeOwnerOnly(signInsFile(storeDir), `${JSON.stringify([...saved.filter((entry) => entry !== known), pinned])}\n`);
226
- return { document: { ok: true, server, account: signIn.account, expiresAt: signIn.expiresAt }, text: `Signed in to ${server} as ${signIn.account}.` };
252
+ // The first deposit stops without a term file, so the login that precedes it says so.
253
+ const { terms: termsFile } = exclusionPaths(runtimeOf(deps).projectsRoot, deps.env);
254
+ const bare = (await loadTerms(termsFile)).length === 0;
255
+ return { document: { ok: true, server, account: signIn.account, expiresAt: signIn.expiresAt, ...(bare ? { termsFile } : {}) }, text: `Signed in to ${server} as ${signIn.account}.${bare ? `
256
+ ${termsHint(termsFile)}` : ''}` };
227
257
  };
228
258
  /** The pool is not answering: it could not be reached, or a gateway in front of it says so. */
229
259
  const poolDown = (status) => status === 502 || status === 503 || status === 504;
@@ -414,6 +444,8 @@ export const postRaw = async (deps, options) => {
414
444
  const rawNote = (consent) => consent === 'granted' ? '' : consent === 'declined' ? 'You answered no to the raw prompt; oars pool raw --consent yes changes it.' : 'None is queued until you answer the raw prompt with oars pool raw --consent yes.';
415
445
  /** The exclusion terms in force now, re-read from the term file each time it is asked: empty when the file is missing or unreadable. */
416
446
  export const currentTerms = (deps) => loadTerms(exclusionPaths(runtimeOf(deps).projectsRoot, deps.env).terms);
447
+ /** The one line a person with no exclusion terms needs: where the file goes and what names another. The deposit stays fail-closed without it (the A38 exclusion rule), so the first run says so before the deposit has to. */
448
+ const termsHint = (file) => `No exclusion terms file at ${file}: until it holds a term, one per line, every session stays out of the outbox. Create it there, or set OARS_EXCLUSION_TERMS to the file of your own.`;
417
449
  /** `deposit --send <hash>`: the send lock the detached sender (hook-bin.js nightly) takes too, then a skip for a hash already in `sent.log`, then the batch lock, then the covenant (accepted here or before, or nothing is sent), then the request. The body is read and hashed again just before the request. */
418
450
  const sendBatch = async (deps, options) => {
419
451
  const { outbox, server, token, hash } = options;
@@ -568,7 +600,7 @@ export const poolDeposit = async (deps, options) => {
568
600
  continue;
569
601
  }
570
602
  // The batch of a session is built once the session and its subagents have ended; a session still in progress waits, and an earlier batch of it is withdrawn. A machine's Stop is the end of a turn, not of a session: its VM may be reclaimed before the session ends, so it builds the open session as it stands.
571
- if (machine === undefined && !hasEnded(records, deps.now(), await lastWritten(runtime, records.main.project, records.main.session))) {
603
+ if (machine === undefined && !hasEnded(records, deps.now(), await lastWritten(runtime, records.main.project, records.main.session), undefined, options.covenant !== undefined)) {
572
604
  counts.notEnded += 1;
573
605
  await withdraw(key);
574
606
  continue;
@@ -644,9 +676,10 @@ export const poolDeposit = async (deps, options) => {
644
676
  `Raw copies queued: ${String(counts.raw.copies)} (${String(counts.raw.bytes)} bytes, ${String(counts.raw.masked)} values masked). ${rawNote(saved.pool.rawConsent)}`.trim(),
645
677
  ...(rawStale(saved.pool, covenant) ? [`Your yes to the raw prompt was given under covenant ${saved.pool.rawConsentVersion ?? 'none'}, not ${covenant ?? ''}, so no raw copy is queued. Run oars pool raw --consent yes to answer for the covenant now in force.`] : []),
646
678
  ...(counts.off === 0 ? [] : [`${String(counts.off)} ${counts.off === 1 ? 'session' : 'sessions'} in a folder switched off: nothing of them is queued.`]),
679
+ ...(termList.length === 0 ? [termsHint(terms)] : []),
647
680
  'Run oars pool outbox to print exactly what would be sent.',
648
681
  ];
649
- return { document: { ok: true, server, ...counts }, text: lines.join('\n') };
682
+ return { document: { ok: true, server, ...counts, ...(termList.length === 0 ? { termsFile: terms } : {}) }, text: lines.join('\n') };
650
683
  };
651
684
  /** Each field of a batch, at any depth, with its row. A field with no row fails the command rather than printing blank. */
652
685
  const explained = (bytes) => {
@@ -313,11 +313,12 @@ export declare const SESSION_IDLE_HOURS = 6;
313
313
  /** No session leaves this machine sooner than this after it ends, whatever `session_idle_hours` is: time for `oars sessions deposit --deny` or a delete to act on it first. Today the longer idle window governs; this is the floor under it. */
314
314
  export declare const HOLD_WINDOW_MS: number;
315
315
  /**
316
- * Whether a session's batch may be built at `now`: no transcript of it, main or subagent, has gained a line for `SESSION_IDLE_HOURS`. A transcript gains a line when capture sees it change, which
317
- * stamps a new `rev` in epoch milliseconds, but a transcript can grow after the last capture, so `written` (the newest write to any raw file of the session, 0 when none is known) counts too: the
318
- * session was last active at the later of the two. A subagent that died without an end marker holds its session back at most that long.
316
+ * Whether a session's batch may be built at `now`: no transcript of it, main or subagent, has gained a line for `SESSION_IDLE_HOURS`. A transcript was last active at its own last line (`tsLast`, the
317
+ * session's clock, not ours), or at its capture (`rev`, epoch milliseconds) when it has none, so a store captured a minute ago from days-old transcripts holds sessions that ended days ago. A transcript can
318
+ * also grow after the last capture, so `written` (the newest write to any raw file of the session, 0 when none is known) counts when it is later than every capture: a write at or before the capture is the
319
+ * file arriving, not the session going on. A last line later than the capture is a bad clock, and the capture caps it. A person's build (`sender` false) takes the ended session at once. The sender, which builds and sends in one run, also waits `HOLD_WINDOW_MS` after the newest capture, so a session oars is seeing for the first time (a days-old transcript of a new harness) leaves no sooner than an hour later, time for `--deny` or a delete. A subagent that died without an end marker holds its session back at most that long.
319
320
  */
320
- export declare const hasEnded: (records: SessionRecords, now: number, written: number, idleMs?: number) => boolean;
321
+ export declare const hasEnded: (records: SessionRecords, now: number, written: number, idleMs?: number, sender?: boolean) => boolean;
321
322
  /** Every main session of a store, with its records. */
322
323
  export declare const sessionsOf: (store: {
323
324
  sessions: readonly SessionObservation[];
@@ -89,11 +89,21 @@ const HOUR_MS = 60 * 60 * 1000;
89
89
  /** No session leaves this machine sooner than this after it ends, whatever `session_idle_hours` is: time for `oars sessions deposit --deny` or a delete to act on it first. Today the longer idle window governs; this is the floor under it. */
90
90
  export const HOLD_WINDOW_MS = HOUR_MS;
91
91
  /**
92
- * Whether a session's batch may be built at `now`: no transcript of it, main or subagent, has gained a line for `SESSION_IDLE_HOURS`. A transcript gains a line when capture sees it change, which
93
- * stamps a new `rev` in epoch milliseconds, but a transcript can grow after the last capture, so `written` (the newest write to any raw file of the session, 0 when none is known) counts too: the
94
- * session was last active at the later of the two. A subagent that died without an end marker holds its session back at most that long.
92
+ * Whether a session's batch may be built at `now`: no transcript of it, main or subagent, has gained a line for `SESSION_IDLE_HOURS`. A transcript was last active at its own last line (`tsLast`, the
93
+ * session's clock, not ours), or at its capture (`rev`, epoch milliseconds) when it has none, so a store captured a minute ago from days-old transcripts holds sessions that ended days ago. A transcript can
94
+ * also grow after the last capture, so `written` (the newest write to any raw file of the session, 0 when none is known) counts when it is later than every capture: a write at or before the capture is the
95
+ * file arriving, not the session going on. A last line later than the capture is a bad clock, and the capture caps it. A person's build (`sender` false) takes the ended session at once. The sender, which builds and sends in one run, also waits `HOLD_WINDOW_MS` after the newest capture, so a session oars is seeing for the first time (a days-old transcript of a new harness) leaves no sooner than an hour later, time for `--deny` or a delete. A subagent that died without an end marker holds its session back at most that long.
95
96
  */
96
- export const hasEnded = (records, now, written, idleMs = SESSION_IDLE_HOURS * HOUR_MS) => now - Math.max(written, ...records.transcripts.map(({ session }) => session.rev)) >= Math.max(idleMs, HOLD_WINDOW_MS);
97
+ export const hasEnded = (records, now, written, idleMs = SESSION_IDLE_HOURS * HOUR_MS, sender = false) => {
98
+ const lastLines = records.transcripts.map(({ session }) => {
99
+ const ts = session.tsLast === null ? Number.NaN : Date.parse(session.tsLast);
100
+ return Number.isNaN(ts) ? session.rev : Math.min(ts, session.rev);
101
+ });
102
+ const captured = Math.max(...records.transcripts.map(({ session }) => session.rev));
103
+ if (sender && now - captured < HOLD_WINDOW_MS)
104
+ return false;
105
+ return now - Math.max(written > captured ? written : 0, ...lastLines) >= Math.max(idleMs, HOLD_WINDOW_MS);
106
+ };
97
107
  const groupBy = (items, keyOf) => {
98
108
  const groups = new Map();
99
109
  for (const item of items) {
@@ -1,6 +1,6 @@
1
1
  // Interaction (spec Section 6, order 2): a tool fails more on one tier than another, beyond how those two tiers differ on every other tool. The difference of differences is
2
2
  // what makes it an interaction: a tier that fails more on everything has a main effect, usually the harder work it is given. The axis is the tier assigned, not a model version
3
- // (ruling mrd5591-08: one version per model on this store), and comparisons hold the version bucket constant. Effort is not in a cell (rule 3), so nothing here holds it. Confounders it does not remove: task assignment by
3
+ // (ruling owner-08: one version per model on this store), and comparisons hold the version bucket constant. Effort is not in a cell (rule 3), so nothing here holds it. Confounders it does not remove: task assignment by
4
4
  // tier, which is the operator's own choice and the largest of them, effort, prompt class, and a failure not being a misfit.
5
5
  import { UNKNOWN } from './cells.js';
6
6
  import { candidateOf, differenceOf, estimateOf, screen, TRIALS_FLOOR } from './read-order.js';
@@ -1,5 +1,5 @@
1
1
  // Lead-lag (spec Section 6, order 3), the labeled experiment: does a tool's failure rate moving come before its share moving? Chosen over a model release moving tool choice
2
- // because each model has one version here with no release date (ruling mrd5591-08), and two version boundaries are too few to cut in time, while consecutive days over many tools
2
+ // because each model has one version here with no release date (ruling owner-08), and two version boundaries are too few to cut in time, while consecutive days over many tools
3
3
  // can be cut. A search over pairs of tools was rejected: it manufactures the false patterns Section 6 warns about, and this is one question asked once.
4
4
  //
5
5
  // A pair shares no day between its sides. A failure move ending on day d is paired with the share move from d+1 to d+2, not d to d+1: a burst of failing calls on d lifts both the
@@ -1,4 +1,4 @@
1
- // What this store cannot read, stated rather than forced (ruling mrd5591-08). A family that finds nothing has made no claim about why, so a reader of an empty family could take
1
+ // What this store cannot read, stated rather than forced (ruling owner-08). A family that finds nothing has made no claim about why, so a reader of an empty family could take
2
2
  // "no reads" for "nothing moved". These rows separate the two: a question the store cannot ask from one it asked and got no answer to.
3
3
  import { interactionCandidates } from './read-interaction.js';
4
4
  import { leadLagPairs, PAIRS_FLOOR } from './read-lead-lag.js';
@@ -8,10 +8,19 @@ export declare const makeRedactor: (domains?: readonly string[]) => Redactor;
8
8
  export declare const placeholders: (text: string) => number;
9
9
  /** The names this machine's person goes by in a transcript: the account's and the home folder's. A name under three characters would mask ordinary words, so it is left to the path rules. */
10
10
  export declare const localNames: (home?: string, account?: () => string) => string[];
11
+ /** What only a sample pack asks of the raw-copy rules (t1111). */
12
+ export interface PackRules {
13
+ /** The accounts the operator's repositories live under (a GitHub user or organization): masked as `<owner>` where one stands as a path segment or after a `:` of a remote. */
14
+ handles?: readonly string[];
15
+ /** Rewrites a tool use id (`toolu_...`, `srvtoolu_...`) to the value the pack keys it to, so a call and its result still pair; without it the entropy rule masks each as `<secret>`. */
16
+ toolId?: (id: string) => string;
17
+ /** Keeps a public host the session itself fetched or searched (the URL or `allowed_domains` of a WebFetch or WebSearch input) in the clear in that call's result. */
18
+ fetchedHosts?: boolean;
19
+ }
11
20
  /** A whole transcript body redacted for upload, and how many values were masked (placeholders added, a count and nothing else). `names` are the local usernames to mask wherever they stand as a whole word. */
12
- export declare const redactTranscript: (body: string, domains?: readonly string[], names?: readonly string[], injectedKey?: string) => {
21
+ export declare const redactTranscript: (body: string, domains?: readonly string[], names?: readonly string[], injectedKey?: string, pack?: PackRules) => {
13
22
  text: string;
14
23
  masked: number;
15
24
  };
16
25
  /** The version of the rules above, stated to the pool with every raw part; a change to any rule of `redactTranscript` raises it. */
17
- export declare const REDACTION_VERSION = "3";
26
+ export declare const REDACTION_VERSION = "4";
@@ -8,25 +8,30 @@ import { injectedRule, learnInjected } from './injected.js';
8
8
  export const PATTERNS = [
9
9
  [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, '<secret>'],
10
10
  [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, '<secret>'],
11
- [/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '<secret>'],
12
- [/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, '<secret>'],
11
+ // A synthetic fixture quoted in a session is masked like a real token (a secret scanner flags the prefix, not the value), so the minimums are short and the
12
+ // left guard is a non-alphanumeric: `FAKE_ghp_...` still starts a token where `\b` would miss it.
13
+ [/(?<![A-Za-z0-9])gh[pousr]_[A-Za-z0-9]{8,}(?![A-Za-z0-9])/g, '<secret>'],
14
+ [/(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{8,}(?![A-Za-z0-9])/g, '<secret>'],
13
15
  [/\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}\b/g, '<secret>'],
14
- [/\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}\b/g, '<secret>'],
15
- [/\bwhsec_[A-Za-z0-9]{10,}\b/g, '<secret>'],
16
- [/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g, '<secret>'],
16
+ [/(?<![A-Za-z0-9])(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{6,}(?![A-Za-z0-9])/g, '<secret>'],
17
+ [/(?<![A-Za-z0-9])whsec_[A-Za-z0-9]{6,}(?![A-Za-z0-9])/g, '<secret>'],
18
+ [/(?<![A-Za-z0-9])xox[abprs]-[A-Za-z0-9-]{6,}(?![A-Za-z0-9])/g, '<secret>'],
17
19
  [/\bAIza[0-9A-Za-z_-]{30,}\b/g, '<secret>'],
18
20
  [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '<secret>'],
19
21
  [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/gi, '$1 <secret>'],
20
22
  // user:password@host inside a URL
21
23
  [/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, '$1<secret>@'],
22
- // NAME=value and "name": "value" where the name says it is a secret
23
- [/\b([A-Za-z0-9_-]*(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)[A-Za-z0-9_-]*)(["']?\s*[=:]\s*)(["']?)[^\s"'&,;]{4,}\3/gi, '$1$2$3<secret>$3'],
24
+ // NAME=value and "name": "value" where the name says it is a secret. A vendor name before a colon (`1password: ...` in prose) is no key. A plural
25
+ // `tokens` name (`tokens`, `max_tokens`, `inputTokens`) is a count and keeps a bare number, as the JSON walk does; any other `token` name masks a number too.
26
+ [/\b(?!1password(?![A-Za-z0-9_-])["']?\s*:\s)([A-Za-z0-9_-]*(?:password|passwd|secret|api[_-]?key|private[_-]?key|credential)[A-Za-z0-9_-]*)(["']?\s*[=:]\s*)(["']?)[^\s"'&,;]{4,}\3/gi, '$1$2$3<secret>$3'],
27
+ [/\b((?![A-Za-z0-9_-]*tokens(?![A-Za-z0-9_-]))[A-Za-z0-9_-]*token[A-Za-z0-9_-]*)(["']?\s*[=:]\s*)(["']?)[^\s"'&,;]{4,}\3/gi, '$1$2$3<secret>$3'],
28
+ [/\b([A-Za-z0-9_-]*tokens)(["']?\s*[=:]\s*)(["']?)(?!\d+(?![^\s"'&,;]))[^\s"'&,;]{4,}\3/gi, '$1$2$3<secret>$3'],
24
29
  ];
25
30
  const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g;
26
31
  // A pinned alias, `foo@npm:bar@1.0.0` or `jsr:@scope/bar@1.0.0`, ends in `bar@1.0.0`, which EMAIL reads as an address. Digits and dots after
27
32
  // the `@` are a version, never a domain, so that one shape right after `npm:` or `jsr:` (and an optional scope) is kept. An all-numeric four-part tail is an
28
33
  // IP address, a real host, so it stays masked (as in `extract.ts` `VERSIONED`); any other address stays masked too.
29
- const ALIAS_PREFIX = /(?<![A-Za-z0-9])(?:npm|jsr):(?:@[A-Za-z0-9._~-]+\/)?$/;
34
+ const ALIAS_PREFIX = /(?<![A-Za-z0-9])(?:npm|jsr):(?:@[A-Za-z0-9._~-]+\/)?$|(?<![A-Za-z0-9._%+-])@[A-Za-z0-9._~-]+\/$/;
30
35
  const DOTTED_VERSION = /@(?!\d+\.\d+\.\d+\.\d+$)\d+(?:\.\d+)+$/;
31
36
  const TOKEN = /[A-Za-z0-9+_=-]{24,}/g;
32
37
  const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -71,7 +76,7 @@ const PROMPT = /(?:<user>|\b[A-Za-z0-9._-]+)@[A-Za-z0-9-]+(?=:[~/.])/g;
71
76
  // Full, compressed, and loopback forms. A clock time (`10:20:30`) has no `::`, so it is never one.
72
77
  const IPV6 = /(?<![0-9A-Za-z:])(?:(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:){1,7}:(?:[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){0,6})?|::[0-9a-f]{1,4}(?::[0-9a-f]{1,4}){0,6})(?![0-9A-Za-z:])/gi;
73
78
  const MIN_NAME = 3;
74
- const PLACEHOLDER = /<(?:secret|email|org-domain|host|user|env)>/g;
79
+ const PLACEHOLDER = /<(?:secret|email|org-domain|host|user|env|owner)>/g;
75
80
  // A `.env` line whose name is lower case (`mapbox=q8z7...`): only a whole line of that shape, so `--flag=value` and `?page=2` are left alone.
76
81
  const LOWER_ENVIRONMENT = /^([ \t]*(?:export[ \t]+)?[a-z][a-z0-9_]{2,}=)(?!<)(?:(["'])[^"'\n]*\2|[^\s"']+)[ \t]*$/gm;
77
82
  // Shapes `makeRedactor` does not know (t955, census S3). They run only on a raw copy, and before the shared rules, because the entropy rule would
@@ -112,7 +117,7 @@ const parseContainer = (text) => {
112
117
  return undefined;
113
118
  }
114
119
  };
115
- const redactDecoded = (body, redactText, injected) => {
120
+ const redactDecoded = (body, redactText, injected, enter) => {
116
121
  // A name reaches the strings of an array under it (`"tokens": ["..."]`); an object's own keys name their values.
117
122
  const walk = (value, name) => {
118
123
  if (typeof value === 'string')
@@ -133,15 +138,27 @@ const redactDecoded = (body, redactText, injected) => {
133
138
  // The text as it was when nothing in it changed; otherwise the value written again, inside the whitespace (a `\r`, a newline) it had around it.
134
139
  // A transcript line is a top-level object: the harness's own blocks in it are marked first (their hash is of the text as written), then the rest is walked.
135
140
  const rewrite = (text, parsed, top) => {
141
+ if (top && !Array.isArray(parsed))
142
+ enter(parsed);
136
143
  const marked = top && !Array.isArray(parsed) ? injected(parsed) : parsed;
137
144
  const next = walk(marked, '');
138
145
  return next === parsed ? text : `${text.slice(0, text.length - text.trimStart().length)}${JSON.stringify(next)}${text.slice(text.trimEnd().length)}`;
139
146
  };
147
+ // A top-level plain-text run follows no tool result: a host a fetch named is kept in that result's line only (a string inside the line is not top level).
148
+ const redactPlain = (run, top) => {
149
+ if (top)
150
+ enter();
151
+ return redactText(run.join('\n'));
152
+ };
140
153
  // `text` is a whole document, or lines of which some are JSON: the JSON ones are walked, the runs between them take the plain-text path.
141
154
  const redactBlock = (text, top = false) => {
142
155
  const whole = parseContainer(text);
143
- if (whole !== undefined)
156
+ // A top-level array line is no tool-result line: it keeps no fetched host.
157
+ if (whole !== undefined) {
158
+ if (top && Array.isArray(whole))
159
+ enter();
144
160
  return rewrite(text, whole, top);
161
+ }
145
162
  const out = [];
146
163
  let run = [];
147
164
  for (const line of text.split('\n')) {
@@ -151,12 +168,12 @@ const redactDecoded = (body, redactText, injected) => {
151
168
  continue;
152
169
  }
153
170
  if (run.length > 0)
154
- out.push(redactText(run.join('\n')));
171
+ out.push(redactPlain(run, top));
155
172
  run = [];
156
173
  out.push(rewrite(line, parsed, top));
157
174
  }
158
175
  if (run.length > 0)
159
- out.push(redactText(run.join('\n')));
176
+ out.push(redactPlain(run, top));
160
177
  return out.join('\n');
161
178
  };
162
179
  return redactBlock(body, true);
@@ -183,15 +200,70 @@ const nameRule = (names) => {
183
200
  const forms = [...new Set(usable.flatMap((name) => [name, name.replace(/[^A-Za-z0-9]/g, '-')]))].sort((a, b) => b.length - a.length);
184
201
  return forms.length === 0 ? undefined : new RegExp(`(?<![A-Za-z0-9<])(?:${forms.map(escapeRegExp).join('|')})(?![A-Za-z0-9>])`, 'gi');
185
202
  };
203
+ const TOOL_ID = /(?<![A-Za-z0-9])(?:srv)?tool(?:u|use)_(?:bdrk_|vrtx_)?[A-Za-z0-9_-]{8,}/g;
204
+ const FETCH_TOOLS = new Set(['WebFetch', 'WebSearch']);
205
+ const HOST_OF_URL = /^[a-z][a-z0-9+.-]*:\/\/([A-Za-z0-9.-]+)/i;
206
+ const URLS = /\b[a-z][a-z0-9+.-]*:\/\/[^\s/@"'\\]*@?[A-Za-z0-9.-]+/gi;
207
+ // Loopback, an IP literal, a bare name, and the internal suffixes of INTERNAL_NAME are never a public host.
208
+ const INTERNAL_SUFFIX = /\.(?:internal|local|lan|corp|intranet|home|localdomain)$/;
209
+ const publicHost = (host) => host.includes('.') && !/^[\d.]+$/.test(host) && !INTERNAL_SUFFIX.test(host) && host !== 'localhost';
210
+ const hostsIn = (value, found, listed = false) => {
211
+ if (typeof value === 'string') {
212
+ for (const url of value.match(URLS) ?? [])
213
+ if (!url.includes('@'))
214
+ found.add((HOST_OF_URL.exec(url)?.[1] ?? '').toLowerCase());
215
+ // `allowed_domains` lists bare domains.
216
+ if (listed)
217
+ found.add(value.toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, '').replace(/[/:].*$/, ''));
218
+ }
219
+ else if (Array.isArray(value))
220
+ for (const item of value)
221
+ hostsIn(item, found, listed);
222
+ else if (typeof value === 'object' && value !== null)
223
+ for (const [key, item] of Object.entries(value))
224
+ hostsIn(item, found, key === 'allowed_domains');
225
+ };
226
+ /** The blocks of a transcript line's message content, when it has any. */
227
+ const blocksOf = (line) => {
228
+ const message = line['message'];
229
+ const content = typeof message === 'object' && message !== null && !Array.isArray(message) ? message['content'] : undefined;
230
+ return Array.isArray(content) ? content.filter((block) => typeof block === 'object' && block !== null && !Array.isArray(block)) : [];
231
+ };
232
+ /** The public hosts each WebFetch or WebSearch call of a transcript named, by the call's tool use id. */
233
+ const fetchedBy = (lines) => {
234
+ const byId = new Map();
235
+ for (const line of lines) {
236
+ for (const block of blocksOf(line)) {
237
+ const { type, id, name, input } = block;
238
+ if (type !== 'tool_use' || typeof id !== 'string' || typeof name !== 'string' || !FETCH_TOOLS.has(name) || input === undefined)
239
+ continue;
240
+ const found = new Set();
241
+ hostsIn(input, found);
242
+ byId.set(id, new Set([...found].filter(publicHost)));
243
+ }
244
+ }
245
+ return byId;
246
+ };
247
+ // A handle is a whole path segment (`/acme/app`, `\acme\app`, `git@host:acme/app.git`) or a hyphen-bounded word of an encoded project folder (`-repos-acme-app`).
248
+ const handleRule = (handles) => {
249
+ const usable = [...new Set(handles.filter((handle) => handle.length >= MIN_NAME))].sort((a, b) => b.length - a.length);
250
+ return usable.length === 0 ? undefined : new RegExp(String.raw `(?<=[\\/:-])(?:${usable.map(escapeRegExp).join('|')})(?=[\\/.-]|["'\s]|$)`, 'gi');
251
+ };
186
252
  /** A whole transcript body redacted for upload, and how many values were masked (placeholders added, a count and nothing else). `names` are the local usernames to mask wherever they stand as a whole word. */
187
- export const redactTranscript = (body, domains = [], names = [], injectedKey = randomBytes(16).toString('hex')) => {
253
+ export const redactTranscript = (body, domains = [], names = [], injectedKey = randomBytes(16).toString('hex'), pack = {}) => {
188
254
  const named = nameRule(names);
255
+ const owner = handleRule(pack.handles ?? []);
189
256
  const known = makeRedactor(domains);
190
- const redactText = (text) => {
257
+ // The hosts the line being redacted may keep: those its tool results' calls fetched.
258
+ let kept = new Set();
259
+ const keepHost = (match, scheme) => (!match.includes('@') && kept.has((HOST_OF_URL.exec(match)?.[1] ?? '').toLowerCase()) ? match : `${scheme}<host>`);
260
+ const redactText = (original) => {
261
+ const text = pack.toolId === undefined ? original : original.replace(TOOL_ID, pack.toolId);
191
262
  // The names go first: a hyphenated or spaced name must be masked whole before the path rules take its first word.
192
263
  const masked = known(rawRules(text));
193
- return (named === undefined ? masked : masked.replace(named, '<user>'))
194
- .replace(URL_HOST, '$1<host>')
264
+ const mine = named === undefined ? masked : masked.replace(named, '<user>');
265
+ return (owner === undefined ? mine : mine.replace(owner, '<owner>'))
266
+ .replace(URL_HOST, keepHost)
195
267
  .replace(INTERNAL_NAME, '<host>')
196
268
  .replace(IPV4, '<host>')
197
269
  .replace(IPV6, '<host>')
@@ -208,8 +280,12 @@ export const redactTranscript = (body, domains = [], names = [], injectedKey = r
208
280
  for (const parsed of whole !== undefined ? [whole] : body.split('\n').map(parseContainer))
209
281
  if (parsed !== undefined && !Array.isArray(parsed))
210
282
  learnInjected(parsed, repeated);
211
- const redacted = redactDecoded(body, redactText, injectedRule(injectedKey, repeated));
283
+ const calls = pack.fetchedHosts === true ? fetchedBy([...(whole !== undefined ? [whole] : body.split('\n').map(parseContainer))].filter((parsed) => parsed !== undefined && !Array.isArray(parsed))) : new Map();
284
+ const enter = (line) => {
285
+ kept = new Set(line === undefined ? [] : blocksOf(line).flatMap((block) => (block['type'] === 'tool_result' && typeof block['tool_use_id'] === 'string' ? [...(calls.get(block['tool_use_id']) ?? [])] : [])));
286
+ };
287
+ const redacted = redactDecoded(body, redactText, injectedRule(injectedKey, repeated), enter);
212
288
  return { text: redacted, masked: Math.max(0, placeholders(redacted) - placeholders(body)) };
213
289
  };
214
290
  /** The version of the rules above, stated to the pool with every raw part; a change to any rule of `redactTranscript` raises it. */
215
- export const REDACTION_VERSION = '3';
291
+ export const REDACTION_VERSION = '4';
@@ -2,6 +2,13 @@
2
2
  export declare const makeRepoRoot: (exists?: (path: string) => boolean) => ((cwd: string) => string | null);
3
3
  /** `https://User:pw@GitHub.com/org/repo.git`, `git@github.com:org/repo.git`, and `ssh://git@github.com/org/repo/` all read `github.com/org/repo`. */
4
4
  export declare const normalizeRemote: (url: string) => string;
5
+ /** The `url` of `[remote "origin"]` in a git `config`, or null. */
6
+ export declare const originUrl: (config: string) => string | null;
7
+ /**
8
+ * The git `config` text of a repository root, or null: `<root>/.git/config`, or for a linked worktree or submodule (`.git` is a file)
9
+ * the common directory's, reached through the file's `gitdir:` line and that directory's `commondir` file.
10
+ */
11
+ export declare const configOf: (root: string, read?: (path: string) => string | null) => string | null;
5
12
  /**
6
13
  * A resolver from a repository root to its identity string: `origin:<normalized remote>` when the repository's `config` names an
7
14
  * `origin`, else `path:<root>`. The `config` is `<root>/.git/config`, or for a linked worktree (`.git` is a file) the common
@@ -46,7 +46,7 @@ export const normalizeRemote = (url) => {
46
46
  return slash < 0 ? bare.toLowerCase() : `${bare.slice(0, slash).toLowerCase()}${bare.slice(slash)}`;
47
47
  };
48
48
  /** The `url` of `[remote "origin"]` in a git `config`, or null. */
49
- const originUrl = (config) => {
49
+ export const originUrl = (config) => {
50
50
  let inOrigin = false;
51
51
  for (const line of config.split(/\r?\n/)) {
52
52
  const section = /^\s*\[(.*)\]\s*$/.exec(line);
@@ -71,6 +71,21 @@ const readOrNull = (path) => {
71
71
  return null;
72
72
  }
73
73
  };
74
+ /**
75
+ * The git `config` text of a repository root, or null: `<root>/.git/config`, or for a linked worktree or submodule (`.git` is a file)
76
+ * the common directory's, reached through the file's `gitdir:` line and that directory's `commondir` file.
77
+ */
78
+ export const configOf = (root, read = readOrNull) => {
79
+ const direct = read(`${root}/.git/config`);
80
+ if (direct !== null)
81
+ return direct;
82
+ const pointer = /^gitdir:\s*(.+?)\s*$/m.exec(read(`${root}/.git`) ?? '')?.[1];
83
+ if (pointer === undefined)
84
+ return null;
85
+ const gitdir = resolveFrom(root, pointer);
86
+ const common = read(`${gitdir}/commondir`)?.trim();
87
+ return read(`${common === undefined || common === '' ? gitdir : resolveFrom(gitdir, common)}/config`);
88
+ };
74
89
  /**
75
90
  * A resolver from a repository root to its identity string: `origin:<normalized remote>` when the repository's `config` names an
76
91
  * `origin`, else `path:<root>`. The `config` is `<root>/.git/config`, or for a linked worktree (`.git` is a file) the common
@@ -78,22 +93,11 @@ const readOrNull = (path) => {
78
93
  */
79
94
  export const makeRepoIdentity = (read = readOrNull) => {
80
95
  const cache = new Map();
81
- const configOf = (root) => {
82
- const direct = read(`${root}/.git/config`);
83
- if (direct !== null)
84
- return direct;
85
- const pointer = /^gitdir:\s*(.+?)\s*$/m.exec(read(`${root}/.git`) ?? '')?.[1];
86
- if (pointer === undefined)
87
- return null;
88
- const gitdir = resolveFrom(root, pointer);
89
- const common = read(`${gitdir}/commondir`)?.trim();
90
- return read(`${common === undefined || common === '' ? gitdir : resolveFrom(gitdir, common)}/config`);
91
- };
92
96
  return (root) => {
93
97
  const known = cache.get(root);
94
98
  if (known !== undefined)
95
99
  return known;
96
- const url = originUrl(configOf(root) ?? '');
100
+ const url = originUrl(configOf(root, read) ?? '');
97
101
  const identity = url === null ? `path:${root.toLowerCase()}` : `origin:${normalizeRemote(url)}`;
98
102
  cache.set(root, identity);
99
103
  return identity;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manyoars/oars",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "mcpName": "io.github.manyoars/oars",
5
5
  "description": "A local, searchable record of your coding agent sessions. Reads Claude Code, Cursor, Codex, Copilot CLI, Gemini CLI, and more, and keeps the record on your machine.",
6
6
  "homepage": "https://manyoars.com/",
@@ -49,7 +49,7 @@
49
49
  "test:live": "vitest run --config vitest.live.config.ts"
50
50
  },
51
51
  "dependencies": {
52
- "@manyoars/oars-client": "^0.1.1",
52
+ "@manyoars/oars-client": "^0.1.2",
53
53
  "commander": "^15.0.0",
54
54
  "openid-client": "^6.8.8"
55
55
  },