@awebai/oats 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/bin/oats.mjs +3294 -0
  4. package/capabilities/oats-authoring/oats.json +12 -0
  5. package/capabilities/oats-aweb/bin/oats-aweb.mjs +297 -0
  6. package/capabilities/oats-aweb/injects/aweb.md +55 -0
  7. package/capabilities/oats-aweb/oats.json +47 -0
  8. package/capabilities/oats-aweb/package.json +20 -0
  9. package/capabilities/oats-jira/bin/oats-jira.mjs +40 -0
  10. package/capabilities/oats-jira/injects/jira.md +10 -0
  11. package/capabilities/oats-jira/oats.json +22 -0
  12. package/capabilities/oats-jira/package.json +25 -0
  13. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +176 -0
  14. package/capabilities/oats-linear/README.md +234 -0
  15. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +32 -0
  16. package/capabilities/oats-linear/bin/oats-linear.mjs +344 -0
  17. package/capabilities/oats-linear/injects/linear.md +8 -0
  18. package/capabilities/oats-linear/oats.json +24 -0
  19. package/capabilities/oats-linear/package.json +29 -0
  20. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +217 -0
  21. package/capabilities/oats-linear/test/oats-linear.test.mjs +168 -0
  22. package/capabilities/oats-okf/agents/memory-harvest.md +25 -0
  23. package/capabilities/oats-okf/bin/oats-okf.mjs +283 -0
  24. package/capabilities/oats-okf/injects/okf.md +62 -0
  25. package/capabilities/oats-okf/oats.json +20 -0
  26. package/capabilities/oats-okf/package.json +22 -0
  27. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +100 -0
  28. package/capabilities/oats-okf/skills/okf/SKILL.md +107 -0
  29. package/capabilities/oats-okf/skills/okf/scripts/okf-validate.mjs +123 -0
  30. package/capabilities/oats-review/agents/reviewer/AGENTS.md +53 -0
  31. package/capabilities/oats-review/agents/reviewer/soul.yaml +6 -0
  32. package/capabilities/oats-review/injects/review.md +68 -0
  33. package/capabilities/oats-review/oats.json +10 -0
  34. package/capabilities/oats-review/skills/code-review/SKILL.md +44 -0
  35. package/capabilities/oats-review/skills/security-review/SKILL.md +59 -0
  36. package/docs/capabilities.md +505 -0
  37. package/docs/capability-manifest.schema.json +223 -0
  38. package/docs/configuration.md +482 -0
  39. package/docs/conventions.md +73 -0
  40. package/docs/design/desktop-ux-plan.md +362 -0
  41. package/docs/design/package-engine-contract.md +813 -0
  42. package/docs/design/package-runtime-api.md +414 -0
  43. package/docs/desktop-cli-api.md +89 -0
  44. package/docs/desktop-succession.md +51 -0
  45. package/docs/desktop.md +187 -0
  46. package/docs/implementation.md +282 -0
  47. package/docs/integrations.md +123 -0
  48. package/docs/knowledge-theory.md +97 -0
  49. package/docs/knowledge.md +139 -0
  50. package/docs/layers.md +110 -0
  51. package/docs/migration-from-oas.md +122 -0
  52. package/docs/oats-config.schema.json +140 -0
  53. package/docs/oats-lock.schema.json +175 -0
  54. package/docs/oats-package.schema.json +129 -0
  55. package/docs/packages.md +442 -0
  56. package/docs/release-lane.md +132 -0
  57. package/docs/release-notes/v0.18.2.md +79 -0
  58. package/docs/release-notes/v0.18.3.md +63 -0
  59. package/docs/release-notes/v0.18.4.md +65 -0
  60. package/docs/release-notes/v0.18.5.md +66 -0
  61. package/docs/release-notes/v0.18.6.md +87 -0
  62. package/docs/release-notes/v0.19.0.md +186 -0
  63. package/docs/release-notes/v0.19.1.md +24 -0
  64. package/docs/release-notes/v0.19.2.md +32 -0
  65. package/docs/release-notes/v0.19.3.md +16 -0
  66. package/docs/release-notes/v0.19.4.md +12 -0
  67. package/docs/release-notes/v0.20.0.md +89 -0
  68. package/docs/release-notes/v0.22.0.md +180 -0
  69. package/docs/souls-and-instances.md +344 -0
  70. package/injects/framework-workspace.md +7 -0
  71. package/injects/instance-boundary.md +43 -0
  72. package/injects/local-soul.md +19 -0
  73. package/injects/oats.md +11 -0
  74. package/injects/work-attached.md +16 -0
  75. package/injects/work-checkout.md +12 -0
  76. package/injects/work-workspace.md +22 -0
  77. package/injects/work-worktree.md +13 -0
  78. package/lib/core.mjs +6792 -0
  79. package/lib/packages.mjs +1375 -0
  80. package/lib/tmux-config.mjs +35 -0
  81. package/package-catalog.json +44 -0
  82. package/package.json +55 -0
  83. package/packages/record/README.md +151 -0
  84. package/packages/record/bin/capture.mjs +256 -0
  85. package/packages/record/bin/recall.mjs +102 -0
  86. package/packages/record/bin/setup.mjs +281 -0
  87. package/packages/record/bin/turn-record.mjs +47 -0
  88. package/packages/record/docs/turn-record-sot.md +350 -0
  89. package/packages/record/lib/canonical.mjs +151 -0
  90. package/packages/record/lib/capture-aw.mjs +179 -0
  91. package/packages/record/lib/capture-cc.mjs +322 -0
  92. package/packages/record/lib/formats.mjs +296 -0
  93. package/packages/record/lib/ignore.mjs +155 -0
  94. package/packages/record/lib/index-db.mjs +524 -0
  95. package/packages/record/lib/project-aweb.mjs +251 -0
  96. package/packages/record/lib/segments.mjs +186 -0
  97. package/packages/record/lib/store.mjs +447 -0
  98. package/packages/record/lib/tags.mjs +114 -0
  99. package/packages/record/package.json +45 -0
  100. package/packages/record/test/vectors/README.md +34 -0
  101. package/packages/record/test/vectors/aweb-projection-v1.json +231 -0
  102. package/packages/record/test/vectors/journal-merge-v1.json +261 -0
  103. package/packages/record/test/vectors/turn-id-v1.json +152 -0
  104. package/packages/record/test/vectors/validate.mjs +391 -0
  105. package/skills/integration-authoring/SKILL.md +76 -0
  106. package/skills/oats/SKILL.md +151 -0
  107. package/skills/oats-config/SKILL.md +162 -0
  108. package/skills/oats-getting-started/SKILL.md +159 -0
  109. package/skills/oats-packages/SKILL.md +180 -0
  110. package/skills/oats-support/SKILL.md +79 -0
  111. package/skills/skill-craft/SKILL.md +109 -0
  112. package/skills/soul-craft/SKILL.md +109 -0
