@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.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/bin/oats.mjs +3294 -0
- package/capabilities/oats-authoring/oats.json +12 -0
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +297 -0
- package/capabilities/oats-aweb/injects/aweb.md +55 -0
- package/capabilities/oats-aweb/oats.json +47 -0
- package/capabilities/oats-aweb/package.json +20 -0
- package/capabilities/oats-jira/bin/oats-jira.mjs +40 -0
- package/capabilities/oats-jira/injects/jira.md +10 -0
- package/capabilities/oats-jira/oats.json +22 -0
- package/capabilities/oats-jira/package.json +25 -0
- package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +176 -0
- package/capabilities/oats-linear/README.md +234 -0
- package/capabilities/oats-linear/bin/oats-linear-hook.mjs +32 -0
- package/capabilities/oats-linear/bin/oats-linear.mjs +344 -0
- package/capabilities/oats-linear/injects/linear.md +8 -0
- package/capabilities/oats-linear/oats.json +24 -0
- package/capabilities/oats-linear/package.json +29 -0
- package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +217 -0
- package/capabilities/oats-linear/test/oats-linear.test.mjs +168 -0
- package/capabilities/oats-okf/agents/memory-harvest.md +25 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +283 -0
- package/capabilities/oats-okf/injects/okf.md +62 -0
- package/capabilities/oats-okf/oats.json +20 -0
- package/capabilities/oats-okf/package.json +22 -0
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +100 -0
- package/capabilities/oats-okf/skills/okf/SKILL.md +107 -0
- package/capabilities/oats-okf/skills/okf/scripts/okf-validate.mjs +123 -0
- package/capabilities/oats-review/agents/reviewer/AGENTS.md +53 -0
- package/capabilities/oats-review/agents/reviewer/soul.yaml +6 -0
- package/capabilities/oats-review/injects/review.md +68 -0
- package/capabilities/oats-review/oats.json +10 -0
- package/capabilities/oats-review/skills/code-review/SKILL.md +44 -0
- package/capabilities/oats-review/skills/security-review/SKILL.md +59 -0
- package/docs/capabilities.md +505 -0
- package/docs/capability-manifest.schema.json +223 -0
- package/docs/configuration.md +482 -0
- package/docs/conventions.md +73 -0
- package/docs/design/desktop-ux-plan.md +362 -0
- package/docs/design/package-engine-contract.md +813 -0
- package/docs/design/package-runtime-api.md +414 -0
- package/docs/desktop-cli-api.md +89 -0
- package/docs/desktop-succession.md +51 -0
- package/docs/desktop.md +187 -0
- package/docs/implementation.md +282 -0
- package/docs/integrations.md +123 -0
- package/docs/knowledge-theory.md +97 -0
- package/docs/knowledge.md +139 -0
- package/docs/layers.md +110 -0
- package/docs/migration-from-oas.md +122 -0
- package/docs/oats-config.schema.json +140 -0
- package/docs/oats-lock.schema.json +175 -0
- package/docs/oats-package.schema.json +129 -0
- package/docs/packages.md +442 -0
- package/docs/release-lane.md +132 -0
- package/docs/release-notes/v0.18.2.md +79 -0
- package/docs/release-notes/v0.18.3.md +63 -0
- package/docs/release-notes/v0.18.4.md +65 -0
- package/docs/release-notes/v0.18.5.md +66 -0
- package/docs/release-notes/v0.18.6.md +87 -0
- package/docs/release-notes/v0.19.0.md +186 -0
- package/docs/release-notes/v0.19.1.md +24 -0
- package/docs/release-notes/v0.19.2.md +32 -0
- package/docs/release-notes/v0.19.3.md +16 -0
- package/docs/release-notes/v0.19.4.md +12 -0
- package/docs/release-notes/v0.20.0.md +89 -0
- package/docs/release-notes/v0.22.0.md +180 -0
- package/docs/souls-and-instances.md +344 -0
- package/injects/framework-workspace.md +7 -0
- package/injects/instance-boundary.md +43 -0
- package/injects/local-soul.md +19 -0
- package/injects/oats.md +11 -0
- package/injects/work-attached.md +16 -0
- package/injects/work-checkout.md +12 -0
- package/injects/work-workspace.md +22 -0
- package/injects/work-worktree.md +13 -0
- package/lib/core.mjs +6792 -0
- package/lib/packages.mjs +1375 -0
- package/lib/tmux-config.mjs +35 -0
- package/package-catalog.json +44 -0
- package/package.json +55 -0
- package/packages/record/README.md +151 -0
- package/packages/record/bin/capture.mjs +256 -0
- package/packages/record/bin/recall.mjs +102 -0
- package/packages/record/bin/setup.mjs +281 -0
- package/packages/record/bin/turn-record.mjs +47 -0
- package/packages/record/docs/turn-record-sot.md +350 -0
- package/packages/record/lib/canonical.mjs +151 -0
- package/packages/record/lib/capture-aw.mjs +179 -0
- package/packages/record/lib/capture-cc.mjs +322 -0
- package/packages/record/lib/formats.mjs +296 -0
- package/packages/record/lib/ignore.mjs +155 -0
- package/packages/record/lib/index-db.mjs +524 -0
- package/packages/record/lib/project-aweb.mjs +251 -0
- package/packages/record/lib/segments.mjs +186 -0
- package/packages/record/lib/store.mjs +447 -0
- package/packages/record/lib/tags.mjs +114 -0
- package/packages/record/package.json +45 -0
- package/packages/record/test/vectors/README.md +34 -0
- package/packages/record/test/vectors/aweb-projection-v1.json +231 -0
- package/packages/record/test/vectors/journal-merge-v1.json +261 -0
- package/packages/record/test/vectors/turn-id-v1.json +152 -0
- package/packages/record/test/vectors/validate.mjs +391 -0
- package/skills/integration-authoring/SKILL.md +76 -0
- package/skills/oats/SKILL.md +151 -0
- package/skills/oats-config/SKILL.md +162 -0
- package/skills/oats-getting-started/SKILL.md +159 -0
- package/skills/oats-packages/SKILL.md +180 -0
- package/skills/oats-support/SKILL.md +79 -0
- package/skills/skill-craft/SKILL.md +109 -0
- 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
|
+
}
|