@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 +2 -2
- package/dist/cli.js +1 -1
- package/dist/sessions/empty-state.d.ts +1 -1
- package/dist/sessions/empty-state.js +1 -1
- package/dist/sessions/injected.js +54 -6
- package/dist/sessions/pool-command.js +39 -6
- package/dist/sessions/pool-wire.d.ts +5 -4
- package/dist/sessions/pool-wire.js +14 -4
- package/dist/sessions/read-interaction.js +1 -1
- package/dist/sessions/read-lead-lag.js +1 -1
- package/dist/sessions/read-readable.js +1 -1
- package/dist/sessions/redact.d.ts +11 -2
- package/dist/sessions/redact.js +95 -19
- package/dist/sessions/repo-root.d.ts +7 -0
- package/dist/sessions/repo-root.js +17 -13
- package/package.json +2 -2
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'] !==
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
317
|
-
*
|
|
318
|
-
*
|
|
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
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 = "
|
|
26
|
+
export declare const REDACTION_VERSION = "4";
|
package/dist/sessions/redact.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
-
[
|
|
15
|
-
[
|
|
16
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
|
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 = '
|
|
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.
|
|
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.
|
|
52
|
+
"@manyoars/oars-client": "^0.1.2",
|
|
53
53
|
"commander": "^15.0.0",
|
|
54
54
|
"openid-client": "^6.8.8"
|
|
55
55
|
},
|