@@ -0,0 +1,151 @@
1
+ // Canonical JSON, turn ids, and signature verification for turn.jsonl v1.
2
+ // Contract: docs/turn-record-sot.md in this package. The byte rules intentionally
3
+ // match awid message signing (sorted keys, minimal separators, no HTML
4
+ // escaping, ensure_ascii=false) so the same canonical form serves both.
5
+
6
+ import { createHash, createPublicKey, verify as edVerify } from "node:crypto";
7
+
8
+ export class CanonicalError extends Error {}
9
+
10
+ // Canonical JSON serialization. Numbers must be integers within the safe
11
+ // range: float serialization is not canonical across languages, so floats
12
+ // are forbidden anywhere in a turn's canonical core.
13
+ export function canonicalJson(value) {
14
+ if (value === null) return "null";
15
+ const t = typeof value;
16
+ if (t === "string") return JSON.stringify(value);
17
+ if (t === "boolean") return value ? "true" : "false";
18
+ if (t === "number") {
19
+ if (!Number.isInteger(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER) {
20
+ throw new CanonicalError(`non-integer number in canonical core: ${value}`);
21
+ }
22
+ return String(value);
23
+ }
24
+ if (Array.isArray(value)) {
25
+ return "[" + value.map(canonicalJson).join(",") + "]";
26
+ }
27
+ if (t === "object") {
28
+ const keys = Object.keys(value).sort();
29
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalJson(value[k])).join(",") + "}";
30
+ }
31
+ throw new CanonicalError(`unsupported value in canonical core: ${t}`);
32
+ }
33
+
34
+ // Canonical core = the turn without `id` and `sig`.
35
+ export function coreString(turn) {
36
+ const core = {};
37
+ for (const k of Object.keys(turn)) {
38
+ if (k !== "id" && k !== "sig") core[k] = turn[k];
39
+ }
40
+ return canonicalJson(core);
41
+ }
42
+
43
+ export function sha256Hex(data) {
44
+ return createHash("sha256").update(data).digest("hex");
45
+ }
46
+
47
+ export function turnId(turn) {
48
+ return "t1:" + sha256Hex(Buffer.from(coreString(turn), "utf8"));
49
+ }
50
+
51
+ // Return a copy of `core` (a turn without id/sig) with its computed id.
52
+ export function finishTurn(core) {
53
+ return { ...core, id: turnId(core) };
54
+ }
55
+
56
+ export function verifyTurnId(turn) {
57
+ return typeof turn.id === "string" && turnId(turn) === turn.id;
58
+ }
59
+
60
+ // --------------------------------------------------------------- did:key
61
+
62
+ const B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
63
+
64
+ export function base58Decode(s) {
65
+ let n = 0n;
66
+ for (const c of s) {
67
+ const i = B58_ALPHABET.indexOf(c);
68
+ if (i < 0) throw new CanonicalError(`invalid base58 character ${JSON.stringify(c)}`);
69
+ n = n * 58n + BigInt(i);
70
+ }
71
+ const bytes = [];
72
+ while (n > 0n) {
73
+ bytes.push(Number(n & 0xffn));
74
+ n >>= 8n;
75
+ }
76
+ bytes.reverse();
77
+ for (const c of s) {
78
+ if (c === "1") bytes.unshift(0);
79
+ else break;
80
+ }
81
+ return Uint8Array.from(bytes);
82
+ }
83
+
84
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
85
+
86
+ export function publicKeyFromDidKey(did) {
87
+ if (!did.startsWith("did:key:z")) throw new CanonicalError(`unsupported did ${did}`);
88
+ const decoded = base58Decode(did.slice("did:key:z".length));
89
+ if (decoded.length !== 34 || decoded[0] !== 0xed || decoded[1] !== 0x01) {
90
+ throw new CanonicalError(`not an ed25519 did:key: ${did}`);
91
+ }
92
+ return createPublicKey({
93
+ key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(decoded.subarray(2))]),
94
+ format: "der",
95
+ type: "spki",
96
+ });
97
+ }
98
+
99
+ // Base64 signatures arrive unpadded, in either the standard or URL-safe
100
+ // alphabet (awid accepts both; so do we).
101
+ export function base64DecodeLoose(s) {
102
+ const normalized = s.replaceAll("-", "+").replaceAll("_", "/");
103
+ const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
104
+ return Buffer.from(padded, "base64");
105
+ }
106
+
107
+ // Verify an Ed25519 signature by a did:key over utf-8 payload text.
108
+ // Returns boolean; malformed inputs are a failed verification, not a throw.
109
+ export function verifyDidKeySignature(did, payloadText, signatureB64) {
110
+ try {
111
+ return edVerify(
112
+ null,
113
+ Buffer.from(payloadText, "utf8"),
114
+ publicKeyFromDidKey(did),
115
+ base64DecodeLoose(signatureB64),
116
+ );
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ // Verify a turn's envelope signature (`sig` over the canonical core).
123
+ export function verifyTurnSig(turn) {
124
+ if (!turn.sig) return false;
125
+ return verifyDidKeySignature(turn.sig.by, coreString(turn), turn.sig.sig);
126
+ }
127
+
128
+ // Value equality via canonical bytes — `to` may legally be an array, so a
129
+ // reference compare would flag every multi-recipient turn read from disk.
130
+ function sameValue(a, b) {
131
+ if (a === b) return true;
132
+ if (a === undefined || b === undefined) return false;
133
+ return canonicalJson(a) === canonicalJson(b);
134
+ }
135
+
136
+ // Consistency rule for turns that carry a source signed_payload: readable
137
+ // duplicated fields must equal the signed_payload fields (the payload is
138
+ // authoritative). Throws CanonicalError on violation.
139
+ export function checkSignedConsistency(turn) {
140
+ const p = JSON.parse(turn.signed_payload);
141
+ const pairs = [
142
+ [turn.ts, p.timestamp, "ts/timestamp"],
143
+ [turn.from, p.from, "from"],
144
+ [turn.to, p.to, "to"],
145
+ [turn.body?.subject, p.subject, "body.subject/subject"],
146
+ [turn.body?.text, p.body, "body.text/body"],
147
+ ];
148
+ for (const [a, b, what] of pairs) {
149
+ if (!sameValue(a, b)) throw new CanonicalError(`signed consistency violation on ${what}`);
150
+ }
151
+ }
@@ -0,0 +1,179 @@
1
+ // Capture of aw client logs into the record.
2
+ //
3
+ // Sources:
4
+ // ~/.config/aw/logs/<account>.jsonl signed-client comm log (mail + chat)
5
+ // <workspace>/.aw/interaction-log.jsonl
6
+ //
7
+ // All aw-log turns from one machine land in one stream, `<owner>~aw` —
8
+ // the stream is the writer (this machine's capture process), not the
9
+ // account; account and file identity live in each turn's provenance.
10
+ // Projection is deterministic, so the same entry captured on two machines
11
+ // dedupes by id. Reconciliation is the capture: scan, project, append what
12
+ // is new, batched with a single fsync per pass.
13
+
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
15
+ import { basename, dirname, join } from "node:path";
16
+ import { homedir } from "node:os";
17
+
18
+ import {
19
+ projectCommLogEntry,
20
+ projectInteractionLogEntry,
21
+ } from "./project-aweb.mjs";
22
+ import { loadIgnore } from "./ignore.mjs";
23
+
24
+ export function defaultCommLogDir(home = homedir()) {
25
+ return join(home, ".config", "aw", "logs");
26
+ }
27
+
28
+ export function awStream(owner) {
29
+ return `${owner}~aw`;
30
+ }
31
+
32
+ export function listCommLogs(dir = defaultCommLogDir()) {
33
+ if (!existsSync(dir)) return [];
34
+ return readdirSync(dir)
35
+ .filter((name) => name.endsWith(".jsonl"))
36
+ .sort()
37
+ .map((name) => join(dir, name));
38
+ }
39
+
40
+ function readEntries(path) {
41
+ const entries = [];
42
+ let skipped = 0;
43
+ const text = readFileSync(path, "utf8");
44
+ const lines = text.split("\n");
45
+ for (let i = 0; i < lines.length; i++) {
46
+ const line = lines[i];
47
+ if (line.trim() === "") continue;
48
+ try {
49
+ entries.push(JSON.parse(line));
50
+ } catch {
51
+ // A torn final line is expected while the client is writing; an
52
+ // interior bad line is skipped and counted, never fatal to capture.
53
+ if (i < lines.length - 1) skipped++;
54
+ }
55
+ }
56
+ return { entries, skipped };
57
+ }
58
+
59
+ function projectFile(entries, project) {
60
+ const turns = [];
61
+ let failed = 0;
62
+ for (const entry of entries) {
63
+ try {
64
+ turns.push(project(entry));
65
+ } catch {
66
+ // One unprojectable entry must not abort the pass, but it fails
67
+ // visibly: counted here and reported by the caller.
68
+ failed++;
69
+ }
70
+ }
71
+ return { turns, failed };
72
+ }
73
+
74
+ // Capture one comm-log file into `<owner>~aw`. The account name is the
75
+ // filename stem. `knownIds` carries the stream's ids across files in a pass.
76
+ //
77
+ // Deliberately does NOT consult the ignore list: this is an explicit
78
+ // "capture this file" command, and the caller has named the file. The
79
+ // `<root>/ignore` policy is enforced at the pass level (captureAwLogs),
80
+ // which is the only entry point the capture bin uses.
81
+ export function captureCommLog(store, { owner, path, knownIds = null }) {
82
+ const account = basename(path, ".jsonl");
83
+ const streamId = awStream(owner);
84
+ const ids = knownIds ?? new Set(store.readStream(streamId).map((t) => t.id));
85
+ const { entries, skipped } = readEntries(path);
86
+ const { turns, failed } = projectFile(entries, (e) =>
87
+ projectCommLogEntry(e, { selfName: account }),
88
+ );
89
+ const fresh = [];
90
+ for (const turn of turns) {
91
+ if (ids.has(turn.id)) continue;
92
+ ids.add(turn.id);
93
+ fresh.push(turn);
94
+ }
95
+ store.appendBatch(streamId, fresh);
96
+ return {
97
+ account,
98
+ stream: streamId,
99
+ entries: entries.length,
100
+ appended: fresh.length,
101
+ skipped,
102
+ failed,
103
+ };
104
+ }
105
+
106
+ // Capture one workspace interaction log into `<owner>~aw`. Like
107
+ // captureCommLog, this deliberately bypasses the ignore list: it is an
108
+ // explicit per-file command; pass-level entry points enforce the policy.
109
+ export function captureInteractionLog(store, { owner, path, selfName, workspace, knownIds = null }) {
110
+ const streamId = awStream(owner);
111
+ const ids = knownIds ?? new Set(store.readStream(streamId).map((t) => t.id));
112
+ const { entries, skipped } = readEntries(path);
113
+ const { turns, failed } = projectFile(entries, (e) =>
114
+ projectInteractionLogEntry(e, { selfName, workspace }),
115
+ );
116
+ const fresh = [];
117
+ for (const turn of turns) {
118
+ if (ids.has(turn.id)) continue;
119
+ ids.add(turn.id);
120
+ fresh.push(turn);
121
+ }
122
+ store.appendBatch(streamId, fresh);
123
+ return { stream: streamId, entries: entries.length, appended: fresh.length, skipped, failed };
124
+ }
125
+
126
+ // Derived seen-files cache (same pattern as capture-cc): skip a log file
127
+ // whose size+mtime match the last pass without reading or re-projecting it.
128
+ function seenCachePath(store) {
129
+ return join(store.root, "index", "capture-aw-seen.json");
130
+ }
131
+
132
+ function loadSeenCache(store) {
133
+ try {
134
+ return JSON.parse(readFileSync(seenCachePath(store), "utf8"));
135
+ } catch {
136
+ return {};
137
+ }
138
+ }
139
+
140
+ function saveSeenCache(store, cache) {
141
+ const path = seenCachePath(store);
142
+ mkdirSync(dirname(path), { recursive: true });
143
+ writeFileSync(path, JSON.stringify(cache));
144
+ }
145
+
146
+ // One reconciliation pass over every default comm log. The stream's known
147
+ // ids are read once and shared across files; unchanged files are skipped.
148
+ //
149
+ // Files matching the record's ignore list (`<root>/ignore`, see ignore.mjs)
150
+ // are skipped before being opened — no turns, no seen-cache entry — and
151
+ // reported as `{ account, path, ignored: true }` so a pass stays visible
152
+ // about what it refused to read.
153
+ export function captureAwLogs(store, { owner, commLogDir, ignore = null } = {}) {
154
+ const streamId = awStream(owner);
155
+ const ign = ignore ?? loadIgnore(store.root);
156
+ let knownIds = null; // lazy: only read the journal if some file changed
157
+ const seen = loadSeenCache(store);
158
+ const results = [];
159
+ for (const path of listCommLogs(commLogDir ?? defaultCommLogDir())) {
160
+ const account = basename(path, ".jsonl");
161
+ if (ign.ignores(path, [basename(path), account])) {
162
+ results.push({ account, path, ignored: true });
163
+ continue; // never opened: nothing stored, nothing remembered
164
+ }
165
+ let stat;
166
+ try {
167
+ stat = statSync(path);
168
+ } catch {
169
+ continue;
170
+ }
171
+ const prev = seen[path];
172
+ if (prev && prev.size === stat.size && prev.mtimeMs === stat.mtimeMs) continue;
173
+ if (!knownIds) knownIds = new Set(store.readStream(streamId).map((t) => t.id));
174
+ results.push(captureCommLog(store, { owner, path, knownIds }));
175
+ seen[path] = { size: stat.size, mtimeMs: stat.mtimeMs };
176
+ }
177
+ saveSeenCache(store, seen);
178
+ return results;
179
+ }
@@ -0,0 +1,322 @@
1
+ // Capture of session transcripts into the record — Claude Code, pi, and
2
+ // Codex (the format registry in formats.mjs says where transcripts live
3
+ // and how to name sessions).
4
+ //
5
+ // Corrected model (Juan, 2026-08-19): sessions are turns like everything
6
+ // else — never file snapshots. Every native transcript record (a cc JSONL
7
+ // line, a pi record, a codex record) becomes ONE turn whose body is the
8
+ // verbatim native line text. Each session gets its own stream
9
+ // (`<owner>~<source>.<session-id>`), so journals stay bounded by their
10
+ // conversation, and capture appends each event exactly once as the file
11
+ // grows — storage is linear in conversation size by construction. The
12
+ // original transcript is reconstructible by concatenating body.line.
13
+ //
14
+ // Incremental by source byte offset (cache under index/, derived state:
15
+ // losing it is recovered from the journal's own last line number, never
16
+ // by re-appending). Reconciliation is the capture: hooks and watchers
17
+ // only decide when to run it.
18
+ //
19
+ // Files matching the record's ignore list (`<root>/ignore`, see
20
+ // ignore.mjs) are skipped before being opened: no turn, no offset entry —
21
+ // un-ignoring a file later makes the next pass capture it normally.
22
+
23
+ import { closeSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
24
+ import { basename, dirname, join } from "node:path";
25
+
26
+ import { finishTurn } from "./canonical.mjs";
27
+ import { jsonlLines, SESSION_FORMATS } from "./formats.mjs";
28
+ import { loadIgnore } from "./ignore.mjs";
29
+
30
+ export const SESSION_STREAM_SOURCE = "cc";
31
+
32
+ // Default transcript roots for Claude Code (kept for compatibility; the
33
+ // per-format defaults live in formats.mjs).
34
+ export function defaultSessionRoots(home = undefined) {
35
+ return SESSION_FORMATS.cc.defaultRoots(home);
36
+ }
37
+
38
+ export function listSessionFiles(roots) {
39
+ return SESSION_FORMATS.cc.listFiles(roots);
40
+ }
41
+
42
+ // Last event timestamp + event count of transcript bytes (utility).
43
+ export function scanTranscript(bytes) {
44
+ let ts = null;
45
+ let events = 0;
46
+ for (const { text } of jsonlLines(bytes)) {
47
+ if (text !== null && text.trim() === "") continue;
48
+ events++;
49
+ if (text === null) continue;
50
+ try {
51
+ const d = JSON.parse(text);
52
+ if (typeof d.timestamp === "string") ts = d.timestamp;
53
+ } catch {
54
+ /* verbatim content; nothing to extract */
55
+ }
56
+ }
57
+ return { ts, events };
58
+ }
59
+
60
+ // The turn core for one native transcript event.
61
+ export function eventTurnCore({ owner, source, sessionId, line, lineNo, ts }) {
62
+ return {
63
+ v: 1,
64
+ ts,
65
+ from: owner,
66
+ thread: `${source}:session:${sessionId}`,
67
+ kind: "session",
68
+ body: { line },
69
+ provenance: {
70
+ source,
71
+ fidelity: "verbatim",
72
+ origin: { session_id: sessionId, line: lineNo },
73
+ },
74
+ };
75
+ }
76
+
77
+ // Offset cache: `<stream>:<absolute source path>` -> { bytes consumed,
78
+ // lines emitted, last carried timestamp }. Derived state under index/.
79
+ function offsetsPath(store) {
80
+ return join(store.root, "index", "capture-offsets.json");
81
+ }
82
+
83
+ function loadOffsets(store) {
84
+ try {
85
+ return JSON.parse(readFileSync(offsetsPath(store), "utf8"));
86
+ } catch {
87
+ return {};
88
+ }
89
+ }
90
+
91
+ function saveOffsets(store, offsets) {
92
+ const path = offsetsPath(store);
93
+ mkdirSync(dirname(path), { recursive: true });
94
+ writeFileSync(path, JSON.stringify(offsets));
95
+ }
96
+
97
+ // Read bytes of `path` in [start, size).
98
+ function readFrom(path, start, size) {
99
+ const fd = openSync(path, "r");
100
+ try {
101
+ const buf = Buffer.alloc(size - start);
102
+ let done = 0;
103
+ while (done < buf.length) {
104
+ const n = readSync(fd, buf, done, buf.length - done, start + done);
105
+ if (n === 0) break;
106
+ done += n;
107
+ }
108
+ return buf.subarray(0, done);
109
+ } finally {
110
+ closeSync(fd);
111
+ }
112
+ }
113
+
114
+ // The journal's last captured source line, read from its tail without
115
+ // parsing the whole file (backward scan, doubling window). 0 when the
116
+ // journal is missing or empty.
117
+ function lastJournalLine(store, streamId) {
118
+ const path = store.journalPath(streamId);
119
+ let size;
120
+ try {
121
+ size = statSync(path).size;
122
+ } catch {
123
+ return 0;
124
+ }
125
+ let window = 64 * 1024;
126
+ while (true) {
127
+ const start = Math.max(0, size - window);
128
+ const tail = readFrom(path, start, size);
129
+ // Last complete line: ignore a torn final line (no trailing newline).
130
+ const text = tail.toString("utf8");
131
+ const endsClean = text.endsWith("\n");
132
+ const parts = text.split("\n").filter((l) => l.trim() !== "");
133
+ const candidates = endsClean ? parts : parts.slice(0, -1);
134
+ for (let i = candidates.length - 1; i >= 0; i--) {
135
+ // The first line of the window may be a fragment; only trust a line
136
+ // we know is whole (preceded by a newline inside the window, or the
137
+ // window covers the whole file).
138
+ if (i === 0 && start > 0) break;
139
+ try {
140
+ return JSON.parse(candidates[i]).provenance?.origin?.line ?? 0;
141
+ } catch {
142
+ continue; // fragment or torn line: look further back
143
+ }
144
+ }
145
+ if (start === 0) return 0;
146
+ window *= 2;
147
+ }
148
+ }
149
+
150
+ // Rebuild a lost offset from the journal: its last turn knows the line
151
+ // number it came from; walk the source to that line's byte offset.
152
+ // Honest limit: an in-place REWRITE of already-captured lines is not
153
+ // detected (only growth is; a shrink triggers a rescan via the size
154
+ // check in the caller). Transcript writers are append-only in practice.
155
+ function offsetFromJournal(store, streamId, sourcePath) {
156
+ const turns = store.readStream(streamId);
157
+ if (turns.length === 0) return { bytes: 0, line: 0, lastTs: "" };
158
+ const last = turns[turns.length - 1];
159
+ const lastLine = last.provenance?.origin?.line ?? 0;
160
+ const bytes = readFileSync(sourcePath);
161
+ let line = 0;
162
+ let offset = 0;
163
+ while (line < lastLine && offset < bytes.length) {
164
+ const nl = bytes.indexOf(10, offset);
165
+ if (nl === -1) break;
166
+ line++;
167
+ offset = nl + 1;
168
+ }
169
+ return { bytes: offset, line, lastTs: last.ts ?? "" };
170
+ }
171
+
172
+ // One reconciliation pass for one format: one turn per NEW complete line
173
+ // of every session file under `roots`. Unstamped leading lines are held
174
+ // until the file shows its first timestamp (then they carry it forward),
175
+ // so every turn is stamped and ts stays a pure function of the source.
176
+ export function captureSessions(store, { owner, roots, format = "cc", ignore = null }) {
177
+ const fmt = SESSION_FORMATS[format];
178
+ if (!fmt) throw new Error(`unknown session format ${format}`);
179
+ const ign = ignore ?? loadIgnore(store.root);
180
+ const offsets = loadOffsets(store);
181
+ let sessions = 0;
182
+ let appended = 0;
183
+ let unchanged = 0;
184
+ let held = 0;
185
+ let ignored = 0;
186
+ const streams = new Set();
187
+
188
+ for (const path of fmt.listFiles(roots)) {
189
+ sessions++;
190
+ const sessionId = fmt.sessionId(path);
191
+ if (ign.ignores(path, [basename(path), sessionId])) {
192
+ ignored++;
193
+ continue; // never opened: nothing stored, nothing remembered
194
+ }
195
+ let stat;
196
+ try {
197
+ stat = statSync(path);
198
+ } catch {
199
+ continue; // vanished between listing and stat; next pass catches it
200
+ }
201
+ const streamId = `${owner}~${fmt.source}.${sessionId}`;
202
+ // Keyed by stream, not by source path: the same source captured under
203
+ // two owners must not share offset state (owner is part of the stream).
204
+ const offKey = `${streamId}:${path}`;
205
+ let state = offsets[offKey];
206
+ if (state && stat.size < state.bytes) state = null; // source shrank: rescan, no guesswork
207
+ // The offset cache is derived state and can disagree with the journal:
208
+ // behind it when another pass appended meanwhile (re-appending would
209
+ // duplicate lines), ahead of it when a pass raced a stream wipe and
210
+ // saved offsets for appends that landed in an unlinked inode (skipping
211
+ // would lose lines — this happened during the live migration). The
212
+ // journal is the truth; before appending anything, any disagreement
213
+ // rebuilds the offset from it (checked only when the source grew:
214
+ // an unchanged file appends nothing, so its cache cannot mislead).
215
+ if (state && stat.size > state.bytes && state.line !== lastJournalLine(store, streamId)) {
216
+ state = null;
217
+ }
218
+ if (!state) state = offsetFromJournal(store, streamId, path);
219
+ if (stat.size <= state.bytes) {
220
+ unchanged++;
221
+ offsets[offKey] = state;
222
+ continue;
223
+ }
224
+
225
+ const chunk = readFrom(path, state.bytes, stat.size);
226
+ // Phase 1: collect the COMPLETE lines of the chunk with their stamps.
227
+ const lines = [];
228
+ let scanned = 0;
229
+ for (const { text } of jsonlLines(chunk)) {
230
+ if (text === null) break; // over-string-limit line: retry later
231
+ const lineBytes = Buffer.byteLength(text, "utf8") + 1;
232
+ if (scanned + lineBytes > chunk.length) break; // no trailing newline yet
233
+ let ts = "";
234
+ if (text.trim() !== "") {
235
+ try {
236
+ const d = JSON.parse(text);
237
+ if (typeof d.timestamp === "string") ts = d.timestamp;
238
+ } catch {
239
+ /* unparseable native line: captured verbatim below */
240
+ }
241
+ }
242
+ lines.push({ text, ts, bytes: lineBytes });
243
+ scanned += lineBytes;
244
+ }
245
+ // Phase 2: every turn needs a stamp. Leading lines before the file's
246
+ // first stamp carry it backward (deterministic: the file's first
247
+ // stamp is invariant however capture is scheduled); if the file has
248
+ // shown no stamp at all yet, hold everything for a later pass.
249
+ let lastTs = state.lastTs ?? "";
250
+ if (!lastTs) {
251
+ const first = lines.find((l) => l.ts);
252
+ if (!first) {
253
+ if (lines.length > 0) held++;
254
+ continue; // do not advance; retry when a stamp exists
255
+ }
256
+ lastTs = first.ts;
257
+ }
258
+ // Turns flush to the journal in bounded batches, so memory stays flat
259
+ // however large the backlog (a first capture of a huge transcript is
260
+ // one file's worth of NEW lines). A crash between flushes cannot
261
+ // duplicate: this file's offset is saved only after its final flush,
262
+ // and a lost offset rebuilds from the journal's own last line number.
263
+ let fresh = [];
264
+ let freshBytes = 0;
265
+ const flush = () => {
266
+ if (fresh.length === 0) return;
267
+ store.appendBatch(streamId, fresh);
268
+ streams.add(streamId);
269
+ appended += fresh.length;
270
+ fresh = [];
271
+ freshBytes = 0;
272
+ };
273
+ let lineNo = state.line;
274
+ let consumed = 0;
275
+ for (const l of lines) {
276
+ if (l.ts) lastTs = l.ts;
277
+ lineNo++;
278
+ consumed += l.bytes;
279
+ // Blank lines are turns too (body.line ""): verbatim-complete means
280
+ // concatenating body.line reconstructs the source byte-exactly.
281
+ fresh.push(
282
+ finishTurn(
283
+ eventTurnCore({ owner, source: fmt.source, sessionId, line: l.text, lineNo, ts: lastTs }),
284
+ ),
285
+ );
286
+ freshBytes += l.bytes;
287
+ if (freshBytes >= 64 * 1024 * 1024) flush();
288
+ }
289
+ const grew = fresh.length > 0 || consumed > 0;
290
+ flush();
291
+ offsets[offKey] = { bytes: state.bytes + consumed, line: lineNo, lastTs };
292
+ // Persist after every file that advanced, not once per pass: a crash
293
+ // later in the pass must not leave THIS file's on-disk offset stale
294
+ // (a stale-but-present offset skips journal recovery, and the rescan
295
+ // re-appends duplicate turn lines — logically deduped by id, but
296
+ // wasted append-only bytes).
297
+ if (grew) saveOffsets(store, offsets);
298
+ }
299
+ saveOffsets(store, offsets);
300
+ return {
301
+ sessions,
302
+ appended,
303
+ unchanged,
304
+ held,
305
+ ignored,
306
+ streams: streams.size,
307
+ stream: `${owner}~${fmt.source}.*`,
308
+ };
309
+ }
310
+
311
+ // One pass over every known format at its default roots. The ignore list
312
+ // is loaded once and shared across formats.
313
+ export function captureAllSessions(store, { owner, ignore = null }) {
314
+ const ign = ignore ?? loadIgnore(store.root);
315
+ const results = [];
316
+ for (const format of Object.keys(SESSION_FORMATS)) {
317
+ const roots = SESSION_FORMATS[format].defaultRoots();
318
+ if (roots.length === 0) continue;
319
+ results.push(captureSessions(store, { owner, roots, format, ignore: ign }));
320
+ }
321
+ return results;
322
+ }