@manyoars/oars 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -29
- package/dist/bin.js +3 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +206 -20
- package/dist/club.d.ts +19 -0
- package/dist/club.js +132 -0
- package/dist/gate/hook-fill-main.js +1 -1
- package/dist/gate/hook-fill.d.ts +1 -1
- package/dist/gate/hook-fill.js +1 -1
- package/dist/gate/hook-main.js +7 -5
- package/dist/gate/hook.d.ts +12 -1
- package/dist/gate/hook.js +38 -7
- package/dist/hook-bin.js +23 -4
- package/dist/main.js +5 -0
- package/dist/node-floor.d.ts +10 -0
- package/dist/node-floor.js +23 -0
- package/dist/oars-hook.d.ts +2 -0
- package/dist/oars-hook.js +7 -0
- package/dist/sessions/allow.d.ts +1 -0
- package/dist/sessions/allow.js +1 -1
- package/dist/sessions/call-shape.d.ts +111 -0
- package/dist/sessions/call-shape.js +464 -0
- package/dist/sessions/candidates.d.ts +65 -0
- package/dist/sessions/candidates.js +236 -0
- package/dist/sessions/card-command.d.ts +24 -0
- package/dist/sessions/card-command.js +71 -0
- package/dist/sessions/card-font.d.ts +6 -0
- package/dist/sessions/card-font.js +36 -0
- package/dist/sessions/card-line.d.ts +30 -0
- package/dist/sessions/card-line.js +105 -0
- package/dist/sessions/card-render.d.ts +15 -0
- package/dist/sessions/card-render.js +146 -0
- package/dist/sessions/card.d.ts +59 -0
- package/dist/sessions/card.js +107 -0
- package/dist/sessions/cell-increments.d.ts +14 -3
- package/dist/sessions/cell-increments.js +79 -12
- package/dist/sessions/cells.d.ts +42 -4
- package/dist/sessions/cells.js +72 -18
- package/dist/sessions/codex-hook-bin.d.ts +1 -0
- package/dist/sessions/codex-hook-bin.js +24 -0
- package/dist/sessions/codex-hook-install.d.ts +21 -0
- package/dist/sessions/codex-hook-install.js +47 -0
- package/dist/sessions/codex-hook-view.d.ts +20 -0
- package/dist/sessions/codex-hook-view.js +20 -0
- package/dist/sessions/codex-hook.d.ts +77 -0
- package/dist/sessions/codex-hook.js +255 -0
- package/dist/sessions/codex.d.ts +9 -0
- package/dist/sessions/codex.js +104 -18
- package/dist/sessions/command.d.ts +17 -2
- package/dist/sessions/command.js +145 -33
- package/dist/sessions/copilot.d.ts +3 -0
- package/dist/sessions/copilot.js +12 -4
- package/dist/sessions/derive.js +2 -1
- package/dist/sessions/empty-state.d.ts +17 -0
- package/dist/sessions/empty-state.js +38 -0
- package/dist/sessions/exclusion.js +48 -3
- package/dist/sessions/extract.d.ts +16 -3
- package/dist/sessions/extract.js +281 -24
- package/dist/sessions/gemini.d.ts +2 -0
- package/dist/sessions/gemini.js +29 -2
- package/dist/sessions/harness-files.d.ts +27 -0
- package/dist/sessions/harness-files.js +109 -0
- package/dist/sessions/head-only.d.ts +7 -0
- package/dist/sessions/head-only.js +21 -0
- package/dist/sessions/hook-calls.js +4 -3
- package/dist/sessions/hooks.d.ts +1 -1
- package/dist/sessions/hooks.js +6 -2
- package/dist/sessions/identity.d.ts +6 -0
- package/dist/sessions/injected.d.ts +13 -0
- package/dist/sessions/injected.js +212 -0
- package/dist/sessions/install.d.ts +31 -20
- package/dist/sessions/install.js +80 -82
- package/dist/sessions/load-status.d.ts +41 -0
- package/dist/sessions/load-status.js +156 -0
- package/dist/sessions/machine-child-deps.d.ts +5 -0
- package/dist/sessions/machine-child-deps.js +83 -0
- package/dist/sessions/machine-child.d.ts +43 -0
- package/dist/sessions/machine-child.js +120 -0
- package/dist/sessions/machine-credential.d.ts +10 -0
- package/dist/sessions/machine-credential.js +20 -0
- package/dist/sessions/machine-enroll.d.ts +47 -0
- package/dist/sessions/machine-enroll.js +230 -0
- package/dist/sessions/machine-hold.d.ts +49 -0
- package/dist/sessions/machine-hold.js +88 -0
- package/dist/sessions/machine-ids.d.ts +6 -0
- package/dist/sessions/machine-ids.js +9 -0
- package/dist/sessions/machine-notice.d.ts +21 -0
- package/dist/sessions/machine-notice.js +89 -0
- package/dist/sessions/machine-pull.d.ts +40 -0
- package/dist/sessions/machine-pull.js +220 -0
- package/dist/sessions/machine-repo.d.ts +7 -0
- package/dist/sessions/machine-repo.js +30 -0
- package/dist/sessions/machine-scan-cache.d.ts +12 -0
- package/dist/sessions/machine-scan-cache.js +47 -0
- package/dist/sessions/machine-send.d.ts +12 -0
- package/dist/sessions/machine-send.js +156 -0
- package/dist/sessions/machine-stop.d.ts +22 -0
- package/dist/sessions/machine-stop.js +53 -0
- package/dist/sessions/machine.d.ts +41 -0
- package/dist/sessions/machine.js +89 -0
- package/dist/sessions/opencode.d.ts +2 -0
- package/dist/sessions/opencode.js +6 -0
- package/dist/sessions/otel.d.ts +21 -0
- package/dist/sessions/otel.js +109 -38
- package/dist/sessions/owner-only.d.ts +7 -0
- package/dist/sessions/owner-only.js +34 -0
- package/dist/sessions/pi.d.ts +13 -1
- package/dist/sessions/pi.js +27 -13
- package/dist/sessions/pool-books.d.ts +1 -1
- package/dist/sessions/pool-books.js +1 -1
- package/dist/sessions/pool-choices.d.ts +4 -2
- package/dist/sessions/pool-choices.js +4 -2
- package/dist/sessions/pool-command.d.ts +46 -6
- package/dist/sessions/pool-command.js +577 -133
- package/dist/sessions/pool-delta.d.ts +7 -0
- package/dist/sessions/pool-delta.js +28 -5
- package/dist/sessions/pool-due.d.ts +43 -0
- package/dist/sessions/pool-due.js +113 -0
- package/dist/sessions/pool-nightly.d.ts +9 -1
- package/dist/sessions/pool-nightly.js +65 -21
- package/dist/sessions/pool-outbox.d.ts +51 -5
- package/dist/sessions/pool-outbox.js +217 -24
- package/dist/sessions/pool-print.d.ts +1 -1
- package/dist/sessions/pool-raw.js +4 -3
- package/dist/sessions/pool-replica.d.ts +4 -4
- package/dist/sessions/pool-replica.js +26 -7
- package/dist/sessions/pool-send-lock.js +1 -1
- package/dist/sessions/pool-sent.d.ts +2 -0
- package/dist/sessions/pool-sent.js +21 -4
- package/dist/sessions/pool-wire.d.ts +172 -12
- package/dist/sessions/pool-wire.js +284 -30
- package/dist/sessions/pool.js +4 -2
- package/dist/sessions/public-vocabulary.d.ts +5 -0
- package/dist/sessions/public-vocabulary.js +16 -0
- package/dist/sessions/read-call-shape.js +12 -6
- package/dist/sessions/read-fit.d.ts +5 -0
- package/dist/sessions/read-fit.js +23 -2
- package/dist/sessions/read.d.ts +1 -1
- package/dist/sessions/recall-consequence.d.ts +2 -2
- package/dist/sessions/recall-consequence.js +14 -9
- package/dist/sessions/recall-object.d.ts +9 -2
- package/dist/sessions/receipt.d.ts +8 -0
- package/dist/sessions/receipt.js +78 -0
- package/dist/sessions/recovery-calls.d.ts +3 -2
- package/dist/sessions/recovery-calls.js +9 -3
- package/dist/sessions/redact.d.ts +2 -2
- package/dist/sessions/redact.js +116 -14
- package/dist/sessions/reversion.d.ts +8 -0
- package/dist/sessions/reversion.js +114 -0
- package/dist/sessions/schema.d.ts +78 -4
- package/dist/sessions/schema.js +31 -1
- package/dist/sessions/session-shape.d.ts +2 -1
- package/dist/sessions/session-shape.js +15 -0
- package/dist/sessions/sources.d.ts +9 -0
- package/dist/sessions/sources.js +16 -5
- package/dist/sessions/steps.js +3 -2
- package/dist/sessions/store.d.ts +41 -2
- package/dist/sessions/store.js +68 -16
- package/dist/sessions/try-demo.d.ts +8 -0
- package/dist/sessions/try-demo.js +90 -0
- package/dist/sessions/try.d.ts +13 -0
- package/dist/sessions/try.js +50 -0
- package/dist/sessions/vscode-copilot.d.ts +4 -0
- package/dist/sessions/vscode-copilot.js +25 -18
- package/dist/telemetry/command.js +12 -6
- package/dist/telemetry/consent.d.ts +9 -3
- package/dist/telemetry/consent.js +25 -9
- package/dist/telemetry/fields.d.ts +11 -0
- package/dist/telemetry/fields.js +56 -0
- package/dist/telemetry/measures.d.ts +13 -0
- package/dist/telemetry/measures.js +30 -0
- package/dist/telemetry/observe.d.ts +15 -2
- package/dist/telemetry/observe.js +34 -7
- package/dist/telemetry/region.d.ts +6 -0
- package/dist/telemetry/region.js +28 -0
- package/dist/telemetry/report.js +1 -1
- package/dist/telemetry/schema.d.ts +3 -0
- package/dist/telemetry/schema.js +11 -1
- package/dist/telemetry/send.d.ts +5 -2
- package/dist/telemetry/send.js +15 -7
- package/dist/telemetry/words.d.ts +6 -0
- package/dist/telemetry/words.js +6 -0
- package/package.json +23 -4
package/dist/sessions/store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { derivedSalt } from './machine-ids.js';
|
|
1
2
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
3
|
import { createReadStream } from 'node:fs';
|
|
3
4
|
import { performance } from 'node:perf_hooks';
|
|
@@ -15,6 +16,7 @@ import { callPositions, captureSession } from './extract.js';
|
|
|
15
16
|
import { foldToolRows, withoutReplays } from './fold.js';
|
|
16
17
|
import { asRepoSize } from './public-vocabulary.js';
|
|
17
18
|
import { makeRepoIdentity, makeRepoRoot } from './repo-root.js';
|
|
19
|
+
import { mkdirOwnerOnly } from './owner-only.js';
|
|
18
20
|
import { makeRedactor } from './redact.js';
|
|
19
21
|
import { CAPTURE_VERSION, EXTRACTOR_VERSION, OLDEST_READABLE_CAPTURE, } from './schema.js';
|
|
20
22
|
const isMissing = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
@@ -25,6 +27,7 @@ export const paths = (storeDir) => ({
|
|
|
25
27
|
skeletons: join(storeDir, 'skeletons'),
|
|
26
28
|
calls: join(storeDir, 'mcp-calls.jsonl'),
|
|
27
29
|
hookCalls: join(storeDir, 'hook-calls.jsonl'),
|
|
30
|
+
hookRows: join(storeDir, 'hook-rows.jsonl'),
|
|
28
31
|
pipelineRuns: join(storeDir, 'pipeline-runs.jsonl'),
|
|
29
32
|
salt: join(storeDir, 'salt'),
|
|
30
33
|
raw: join(storeDir, 'raw'),
|
|
@@ -80,14 +83,16 @@ export const readState = async (storeDir) => {
|
|
|
80
83
|
for (const [entry, mode] of Object.entries(pool.folders))
|
|
81
84
|
if (mode === 'skeleton' || mode === 'off')
|
|
82
85
|
folders[entry] = mode;
|
|
83
|
-
|
|
86
|
+
const yesVersion = typeof pool.rawConsentVersion === 'string' && pool.rawConsentVersion !== '' ? pool.rawConsentVersion : null;
|
|
87
|
+
const granted = pool.rawConsent === 'granted' && yesVersion !== null;
|
|
88
|
+
return { files, redactDomains: domains, allow, pool: { rawConsent: granted ? 'granted' : pool.rawConsent === 'declined' ? 'declined' : null, rawConsentVersion: granted ? yesVersion : null, withdrawalPending: pool.withdrawalPending === true && !granted, folders } };
|
|
84
89
|
}
|
|
85
90
|
}
|
|
86
91
|
catch (error) {
|
|
87
92
|
if (!isMissing(error) && !(error instanceof SyntaxError))
|
|
88
93
|
throw error;
|
|
89
94
|
}
|
|
90
|
-
return { files: {}, redactDomains: [], allow: [], pool: { rawConsent: null, folders: {} } };
|
|
95
|
+
return { files: {}, redactDomains: [], allow: [], pool: { rawConsent: null, rawConsentVersion: null, withdrawalPending: false, folders: {} } };
|
|
91
96
|
};
|
|
92
97
|
/** Changes the pool choices under the store lock, so a deposit running at the same time keeps them (it writes the state it read). */
|
|
93
98
|
export const updatePoolChoices = (storeDir, change) => withLock(storeDir, async (held) => {
|
|
@@ -111,7 +116,7 @@ export const writeAtomic = async (file, text) => {
|
|
|
111
116
|
await rename(temp, file);
|
|
112
117
|
};
|
|
113
118
|
export { slugOf };
|
|
114
|
-
const projectFolders = async (projectsRoot) => {
|
|
119
|
+
export const projectFolders = async (projectsRoot) => {
|
|
115
120
|
try {
|
|
116
121
|
return (await readdir(projectsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
117
122
|
}
|
|
@@ -164,6 +169,7 @@ const contextFor = async (runtime, state, now, held) => {
|
|
|
164
169
|
domains: [...state.redactDomains].sort(),
|
|
165
170
|
redact: makeRedactor(state.redactDomains),
|
|
166
171
|
salt,
|
|
172
|
+
repoSalt: runtime.repoSalt ?? derivedSalt(salt.salt, 'repoKey/1'),
|
|
167
173
|
repoRoot: makeRepoRoot(),
|
|
168
174
|
repoIdentity: makeRepoIdentity(),
|
|
169
175
|
captureRuntime: captureRuntime(),
|
|
@@ -175,7 +181,7 @@ const contextFor = async (runtime, state, now, held) => {
|
|
|
175
181
|
now,
|
|
176
182
|
};
|
|
177
183
|
};
|
|
178
|
-
const mainTranscript = (folder, name) => ({ file: join(folder, name), session: name.slice(0, -'.jsonl'.length), relative: name });
|
|
184
|
+
export const mainTranscript = (folder, name) => ({ file: join(folder, name), session: name.slice(0, -'.jsonl'.length), relative: name });
|
|
179
185
|
export const listDir = async (dir) => {
|
|
180
186
|
try {
|
|
181
187
|
return (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -221,7 +227,7 @@ export const subagentFiles = async (folder, sessions) => {
|
|
|
221
227
|
return found;
|
|
222
228
|
};
|
|
223
229
|
// The subagent transcripts of the given sessions, each with its `.meta.json` read.
|
|
224
|
-
const subagentTranscripts = async (folder, sessions) => {
|
|
230
|
+
export const subagentTranscripts = async (folder, sessions) => {
|
|
225
231
|
const found = [];
|
|
226
232
|
for (const { dir, path, parent, name } of await subagentFiles(folder, sessions)) {
|
|
227
233
|
const agent = name.slice(0, -'.jsonl'.length);
|
|
@@ -340,6 +346,7 @@ const depositFile = async (context, project, transcript) => {
|
|
|
340
346
|
bytes: info.size,
|
|
341
347
|
redact: context.redact,
|
|
342
348
|
salt: context.salt.salt,
|
|
349
|
+
repoSalt: context.repoSalt,
|
|
343
350
|
sid: context.salt.sid,
|
|
344
351
|
repoRoot: context.repoRoot,
|
|
345
352
|
repoIdentity: context.repoIdentity,
|
|
@@ -439,6 +446,24 @@ const commitCapture = async (context, key, extract, cursor) => {
|
|
|
439
446
|
await writeAtomic(join(files.skeletons, `${extract.session.session}.json`), JSON.stringify(extract.skeleton));
|
|
440
447
|
context.state.files[key] = { ...cursor, v: CAPTURE_VERSION, s: context.salt.sid, d: context.domains, repoSize: extract.session.frame?.stack.size ?? null };
|
|
441
448
|
};
|
|
449
|
+
/**
|
|
450
|
+
* Records that a session is superseded: its capture of no turns, marked with the session that holds them now, appended as the next rev (the log is never edited), and its
|
|
451
|
+
* skeleton removed, since a skeleton is derived and a reader of skeletons would count the session. The cursor records it, so a repeat deposit adds nothing. The nested agents of the
|
|
452
|
+
* session are read again, since their parent is now the merged session.
|
|
453
|
+
*/
|
|
454
|
+
const commitSupersede = async (context, key, extract, by, cursor) => {
|
|
455
|
+
const files = paths(context.runtime.storeDir);
|
|
456
|
+
extract.session.supersededBy = by;
|
|
457
|
+
await requireHold(context.held);
|
|
458
|
+
await appendFile(files.observations, `${JSON.stringify(extract.session)}\n`, { mode: 0o600 });
|
|
459
|
+
await requireHold(context.held);
|
|
460
|
+
await rm(join(files.skeletons, `${extract.session.session}.json`), { force: true });
|
|
461
|
+
context.state.files[key] = { ...cursor, v: CAPTURE_VERSION, s: context.salt.sid, d: context.domains, repoSize: null };
|
|
462
|
+
// A nested agent's id is `<trace>-<span>`, so it stays when its parent moves to the merged session, and its spans may not change: stale its cursor so the next read names the new parent.
|
|
463
|
+
for (const [other, entry] of Object.entries(context.state.files))
|
|
464
|
+
if (other.startsWith(`${key}-`))
|
|
465
|
+
entry.v = '';
|
|
466
|
+
};
|
|
442
467
|
/** Another deposit holds the store lock and the wait ran out: the one failure the Stop hook defers instead of counting as a failed run. */
|
|
443
468
|
export class StoreLockedError extends CliError {
|
|
444
469
|
constructor() {
|
|
@@ -496,13 +521,19 @@ const breakStale = async (lock, staleMs) => {
|
|
|
496
521
|
await rm(guard, { force: true });
|
|
497
522
|
}
|
|
498
523
|
};
|
|
524
|
+
/**
|
|
525
|
+
* What a command a person typed waits for the store lock (`deposit --deny`, the pool choices). The background sweep starts after any command and its deposit
|
|
526
|
+
* holds the lock for the whole run, which a full re-capture stretches past 10 minutes, so a short wait made `--deny` fail right when the hold window
|
|
527
|
+
* wants it to work; two minutes is the wait the send lock gives. The hook path keeps `HOOK_LOCK`: a hook must not block a session.
|
|
528
|
+
*/
|
|
529
|
+
const FOREGROUND_LOCK = { waitMs: 120_000, staleMs: 600_000 };
|
|
499
530
|
/**
|
|
500
531
|
* Runs `work` as the only writer of `state.json` and the observations file. The SessionEnd hooks of parallel sessions and the nightly
|
|
501
532
|
* run each read, extract, and rewrite `state.json`; without this one's `files` and `allow` entries are lost to another's write.
|
|
502
533
|
*/
|
|
503
|
-
export const withLock = async (storeDir, work, options =
|
|
534
|
+
export const withLock = async (storeDir, work, options = FOREGROUND_LOCK) => {
|
|
504
535
|
const lock = join(storeDir, 'store.lock');
|
|
505
|
-
await
|
|
536
|
+
await mkdirOwnerOnly(storeDir);
|
|
506
537
|
const started = Date.now();
|
|
507
538
|
// The token names this claim, so a holder whose lock was broken cannot delete the next holder's lock when it ends.
|
|
508
539
|
const token = `${String(process.pid)} ${randomUUID()}`;
|
|
@@ -654,11 +685,11 @@ const depositLocked = async (runtime, options, holds, trace) => {
|
|
|
654
685
|
return result;
|
|
655
686
|
};
|
|
656
687
|
/** The table in force: the runtime's own (a test's), else every source. */
|
|
657
|
-
const sourcesOf = (runtime) => runtime.sources ?? SOURCES;
|
|
688
|
+
export const sourcesOf = (runtime) => runtime.sources ?? SOURCES;
|
|
658
689
|
/** A source's name is the first `state.json` key segment, and no project folder uses it, so the Claude Code walk skips it. */
|
|
659
690
|
const isSource = (table, name) => table.some((source) => source.name === name);
|
|
660
691
|
/** The roots the runtime names, by source name; `cursorRoot` stays the alias of the Cursor entry. */
|
|
661
|
-
const sourceRoots = (runtime) => new Map(Object.entries({ ...(runtime.cursorRoot === undefined ? {} : { cursor: runtime.cursorRoot }), ...runtime.sourceRoots }));
|
|
692
|
+
export const sourceRoots = (runtime) => new Map(Object.entries({ ...(runtime.cursorRoot === undefined ? {} : { cursor: runtime.cursorRoot }), ...runtime.sourceRoots }));
|
|
662
693
|
/** Reports an unreadable source store or file, once per reason: the full path for the terminal, the base name for the hook-call record. */
|
|
663
694
|
const noteReason = (counts, file, reason) => {
|
|
664
695
|
const line = `${file}: ${reason}`;
|
|
@@ -696,16 +727,23 @@ const readSource = async (context, source, root, result, trace) => {
|
|
|
696
727
|
for (const entry of store.unreadable)
|
|
697
728
|
noteReason(counts, entry.file, entry.reason);
|
|
698
729
|
try {
|
|
699
|
-
|
|
730
|
+
// The superseded sessions first: committing one stales the cursors of its nested agents, which are then read in this same deposit.
|
|
731
|
+
const ordered = [...store.sessions.filter((session) => session.supersededBy !== undefined), ...store.sessions.filter((session) => session.supersededBy === undefined)];
|
|
732
|
+
for (const session of ordered) {
|
|
700
733
|
const key = `${source.name}/${session.id}`;
|
|
701
734
|
const previous = context.state.files[key];
|
|
735
|
+
// A session this store never held has nothing to supersede.
|
|
736
|
+
if (session.supersededBy !== undefined && previous === undefined)
|
|
737
|
+
continue;
|
|
702
738
|
if (previous !== undefined &&
|
|
703
739
|
previous.size === session.size &&
|
|
704
740
|
previous.mtimeMs === session.mtimeMs &&
|
|
705
741
|
previous.v === CAPTURE_VERSION &&
|
|
706
742
|
previous.s === context.salt.sid &&
|
|
707
743
|
sameSet(previous.d, context.domains)) {
|
|
708
|
-
|
|
744
|
+
// A superseded session is recorded once and is no file of its own, so it is not one more file left unchanged.
|
|
745
|
+
if (session.supersededBy === undefined)
|
|
746
|
+
result.unchanged += 1;
|
|
709
747
|
continue;
|
|
710
748
|
}
|
|
711
749
|
let text;
|
|
@@ -729,6 +767,7 @@ const readSource = async (context, source, root, result, trace) => {
|
|
|
729
767
|
bytes: Buffer.byteLength(text),
|
|
730
768
|
redact: context.redact,
|
|
731
769
|
salt: context.salt.salt,
|
|
770
|
+
repoSalt: context.repoSalt,
|
|
732
771
|
sid: context.salt.sid,
|
|
733
772
|
repoRoot: context.repoRoot,
|
|
734
773
|
repoIdentity: context.repoIdentity,
|
|
@@ -739,6 +778,10 @@ const readSource = async (context, source, root, result, trace) => {
|
|
|
739
778
|
});
|
|
740
779
|
// The input names the frame and the action table; the session record carries the harness for `harnessOf`, `books --by harness`, and `measure`.
|
|
741
780
|
extract.session.harness = source.harness;
|
|
781
|
+
if (session.supersededBy !== undefined) {
|
|
782
|
+
await commitSupersede(context, key, extract, `${source.name}-${session.supersededBy}`, { size: session.size, mtimeMs: session.mtimeMs, rev });
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
742
785
|
await commitCapture(context, key, extract, { size: session.size, mtimeMs: session.mtimeMs, rev });
|
|
743
786
|
tally(trace, extract, session.subagent !== undefined);
|
|
744
787
|
if (session.subagent === undefined) {
|
|
@@ -918,7 +961,7 @@ const rawRelatives = async (dir, path) => {
|
|
|
918
961
|
}
|
|
919
962
|
return found.sort();
|
|
920
963
|
};
|
|
921
|
-
const HOOK_LOCK = { waitMs:
|
|
964
|
+
export const HOOK_LOCK = { waitMs: 500, staleMs: 600_000 };
|
|
922
965
|
/**
|
|
923
966
|
* Deposits one transcript, as the SessionEnd hook does: only from a folder that earlier `--allow` entries name, and idempotent by size and
|
|
924
967
|
* mtime. It does not rebuild `derived.json` or the hook index (that is a whole-store pass; the nightly run does it), so it stays fast.
|
|
@@ -927,7 +970,7 @@ const HOOK_LOCK = { waitMs: 3_000, staleMs: 600_000 };
|
|
|
927
970
|
*/
|
|
928
971
|
export const depositSession = async (runtime, transcript, now, trace) => {
|
|
929
972
|
try {
|
|
930
|
-
// The hook path waits
|
|
973
|
+
// The hook path waits under a second for the lock, well under the harness's 30 s ceiling on the hook (install.ts), so a SessionEnd behind the sender's sweep cannot hold session exit; `deferSession` covers a lock still held.
|
|
931
974
|
return await withLock(runtime.storeDir, (held) => depositSessionLocked(runtime, transcript, now, held, trace), HOOK_LOCK);
|
|
932
975
|
}
|
|
933
976
|
catch (error) {
|
|
@@ -1091,12 +1134,20 @@ const internTool = (tool, intern) => ({
|
|
|
1091
1134
|
target: tool.target === null ? null : intern(tool.target),
|
|
1092
1135
|
...(tool.segments === undefined ? {} : { segments: tool.segments.map((segment) => ({ ...segment, run: intern(segment.run) })) }),
|
|
1093
1136
|
});
|
|
1137
|
+
const withoutMessageUsage = (session) => {
|
|
1138
|
+
if (session.messageUsage === undefined)
|
|
1139
|
+
return session;
|
|
1140
|
+
const rest = { ...session };
|
|
1141
|
+
Reflect.deleteProperty(rest, 'messageUsage');
|
|
1142
|
+
return rest;
|
|
1143
|
+
};
|
|
1094
1144
|
/**
|
|
1095
1145
|
* The store as a reader sees it: for each session only the highest `rev`. A version-3 or later session is derived here from its capture records
|
|
1096
1146
|
* (EXTRACTOR_VERSION rules); a session whose last deposit was version 2 keeps the prompt and error lines that deposit wrote.
|
|
1097
1147
|
* `afterFirstPass` runs between the two passes over the log: a seam for the test that compacts the log there.
|
|
1148
|
+
* `options.messageUsage` keeps the per-message usage entries on each session record; without it they are dropped as the record is read.
|
|
1098
1149
|
*/
|
|
1099
|
-
export const loadStore = async (storeDir, afterFirstPass) => {
|
|
1150
|
+
export const loadStore = async (storeDir, afterFirstPass, options = {}) => {
|
|
1100
1151
|
// Two passes, so a superseded record is never held: the first keeps one number per session, the second keeps only the records at that rev.
|
|
1101
1152
|
// A re-capture leaves the old copies in the log until a compaction, and holding them as well doubled the heap of every read.
|
|
1102
1153
|
// Both passes read one open file: a lock-free reader can meet a compaction between them, and a second open would see the compacted log, where
|
|
@@ -1119,7 +1170,7 @@ export const loadStore = async (storeDir, afterFirstPass) => {
|
|
|
1119
1170
|
if (observation === undefined || observation.rev !== latest.get(observation.session))
|
|
1120
1171
|
continue;
|
|
1121
1172
|
if (observation.kind === 'session')
|
|
1122
|
-
of(observation.session).session = observation;
|
|
1173
|
+
of(observation.session).session = options.messageUsage === true ? observation : withoutMessageUsage(observation);
|
|
1123
1174
|
else if (observation.kind === 'user-prompt')
|
|
1124
1175
|
of(observation.session).prompts.push(observation);
|
|
1125
1176
|
else if (observation.kind === 'tool')
|
|
@@ -1138,7 +1189,8 @@ export const loadStore = async (storeDir, afterFirstPass) => {
|
|
|
1138
1189
|
const redact = makeRedactor((await readState(storeDir)).redactDomains);
|
|
1139
1190
|
const agents = [];
|
|
1140
1191
|
for (const { session: written, prompts, tools: writtenTools, errorLines: writtenErrorLines } of captured.values()) {
|
|
1141
|
-
|
|
1192
|
+
// A superseded session holds no turns: the session that folded it in holds them.
|
|
1193
|
+
if (written === undefined || written.supersededBy !== undefined)
|
|
1142
1194
|
continue;
|
|
1143
1195
|
// One tool call is one record: a replay an older capture wrote beside the call is folded here.
|
|
1144
1196
|
const folded = foldToolRows(writtenTools, writtenErrorLines);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The invented working directory the demo sessions claim; no such folder is read. Resolved, because a folder's name is its path's slug on this platform. */
|
|
2
|
+
export declare const DEMO_CWD: string;
|
|
3
|
+
/** The folder Claude Code would name for `DEMO_CWD` under `~/.claude/projects`. */
|
|
4
|
+
export declare const DEMO_FOLDER: string;
|
|
5
|
+
/** An error the three demo sessions all hit, as a stranger would paste it. */
|
|
6
|
+
export declare const DEMO_ERROR = "unexpected EOF while looking for matching quote";
|
|
7
|
+
/** Writes the demo sessions under `<projectsRoot>/<DEMO_FOLDER>/`, the layout Claude Code uses. */
|
|
8
|
+
export declare const writeDemoSessions: (projectsRoot: string) => Promise<void>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The bundled demo behind `oars sessions try`. EVERYTHING HERE IS MADE UP: three invented Claude Code sessions in an invented project
|
|
2
|
+
// (`/work/demo`), written so the same two errors recur. No line comes from a real session, a real person, or a real repository, and no
|
|
3
|
+
// number `try` prints from it describes anyone's work. The sessions are lines of the same shape Claude Code writes, so the demo runs the
|
|
4
|
+
// real deposit, recall, books, and hook code and not a canned answer.
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
import { slugOf } from './allow.js';
|
|
8
|
+
/** The invented working directory the demo sessions claim; no such folder is read. Resolved, because a folder's name is its path's slug on this platform. */
|
|
9
|
+
export const DEMO_CWD = resolve('/work/demo');
|
|
10
|
+
/** The folder Claude Code would name for `DEMO_CWD` under `~/.claude/projects`. */
|
|
11
|
+
export const DEMO_FOLDER = slugOf(DEMO_CWD);
|
|
12
|
+
/** An error the three demo sessions all hit, as a stranger would paste it. */
|
|
13
|
+
export const DEMO_ERROR = 'unexpected EOF while looking for matching quote';
|
|
14
|
+
const SHELL_ERROR = `Exit code 2\nbash: -c: line 1: ${DEMO_ERROR}\n`;
|
|
15
|
+
const WRITE_ERROR = 'File has not been read yet. Read it first before writing to it.';
|
|
16
|
+
const shell = (command, result, isError = false) => ({ tool: 'Bash', input: { command }, result, isError });
|
|
17
|
+
const read = (file) => ({ tool: 'Read', input: { file_path: file }, result: 'file contents', isError: false });
|
|
18
|
+
const edit = (file, result, isError = false) => ({ tool: 'Write', input: { file_path: file, content: 'echo ok' }, result, isError });
|
|
19
|
+
const SESSIONS = [
|
|
20
|
+
{
|
|
21
|
+
id: 'demo-0001',
|
|
22
|
+
day: '2026-10-06',
|
|
23
|
+
prompt: 'fix the failing build in /work/demo/src/main.ts',
|
|
24
|
+
turns: [read('/work/demo/src/main.ts'), shell("echo 'it's broken", SHELL_ERROR, true), edit('/work/demo/fix.sh', WRITE_ERROR, true), read('/work/demo/fix.sh'), edit('/work/demo/fix.sh', 'ok'), shell('npm test', 'all passed')],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'demo-0002',
|
|
28
|
+
day: '2026-10-13',
|
|
29
|
+
prompt: 'add a test for the parser in /work/demo/src/parse.ts',
|
|
30
|
+
turns: [read('/work/demo/src/parse.ts'), shell("git commit -m 'add parser test", SHELL_ERROR, true), shell('git commit -m "add parser test"', 'committed'), shell('npm test', 'all passed')],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'demo-0003',
|
|
34
|
+
day: '2026-10-20',
|
|
35
|
+
prompt: 'why is the deploy script slow',
|
|
36
|
+
turns: [read('/work/demo/deploy.sh'), edit('/work/demo/deploy.sh', WRITE_ERROR, true), read('/work/demo/deploy.sh'), shell("echo 'timing", SHELL_ERROR, true)],
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
const stamp = (day, second) => `${day}T10:${String(Math.floor(second / 60)).padStart(2, '0')}:${String(second % 60).padStart(2, '0')}.000Z`;
|
|
40
|
+
/** One session as the JSON lines Claude Code writes: a prompt, then per turn an assistant tool call and the user line that carries its result. */
|
|
41
|
+
const linesOf = (session) => {
|
|
42
|
+
let at = 0;
|
|
43
|
+
let parent = null;
|
|
44
|
+
const next = () => {
|
|
45
|
+
at += 3;
|
|
46
|
+
const uuid = `${session.id}-${String(at).padStart(4, '0')}`;
|
|
47
|
+
const line = { uuid, time: stamp(session.day, at), parentUuid: parent };
|
|
48
|
+
parent = uuid;
|
|
49
|
+
return line;
|
|
50
|
+
};
|
|
51
|
+
const common = { isSidechain: false, version: '2.1.300' };
|
|
52
|
+
const first = next();
|
|
53
|
+
const lines = [{ type: 'user', ...common, uuid: first.uuid, parentUuid: null, timestamp: first.time, cwd: DEMO_CWD, gitBranch: 'main', message: { role: 'user', content: session.prompt } }];
|
|
54
|
+
session.turns.forEach((turn, index) => {
|
|
55
|
+
const call = next();
|
|
56
|
+
const result = next();
|
|
57
|
+
const id = `t${String(index + 1)}`;
|
|
58
|
+
lines.push({
|
|
59
|
+
type: 'assistant',
|
|
60
|
+
...common,
|
|
61
|
+
uuid: call.uuid,
|
|
62
|
+
parentUuid: call.parentUuid,
|
|
63
|
+
timestamp: call.time,
|
|
64
|
+
requestedModel: 'claude-opus-5-5',
|
|
65
|
+
requestId: `req_${session.id}_${String(index + 1)}`,
|
|
66
|
+
apiBlockIndex: 0,
|
|
67
|
+
message: {
|
|
68
|
+
role: 'assistant',
|
|
69
|
+
model: 'claude-opus-5-5',
|
|
70
|
+
content: [{ type: 'tool_use', id, name: turn.tool, input: turn.input }],
|
|
71
|
+
usage: { input_tokens: 10, cache_creation_input_tokens: 100, cache_read_input_tokens: 9000, output_tokens: 80 },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
lines.push({
|
|
75
|
+
type: 'user',
|
|
76
|
+
...common,
|
|
77
|
+
uuid: result.uuid,
|
|
78
|
+
parentUuid: result.parentUuid,
|
|
79
|
+
timestamp: result.time,
|
|
80
|
+
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error: turn.isError, content: turn.result }] },
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
return lines.map((line) => JSON.stringify(line));
|
|
84
|
+
};
|
|
85
|
+
/** Writes the demo sessions under `<projectsRoot>/<DEMO_FOLDER>/`, the layout Claude Code uses. */
|
|
86
|
+
export const writeDemoSessions = async (projectsRoot) => {
|
|
87
|
+
const folder = join(projectsRoot, DEMO_FOLDER);
|
|
88
|
+
await mkdir(folder, { recursive: true });
|
|
89
|
+
await Promise.all(SESSIONS.map((session) => writeFile(join(folder, `${session.id}.jsonl`), `${linesOf(session).join('\n')}\n`)));
|
|
90
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Outcome } from '../cli.js';
|
|
2
|
+
import { type SessionsDeps } from './command.js';
|
|
3
|
+
/** Printed once at the top and under every heading, so no line of the output can be mistaken for the reader's own history. */
|
|
4
|
+
export declare const SYNTHETIC_LABEL = "SYNTHETIC DATA: made-up sessions bundled with oars, not your history";
|
|
5
|
+
/** A prefix the throwaway folder carries, so a leftover one is recognizable in the temp folder. */
|
|
6
|
+
export declare const TRY_PREFIX = "oars-try-";
|
|
7
|
+
/**
|
|
8
|
+
* Builds the demo store under the OS temp folder, runs the three commands over it with the same code `oars sessions` runs, and removes the
|
|
9
|
+
* folder on the way out, success or failure (a thrown step included). A SIGINT or SIGTERM during the run skips `finally` and leaves the
|
|
10
|
+
* folder in the temp folder for the OS to clear; the run is about a second long. The runtime is its own: it never takes the caller's store, projects folder, or other sources,
|
|
11
|
+
* and no command here reaches the network or a model.
|
|
12
|
+
*/
|
|
13
|
+
export declare const sessionsTry: (deps: Pick<SessionsDeps, "now">) => Promise<Outcome>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// `oars sessions try`: recall, books, and the hook line over a throwaway store of made-up sessions, for someone with no history of their own.
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { PassThrough } from 'node:stream';
|
|
6
|
+
import { sessionsBooks, sessionsContext, sessionsDeposit, sessionsRecall } from './command.js';
|
|
7
|
+
import { DEMO_CWD, DEMO_ERROR, writeDemoSessions } from './try-demo.js';
|
|
8
|
+
/** Printed once at the top and under every heading, so no line of the output can be mistaken for the reader's own history. */
|
|
9
|
+
export const SYNTHETIC_LABEL = 'SYNTHETIC DATA: made-up sessions bundled with oars, not your history';
|
|
10
|
+
/** A prefix the throwaway folder carries, so a leftover one is recognizable in the temp folder. */
|
|
11
|
+
export const TRY_PREFIX = 'oars-try-';
|
|
12
|
+
const heading = (title, command) => ['', `== ${title} (${SYNTHETIC_LABEL}) ==`, `$ ${command}`];
|
|
13
|
+
/**
|
|
14
|
+
* Builds the demo store under the OS temp folder, runs the three commands over it with the same code `oars sessions` runs, and removes the
|
|
15
|
+
* folder on the way out, success or failure (a thrown step included). A SIGINT or SIGTERM during the run skips `finally` and leaves the
|
|
16
|
+
* folder in the temp folder for the OS to clear; the run is about a second long. The runtime is its own: it never takes the caller's store, projects folder, or other sources,
|
|
17
|
+
* and no command here reaches the network or a model.
|
|
18
|
+
*/
|
|
19
|
+
export const sessionsTry = async (deps) => {
|
|
20
|
+
const root = await mkdtemp(join(tmpdir(), TRY_PREFIX));
|
|
21
|
+
try {
|
|
22
|
+
const projectsRoot = join(root, 'projects');
|
|
23
|
+
await writeDemoSessions(projectsRoot);
|
|
24
|
+
const runtime = { storeDir: join(root, 'store'), projectsRoot, sources: [], stdin: new PassThrough(), stdout: { write: () => true } };
|
|
25
|
+
const own = { sessions: runtime, install: undefined, now: deps.now };
|
|
26
|
+
const deposited = await sessionsDeposit(own, { allow: [], allowFiles: [], redactDomains: [] });
|
|
27
|
+
const recalled = await sessionsRecall(own, { error: DEMO_ERROR });
|
|
28
|
+
const booked = await sessionsBooks(own, { by: 'class', sinceDays: undefined });
|
|
29
|
+
const hooked = await sessionsContext(own, { cwd: DEMO_CWD, top: 5, maxChars: 1200 });
|
|
30
|
+
const text = [
|
|
31
|
+
`${SYNTHETIC_LABEL}.`,
|
|
32
|
+
'oars read three invented Claude Code sessions into a throwaway store, ran the commands below over it, and deletes that store when it exits.',
|
|
33
|
+
'Your own store and session folders were not read or written, and nothing left this machine.',
|
|
34
|
+
...heading('Deposit', 'oars sessions deposit'),
|
|
35
|
+
deposited.text,
|
|
36
|
+
...heading('Recall', `oars sessions recall --error "${DEMO_ERROR}"`),
|
|
37
|
+
recalled.text,
|
|
38
|
+
...heading('Books', 'oars sessions books --by class'),
|
|
39
|
+
booked.text,
|
|
40
|
+
...heading('Hook line', `oars sessions recall --context --cwd ${DEMO_CWD}`),
|
|
41
|
+
hooked.text,
|
|
42
|
+
'',
|
|
43
|
+
'To use oars on your own sessions: oars sessions deposit',
|
|
44
|
+
].join('\n');
|
|
45
|
+
return { document: { ok: true, synthetic: true, label: SYNTHETIC_LABEL, deposit: deposited.document, recall: recalled.document, books: booked.document, hook: hooked.document }, text };
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await rm(root, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
};
|
|
@@ -20,6 +20,8 @@ export interface VscodeStore {
|
|
|
20
20
|
sessions: VscodeSession[];
|
|
21
21
|
/** The session as transcript text for `captureSession`; throws when no file of it can be read or holds no parseable entry. */
|
|
22
22
|
transcript: (session: VscodeSession) => Promise<string>;
|
|
23
|
+
/** True when the session's chat data holds an empty request list (`chatHeadOnly`); `sessions verify` counts such a file as empty, not unreadable. */
|
|
24
|
+
headOnly: (session: VscodeSession) => Promise<boolean>;
|
|
23
25
|
/** Files skipped because their name is not a safe file name (it names a skeleton), counted so the skip is never silent. */
|
|
24
26
|
rejected: number;
|
|
25
27
|
/** Folders and files that could not be inspected, reported once each and never fatal; the reason names no path. */
|
|
@@ -34,5 +36,7 @@ export declare const chatBadLines: (text: string) => string[];
|
|
|
34
36
|
export declare const chatData: (text: string, jsonl: boolean) => Obj;
|
|
35
37
|
/** One chat session as the lines `captureSession` reads. */
|
|
36
38
|
export declare const chatLines: (data: Obj, fallbackId: string, folder: string | null) => Obj[];
|
|
39
|
+
/** A session object whose request list is empty: the chat was opened and nothing was asked. Anything else with no turn (a renamed list, say) is not head-only. */
|
|
40
|
+
export declare const chatHeadOnly: (data: Obj) => boolean;
|
|
37
41
|
export declare const openVscodeCopilotStore: (root: string) => Promise<VscodeStore | undefined>;
|
|
38
42
|
export {};
|
|
@@ -378,6 +378,26 @@ export const chatLines = (data, fallbackId, folder) => {
|
|
|
378
378
|
}
|
|
379
379
|
return lines;
|
|
380
380
|
};
|
|
381
|
+
/** The first of the session's files that reads, handed to `use`; the failure of the last one when none does. */
|
|
382
|
+
const firstReadable = async (session, use) => {
|
|
383
|
+
let failure = new Error('no file');
|
|
384
|
+
for (const file of session.files) {
|
|
385
|
+
try {
|
|
386
|
+
const text = await readFile(file, 'utf8');
|
|
387
|
+
const jsonl = file.endsWith('.jsonl');
|
|
388
|
+
// VS Code throws on an empty log ("Empty log file", objectMutationLog.ts:393) and falls back to the `.json`; the last file alone may read as an empty session.
|
|
389
|
+
if (jsonl && text.trim() === '' && file !== session.files[session.files.length - 1])
|
|
390
|
+
throw new Error('empty log file');
|
|
391
|
+
return use(chatData(text, jsonl), text, jsonl);
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
failure = error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
throw failure;
|
|
398
|
+
};
|
|
399
|
+
/** A session object whose request list is empty: the chat was opened and nothing was asked. Anything else with no turn (a renamed list, say) is not head-only. */
|
|
400
|
+
export const chatHeadOnly = (data) => Array.isArray(data.requests) && data.requests.length === 0;
|
|
381
401
|
export const openVscodeCopilotStore = async (root) => {
|
|
382
402
|
const unreadable = [];
|
|
383
403
|
const found = new Map();
|
|
@@ -472,23 +492,10 @@ export const openVscodeCopilotStore = async (root) => {
|
|
|
472
492
|
sessions,
|
|
473
493
|
rejected,
|
|
474
494
|
unreadable,
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const jsonl = file.endsWith('.jsonl');
|
|
481
|
-
// VS Code throws on an empty log ("Empty log file", objectMutationLog.ts:393) and falls back to the `.json`; the last file alone may read as an empty session.
|
|
482
|
-
if (jsonl && text.trim() === '' && file !== session.files[session.files.length - 1])
|
|
483
|
-
throw new Error('empty log file');
|
|
484
|
-
const lines = [...chatLines(chatData(text, jsonl), session.id, session.folder).map((line) => JSON.stringify(line)), ...(jsonl ? chatBadLines(text) : [])];
|
|
485
|
-
return lines.join('\n') + (lines.length === 0 ? '' : '\n');
|
|
486
|
-
}
|
|
487
|
-
catch (error) {
|
|
488
|
-
failure = error;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
throw failure;
|
|
492
|
-
},
|
|
495
|
+
headOnly: async (session) => await firstReadable(session, (data) => chatHeadOnly(data)),
|
|
496
|
+
transcript: async (session) => await firstReadable(session, (data, text, jsonl) => {
|
|
497
|
+
const lines = [...chatLines(data, session.id, session.folder).map((line) => JSON.stringify(line)), ...(jsonl ? chatBadLines(text) : [])];
|
|
498
|
+
return lines.join('\n') + (lines.length === 0 ? '' : '\n');
|
|
499
|
+
}),
|
|
493
500
|
};
|
|
494
501
|
};
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// `oars telemetry status|on|off|show`.
|
|
2
2
|
import { statSync } from 'node:fs';
|
|
3
3
|
import { CliError, ExitCode } from '../errors.js';
|
|
4
|
-
import { consentOf, markNoticeShown, NOTICE, setEnabled } from './consent.js';
|
|
4
|
+
import { consentOf, everShownNotice, markNoticeShown, NOTICE, setEnabled } from './consent.js';
|
|
5
|
+
import { recordFirstRun } from './observe.js';
|
|
5
6
|
import { readFleet, reportOf, textOf } from './report.js';
|
|
6
7
|
import { readSpool, spoolPaths } from './spool.js';
|
|
7
8
|
const STEP = 'telemetry';
|
|
@@ -15,6 +16,7 @@ const REASONS = {
|
|
|
15
16
|
setting: 'by `oars telemetry on` or `off`',
|
|
16
17
|
OARS_TELEMETRY: 'by OARS_TELEMETRY',
|
|
17
18
|
DO_NOT_TRACK: 'by DO_NOT_TRACK',
|
|
19
|
+
region: "because this machine's time zone is in the EU, the EEA, or the UK",
|
|
18
20
|
};
|
|
19
21
|
const sizeOf = (path) => {
|
|
20
22
|
try {
|
|
@@ -34,7 +36,7 @@ export const telemetryStatus = (deps) => {
|
|
|
34
36
|
describe(consent),
|
|
35
37
|
`The notice has ${consent.noticeShown ? '' : 'not '}been shown.`,
|
|
36
38
|
`${String(events)} events (${String(bytes)} bytes) in ${dir}.`,
|
|
37
|
-
'Events are sent to the pool server in OARS_POOL_URL, else to the Many Oars pool (pool.manyoars.com, which is live), and only while telemetry is on and the notice has been shown.',
|
|
39
|
+
'Events are sent to the pool server in OARS_POOL_URL, else to the Many Oars pool (pool.manyoars.com, which is live), and only while telemetry is on and the notice has been shown, and never from a machine whose time zone is in the EU, the EEA, or the UK.',
|
|
38
40
|
];
|
|
39
41
|
return { document: { ok: true, step: STEP, enabled: consent.enabled, reason: consent.reason, noticeShown: consent.noticeShown, events, bytes, dir }, text: lines.join('\n') };
|
|
40
42
|
};
|
|
@@ -47,12 +49,16 @@ const save = (dir, enabled) => {
|
|
|
47
49
|
}
|
|
48
50
|
};
|
|
49
51
|
export const telemetryOn = (deps) => {
|
|
50
|
-
const
|
|
52
|
+
const runtime = runtimeOf(deps);
|
|
53
|
+
const { dir } = runtime;
|
|
54
|
+
const first = !everShownNotice(dir);
|
|
51
55
|
save(dir, true);
|
|
52
|
-
// Running `on` is the notice: it is printed here and recorded as shown.
|
|
53
|
-
markNoticeShown(dir, deps.now);
|
|
56
|
+
// Running `on` is the notice: it is printed here and recorded as shown, so the run's own check finds it shown and sends the `first-run` event from here.
|
|
57
|
+
const marked = markNoticeShown(dir, deps.now);
|
|
54
58
|
const consent = consentOf(deps.env, dir);
|
|
55
|
-
|
|
59
|
+
if (first && marked && consent.enabled)
|
|
60
|
+
recordFirstRun(deps, runtime);
|
|
61
|
+
const override = consent.enabled ? '' : consent.reason === 'region' ? `\nIt stays off: this machine's time zone is in the EU, the EEA, or the UK.` : `\nIt stays off in this shell: ${consent.reason} is set.`;
|
|
56
62
|
return { document: { ok: true, step: STEP, enabled: consent.enabled, reason: consent.reason, notice: NOTICE }, text: `Telemetry is on.\n${NOTICE}${override}` };
|
|
57
63
|
};
|
|
58
64
|
export const telemetryOff = (deps) => {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
/** The version of the notice below. `markNoticeShown` stores it and each batch is stamped with the stored version; raise it whenever the notice changes in meaning. */
|
|
2
|
+
export declare const NOTICE_VERSION = 2;
|
|
1
3
|
/** Printed once on stderr, on the first run that is not a hook; also what `oars telemetry on` prints. */
|
|
2
4
|
export declare const NOTICE: string;
|
|
3
|
-
export type Reason = 'default' | 'setting' | 'OARS_TELEMETRY' | 'DO_NOT_TRACK';
|
|
5
|
+
export type Reason = 'default' | 'setting' | 'OARS_TELEMETRY' | 'DO_NOT_TRACK' | 'region';
|
|
4
6
|
export interface Consent {
|
|
5
7
|
enabled: boolean;
|
|
6
8
|
/** Which switch decided `enabled`. */
|
|
@@ -8,10 +10,14 @@ export interface Consent {
|
|
|
8
10
|
/** The notice was printed (or `on` was run) on this install. */
|
|
9
11
|
noticeShown: boolean;
|
|
10
12
|
}
|
|
11
|
-
export declare const consentOf: (env: NodeJS.ProcessEnv, dir: string) => Consent;
|
|
13
|
+
export declare const consentOf: (env: NodeJS.ProcessEnv, dir: string, zone?: string | undefined) => Consent;
|
|
12
14
|
/** Saves `on` or `off`. Throws when the folder cannot be written: the command that asked reports it. */
|
|
13
15
|
export declare const setEnabled: (dir: string, enabled: boolean) => void;
|
|
14
16
|
/** Records that the notice was shown; `false` when it could not be recorded (nothing is recorded or sent from a folder that cannot be written). */
|
|
15
17
|
export declare const markNoticeShown: (dir: string, now: () => number) => boolean;
|
|
18
|
+
/** Whether this install has ever been shown any notice (an older version included). */
|
|
19
|
+
export declare const everShownNotice: (dir: string) => boolean;
|
|
20
|
+
/** The notice version this install was shown, or `undefined` when none (or an unreadable file). */
|
|
21
|
+
export declare const shownNoticeVersion: (dir: string) => number | undefined;
|
|
16
22
|
/** Whether the sender (t875) may send: telemetry on, and the notice shown. Off, or notice not yet shown, means nothing sends. */
|
|
17
|
-
export declare const telemetrySendAllowed: (env: NodeJS.ProcessEnv, dir: string) => boolean;
|
|
23
|
+
export declare const telemetrySendAllowed: (env: NodeJS.ProcessEnv, dir: string, zone?: string | undefined) => boolean;
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// Whether telemetry records and may send, and the one-time notice. The off switches are `oars telemetry off` (a saved setting), `OARS_TELEMETRY=0`, and
|
|
2
|
-
// `DO_NOT_TRACK=1
|
|
2
|
+
// `DO_NOT_TRACK=1`, and a machine whose time zone is in the EU, the EEA, or the UK (`region.ts`), which no switch overrides; the environment wins over a saved `on`. `telemetrySendAllowed` is the check the sender must call before any byte leaves.
|
|
3
3
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { isClosedZone, machineZone } from './region.js';
|
|
4
5
|
import { spoolPaths } from './spool.js';
|
|
6
|
+
/** The version of the notice below. `markNoticeShown` stores it and each batch is stamped with the stored version; raise it whenever the notice changes in meaning. */
|
|
7
|
+
export const NOTICE_VERSION = 2;
|
|
5
8
|
/** Printed once on stderr, on the first run that is not a hook; also what `oars telemetry on` prints. */
|
|
6
9
|
export const NOTICE = [
|
|
7
|
-
'oars records how it is used, on this machine: one event per command with its path, flag names (never their values), how long it took, the exit code, and the class of any error; one per hook fire, recall, deposit, and fetch, as counts and closed words (which hook, how long, hit or miss, how many bytes, the harness name and version and the model name, how many sessions a deposit counted and how many each gate kept out, never which); each with a random install id, the oars version, the OS, and the time.
|
|
8
|
-
'It never records session content, prompts, file paths, URLs, arguments, or anything you typed. oars sends the events in batches to the pool server in OARS_POOL_URL, else to the Many Oars pool (pool.manyoars.com, which is live)
|
|
10
|
+
'oars records how it is used, on this machine: one event per command with its path, flag names (never their values), how long it took, the exit code, and the class of any error; one per hook fire, recall, deposit, and fetch, as counts and closed words (which hook, how long, hit or miss, how many bytes, the harness name and version and the model name, how many sessions a deposit counted and how many each gate kept out, never which); each with a random install id, the oars version, the OS, and the time. Each time the background sender runs, at most about once an hour, it also records counts and sizes of the session store, the lists of harness versions and model names it holds, and what the run cost. Events are sent in a batch at most once a day. A crash is recorded as the error\'s class and a hash of its stack (never its message). Each pool deposit records, per batch, which fields it held and how many bytes each took, and each send (a wire-send event) its HTTP status, the pool\'s refusal code, and its size. It also records one event on the first run with whether it ran in CI and how this copy was installed, as closed words; whether each command ran in CI, as a closed word; and the covenant version each time the covenant screen is shown at a terminal.',
|
|
11
|
+
'It never records session content, prompts, file paths, URLs, arguments, or anything you typed. oars sends the events in batches to the pool server in OARS_POOL_URL, else to the Many Oars pool (pool.manyoars.com, which is live). Many Oars Co. may license and sell Usage Data. oars sends no telemetry from a machine whose time zone is in the EU, the EEA, or the UK.',
|
|
9
12
|
'See exactly what is kept: oars telemetry show. Turn it off: oars telemetry off, or set OARS_TELEMETRY=0 or DO_NOT_TRACK=1. More: docs/telemetry.md.',
|
|
10
13
|
].join('\n');
|
|
11
14
|
const readSettings = (dir) => {
|
|
@@ -15,7 +18,12 @@ const readSettings = (dir) => {
|
|
|
15
18
|
return {};
|
|
16
19
|
const enabled = Reflect.get(parsed, 'enabled');
|
|
17
20
|
const shown = Reflect.get(parsed, 'noticeShownAt');
|
|
18
|
-
|
|
21
|
+
const version = Reflect.get(parsed, 'noticeVersion');
|
|
22
|
+
return {
|
|
23
|
+
...(typeof enabled === 'boolean' ? { enabled } : {}),
|
|
24
|
+
...(typeof shown === 'string' ? { noticeShownAt: shown } : {}),
|
|
25
|
+
...(typeof version === 'number' ? { noticeVersion: version } : {}),
|
|
26
|
+
};
|
|
19
27
|
}
|
|
20
28
|
catch {
|
|
21
29
|
return {};
|
|
@@ -31,9 +39,13 @@ const writeSettings = (dir, settings) => {
|
|
|
31
39
|
};
|
|
32
40
|
const OFF_WORDS = new Set(['0', 'false', 'off', 'no']);
|
|
33
41
|
const DO_NOT_TRACK_WORDS = new Set(['1', 'true']);
|
|
34
|
-
export const consentOf = (env, dir) => {
|
|
42
|
+
export const consentOf = (env, dir, zone = machineZone()) => {
|
|
35
43
|
const settings = readSettings(dir);
|
|
36
|
-
|
|
44
|
+
// An install that saw an older notice (no stored version, or another one) has not seen this one: it sees it again, and sends nothing until then.
|
|
45
|
+
const noticeShown = settings.noticeShownAt !== undefined && settings.noticeVersion === NOTICE_VERSION;
|
|
46
|
+
// First, so that no setting or `oars telemetry on` turns it back on in a closed region.
|
|
47
|
+
if (isClosedZone(zone))
|
|
48
|
+
return { enabled: false, reason: 'region', noticeShown };
|
|
37
49
|
if (OFF_WORDS.has((env.OARS_TELEMETRY ?? '').toLowerCase()))
|
|
38
50
|
return { enabled: false, reason: 'OARS_TELEMETRY', noticeShown };
|
|
39
51
|
if (DO_NOT_TRACK_WORDS.has((env.DO_NOT_TRACK ?? '').toLowerCase()))
|
|
@@ -49,15 +61,19 @@ export const setEnabled = (dir, enabled) => {
|
|
|
49
61
|
/** Records that the notice was shown; `false` when it could not be recorded (nothing is recorded or sent from a folder that cannot be written). */
|
|
50
62
|
export const markNoticeShown = (dir, now) => {
|
|
51
63
|
try {
|
|
52
|
-
writeSettings(dir, { ...readSettings(dir), noticeShownAt: new Date(now()).toISOString() });
|
|
64
|
+
writeSettings(dir, { ...readSettings(dir), noticeShownAt: new Date(now()).toISOString(), noticeVersion: NOTICE_VERSION });
|
|
53
65
|
return true;
|
|
54
66
|
}
|
|
55
67
|
catch {
|
|
56
68
|
return false;
|
|
57
69
|
}
|
|
58
70
|
};
|
|
71
|
+
/** Whether this install has ever been shown any notice (an older version included). */
|
|
72
|
+
export const everShownNotice = (dir) => readSettings(dir).noticeShownAt !== undefined;
|
|
73
|
+
/** The notice version this install was shown, or `undefined` when none (or an unreadable file). */
|
|
74
|
+
export const shownNoticeVersion = (dir) => readSettings(dir).noticeVersion;
|
|
59
75
|
/** Whether the sender (t875) may send: telemetry on, and the notice shown. Off, or notice not yet shown, means nothing sends. */
|
|
60
|
-
export const telemetrySendAllowed = (env, dir) => {
|
|
61
|
-
const consent = consentOf(env, dir);
|
|
76
|
+
export const telemetrySendAllowed = (env, dir, zone = machineZone()) => {
|
|
77
|
+
const consent = consentOf(env, dir, zone);
|
|
62
78
|
return consent.enabled && consent.noticeShown;
|
|
63
79
|
};
|