@awebai/oats 0.22.19 → 0.23.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 +54 -20
- package/bin/oats.mjs +24 -10
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +18 -24
- package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +2 -2
- package/capabilities/oats-okf/bin/oats-okf.mjs +105 -517
- package/capabilities/oats-okf/injects/okf.md +32 -67
- package/capabilities/oats-okf/lib/config.mjs +112 -0
- package/capabilities/oats-okf/lib/inspection.mjs +96 -0
- package/capabilities/oats-okf/lib/io.mjs +103 -0
- package/capabilities/oats-okf/lib/migration.mjs +116 -0
- package/capabilities/oats-okf/lib/sources.mjs +238 -0
- package/capabilities/oats-okf/lib/stores.mjs +331 -0
- package/capabilities/oats-okf/lib/worker.mjs +352 -0
- package/capabilities/oats-okf/oats.json +23 -7
- package/capabilities/oats-okf/schemas/okf-base.schema.json +46 -0
- package/capabilities/oats-okf/schemas/okf-bindings.schema.json +112 -0
- package/capabilities/oats-okf/schemas/okf-soul.schema.json +37 -0
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +263 -140
- package/capabilities/oats-okf/skills/okf/SKILL.md +13 -4
- package/docs/capabilities.md +14 -3
- package/docs/configuration.md +11 -1
- package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +558 -0
- package/docs/design/2026-09-13-knowledge-and-memory-direction.md +744 -0
- package/docs/design/2026-09-13-knowledge-implementation.md +127 -0
- package/docs/design/2026-09-13-knowledge-location-contract.md +340 -0
- package/docs/design/okf-mirror-provenance.md +105 -0
- package/docs/design/package-runtime-api.md +177 -3
- package/docs/desktop-cli-api.md +60 -11
- package/docs/execution-targets.md +16 -0
- package/docs/first-team-demo.md +6 -1
- package/docs/first-team.md +151 -115
- package/docs/integrations.md +42 -42
- package/docs/knowledge-capability-authoring.md +101 -0
- package/docs/knowledge-migration.md +138 -0
- package/docs/knowledge-reference/acceptance.md +108 -0
- package/docs/knowledge-reference/adoption.md +61 -0
- package/docs/knowledge-reference/harvester.md +107 -0
- package/docs/knowledge-reference/model.md +84 -0
- package/docs/knowledge-reference/package-craft.md +126 -0
- package/docs/knowledge-reference/provider-mapping.md +77 -0
- package/docs/knowledge-reference/reader-capture.md +87 -0
- package/docs/knowledge-theory.md +20 -6
- package/docs/knowledge.md +316 -129
- package/docs/layers.md +65 -69
- package/docs/migration-from-oas.md +7 -1
- package/docs/oats-config.schema.json +5 -2
- package/docs/packages.md +26 -2
- package/docs/release-notes/v0.23.0.md +93 -0
- package/docs/release-notes/v0.23.1.md +97 -0
- package/docs/schedules.md +42 -3
- package/docs/souls-and-instances.md +72 -49
- package/injects/work-directory.md +18 -0
- package/lib/core.mjs +279 -56
- package/lib/schedule.mjs +12 -2
- package/package-catalog.json +6 -1
- package/package.json +2 -2
- package/packages/record/README.md +19 -0
- package/packages/record/bin/capture.mjs +96 -48
- package/packages/record/bin/recall.mjs +17 -11
- package/packages/record/bin/record-native-start.mjs +11 -0
- package/packages/record/lib/capture-cc.mjs +82 -27
- package/packages/record/lib/capture-lock.mjs +15 -2
- package/packages/record/lib/formats.mjs +108 -21
- package/packages/record/lib/native-history.mjs +87 -0
- package/packages/record/lib/session-roots.mjs +90 -0
- package/packages/record/lib/session-snapshot.mjs +61 -0
- package/packages/record/lib/sessions-for-home.mjs +88 -56
- package/skills/oats/SKILL.md +3 -1
- package/capabilities/oats-okf/lib/harvest-branch.mjs +0 -43
package/lib/schedule.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { homedir } from "node:os";
|
|
|
24
24
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
26
|
import { Cron } from "croner";
|
|
27
|
-
import { ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
|
|
27
|
+
import { RESERVED_LAUNCH_ENV, ensureRoot, findAgent, findCapabilityAgent, findInstanceHomes, teamAgentRoots, configChain, spawnInstance, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
|
|
28
28
|
|
|
29
29
|
export const SCHEDULE_FILE = "oats-schedules.json";
|
|
30
30
|
export const SCHEDULE_API = 1;
|
|
@@ -355,7 +355,17 @@ function launchCommand(ws, def, io) {
|
|
|
355
355
|
let envelope, timedOut = false, raw = "";
|
|
356
356
|
if (io?.command) envelope = io.command({ cwd: def.cwd, argv });
|
|
357
357
|
else {
|
|
358
|
-
const env = { ...process.env };
|
|
358
|
+
const env = { ...process.env };
|
|
359
|
+
// A schedule may be ticked/run-now from inside an unrelated instance.
|
|
360
|
+
// Dispatch belongs to the job's cwd/explicit selectors, never that
|
|
361
|
+
// caller's frozen capability snapshot (including legacy OATS_HOME).
|
|
362
|
+
// Keep host configuration such as OATS_HOME_DIR and credentials intact.
|
|
363
|
+
for (const key of [...RESERVED_LAUNCH_ENV,
|
|
364
|
+
"OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
|
|
365
|
+
"OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
|
|
366
|
+
"OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
|
|
367
|
+
"OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
|
|
368
|
+
]) delete env[key];
|
|
359
369
|
const r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: def.cwd, encoding: "utf8", timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env });
|
|
360
370
|
timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && r.signal === "SIGTERM");
|
|
361
371
|
raw = String(r.stdout || "");
|
package/package-catalog.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"packages": {
|
|
3
3
|
"oats.okf": {
|
|
4
4
|
"url": "https://github.com/awebai/oats-okf.git",
|
|
5
|
-
"ref": "
|
|
5
|
+
"ref": "v2.0.0",
|
|
6
6
|
"path": "oats-package"
|
|
7
7
|
},
|
|
8
8
|
"oats.aweb": {
|
|
@@ -29,6 +29,11 @@
|
|
|
29
29
|
"url": "https://github.com/awebai/oats-dev.git",
|
|
30
30
|
"ref": "v1.0.0",
|
|
31
31
|
"path": "oats-package"
|
|
32
|
+
},
|
|
33
|
+
"oats.knowledge-theory": {
|
|
34
|
+
"url": "https://github.com/awebai/oats.git",
|
|
35
|
+
"ref": "v0.23.0",
|
|
36
|
+
"path": "oats-package"
|
|
32
37
|
}
|
|
33
38
|
},
|
|
34
39
|
"capabilities": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"type": "module",
|
|
16
16
|
"scripts": {
|
|
17
17
|
"test": "node scripts/run-tests.mjs",
|
|
18
|
-
"check": "node
|
|
18
|
+
"check": "node scripts/check-package-dry-runs.mjs --syntax-only",
|
|
19
19
|
"check:pi": "node --experimental-strip-types --check packages/pi/extension/index.ts",
|
|
20
20
|
"validate": "node scripts/validate-project.mjs",
|
|
21
21
|
"validate:okf": "node scripts/validate-okf.mjs",
|
|
@@ -209,3 +209,22 @@ into an orphaned inode until it restarts.
|
|
|
209
209
|
|
|
210
210
|
- Deletion via tombstone is eventual: an offline replica retains bytes until
|
|
211
211
|
it reconnects. The SOT says this plainly; so do we.
|
|
212
|
+
|
|
213
|
+
## Per-home source authority
|
|
214
|
+
|
|
215
|
+
`capture --home <dir>` defaults to the kernel's independent managed-launch
|
|
216
|
+
record-location history, retained beside the home under `.oats-native-record/`.
|
|
217
|
+
It does not re-resolve old `fromEnv` references using the capturing process.
|
|
218
|
+
Missing history (legacy/standalone), pending launches, and missing historical
|
|
219
|
+
roots fail closed instead of certifying empty observer storage. Runtime switches
|
|
220
|
+
and resumed starts retain earlier roots. A managed scaffold with no starts has
|
|
221
|
+
an empty managed-launch inventory; executing a saved recipe by hand is not a
|
|
222
|
+
managed start.
|
|
223
|
+
|
|
224
|
+
For a deliberately observer-time inventory, explicitly pass `--current-roots`.
|
|
225
|
+
The JSON labels this `sourceRoots: "current-env"`, rather than `"launch-history"`;
|
|
226
|
+
completion then applies only to the chosen current inventory, not historical
|
|
227
|
+
source custody. The library alternative is `sessionsForHome(home, { roots })`:
|
|
228
|
+
unspecified formats are excluded, and missing supplied roots fail. Synthetic
|
|
229
|
+
standalone tests must choose one of these explicitly, not masquerade as a
|
|
230
|
+
managed native launch. Background capture without `--home` is unchanged.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { watch } from "node:fs";
|
|
16
16
|
import { homedir, hostname } from "node:os";
|
|
17
|
-
import {
|
|
17
|
+
import { join } from "node:path";
|
|
18
18
|
import process from "node:process";
|
|
19
19
|
|
|
20
20
|
import { RecordStore } from "../lib/store.mjs";
|
|
@@ -53,6 +53,7 @@ const BOOL_FLAGS = new Set([
|
|
|
53
53
|
"sessions-only",
|
|
54
54
|
"aw-only",
|
|
55
55
|
"no-index",
|
|
56
|
+
"current-roots",
|
|
56
57
|
]);
|
|
57
58
|
|
|
58
59
|
const USAGE = `capture — land sessions and aw client logs in the turn record.
|
|
@@ -65,10 +66,21 @@ const USAGE = `capture — land sessions and aw client logs in the turn record.
|
|
|
65
66
|
capture --status show store/stream summary, capture nothing
|
|
66
67
|
capture --home <dir> capture the sessions that ran inside <dir> (an
|
|
67
68
|
OATS instance home) and print them as JSON:
|
|
68
|
-
thread, stream, turn count, first/last turn id
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
thread, stream, turn count, first/last turn id;
|
|
70
|
+
status, complete, skipped, held, incomplete,
|
|
71
|
+
failed, ignored (explicit privacy exclusions).
|
|
72
|
+
Only complete:true confirms a performed pass
|
|
73
|
+
with no holds, incomplete tails, unattributed
|
|
74
|
+
sources or errors. Lock skips/holds exit 0 but
|
|
75
|
+
report complete:false; failures exit 1.
|
|
76
|
+
Only attributed files are captured, even in
|
|
77
|
+
shared directories. Unattributed non-ignored
|
|
78
|
+
sources conservatively block completion.
|
|
79
|
+
Tombstoned turns are never a boundary.
|
|
80
|
+
capture --current-roots --home <dir>
|
|
81
|
+
explicit observer-time inventory for standalone
|
|
82
|
+
or legacy sources lacking launch history. Not a
|
|
83
|
+
certificate of all historical source locations.
|
|
72
84
|
capture --install-hint print the Claude Code hook snippet
|
|
73
85
|
capture --help this text
|
|
74
86
|
capture --quiet suppress per-pass progress
|
|
@@ -188,8 +200,9 @@ function withCaptureLock(fn) {
|
|
|
188
200
|
// Never quiet: a stale lock after a killed pass needs the operator, and
|
|
189
201
|
// the line says exactly what to check and what to remove.
|
|
190
202
|
const line = `capture: another pass holds ${root}: ${lock.held.recovery}; skipping this pass`;
|
|
191
|
-
if (lock.held.liveness
|
|
192
|
-
|
|
203
|
+
if (args.home) { if (!quiet || lock.held.liveness !== "alive") console.error(line); }
|
|
204
|
+
else if (lock.held.liveness === "alive") log(line); else console.error(line);
|
|
205
|
+
return { appended: 0, skipped: true, lock: lock.held };
|
|
193
206
|
}
|
|
194
207
|
try {
|
|
195
208
|
return fn();
|
|
@@ -217,7 +230,7 @@ function pass() {
|
|
|
217
230
|
if (!args["aw-only"]) {
|
|
218
231
|
for (const r of captureAllSessions(store, { owner, ignore })) {
|
|
219
232
|
out.appended += r.appended;
|
|
220
|
-
const extras = [r.ignored ? `${r.ignored} ignored` : "", r.held ? `${r.held} held` : ""]
|
|
233
|
+
const extras = [r.ignored ? `${r.ignored} ignored` : "", r.held ? `${r.held} held` : "", r.incomplete ? `${r.incomplete} incomplete` : ""]
|
|
221
234
|
.filter(Boolean)
|
|
222
235
|
.join(", ");
|
|
223
236
|
log(
|
|
@@ -300,52 +313,85 @@ if (args.status) {
|
|
|
300
313
|
// exists for programs.
|
|
301
314
|
if (args.home) {
|
|
302
315
|
warnOnStrangerOwner();
|
|
303
|
-
const ignore = loadIgnoreOrExit(root);
|
|
304
316
|
const unattributed = [];
|
|
305
|
-
|
|
317
|
+
let found = [];
|
|
306
318
|
const sessions = [];
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
let
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
319
|
+
const outcome = { appended: 0, skipped: false, held: 0, incomplete: 0, failed: 0, ignored: 0 };
|
|
320
|
+
const issues = [];
|
|
321
|
+
let error;
|
|
322
|
+
try {
|
|
323
|
+
// Unlike background passes, --home always answers JSON, including errors.
|
|
324
|
+
// Do not exit from inside the lock callback: its finally owns release.
|
|
325
|
+
const ignore = loadIgnore(root);
|
|
326
|
+
found = sessionsForHome(args.home, {
|
|
327
|
+
ignore,
|
|
328
|
+
...(args["current-roots"] ? { fallback: "current-env" } : {}),
|
|
329
|
+
onIgnored: () => outcome.ignored++,
|
|
330
|
+
onUnattributed: (source, path) => unattributed.push({ source, path }),
|
|
331
|
+
});
|
|
332
|
+
const formats = new Map(); // exact files, not their shared directories
|
|
333
|
+
for (const s of found) {
|
|
334
|
+
if (!formats.has(s.source)) formats.set(s.source, []);
|
|
335
|
+
formats.get(s.source).push(s);
|
|
314
336
|
}
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
337
|
+
Object.assign(outcome, withCaptureLock(() => {
|
|
338
|
+
for (const [format, files] of formats) {
|
|
339
|
+
const r = captureSessions(store, { owner, files, format, ignore, final: true });
|
|
340
|
+
outcome.appended += r.appended;
|
|
341
|
+
outcome.held += r.held;
|
|
342
|
+
outcome.incomplete += r.incomplete;
|
|
343
|
+
outcome.ignored += r.ignored;
|
|
344
|
+
issues.push(...r.issues);
|
|
345
|
+
}
|
|
346
|
+
if (!args["no-index"]) { // an earlier append-only pass may have left unindexed turns
|
|
347
|
+
const index = new RecordIndex(store);
|
|
348
|
+
try {
|
|
349
|
+
index.update();
|
|
350
|
+
} finally {
|
|
351
|
+
index.close();
|
|
352
|
+
}
|
|
321
353
|
}
|
|
354
|
+
return outcome;
|
|
355
|
+
}));
|
|
356
|
+
// A tombstoned turn is hidden everywhere; a boundary naming one would be
|
|
357
|
+
// refused by recall, so boundaries come from the visible turns only.
|
|
358
|
+
const claims = store.tombstoneClaims();
|
|
359
|
+
for (const s of found) {
|
|
360
|
+
const stream = `${owner}~${s.source}.${s.sessionId}`;
|
|
361
|
+
const turns = store.readStream(stream).filter((t) => !store.claimHides(claims, t));
|
|
362
|
+
if (!turns.length) continue; // ignored by rule, nothing capturable yet, or all hidden
|
|
363
|
+
sessions.push({
|
|
364
|
+
thread: s.thread,
|
|
365
|
+
source: s.source,
|
|
366
|
+
sessionId: s.sessionId,
|
|
367
|
+
path: s.path,
|
|
368
|
+
cwd: s.cwd,
|
|
369
|
+
stream,
|
|
370
|
+
turns: turns.length,
|
|
371
|
+
firstTurnId: turns[0].id,
|
|
372
|
+
lastTurnId: turns[turns.length - 1].id,
|
|
373
|
+
lastTs: turns[turns.length - 1].ts,
|
|
374
|
+
});
|
|
322
375
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const turns = store.readStream(stream).filter((t) => !store.claimHides(claims, t));
|
|
332
|
-
if (!turns.length) continue; // ignored by rule, nothing capturable yet, or all hidden
|
|
333
|
-
sessions.push({
|
|
334
|
-
thread: s.thread,
|
|
335
|
-
source: s.source,
|
|
336
|
-
sessionId: s.sessionId,
|
|
337
|
-
path: s.path,
|
|
338
|
-
cwd: s.cwd,
|
|
339
|
-
stream,
|
|
340
|
-
turns: turns.length,
|
|
341
|
-
firstTurnId: turns[0].id,
|
|
342
|
-
lastTurnId: turns[turns.length - 1].id,
|
|
343
|
-
lastTs: turns[turns.length - 1].ts,
|
|
344
|
-
});
|
|
376
|
+
} catch (err) {
|
|
377
|
+
error = err.message || String(err);
|
|
378
|
+
console.error(`capture pass failed: ${error}`);
|
|
379
|
+
outcome.failed++;
|
|
380
|
+
// captureSessions can throw after appending part of a directory. Do not
|
|
381
|
+
// claim zero (or a complete count) for a partially performed failed pass.
|
|
382
|
+
outcome.appended = null;
|
|
383
|
+
process.exitCode = 1;
|
|
345
384
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
385
|
+
if (process.exitCode && !outcome.failed) {
|
|
386
|
+
outcome.failed++;
|
|
387
|
+
error = "capture lock release failed; see stderr for recovery";
|
|
388
|
+
}
|
|
389
|
+
const status = outcome.failed ? "failed" : outcome.skipped ? "skipped" : outcome.held ? "held" : outcome.incomplete || unattributed.length ? "incomplete" : "complete";
|
|
390
|
+
console.log(JSON.stringify({ home: args.home, owner, ...outcome, status, complete: status === "complete", sessions, sourceRoots: args["current-roots"] ? "current-env" : "launch-history",
|
|
391
|
+
...(error ? { error } : {}), ...(issues.length ? { issues } : {}), ...(unattributed.length ? { unattributed } : {}),
|
|
392
|
+
}, null, 2));
|
|
393
|
+
// Let stdout drain naturally, including large session-boundary receipts.
|
|
394
|
+
} else {
|
|
349
395
|
|
|
350
396
|
warnOnStrangerOwner();
|
|
351
397
|
try {
|
|
@@ -383,3 +429,5 @@ if (args.watch) {
|
|
|
383
429
|
}
|
|
384
430
|
setInterval(schedule, 15 * 60 * 1000); // reconcile even if events were missed
|
|
385
431
|
}
|
|
432
|
+
|
|
433
|
+
}
|
|
@@ -43,12 +43,13 @@ const store = new RecordStore(root, {});
|
|
|
43
43
|
let index;
|
|
44
44
|
const getIndex = () => (index ??= new RecordIndex(store));
|
|
45
45
|
|
|
46
|
+
async function main() {
|
|
46
47
|
try {
|
|
47
48
|
if (args.reindex) {
|
|
48
49
|
const index = getIndex();
|
|
49
50
|
index.rebuild();
|
|
50
51
|
console.log(JSON.stringify(index.counts()));
|
|
51
|
-
|
|
52
|
+
return;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
if (args.show) {
|
|
@@ -58,21 +59,21 @@ try {
|
|
|
58
59
|
const turn = resolveTurn(store, index, args.show, byId);
|
|
59
60
|
if (!turn) {
|
|
60
61
|
console.error(`no turn ${args.show}`);
|
|
61
|
-
process.
|
|
62
|
+
process.exitCode = 1; return;
|
|
62
63
|
}
|
|
63
64
|
// Tombstoned turns are hidden from tool output, id lookup included.
|
|
64
65
|
if (store.hiddenIds(byId).has(args.show)) {
|
|
65
66
|
console.error(`turn ${args.show} is tombstoned`);
|
|
66
|
-
process.
|
|
67
|
+
process.exitCode = 1; return;
|
|
67
68
|
}
|
|
68
69
|
console.log(JSON.stringify(turn, null, 2));
|
|
69
|
-
|
|
70
|
+
return;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
const query = args._.join(" ").trim();
|
|
73
74
|
if (!query && !args.thread) {
|
|
74
75
|
console.error("usage: recall [--kind k] [--thread t] [--from f] [--role r] [--limit n] <query>");
|
|
75
|
-
process.
|
|
76
|
+
process.exitCode = 2; return;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
// Thread turns straight from the journal, in capture SEQUENCE: the order a
|
|
@@ -100,12 +101,12 @@ try {
|
|
|
100
101
|
let end = turns.length;
|
|
101
102
|
if (args.after) {
|
|
102
103
|
const i = ids.indexOf(args.after);
|
|
103
|
-
if (i < 0) { console.error(`--after: no turn ${args.after} in thread ${args.thread}`); process.
|
|
104
|
+
if (i < 0) { console.error(`--after: no turn ${args.after} in thread ${args.thread}`); process.exitCode = 1; return; }
|
|
104
105
|
start = i + 1;
|
|
105
106
|
}
|
|
106
107
|
if (args.until) {
|
|
107
108
|
const i = ids.indexOf(args.until);
|
|
108
|
-
if (i < 0) { console.error(`--until: no turn ${args.until} in thread ${args.thread}`); process.
|
|
109
|
+
if (i < 0) { console.error(`--until: no turn ${args.until} in thread ${args.thread}`); process.exitCode = 1; return; }
|
|
109
110
|
end = i + 1;
|
|
110
111
|
}
|
|
111
112
|
const cap = args.limit ? Number(args.limit) : Infinity;
|
|
@@ -123,7 +124,7 @@ try {
|
|
|
123
124
|
return full;
|
|
124
125
|
});
|
|
125
126
|
console.log(JSON.stringify({ thread: args.thread, total: turns.length, from: start, to: stop, remaining: end - stop, turns: out }, null, 2));
|
|
126
|
-
|
|
127
|
+
return;
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
const index = getIndex();
|
|
@@ -150,15 +151,15 @@ try {
|
|
|
150
151
|
|
|
151
152
|
if (rows.length === 0 && args.json) {
|
|
152
153
|
console.log("[]");
|
|
153
|
-
|
|
154
|
+
return;
|
|
154
155
|
}
|
|
155
156
|
if (rows.length === 0) {
|
|
156
157
|
console.error("no matches");
|
|
157
|
-
process.
|
|
158
|
+
process.exitCode = 1; return;
|
|
158
159
|
}
|
|
159
160
|
if (args.json) {
|
|
160
161
|
console.log(JSON.stringify(rows, null, 2));
|
|
161
|
-
|
|
162
|
+
return;
|
|
162
163
|
}
|
|
163
164
|
for (const r of rows) {
|
|
164
165
|
const where = r.loc ? ` @${r.loc}` : "";
|
|
@@ -170,3 +171,8 @@ try {
|
|
|
170
171
|
} finally {
|
|
171
172
|
index?.close();
|
|
172
173
|
}
|
|
174
|
+
|
|
175
|
+
}
|
|
176
|
+
// Natural process termination drains piped stdout; process.exit after a JSON
|
|
177
|
+
// write truncates large recall windows even though it reports exit status 0.
|
|
178
|
+
await main();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The environment/argv stays in this process, never in the custody receipt.
|
|
3
|
+
import { recordNativeStart } from "../lib/native-history.mjs";
|
|
4
|
+
try {
|
|
5
|
+
const [home, id, runtime, args] = process.argv.slice(2);
|
|
6
|
+
recordNativeStart(home, id, runtime, JSON.parse(args));
|
|
7
|
+
} catch {
|
|
8
|
+
// Native paths can contain user-chosen data; never echo argv or env on errors.
|
|
9
|
+
console.error("native record location receipt failed; launch refused and pending custody retained");
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
}
|
|
@@ -20,12 +20,14 @@
|
|
|
20
20
|
// ignore.mjs) are skipped before being opened: no turn, no offset entry —
|
|
21
21
|
// un-ignoring a file later makes the next pass capture it normally.
|
|
22
22
|
|
|
23
|
-
import {
|
|
23
|
+
import { isUtf8 } from "node:buffer";
|
|
24
|
+
import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
|
|
24
25
|
import { basename, dirname, join } from "node:path";
|
|
25
26
|
|
|
26
27
|
import { finishTurn } from "./canonical.mjs";
|
|
27
28
|
import { jsonlLines, SESSION_FORMATS } from "./formats.mjs";
|
|
28
29
|
import { loadIgnore } from "./ignore.mjs";
|
|
30
|
+
import { assertIdentity, digest, identity, readRange, verifySnapshot } from "./session-snapshot.mjs";
|
|
29
31
|
|
|
30
32
|
export const SESSION_STREAM_SOURCE = "cc";
|
|
31
33
|
|
|
@@ -49,7 +51,7 @@ export function scanTranscript(bytes) {
|
|
|
49
51
|
if (text === null) continue;
|
|
50
52
|
try {
|
|
51
53
|
const d = JSON.parse(text);
|
|
52
|
-
if (typeof d
|
|
54
|
+
if (typeof d?.timestamp === "string") ts = d.timestamp;
|
|
53
55
|
} catch {
|
|
54
56
|
/* verbatim content; nothing to extract */
|
|
55
57
|
}
|
|
@@ -83,8 +85,11 @@ function offsetsPath(store) {
|
|
|
83
85
|
function loadOffsets(store) {
|
|
84
86
|
try {
|
|
85
87
|
return JSON.parse(readFileSync(offsetsPath(store), "utf8"));
|
|
86
|
-
} catch {
|
|
87
|
-
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// This cache is derived, so malformed JSON can be rebuilt. An I/O
|
|
90
|
+
// failure is different: never hide an unreadable cache as missing.
|
|
91
|
+
if (err.code === "ENOENT" || err instanceof SyntaxError) return {};
|
|
92
|
+
throw err;
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -102,7 +107,7 @@ function readFrom(path, start, size) {
|
|
|
102
107
|
let done = 0;
|
|
103
108
|
while (done < buf.length) {
|
|
104
109
|
const n = readSync(fd, buf, done, buf.length - done, start + done);
|
|
105
|
-
if (n === 0)
|
|
110
|
+
if (n === 0) throw new Error(`short read of session/journal source: ${path}`);
|
|
106
111
|
done += n;
|
|
107
112
|
}
|
|
108
113
|
return buf.subarray(0, done);
|
|
@@ -119,8 +124,9 @@ function lastJournalLine(store, streamId) {
|
|
|
119
124
|
let size;
|
|
120
125
|
try {
|
|
121
126
|
size = statSync(path).size;
|
|
122
|
-
} catch {
|
|
123
|
-
return 0;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err.code === "ENOENT") return 0;
|
|
129
|
+
throw err;
|
|
124
130
|
}
|
|
125
131
|
let window = 64 * 1024;
|
|
126
132
|
while (true) {
|
|
@@ -152,12 +158,12 @@ function lastJournalLine(store, streamId) {
|
|
|
152
158
|
// Honest limit: an in-place REWRITE of already-captured lines is not
|
|
153
159
|
// detected (only growth is; a shrink triggers a rescan via the size
|
|
154
160
|
// check in the caller). Transcript writers are append-only in practice.
|
|
155
|
-
function offsetFromJournal(store, streamId, sourcePath) {
|
|
161
|
+
function offsetFromJournal(store, streamId, sourcePath, final, sourceBytes) {
|
|
156
162
|
const turns = store.readStream(streamId);
|
|
157
163
|
if (turns.length === 0) return { bytes: 0, line: 0, lastTs: "" };
|
|
158
164
|
const last = turns[turns.length - 1];
|
|
159
165
|
const lastLine = last.provenance?.origin?.line ?? 0;
|
|
160
|
-
const bytes = readFileSync(sourcePath);
|
|
166
|
+
const bytes = sourceBytes ?? readFileSync(sourcePath);
|
|
161
167
|
let line = 0;
|
|
162
168
|
let offset = 0;
|
|
163
169
|
while (line < lastLine && offset < bytes.length) {
|
|
@@ -166,6 +172,7 @@ function offsetFromJournal(store, streamId, sourcePath) {
|
|
|
166
172
|
line++;
|
|
167
173
|
offset = nl + 1;
|
|
168
174
|
}
|
|
175
|
+
if (final && line < lastLine) throw new Error(`session source is shorter than its captured journal: ${sourcePath}`);
|
|
169
176
|
return { bytes: offset, line, lastTs: last.ts ?? "" };
|
|
170
177
|
}
|
|
171
178
|
|
|
@@ -173,7 +180,14 @@ function offsetFromJournal(store, streamId, sourcePath) {
|
|
|
173
180
|
// of every session file under `roots`. Unstamped leading lines are held
|
|
174
181
|
// until the file shows its first timestamp (then they carry it forward),
|
|
175
182
|
// so every turn is stamped and ts stays a pure function of the source.
|
|
176
|
-
|
|
183
|
+
// `files` accepts discovery entries (including their attribution snapshot) or
|
|
184
|
+
// explicit paths for callers not making home-attribution claims. It pins a set,
|
|
185
|
+
// rather than rescanning directories
|
|
186
|
+
// and silently losing disappeared sources (or sweeping in other homes).
|
|
187
|
+
// `final` also verifies unchanged offsets against journals and checks source
|
|
188
|
+
// stability through the pass. The caller must quiesce writers for retirement;
|
|
189
|
+
// a performed pass is a snapshot, not a promise about future writes.
|
|
190
|
+
export function captureSessions(store, { owner, roots, files, format = "cc", ignore = null, final = false }) {
|
|
177
191
|
const fmt = SESSION_FORMATS[format];
|
|
178
192
|
if (!fmt) throw new Error(`unknown session format ${format}`);
|
|
179
193
|
const ign = ignore ?? loadIgnore(store.root);
|
|
@@ -183,21 +197,35 @@ export function captureSessions(store, { owner, roots, format = "cc", ignore = n
|
|
|
183
197
|
let unchanged = 0;
|
|
184
198
|
let held = 0;
|
|
185
199
|
let ignored = 0;
|
|
200
|
+
let incomplete = 0;
|
|
201
|
+
const issues = []; // source metadata only, never native record contents
|
|
186
202
|
const streams = new Set();
|
|
187
203
|
|
|
188
|
-
for (const
|
|
204
|
+
for (const file of files ?? fmt.listFiles(roots)) {
|
|
205
|
+
const path = typeof file === "string" ? file : file.path;
|
|
206
|
+
const expected = typeof file === "string" ? null : file.snapshot;
|
|
189
207
|
sessions++;
|
|
190
208
|
const sessionId = fmt.sessionId(path);
|
|
191
|
-
if (ign.ignores(path, [basename(path), sessionId])) {
|
|
209
|
+
if (ign.ignores(path, [basename(path), sessionId, ...(fmt.ignoreKeys?.(path) ?? [])])) {
|
|
192
210
|
ignored++;
|
|
193
211
|
continue; // never opened: nothing stored, nothing remembered
|
|
194
212
|
}
|
|
195
|
-
|
|
213
|
+
const fd = openSync(path, "r");
|
|
196
214
|
try {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
215
|
+
const stat = fstatSync(fd);
|
|
216
|
+
const snapshot = identity(stat);
|
|
217
|
+
if (expected) assertIdentity(stat, expected, path); // BEFORE reading bytes
|
|
218
|
+
// Final home capture stages a descriptor-pinned snapshot. All attribution
|
|
219
|
+
// and stability checks precede the first append, never a post-write alarm.
|
|
220
|
+
const sourceBytes = final || expected ? readRange(fd, 0, stat.size, path) : undefined;
|
|
221
|
+
if (expected && digest(sourceBytes.subarray(0, expected.size)) !== expected.hash) {
|
|
222
|
+
throw new Error(`session source content changed since attribution: ${path}`);
|
|
200
223
|
}
|
|
224
|
+
if (sourceBytes) snapshot.hash = digest(sourceBytes);
|
|
225
|
+
const verifySource = () => {
|
|
226
|
+
if (sourceBytes) verifySnapshot(fd, path, snapshot);
|
|
227
|
+
};
|
|
228
|
+
verifySource();
|
|
201
229
|
const streamId = `${owner}~${fmt.source}.${sessionId}`;
|
|
202
230
|
// Keyed by stream, not by source path: the same source captured under
|
|
203
231
|
// two owners must not share offset state (owner is part of the stream).
|
|
@@ -210,31 +238,44 @@ export function captureSessions(store, { owner, roots, format = "cc", ignore = n
|
|
|
210
238
|
// saved offsets for appends that landed in an unlinked inode (skipping
|
|
211
239
|
// would lose lines — this happened during the live migration). The
|
|
212
240
|
// journal is the truth; before appending anything, any disagreement
|
|
213
|
-
// rebuilds the offset from it
|
|
214
|
-
//
|
|
215
|
-
if (state && stat.size > state.bytes && state.line !== lastJournalLine(store, streamId)) {
|
|
241
|
+
// rebuilds the offset from it. Background passes check on growth;
|
|
242
|
+
// final passes also verify unchanged files before confirming capture.
|
|
243
|
+
if (state && (final || stat.size > state.bytes) && state.line !== lastJournalLine(store, streamId)) {
|
|
216
244
|
state = null;
|
|
217
245
|
}
|
|
218
|
-
if (!state) state = offsetFromJournal(store, streamId, path);
|
|
246
|
+
if (!state) state = offsetFromJournal(store, streamId, path, final, sourceBytes);
|
|
219
247
|
if (stat.size <= state.bytes) {
|
|
220
248
|
unchanged++;
|
|
221
249
|
offsets[offKey] = state;
|
|
250
|
+
verifySource();
|
|
222
251
|
continue;
|
|
223
252
|
}
|
|
224
253
|
|
|
225
|
-
const chunk =
|
|
254
|
+
const chunk = sourceBytes ? sourceBytes.subarray(state.bytes) : readRange(fd, state.bytes, stat.size, path);
|
|
226
255
|
// Phase 1: collect the COMPLETE lines of the chunk with their stamps.
|
|
227
256
|
const lines = [];
|
|
228
257
|
let scanned = 0;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
if (
|
|
258
|
+
let reason;
|
|
259
|
+
while (scanned < chunk.length) {
|
|
260
|
+
const nl = chunk.indexOf(10, scanned);
|
|
261
|
+
if (nl === -1) { reason = "torn-tail"; break; }
|
|
262
|
+
const bytes = chunk.subarray(scanned, nl);
|
|
263
|
+
// Decoding replacement characters would change both the verbatim line
|
|
264
|
+
// and its byte offset, possibly treating a later fragment as a record.
|
|
265
|
+
if (!isUtf8(bytes)) { reason = "invalid-utf8"; break; }
|
|
266
|
+
let text;
|
|
267
|
+
try { text = bytes.toString("utf8"); }
|
|
268
|
+
catch (err) {
|
|
269
|
+
if (err.code !== "ERR_STRING_TOO_LONG") throw err;
|
|
270
|
+
reason = "oversized-line";
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
const lineBytes = nl - scanned + 1;
|
|
233
274
|
let ts = "";
|
|
234
275
|
if (text.trim() !== "") {
|
|
235
276
|
try {
|
|
236
277
|
const d = JSON.parse(text);
|
|
237
|
-
if (typeof d
|
|
278
|
+
if (typeof d?.timestamp === "string") ts = d.timestamp;
|
|
238
279
|
} catch {
|
|
239
280
|
/* unparseable native line: captured verbatim below */
|
|
240
281
|
}
|
|
@@ -242,6 +283,10 @@ export function captureSessions(store, { owner, roots, format = "cc", ignore = n
|
|
|
242
283
|
lines.push({ text, ts, bytes: lineBytes });
|
|
243
284
|
scanned += lineBytes;
|
|
244
285
|
}
|
|
286
|
+
if (reason) {
|
|
287
|
+
incomplete++;
|
|
288
|
+
issues.push({ source: fmt.source, path, reason, offset: state.bytes + scanned });
|
|
289
|
+
}
|
|
245
290
|
// Phase 2: every turn needs a stamp. Leading lines before the file's
|
|
246
291
|
// first stamp carry it backward (deterministic: the file's first
|
|
247
292
|
// stamp is invariant however capture is scheduled); if the file has
|
|
@@ -250,11 +295,16 @@ export function captureSessions(store, { owner, roots, format = "cc", ignore = n
|
|
|
250
295
|
if (!lastTs) {
|
|
251
296
|
const first = lines.find((l) => l.ts);
|
|
252
297
|
if (!first) {
|
|
253
|
-
if (lines.length > 0)
|
|
298
|
+
if (lines.length > 0) {
|
|
299
|
+
held++;
|
|
300
|
+
issues.push({ source: fmt.source, path, reason: "unstamped", offset: state.bytes });
|
|
301
|
+
}
|
|
302
|
+
verifySource();
|
|
254
303
|
continue; // do not advance; retry when a stamp exists
|
|
255
304
|
}
|
|
256
305
|
lastTs = first.ts;
|
|
257
306
|
}
|
|
307
|
+
verifySource(); // parsing/recovery may take time; still no journal write yet
|
|
258
308
|
// Turns flush to the journal in bounded batches, so memory stays flat
|
|
259
309
|
// however large the backlog (a first capture of a huge transcript is
|
|
260
310
|
// one file's worth of NEW lines). A crash between flushes cannot
|
|
@@ -295,6 +345,8 @@ export function captureSessions(store, { owner, roots, format = "cc", ignore = n
|
|
|
295
345
|
// re-appends duplicate turn lines — logically deduped by id, but
|
|
296
346
|
// wasted append-only bytes).
|
|
297
347
|
if (grew) saveOffsets(store, offsets);
|
|
348
|
+
verifySource();
|
|
349
|
+
} finally { closeSync(fd); }
|
|
298
350
|
}
|
|
299
351
|
saveOffsets(store, offsets);
|
|
300
352
|
return {
|
|
@@ -303,6 +355,9 @@ export function captureSessions(store, { owner, roots, format = "cc", ignore = n
|
|
|
303
355
|
unchanged,
|
|
304
356
|
held,
|
|
305
357
|
ignored,
|
|
358
|
+
incomplete,
|
|
359
|
+
complete: held === 0 && incomplete === 0,
|
|
360
|
+
issues,
|
|
306
361
|
streams: streams.size,
|
|
307
362
|
stream: `${owner}~${fmt.source}.*`,
|
|
308
363
|
};
|