@awebai/oats 0.22.0 → 0.22.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -50
- package/bin/oats.mjs +242 -22
- package/capabilities/oats-authoring/LICENSE +21 -0
- package/capabilities/oats-authoring/oats-package.json +11 -0
- package/capabilities/oats-authoring/oats.json +4 -4
- package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +63 -0
- package/capabilities/oats-authoring/skills/skill-craft/SKILL.md +109 -0
- package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +109 -0
- package/capabilities/oats-aweb/injects/aweb.md +4 -3
- package/capabilities/oats-aweb/oats.json +7 -7
- package/capabilities/oats-aweb/skills/LICENSE +21 -0
- package/capabilities/oats-aweb/skills/VENDORED.md +26 -0
- package/capabilities/oats-aweb/skills/aweb-identity/SKILL.md +201 -0
- package/capabilities/oats-aweb/skills/aweb-messaging/SKILL.md +161 -0
- package/capabilities/oats-aweb/skills/aweb-messaging/references/messaging-scenarios.md +61 -0
- package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +328 -0
- package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +74 -0
- package/capabilities/oats-jira/oats.json +1 -1
- package/capabilities/oats-linear/oats.json +1 -1
- package/capabilities/oats-okf/agents/{memory-harvest.md → memory-harvest/AGENTS.md} +3 -1
- package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +6 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +201 -54
- package/capabilities/oats-okf/injects/okf.md +7 -0
- package/capabilities/oats-okf/oats.json +5 -2
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +42 -1
- package/capabilities/oats-review/oats.json +1 -1
- package/docs/2026-09-03-architecture-proposal.md +642 -0
- package/docs/execution-targets.md +181 -0
- package/docs/first-team-demo.md +87 -0
- package/docs/first-team.md +179 -0
- package/docs/implementation.md +14 -1
- package/docs/integrations.md +83 -65
- package/docs/layers.md +356 -80
- package/docs/migration-from-oas.md +80 -116
- package/docs/oats-config.schema.json +1 -0
- package/docs/operating-team-migration.md +217 -0
- package/docs/release-notes/v0.22.1.md +106 -0
- package/docs/release-notes/v0.22.2.md +69 -0
- package/docs/servers.md +94 -0
- package/docs/souls-and-instances.md +30 -3
- package/lib/core.mjs +626 -415
- package/lib/herdr.mjs +95 -0
- package/lib/servers.mjs +436 -0
- package/lib/session-input.mjs +78 -0
- package/lib/session-viewer.mjs +51 -0
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/packages/record/README.md +76 -16
- package/packages/record/bin/capture.mjs +59 -3
- package/packages/record/bin/recall.mjs +67 -1
- package/packages/record/docs/turn-record-sot.md +1 -1
- package/packages/record/lib/sessions-for-home.mjs +130 -0
- package/packages/record/lib/store.mjs +207 -43
- package/skills/oats/SKILL.md +6 -2
- package/capabilities/oats-aweb/package.json +0 -20
- package/capabilities/oats-jira/package.json +0 -25
- package/capabilities/oats-linear/README.md +0 -234
- package/capabilities/oats-linear/package.json +0 -29
- package/capabilities/oats-linear/test/oats-linear.test.mjs +0 -168
- package/capabilities/oats-okf/package.json +0 -22
package/package-catalog.json
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
"packages": {
|
|
3
3
|
"oats.okf": {
|
|
4
4
|
"url": "https://github.com/awebai/oats-okf.git",
|
|
5
|
-
"ref": "v1.
|
|
5
|
+
"ref": "v1.5.0",
|
|
6
6
|
"path": "oats-package"
|
|
7
7
|
},
|
|
8
8
|
"oats.aweb": {
|
|
9
9
|
"url": "https://github.com/awebai/oats-aweb.git",
|
|
10
|
-
"ref": "v1.
|
|
10
|
+
"ref": "v1.9.0",
|
|
11
11
|
"path": "oats-package"
|
|
12
12
|
},
|
|
13
13
|
"oats.jira": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.2",
|
|
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",
|
|
@@ -6,12 +6,17 @@ contract is [`docs/turn-record-sot.md`](docs/turn-record-sot.md) in this
|
|
|
6
6
|
package; the conformance vectors under `test/vectors/` pin the format
|
|
7
7
|
(`node test/vectors/validate.mjs`, dependency-free).
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
Turns in this append-only record are content-addressed. Native session turns
|
|
10
|
+
are not signed. Projected aweb mail and chat keep their original message
|
|
11
|
+
signatures verbatim.
|
|
12
|
+
|
|
13
|
+
After `turn-record setup` runs on a machine, this package gathers Claude Code,
|
|
14
|
+
Pi, and Codex transcripts plus aw client logs. It skips sources matched by the
|
|
15
|
+
local record's ignore list. The package keeps captured conversations durably
|
|
16
|
+
and finds them again. The experimental tools that select and synthesize over
|
|
17
|
+
the record (dress, spawn, segments, mind) live in `packages/experimental` of
|
|
18
|
+
the oats repo and run as `oats experimental <cmd>`. They are deliberately not
|
|
19
|
+
part of this package.
|
|
15
20
|
|
|
16
21
|
- **`lib/canonical.mjs`** — canonical JSON (integers only in the core),
|
|
17
22
|
`t1:` content ids, did:key Ed25519 verification. Byte-compatible with awid
|
|
@@ -126,16 +131,71 @@ non-prefix copy of a stream quarantines it loudly.
|
|
|
126
131
|
|
|
127
132
|
## Durability and concurrency
|
|
128
133
|
|
|
129
|
-
Appends and merges run under a per-stream lockfile
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
Appends and merges run under a per-stream lockfile (`streams/<id>/.lock`),
|
|
135
|
+
because the torn-tail repair is a read-truncate-write sequence and hooks,
|
|
136
|
+
watchers and manual passes can fire concurrently for the same owner.
|
|
137
|
+
|
|
138
|
+
The lock carries an **owner token** — the holder's pid, its hostname, and a
|
|
139
|
+
random nonce — written by hard-linking a fully-written temp file into place,
|
|
140
|
+
so a lock that exists is always readable in full. Two guarantees rest on
|
|
141
|
+
that token:
|
|
142
|
+
|
|
143
|
+
- **A live holder is never stolen from, at any age.** Staleness is proven
|
|
144
|
+
from the holder's liveness, not from how long ago the lock was created.
|
|
145
|
+
A contender that finds the holder's pid alive on this host waits, and
|
|
146
|
+
fails with a timeout naming the holder — it does not reclaim. (The
|
|
147
|
+
earlier design judged a lock stale by age while the waiter's timeout
|
|
148
|
+
started when the contender arrived; a contender arriving late therefore
|
|
149
|
+
reclaimed a lock whose holder was still inside its critical section, and
|
|
150
|
+
both ran at once.)
|
|
151
|
+
- **A holder releases only its own lock.** Release unlinks `.lock` only
|
|
152
|
+
while it still carries the releaser's nonce, so a holder whose lock was
|
|
153
|
+
reclaimed cannot delete the replacement lock out from under its new
|
|
154
|
+
owner.
|
|
155
|
+
|
|
156
|
+
**Crash recovery is immediate, not timed**: a lock whose pid is gone from
|
|
157
|
+
this host is provably stale and is reclaimed at once, without waiting out
|
|
158
|
+
any threshold.
|
|
159
|
+
|
|
160
|
+
A lock that cannot be read at all (anything but "not there" — a permission
|
|
161
|
+
error, a directory in its place) is not retried: acquisition fails at once
|
|
162
|
+
with the underlying error code and the lock's path, because retrying cannot
|
|
163
|
+
clear such a condition. Everything else that fails to acquire — including a
|
|
164
|
+
lock judged stale whose removal keeps failing — is bounded by
|
|
165
|
+
`lockTimeoutMs` and reports why on timeout.
|
|
166
|
+
|
|
167
|
+
`lockStaleMs` (30 s by default) is the fallback for the two cases where
|
|
168
|
+
liveness cannot be checked here: a lock created on **another host** (file
|
|
169
|
+
sync can copy one in) and a lock with **no readable token** (written by an
|
|
170
|
+
older version of this package, or by hand). Those are still reclaimed by
|
|
171
|
+
age. `lockTimeoutMs` (10 s) is how long a contender waits; the two
|
|
172
|
+
thresholds are independent, and neither ordering of them is required or
|
|
173
|
+
assumed.
|
|
174
|
+
|
|
175
|
+
Honest limits:
|
|
176
|
+
|
|
177
|
+
- **Pid reuse across a reboot.** A lock left by a process whose pid was
|
|
178
|
+
later reused by an unrelated live process reads as held forever, and
|
|
179
|
+
contenders on that stream time out until it is deleted by hand. The
|
|
180
|
+
timeout message names the lock path and the recorded holder. This is the
|
|
181
|
+
chosen direction of failure: refusing to write is recoverable, writing
|
|
182
|
+
concurrently with a live holder is not.
|
|
183
|
+
- **Reclaim is not perfectly atomic.** A contender re-checks the lock's
|
|
184
|
+
identity (inode and mtime) immediately before removing it. That recheck
|
|
185
|
+
**detects** a replacement that landed while the old lock was being proven
|
|
186
|
+
stale — the common cascade, one contender reclaiming from whoever
|
|
187
|
+
reclaimed first — and the contender gives up and retries. It is a
|
|
188
|
+
detection, not an exclusion: a replacement landing in the window between
|
|
189
|
+
the recheck and the unlink is not seen and is removed. Nothing here
|
|
190
|
+
defends against that window.
|
|
191
|
+
- **Sync tools may copy a `.lock` file.** That cannot corrupt data, but a
|
|
192
|
+
synced-in lock from another host is judged by age, so it can delay a local
|
|
193
|
+
`mergeStreamCopy` on that stream by up to 30 s — excluding `.lock` from
|
|
194
|
+
sync patterns remains the right configuration.
|
|
195
|
+
|
|
196
|
+
Journal writes fsync; note that on macOS `fsync(2)` does not guarantee media
|
|
197
|
+
durability (that would need `F_FULLFSYNC`, which Node's fs API does not
|
|
198
|
+
expose) — the guarantee is OS-crash-level, not power-loss-level.
|
|
139
199
|
|
|
140
200
|
## Upgrading
|
|
141
201
|
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
import { watch } from "node:fs";
|
|
16
16
|
import { homedir, hostname } from "node:os";
|
|
17
|
-
import { join } from "node:path";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
18
|
import process from "node:process";
|
|
19
19
|
|
|
20
20
|
import { RecordStore } from "../lib/store.mjs";
|
|
21
|
-
import { captureAllSessions } from "../lib/capture-cc.mjs";
|
|
21
|
+
import { captureAllSessions, captureSessions } from "../lib/capture-cc.mjs";
|
|
22
|
+
import { sessionsForHome } from "../lib/sessions-for-home.mjs";
|
|
22
23
|
import { SESSION_FORMATS } from "../lib/formats.mjs";
|
|
23
24
|
import { captureAwLogs, defaultCommLogDir } from "../lib/capture-aw.mjs";
|
|
24
25
|
import { RecordIndex } from "../lib/index-db.mjs";
|
|
@@ -41,7 +42,7 @@ function loadIgnoreOrExit(recordRoot) {
|
|
|
41
42
|
// hostname-derived owner, which forked the whole record into a second owner
|
|
42
43
|
// namespace: 571 duplicate journals from one typo. Parsing must refuse what
|
|
43
44
|
// it does not understand before anything can be written.
|
|
44
|
-
const VALUE_FLAGS = new Set(["root", "owner"]);
|
|
45
|
+
const VALUE_FLAGS = new Set(["root", "owner", "home"]);
|
|
45
46
|
const BOOL_FLAGS = new Set([
|
|
46
47
|
"watch",
|
|
47
48
|
"status",
|
|
@@ -61,6 +62,12 @@ const USAGE = `capture — land sessions and aw client logs in the turn record.
|
|
|
61
62
|
capture --watch pass now, then re-pass on filesystem change
|
|
62
63
|
(debounced) and every 15 minutes regardless
|
|
63
64
|
capture --status show store/stream summary, capture nothing
|
|
65
|
+
capture --home <dir> capture the sessions that ran inside <dir> (an
|
|
66
|
+
OATS instance home) and print them as JSON:
|
|
67
|
+
thread, stream, turn count, first/last turn id.
|
|
68
|
+
Tombstoned turns are never a boundary. Codex
|
|
69
|
+
keeps a day's rollouts in one directory, so the
|
|
70
|
+
pass captures that day; the list is filtered.
|
|
64
71
|
capture --install-hint print the Claude Code hook snippet
|
|
65
72
|
capture --help this text
|
|
66
73
|
capture --quiet suppress per-pass progress
|
|
@@ -224,6 +231,55 @@ if (args.status) {
|
|
|
224
231
|
process.exit(0);
|
|
225
232
|
}
|
|
226
233
|
|
|
234
|
+
// One instance home: capture its own sessions and report them with exact
|
|
235
|
+
// sequence boundaries (first and last captured turn id), so a consumer such
|
|
236
|
+
// as the OKF harvester can name what it read without timestamps, which tie
|
|
237
|
+
// and which late capture appends behind. Output is JSON, always: this mode
|
|
238
|
+
// exists for programs.
|
|
239
|
+
if (args.home) {
|
|
240
|
+
warnOnStrangerOwner();
|
|
241
|
+
const ignore = loadIgnoreOrExit(root);
|
|
242
|
+
const unattributed = [];
|
|
243
|
+
const found = sessionsForHome(args.home, { onUnattributed: (source, path) => unattributed.push({ source, path }) });
|
|
244
|
+
const sessions = [];
|
|
245
|
+
const dirs = new Map(); // one capture pass per (format, directory)
|
|
246
|
+
for (const s of found) dirs.set(`${s.source}\0${dirname(s.path)}`, { format: s.source, dir: dirname(s.path) });
|
|
247
|
+
let appended = 0;
|
|
248
|
+
for (const { format, dir } of dirs.values()) {
|
|
249
|
+
appended += captureSessions(store, { owner, roots: [dir], format, ignore }).appended;
|
|
250
|
+
}
|
|
251
|
+
if (appended > 0 && !args["no-index"]) {
|
|
252
|
+
const index = new RecordIndex(store);
|
|
253
|
+
try {
|
|
254
|
+
index.update();
|
|
255
|
+
} finally {
|
|
256
|
+
index.close();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// A tombstoned turn is hidden everywhere; a boundary naming one would be
|
|
260
|
+
// refused by recall, so boundaries come from the visible turns only.
|
|
261
|
+
const claims = store.tombstoneClaims();
|
|
262
|
+
for (const s of found) {
|
|
263
|
+
const stream = `${owner}~${s.source}.${s.sessionId}`;
|
|
264
|
+
const turns = store.readStream(stream).filter((t) => !store.claimHides(claims, t));
|
|
265
|
+
if (!turns.length) continue; // ignored by rule, nothing capturable yet, or all hidden
|
|
266
|
+
sessions.push({
|
|
267
|
+
thread: s.thread,
|
|
268
|
+
source: s.source,
|
|
269
|
+
sessionId: s.sessionId,
|
|
270
|
+
path: s.path,
|
|
271
|
+
cwd: s.cwd,
|
|
272
|
+
stream,
|
|
273
|
+
turns: turns.length,
|
|
274
|
+
firstTurnId: turns[0].id,
|
|
275
|
+
lastTurnId: turns[turns.length - 1].id,
|
|
276
|
+
lastTs: turns[turns.length - 1].ts,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
console.log(JSON.stringify({ home: args.home, owner, appended, sessions, ...(unattributed.length ? { unattributed } : {}) }, null, 2));
|
|
280
|
+
process.exit(0);
|
|
281
|
+
}
|
|
282
|
+
|
|
227
283
|
warnOnStrangerOwner();
|
|
228
284
|
pass();
|
|
229
285
|
|
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
// recall <query...> FTS5 query over mail, chat, sessions
|
|
5
5
|
// recall --kind mail <query...> filter by kind (mail|chat|session|note)
|
|
6
6
|
// recall --thread <thread> [query] filter/list by thread
|
|
7
|
+
// recall --thread <thread> --json the thread's turns in journal order,
|
|
8
|
+
// [--after <id>] [--until <id>] with extracted text; exact id bounds
|
|
9
|
+
// [--limit n] [--ids-only] (after is exclusive, until inclusive);
|
|
10
|
+
// --ids-only lists id, ts and the bytes
|
|
11
|
+
// each turn occupies in the --json output
|
|
12
|
+
// (text extracted but not emitted), so a
|
|
13
|
+
// consumer can size a window it will read
|
|
7
14
|
// recall --from <name> <query...> filter by speaker
|
|
8
15
|
// recall --limit N max results (default 20)
|
|
9
16
|
// recall --show <turn-id> print one turn as JSON
|
|
@@ -22,7 +29,7 @@ function parseArgs(argv) {
|
|
|
22
29
|
const args = { _: [] };
|
|
23
30
|
for (let i = 0; i < argv.length; i++) {
|
|
24
31
|
const a = argv[i];
|
|
25
|
-
if (["--root", "--kind", "--thread", "--from", "--role", "--limit", "--show"].includes(a)) {
|
|
32
|
+
if (["--root", "--kind", "--thread", "--from", "--role", "--limit", "--show", "--after", "--until"].includes(a)) {
|
|
26
33
|
args[a.slice(2)] = argv[++i];
|
|
27
34
|
} else if (a.startsWith("--")) args[a.slice(2)] = true;
|
|
28
35
|
else args._.push(a);
|
|
@@ -65,6 +72,57 @@ try {
|
|
|
65
72
|
process.exit(2);
|
|
66
73
|
}
|
|
67
74
|
|
|
75
|
+
// Thread turns straight from the journal, in capture SEQUENCE: the order a
|
|
76
|
+
// consumer can bound exactly with turn ids. Timestamps tie within a turn
|
|
77
|
+
// and late capture appends older stamps behind newer ones, so a window
|
|
78
|
+
// named by ids is the only one two passes agree on. after is exclusive,
|
|
79
|
+
// until inclusive; an unknown id is an error, never an empty answer.
|
|
80
|
+
if (args.json && args.thread && !query) {
|
|
81
|
+
// Session streams are not in readAll(), so hiddenIds() cannot see them:
|
|
82
|
+
// resolve tombstone claims once and test each session turn directly
|
|
83
|
+
// (store.mjs, tombstoneClaims). A redacted line must never reach the
|
|
84
|
+
// harvester, which promotes what it reads into a durable soul.
|
|
85
|
+
const claims = store.tombstoneClaims();
|
|
86
|
+
const turns = [];
|
|
87
|
+
const sessionStreams = store.sessionStreamsFor(args.thread);
|
|
88
|
+
for (const streamId of sessionStreams) {
|
|
89
|
+
for (const t of store.readStream(streamId)) if (!store.claimHides(claims, t)) turns.push(t);
|
|
90
|
+
}
|
|
91
|
+
if (!sessionStreams.length) {
|
|
92
|
+
// not a session thread (mail, chat, note): the bulk read has them
|
|
93
|
+
for (const { turn } of store.readAll().values()) if (turn.thread === args.thread && !store.claimHides(claims, turn)) turns.push(turn);
|
|
94
|
+
}
|
|
95
|
+
const ids = turns.map((t) => t.id);
|
|
96
|
+
let start = 0;
|
|
97
|
+
let end = turns.length;
|
|
98
|
+
if (args.after) {
|
|
99
|
+
const i = ids.indexOf(args.after);
|
|
100
|
+
if (i < 0) { console.error(`--after: no turn ${args.after} in thread ${args.thread}`); process.exit(1); }
|
|
101
|
+
start = i + 1;
|
|
102
|
+
}
|
|
103
|
+
if (args.until) {
|
|
104
|
+
const i = ids.indexOf(args.until);
|
|
105
|
+
if (i < 0) { console.error(`--until: no turn ${args.until} in thread ${args.thread}`); process.exit(1); }
|
|
106
|
+
end = i + 1;
|
|
107
|
+
}
|
|
108
|
+
const cap = args.limit ? Number(args.limit) : Infinity;
|
|
109
|
+
const stop = Math.min(end, start + cap);
|
|
110
|
+
const window = turns.slice(start, stop);
|
|
111
|
+
// A window is a bounded read: a consumer that plans one (the OKF
|
|
112
|
+
// harvester) sizes it with --ids-only first, then reads exactly that.
|
|
113
|
+
const out = window.map((t) => {
|
|
114
|
+
const docs = index.extractText(t);
|
|
115
|
+
const base = { id: t.id, ts: t.ts, thread: t.thread, kind: t.kind, source: t.provenance?.source ?? null };
|
|
116
|
+
const full = { ...base, text: docs.map((d) => ({ role: d.role, text: d.text })) };
|
|
117
|
+
// bytes = what this turn occupies in the pretty-printed --json answer,
|
|
118
|
+
// so a consumer's byte cap bounds what it will actually receive.
|
|
119
|
+
if (args["ids-only"]) return { ...base, bytes: Buffer.byteLength(JSON.stringify(full, null, 2), "utf8") + 8 };
|
|
120
|
+
return full;
|
|
121
|
+
});
|
|
122
|
+
console.log(JSON.stringify({ thread: args.thread, total: turns.length, from: start, to: stop, remaining: end - stop, turns: out }, null, 2));
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
68
126
|
const limit = args.limit ? Number(args.limit) : 20;
|
|
69
127
|
let rows;
|
|
70
128
|
if (query) {
|
|
@@ -86,10 +144,18 @@ try {
|
|
|
86
144
|
.all(args.thread, limit);
|
|
87
145
|
}
|
|
88
146
|
|
|
147
|
+
if (rows.length === 0 && args.json) {
|
|
148
|
+
console.log("[]");
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
89
151
|
if (rows.length === 0) {
|
|
90
152
|
console.error("no matches");
|
|
91
153
|
process.exit(1);
|
|
92
154
|
}
|
|
155
|
+
if (args.json) {
|
|
156
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
93
159
|
for (const r of rows) {
|
|
94
160
|
const where = r.loc ? ` @${r.loc}` : "";
|
|
95
161
|
const to = r.to_name ? ` -> ${r.to_name}` : "";
|
|
@@ -8,7 +8,7 @@ aweb mail and chat messages into it. It is the contract implemented by the
|
|
|
8
8
|
record tools (`capture`, `recall`, and the experimental tools over them); this
|
|
9
9
|
package (`@awebai/turn-record`, in the oats repo) is the reference
|
|
10
10
|
implementation, and the spec lives beside it on purpose: the record spans
|
|
11
|
-
agent sessions from
|
|
11
|
+
agent sessions from supported harnesses, and the aweb server is one projected
|
|
12
12
|
source, not the record's home. The architecture decision it implements is
|
|
13
13
|
`2026-08-18-turn-record-and-tools.md` in the strategy repo
|
|
14
14
|
(github.com/awebai/strategy); the identity and messaging contracts it builds
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// sessionsForHome — the session transcripts one OATS instance home produced.
|
|
2
|
+
//
|
|
3
|
+
// Nothing in a captured session turn names the instance that ran it: turns
|
|
4
|
+
// carry owner, thread, kind and text. What every harness DOES record is the
|
|
5
|
+
// working directory the session started in — Claude Code on each line, pi in
|
|
6
|
+
// its session header, Codex in session_meta — and an OATS instance runs with
|
|
7
|
+
// its home as that directory. So "the instance's own sessions" is exactly
|
|
8
|
+
// "the session files whose recorded cwd is the home or a directory below it".
|
|
9
|
+
//
|
|
10
|
+
// The match is on canonical paths and is exact-or-descendant, never looser:
|
|
11
|
+
// a home is disposable and unique, so anything that ran inside it is the
|
|
12
|
+
// instance's own, and nothing outside it — not the parent workspace, not a
|
|
13
|
+
// sibling home — is ever swept in.
|
|
14
|
+
|
|
15
|
+
import { closeSync, openSync, readSync, realpathSync, statSync } from "node:fs";
|
|
16
|
+
import { resolve, sep } from "node:path";
|
|
17
|
+
import { StringDecoder } from "node:string_decoder";
|
|
18
|
+
|
|
19
|
+
import { SESSION_FORMATS } from "./formats.mjs";
|
|
20
|
+
|
|
21
|
+
// A session's first lines can be large (Claude Code queue operations and
|
|
22
|
+
// file-history snapshots run to 100 KB and more) and the first cwd-bearing
|
|
23
|
+
// line can sit past 100 KB of bookkeeping lines, so the scan is incremental:
|
|
24
|
+
// whole lines only, chunk by chunk, until the first cwd or the byte bound.
|
|
25
|
+
// A file whose bound is exhausted without a cwd is reported as unattributable
|
|
26
|
+
// through the optional `onUnattributed` hook, never silently dropped.
|
|
27
|
+
const CHUNK_BYTES = 64 * 1024;
|
|
28
|
+
export const CWD_SCAN_BOUND_BYTES = 8 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
function* wholeLines(path, bound) {
|
|
31
|
+
const fd = openSync(path, "r");
|
|
32
|
+
try {
|
|
33
|
+
const buf = Buffer.alloc(CHUNK_BYTES);
|
|
34
|
+
const decoder = new StringDecoder("utf8"); // a multi-byte character may straddle two chunks
|
|
35
|
+
let carry = "";
|
|
36
|
+
let offset = 0;
|
|
37
|
+
while (offset < bound) {
|
|
38
|
+
const n = readSync(fd, buf, 0, Math.min(CHUNK_BYTES, bound - offset), offset);
|
|
39
|
+
if (n === 0) break;
|
|
40
|
+
offset += n;
|
|
41
|
+
carry += decoder.write(buf.subarray(0, n));
|
|
42
|
+
let nl;
|
|
43
|
+
while ((nl = carry.indexOf("\n")) >= 0) {
|
|
44
|
+
yield carry.slice(0, nl);
|
|
45
|
+
carry = carry.slice(nl + 1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// The remainder is a fragment unless the file ended exactly there.
|
|
49
|
+
if (carry && offset < bound) yield carry;
|
|
50
|
+
} finally {
|
|
51
|
+
closeSync(fd);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cwdOfLine(source, line) {
|
|
56
|
+
if (!line.trim()) return undefined;
|
|
57
|
+
let d;
|
|
58
|
+
try {
|
|
59
|
+
d = JSON.parse(line);
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined; // a non-JSON native line
|
|
62
|
+
}
|
|
63
|
+
if (source === "cc" && typeof d.cwd === "string") return d.cwd;
|
|
64
|
+
if (source === "pi" && d.type === "session" && typeof d.cwd === "string") return d.cwd;
|
|
65
|
+
if (source === "codex" && d.type === "session_meta" && typeof d.payload?.cwd === "string") return d.payload.cwd;
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The working directory a session file records, scanning whole lines from
|
|
70
|
+
* the start until the first one that carries it, or undefined when none
|
|
71
|
+
* does within `bound` bytes (unknown format, torn file, no cwd at all). */
|
|
72
|
+
export function sessionCwd(source, path, { bound = CWD_SCAN_BOUND_BYTES } = {}) {
|
|
73
|
+
try {
|
|
74
|
+
for (const line of wholeLines(path, bound)) {
|
|
75
|
+
const cwd = cwdOfLine(source, line);
|
|
76
|
+
if (cwd) return cwd;
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function canonical(p) {
|
|
85
|
+
try {
|
|
86
|
+
return realpathSync(p);
|
|
87
|
+
} catch {
|
|
88
|
+
return resolve(p); // a retired home's cwd no longer exists; compare the lexical path
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function within(child, parent) {
|
|
93
|
+
return child === parent || child.startsWith(parent + sep);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Session files whose recorded cwd is `home` or below it, oldest first.
|
|
97
|
+
* `roots` may override the per-format search roots ({ cc, pi, codex }),
|
|
98
|
+
* otherwise each format's default roots under the current HOME are used.
|
|
99
|
+
* `onUnattributed(source, path)` is called for a file that carries no cwd
|
|
100
|
+
* within the scan bound, so a caller can report it instead of losing it.
|
|
101
|
+
* Each entry: { source, sessionId, thread, path, cwd, bytes, mtime }. */
|
|
102
|
+
export function sessionsForHome(home, { roots, onUnattributed, bound } = {}) {
|
|
103
|
+
const target = canonical(home);
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const fmt of Object.values(SESSION_FORMATS)) {
|
|
106
|
+
const rs = roots?.[fmt.source] ?? fmt.defaultRoots();
|
|
107
|
+
for (const path of fmt.listFiles(rs)) {
|
|
108
|
+
const cwd = sessionCwd(fmt.source, path, bound ? { bound } : {});
|
|
109
|
+
if (!cwd) { if (onUnattributed) onUnattributed(fmt.source, path); continue; }
|
|
110
|
+
if (!within(canonical(cwd), target)) continue;
|
|
111
|
+
let stat;
|
|
112
|
+
try {
|
|
113
|
+
stat = statSync(path);
|
|
114
|
+
} catch {
|
|
115
|
+
continue; // vanished between listing and stat
|
|
116
|
+
}
|
|
117
|
+
const sessionId = fmt.sessionId(path);
|
|
118
|
+
out.push({
|
|
119
|
+
source: fmt.source,
|
|
120
|
+
sessionId,
|
|
121
|
+
thread: `${fmt.source}:session:${sessionId}`,
|
|
122
|
+
path,
|
|
123
|
+
cwd,
|
|
124
|
+
bytes: stat.size,
|
|
125
|
+
mtime: stat.mtime.toISOString(),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out.sort((a, b) => a.mtime.localeCompare(b.mtime) || a.path.localeCompare(b.path));
|
|
130
|
+
}
|