@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,447 @@
|
|
|
1
|
+
// RecordStore — the on-disk turn record per docs/turn-record-sot.md.
|
|
2
|
+
//
|
|
3
|
+
// Layout:
|
|
4
|
+
// <root>/streams/<stream-id>/journal.jsonl replicated, append-only, owner-write
|
|
5
|
+
// <root>/objects/sha256/<hh>/<hex> replicated, immutable
|
|
6
|
+
// <root>/index/ derived, rebuildable, never replicated
|
|
7
|
+
//
|
|
8
|
+
// Invariants enforced here:
|
|
9
|
+
// - appends go only to streams owned by this store's configured owner;
|
|
10
|
+
// - a truncated final journal line is ignored by readers and repaired
|
|
11
|
+
// (truncated away) by the owner on its next append;
|
|
12
|
+
// - merge of two copies of one stream requires the prefix relation;
|
|
13
|
+
// - the effective record is the id-deduplicated union across streams,
|
|
14
|
+
// minus turns hidden by valid tombstones.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
appendFileSync,
|
|
18
|
+
closeSync,
|
|
19
|
+
existsSync,
|
|
20
|
+
fsyncSync,
|
|
21
|
+
ftruncateSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
openSync,
|
|
24
|
+
readdirSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
readSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
statSync,
|
|
29
|
+
unlinkSync,
|
|
30
|
+
writeFileSync,
|
|
31
|
+
writeSync,
|
|
32
|
+
} from "node:fs";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
|
|
35
|
+
import { finishTurn, sha256Hex, verifyTurnId } from "./canonical.mjs";
|
|
36
|
+
import { jsonlLines } from "./formats.mjs";
|
|
37
|
+
|
|
38
|
+
export class StoreError extends Error {}
|
|
39
|
+
|
|
40
|
+
function sleepSync(ms) {
|
|
41
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function syncPath(path) {
|
|
45
|
+
const fd = openSync(path, "r");
|
|
46
|
+
try {
|
|
47
|
+
fsyncSync(fd);
|
|
48
|
+
} finally {
|
|
49
|
+
closeSync(fd);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Repair a torn journal tail without reading the whole file: our writer
|
|
54
|
+
// appends complete lines in one write, so the only crash artifact is a
|
|
55
|
+
// final line without its newline. Truncate back to the last newline,
|
|
56
|
+
// scanning backward in bounded chunks.
|
|
57
|
+
export function repairTail(path) {
|
|
58
|
+
if (!existsSync(path)) return;
|
|
59
|
+
const size = statSync(path).size;
|
|
60
|
+
if (size === 0) return;
|
|
61
|
+
const fd = openSync(path, "r+");
|
|
62
|
+
try {
|
|
63
|
+
const one = Buffer.alloc(1);
|
|
64
|
+
readSync(fd, one, 0, 1, size - 1);
|
|
65
|
+
if (one[0] === 10) return; // ends with \n: intact
|
|
66
|
+
const CHUNK = 65536;
|
|
67
|
+
let pos = size - 1; // last byte is already known not to be \n
|
|
68
|
+
while (pos > 0) {
|
|
69
|
+
const start = Math.max(0, pos - CHUNK);
|
|
70
|
+
const buf = Buffer.alloc(pos - start);
|
|
71
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
72
|
+
const nl = buf.lastIndexOf(10);
|
|
73
|
+
if (nl !== -1) {
|
|
74
|
+
ftruncateSync(fd, start + nl + 1);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
pos = start;
|
|
78
|
+
}
|
|
79
|
+
ftruncateSync(fd, 0); // the whole file was one torn line
|
|
80
|
+
} finally {
|
|
81
|
+
closeSync(fd);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A stream id is `<owner>~<source>`; `~` cannot appear in aweb aliases or
|
|
86
|
+
// DID identifiers, and neither side may contain path separators.
|
|
87
|
+
export function streamOwner(streamId) {
|
|
88
|
+
const i = streamId.indexOf("~");
|
|
89
|
+
if (i <= 0) throw new StoreError(`invalid stream id ${JSON.stringify(streamId)}`);
|
|
90
|
+
return streamId.slice(0, i);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function validStreamId(streamId) {
|
|
94
|
+
return /^[A-Za-z0-9._-]+~[A-Za-z0-9._-]+$/.test(streamId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Parse journal text into turns. The final line may be a torn write: if it
|
|
98
|
+
// does not end in a newline or is not valid JSON, it is ignored and its byte
|
|
99
|
+
// offset reported so the owner can repair. Interior blank lines are
|
|
100
|
+
// tolerated as no-ops (they cannot hide data and must stay self-healable);
|
|
101
|
+
// interior invalid non-blank lines are corruption and throw.
|
|
102
|
+
export function parseJournal(input) {
|
|
103
|
+
const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input, "utf8");
|
|
104
|
+
const turns = [];
|
|
105
|
+
let validEnd = 0;
|
|
106
|
+
let offset = 0;
|
|
107
|
+
while (offset < bytes.length) {
|
|
108
|
+
let nl = bytes.indexOf(10, offset);
|
|
109
|
+
const isFinal = nl === -1;
|
|
110
|
+
if (isFinal) nl = bytes.length;
|
|
111
|
+
let text = null;
|
|
112
|
+
try {
|
|
113
|
+
text = bytes.toString("utf8", offset, nl);
|
|
114
|
+
} catch {
|
|
115
|
+
// single line beyond the string limit: treat as torn if final,
|
|
116
|
+
// corrupt if interior (same rule as unparseable JSON below)
|
|
117
|
+
}
|
|
118
|
+
if (text !== null && text.trim() === "") {
|
|
119
|
+
if (isFinal) break;
|
|
120
|
+
validEnd = nl + 1;
|
|
121
|
+
offset = nl + 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let turn = null;
|
|
125
|
+
if (text !== null) {
|
|
126
|
+
try {
|
|
127
|
+
turn = JSON.parse(text);
|
|
128
|
+
} catch {
|
|
129
|
+
turn = null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (turn === null) {
|
|
133
|
+
if (isFinal) break; // torn tail: ignored, repaired on next append
|
|
134
|
+
throw new StoreError(`corrupt interior journal line at byte ${offset}`);
|
|
135
|
+
}
|
|
136
|
+
if (isFinal) break; // valid JSON but no newline: still a torn tail
|
|
137
|
+
turns.push(turn);
|
|
138
|
+
validEnd = nl + 1;
|
|
139
|
+
offset = nl + 1;
|
|
140
|
+
}
|
|
141
|
+
return { turns, validEnd, torn: validEnd < bytes.length };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class RecordStore {
|
|
145
|
+
constructor(root, { owner, lockTimeoutMs = 10000, lockStaleMs = 30000 } = {}) {
|
|
146
|
+
if (!root) throw new StoreError("record root is required");
|
|
147
|
+
// The lock has no liveness refresh: a holder that outlives lockStaleMs
|
|
148
|
+
// can be stolen from mid-critical-section. Keeping the waiter timeout
|
|
149
|
+
// strictly below the stale threshold guarantees a contender errors out
|
|
150
|
+
// loudly before it could ever steal a live holder's lock.
|
|
151
|
+
if (lockTimeoutMs >= lockStaleMs) {
|
|
152
|
+
throw new StoreError("lockTimeoutMs must be < lockStaleMs (stale-steal would race a live holder)");
|
|
153
|
+
}
|
|
154
|
+
this.root = root;
|
|
155
|
+
this.owner = owner ?? null;
|
|
156
|
+
this.lockTimeoutMs = lockTimeoutMs;
|
|
157
|
+
this.lockStaleMs = lockStaleMs;
|
|
158
|
+
// Streams whose full journal this instance has parsed without error.
|
|
159
|
+
// The write path validates each journal once before its first append
|
|
160
|
+
// (interior corruption must fail loud, never accept appends after the
|
|
161
|
+
// damage); its own appends are trusted afterwards.
|
|
162
|
+
this.validatedStreams = new Set();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Serialize every read-repair-write sequence on one stream across
|
|
166
|
+
// processes: hooks, watchers and manual passes can all fire close
|
|
167
|
+
// together for the same owner, and an unguarded repair can truncate a
|
|
168
|
+
// concurrent writer's already-fsynced turn. Exclusive-create lockfile
|
|
169
|
+
// with stale-steal (a crashed holder's lock older than lockStaleMs is
|
|
170
|
+
// removed).
|
|
171
|
+
withStreamLock(streamId, fn) {
|
|
172
|
+
const dir = join(this.streamsDir(), streamId);
|
|
173
|
+
mkdirSync(dir, { recursive: true });
|
|
174
|
+
const lockPath = join(dir, ".lock");
|
|
175
|
+
const deadline = Date.now() + this.lockTimeoutMs;
|
|
176
|
+
for (;;) {
|
|
177
|
+
try {
|
|
178
|
+
const fd = openSync(lockPath, "wx");
|
|
179
|
+
try {
|
|
180
|
+
writeSync(fd, String(process.pid));
|
|
181
|
+
} finally {
|
|
182
|
+
closeSync(fd);
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (err.code !== "EEXIST") throw err;
|
|
187
|
+
let stale = false;
|
|
188
|
+
try {
|
|
189
|
+
stale = Date.now() - statSync(lockPath).mtimeMs > this.lockStaleMs;
|
|
190
|
+
} catch {
|
|
191
|
+
continue; // lock vanished between open and stat: retry now
|
|
192
|
+
}
|
|
193
|
+
if (stale) {
|
|
194
|
+
try {
|
|
195
|
+
unlinkSync(lockPath);
|
|
196
|
+
} catch {
|
|
197
|
+
/* another process stole it first */
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (Date.now() >= deadline) {
|
|
202
|
+
throw new StoreError(`timed out waiting for lock on stream ${streamId} (${lockPath})`);
|
|
203
|
+
}
|
|
204
|
+
sleepSync(25);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
return fn();
|
|
209
|
+
} finally {
|
|
210
|
+
try {
|
|
211
|
+
unlinkSync(lockPath);
|
|
212
|
+
} catch {
|
|
213
|
+
/* already stolen as stale; nothing to release */
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
streamsDir() {
|
|
219
|
+
return join(this.root, "streams");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
journalPath(streamId) {
|
|
223
|
+
if (!validStreamId(streamId)) {
|
|
224
|
+
throw new StoreError(`invalid stream id ${JSON.stringify(streamId)}`);
|
|
225
|
+
}
|
|
226
|
+
return join(this.streamsDir(), streamId, "journal.jsonl");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
listStreams() {
|
|
230
|
+
const dir = this.streamsDir();
|
|
231
|
+
if (!existsSync(dir)) return [];
|
|
232
|
+
return readdirSync(dir)
|
|
233
|
+
.filter((name) => validStreamId(name) && existsSync(join(dir, name, "journal.jsonl")))
|
|
234
|
+
.sort();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
readStream(streamId) {
|
|
238
|
+
const path = this.journalPath(streamId);
|
|
239
|
+
if (!existsSync(path)) return [];
|
|
240
|
+
return parseJournal(readFileSync(path)).turns;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Is this a session-content stream (`<owner>~<source>.<session-id>`)?
|
|
244
|
+
// Their journals hold whole conversations and can be large, so bulk
|
|
245
|
+
// reads exclude them unless asked; access them per-thread instead.
|
|
246
|
+
static isSessionStream(streamId) {
|
|
247
|
+
return /~(cc|pi|codex)\./.test(streamId);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Streams holding the turns of one session thread, across owners.
|
|
251
|
+
sessionStreamsFor(thread) {
|
|
252
|
+
const m = /^(cc|pi|codex):session:(.+)$/.exec(thread);
|
|
253
|
+
if (!m) return [];
|
|
254
|
+
const suffix = `~${m[1]}.${m[2]}`;
|
|
255
|
+
return this.listStreams().filter((s) => s.endsWith(suffix));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Read every turn in the store, id-deduplicated union across streams.
|
|
259
|
+
// Session-content streams are excluded by default (their journals hold
|
|
260
|
+
// whole conversations; read those per-thread via sessionStreamsFor).
|
|
261
|
+
// Returns Map id -> { turn, stream }.
|
|
262
|
+
readAll({ includeSessionContent = false } = {}) {
|
|
263
|
+
const byId = new Map();
|
|
264
|
+
for (const streamId of this.listStreams()) {
|
|
265
|
+
if (!includeSessionContent && RecordStore.isSessionStream(streamId)) continue;
|
|
266
|
+
for (const turn of this.readStream(streamId)) {
|
|
267
|
+
if (typeof turn.id === "string" && !byId.has(turn.id)) {
|
|
268
|
+
byId.set(turn.id, { turn, stream: streamId });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return byId;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Ids hidden by valid tombstones. v1 authority: the tombstone's `from`
|
|
276
|
+
// must equal the target turn's `from`, or the record owner.
|
|
277
|
+
hiddenIds(byId = this.readAll()) {
|
|
278
|
+
const claims = this.tombstoneClaims(byId);
|
|
279
|
+
const hidden = new Set();
|
|
280
|
+
for (const { turn } of byId.values()) {
|
|
281
|
+
if (this.claimHides(claims, turn)) hidden.add(turn.id);
|
|
282
|
+
}
|
|
283
|
+
return hidden;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Tombstone claims by target ref: ref -> Set of tombstoning authors.
|
|
287
|
+
// hiddenIds() resolves claims against the turns present in byId; use
|
|
288
|
+
// claims + claimHides() directly when iterating turns that are NOT in
|
|
289
|
+
// byId — session streams are excluded from readAll() by default, yet a
|
|
290
|
+
// tombstone in any stream must still hide a session event (per-line
|
|
291
|
+
// redaction is a first-class operation in the per-event model).
|
|
292
|
+
tombstoneClaims(byId = this.readAll()) {
|
|
293
|
+
const claims = new Map();
|
|
294
|
+
for (const { turn } of byId.values()) {
|
|
295
|
+
if (turn.kind !== "tombstone") continue;
|
|
296
|
+
for (const link of turn.links ?? []) {
|
|
297
|
+
if (link.rel !== "tombstones" || typeof link.ref !== "string") continue;
|
|
298
|
+
let authors = claims.get(link.ref);
|
|
299
|
+
if (!authors) claims.set(link.ref, (authors = new Set()));
|
|
300
|
+
authors.add(turn.from);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return claims;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// v1 authority: a claim hides a turn when its author is the turn's
|
|
307
|
+
// author or the record owner.
|
|
308
|
+
claimHides(claims, turn) {
|
|
309
|
+
const authors = claims.get(turn.id);
|
|
310
|
+
if (!authors) return false;
|
|
311
|
+
return authors.has(turn.from) || (this.owner !== null && authors.has(this.owner));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Append a finished turn (id present and correct) to an owned stream.
|
|
315
|
+
// Repairs a torn tail first. Durability: fsync after append.
|
|
316
|
+
append(streamId, turn) {
|
|
317
|
+
this.appendBatch(streamId, [turn]);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Build, finish (compute id), dedupe, and append in one step.
|
|
321
|
+
// Returns { turn, appended } — appended=false when the id already exists.
|
|
322
|
+
appendCore(streamId, core, knownIds = null) {
|
|
323
|
+
const turn = finishTurn(core);
|
|
324
|
+
const ids = knownIds ?? new Set(this.readStream(streamId).map((t) => t.id));
|
|
325
|
+
if (ids.has(turn.id)) return { turn, appended: false };
|
|
326
|
+
this.append(streamId, turn);
|
|
327
|
+
ids.add(turn.id);
|
|
328
|
+
return { turn, appended: true };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Append many finished turns with bounded writes and one fsync. Same
|
|
332
|
+
// ownership and id checks as append(); an empty batch is a no-op. The
|
|
333
|
+
// read-repair-append sequence runs under the stream lock. Writes are
|
|
334
|
+
// chunked by bytes, never one join of the whole batch: a large session
|
|
335
|
+
// captured in one pass is a single batch, and joining it can exceed
|
|
336
|
+
// V8's maximum string length (this crashed the first live recapture).
|
|
337
|
+
appendBatch(streamId, turns, maxChunkBytes = 32 * 1024 * 1024) {
|
|
338
|
+
if (turns.length === 0) return;
|
|
339
|
+
if (!this.owner) throw new StoreError("store has no owner; cannot append");
|
|
340
|
+
if (streamOwner(streamId) !== this.owner) {
|
|
341
|
+
throw new StoreError(
|
|
342
|
+
`stream ${streamId} is not owned by ${this.owner}; appends are owner-only`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
for (const turn of turns) {
|
|
346
|
+
if (!verifyTurnId(turn)) throw new StoreError("turn id missing or does not match content");
|
|
347
|
+
}
|
|
348
|
+
const path = this.journalPath(streamId);
|
|
349
|
+
this.withStreamLock(streamId, () => {
|
|
350
|
+
// First append to this stream in this instance: parse the whole
|
|
351
|
+
// journal, so interior corruption throws here instead of silently
|
|
352
|
+
// collecting appends behind the damage. A torn tail is tolerated
|
|
353
|
+
// (parseJournal treats it as final-line-torn) and repaired below.
|
|
354
|
+
if (!this.validatedStreams.has(streamId) && existsSync(path)) {
|
|
355
|
+
parseJournal(readFileSync(path));
|
|
356
|
+
}
|
|
357
|
+
this.validatedStreams.add(streamId);
|
|
358
|
+
repairTail(path);
|
|
359
|
+
let chunk = [];
|
|
360
|
+
let chunkBytes = 0;
|
|
361
|
+
const flush = () => {
|
|
362
|
+
if (chunk.length === 0) return;
|
|
363
|
+
appendFileSync(path, chunk.join("\n") + "\n");
|
|
364
|
+
chunk = [];
|
|
365
|
+
chunkBytes = 0;
|
|
366
|
+
};
|
|
367
|
+
for (const turn of turns) {
|
|
368
|
+
const line = JSON.stringify(turn);
|
|
369
|
+
if (chunkBytes > 0 && chunkBytes + line.length > maxChunkBytes) flush();
|
|
370
|
+
chunk.push(line);
|
|
371
|
+
chunkBytes += line.length + 1;
|
|
372
|
+
}
|
|
373
|
+
flush();
|
|
374
|
+
syncPath(path);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ------------------------------------------------------------- objects
|
|
379
|
+
|
|
380
|
+
objectPath(hexDigest) {
|
|
381
|
+
if (!/^[0-9a-f]{64}$/.test(hexDigest)) {
|
|
382
|
+
throw new StoreError(`invalid sha256 digest ${JSON.stringify(hexDigest)}`);
|
|
383
|
+
}
|
|
384
|
+
return join(this.root, "objects", "sha256", hexDigest.slice(0, 2), hexDigest);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
putObject(bytes) {
|
|
388
|
+
const digest = sha256Hex(bytes);
|
|
389
|
+
const path = this.objectPath(digest);
|
|
390
|
+
if (!existsSync(path)) {
|
|
391
|
+
mkdirSync(join(this.root, "objects", "sha256", digest.slice(0, 2)), { recursive: true });
|
|
392
|
+
const tmp = path + ".tmp-" + process.pid;
|
|
393
|
+
writeFileSync(tmp, bytes);
|
|
394
|
+
renameSync(tmp, path);
|
|
395
|
+
}
|
|
396
|
+
return "sha256:" + digest;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
hasObject(ref) {
|
|
400
|
+
return existsSync(this.objectPath(refDigest(ref)));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
getObject(ref) {
|
|
404
|
+
return readFileSync(this.objectPath(refDigest(ref)));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------- merge
|
|
408
|
+
|
|
409
|
+
// Merge a foreign copy of a stream into this store. Copies of one stream
|
|
410
|
+
// must be prefix-related; the longer wins. Non-prefix copies are
|
|
411
|
+
// quarantined (written beside the journal), never merged silently.
|
|
412
|
+
mergeStreamCopy(streamId, foreignTurns) {
|
|
413
|
+
for (const turn of foreignTurns) {
|
|
414
|
+
if (!verifyTurnId(turn)) {
|
|
415
|
+
throw new StoreError(`foreign copy of ${streamId} contains a turn with a bad id`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const path = this.journalPath(streamId);
|
|
419
|
+
return this.withStreamLock(streamId, () => {
|
|
420
|
+
const local = this.readStream(streamId);
|
|
421
|
+
const [shorter, longer] =
|
|
422
|
+
local.length <= foreignTurns.length ? [local, foreignTurns] : [foreignTurns, local];
|
|
423
|
+
for (let i = 0; i < shorter.length; i++) {
|
|
424
|
+
if (shorter[i].id !== longer[i].id) {
|
|
425
|
+
const qPath = join(this.streamsDir(), streamId, `quarantine-${Date.now()}.jsonl`);
|
|
426
|
+
writeFileSync(qPath, foreignTurns.map((t) => JSON.stringify(t)).join("\n") + "\n");
|
|
427
|
+
throw new StoreError(
|
|
428
|
+
`non-prefix copies of stream ${streamId}: foreign copy quarantined at ${qPath}`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (longer === local) return { extended: 0 };
|
|
433
|
+
repairTail(path);
|
|
434
|
+
const suffix = longer.slice(local.length);
|
|
435
|
+
appendFileSync(path, suffix.map((t) => JSON.stringify(t)).join("\n") + "\n");
|
|
436
|
+
syncPath(path);
|
|
437
|
+
return { extended: suffix.length };
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function refDigest(ref) {
|
|
443
|
+
if (typeof ref !== "string" || !ref.startsWith("sha256:")) {
|
|
444
|
+
throw new StoreError(`invalid object ref ${JSON.stringify(ref)}`);
|
|
445
|
+
}
|
|
446
|
+
return ref.slice("sha256:".length);
|
|
447
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Tag and outfit conventions — the record's selection map.
|
|
2
|
+
//
|
|
3
|
+
// Tags and outfits are ordinary note turns (conventions over the record,
|
|
4
|
+
// never kernel objects): a tag annotates one turn with what it is about
|
|
5
|
+
// and what it does; an outfit records a selection of turns that served a
|
|
6
|
+
// task, with its validation status. Both are written by the intelligence
|
|
7
|
+
// layer (the librarian-dresser now, the capture-following mind later)
|
|
8
|
+
// into the tagger's own stream, and become queryable through the derived
|
|
9
|
+
// index like everything else.
|
|
10
|
+
//
|
|
11
|
+
// Fidelity is "summary": tag content is model judgment, not source truth.
|
|
12
|
+
// The link to the target turn is the truth; the annotation is an opinion
|
|
13
|
+
// with provenance.
|
|
14
|
+
|
|
15
|
+
export const TAG_ACTS = new Set([
|
|
16
|
+
"decides",
|
|
17
|
+
"commits",
|
|
18
|
+
"closes",
|
|
19
|
+
"asks",
|
|
20
|
+
"answers",
|
|
21
|
+
"reports",
|
|
22
|
+
"explores",
|
|
23
|
+
"concludes",
|
|
24
|
+
"corrects",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const slugish = (s) =>
|
|
28
|
+
String(s)
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
31
|
+
.replace(/^-|-$/g, "")
|
|
32
|
+
.slice(0, 64);
|
|
33
|
+
|
|
34
|
+
// Build a tag turn core annotating `targetRef` (turn id, or id@loc for a
|
|
35
|
+
// session event). ts mirrors the target's ts so tag ordering follows the
|
|
36
|
+
// content it describes, not tagging time.
|
|
37
|
+
export function tagTurnCore({ owner, targetRef, targetTs, about = [], acts = [], caseSlug, note, model }) {
|
|
38
|
+
const tag = {};
|
|
39
|
+
const cleanAbout = [...new Set(about.map(slugish).filter(Boolean))].sort();
|
|
40
|
+
const cleanActs = [...new Set(acts.filter((a) => TAG_ACTS.has(a)))].sort();
|
|
41
|
+
if (cleanAbout.length > 0) tag.about = cleanAbout;
|
|
42
|
+
if (cleanActs.length > 0) tag.acts = cleanActs;
|
|
43
|
+
const cs = caseSlug ? slugish(caseSlug) : "";
|
|
44
|
+
if (cs) tag.case = cs;
|
|
45
|
+
if (note) tag.note = String(note).slice(0, 200);
|
|
46
|
+
return {
|
|
47
|
+
v: 1,
|
|
48
|
+
ts: targetTs,
|
|
49
|
+
from: owner,
|
|
50
|
+
kind: "note",
|
|
51
|
+
links: [{ rel: "tags", ref: targetRef }],
|
|
52
|
+
body: { text: tag.note ?? "", tag },
|
|
53
|
+
provenance: {
|
|
54
|
+
source: "mind",
|
|
55
|
+
fidelity: "summary",
|
|
56
|
+
origin: { target: targetRef, ...(model ? { model } : {}) },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Build an outfit turn core recording a selection that served a task.
|
|
62
|
+
export function outfitTurnCore({ owner, task, members, status = "proposed", evidence, ts, model }) {
|
|
63
|
+
return {
|
|
64
|
+
v: 1,
|
|
65
|
+
ts,
|
|
66
|
+
from: owner,
|
|
67
|
+
kind: "note",
|
|
68
|
+
links: members.map((ref) => ({ rel: "member", ref })),
|
|
69
|
+
body: {
|
|
70
|
+
text: `outfit for: ${String(task).slice(0, 120)}`,
|
|
71
|
+
outfit: {
|
|
72
|
+
task: String(task),
|
|
73
|
+
status,
|
|
74
|
+
...(evidence ? { evidence } : {}),
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
provenance: {
|
|
78
|
+
source: "mind",
|
|
79
|
+
fidelity: "summary",
|
|
80
|
+
origin: { ...(model ? { model } : {}) },
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Parse helpers: return the structured payload or null.
|
|
86
|
+
export function parseTag(turn) {
|
|
87
|
+
const tag = turn?.body?.tag;
|
|
88
|
+
if (!tag || typeof tag !== "object") return null;
|
|
89
|
+
const links = Array.isArray(turn.links) ? turn.links : [];
|
|
90
|
+
const target = links.find((l) => l?.rel === "tags")?.ref;
|
|
91
|
+
if (!target) return null;
|
|
92
|
+
return {
|
|
93
|
+
target,
|
|
94
|
+
about: Array.isArray(tag.about) ? tag.about : [],
|
|
95
|
+
acts: Array.isArray(tag.acts) ? tag.acts : [],
|
|
96
|
+
caseSlug: typeof tag.case === "string" ? tag.case : null,
|
|
97
|
+
note: typeof tag.note === "string" ? tag.note : "",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function parseOutfit(turn) {
|
|
102
|
+
const outfit = turn?.body?.outfit;
|
|
103
|
+
if (!outfit || typeof outfit !== "object") return null;
|
|
104
|
+
return {
|
|
105
|
+
task: String(outfit.task ?? ""),
|
|
106
|
+
status: String(outfit.status ?? "proposed"),
|
|
107
|
+
evidence: outfit.evidence ?? null,
|
|
108
|
+
members: (Array.isArray(turn.links) ? turn.links : [])
|
|
109
|
+
.filter((l) => l?.rel === "member")
|
|
110
|
+
.map((l) => l.ref),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { slugish };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@awebai/turn-record",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Turn record v1 — append-only, content-addressed, replicated record of turns, with capture (Claude Code/pi/codex sessions, aw client logs) and recall (FTS search). The spec (docs/turn-record-sot.md) and its conformance vectors live in this package.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/awebai/oats",
|
|
10
|
+
"directory": "packages/record"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"agents",
|
|
14
|
+
"record",
|
|
15
|
+
"capture",
|
|
16
|
+
"recall",
|
|
17
|
+
"aweb"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.5.0"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./lib/store.mjs",
|
|
24
|
+
"./canonical": "./lib/canonical.mjs",
|
|
25
|
+
"./store": "./lib/store.mjs",
|
|
26
|
+
"./project-aweb": "./lib/project-aweb.mjs",
|
|
27
|
+
"./capture-cc": "./lib/capture-cc.mjs",
|
|
28
|
+
"./capture-aw": "./lib/capture-aw.mjs",
|
|
29
|
+
"./index-db": "./lib/index-db.mjs",
|
|
30
|
+
"./ignore": "./lib/ignore.mjs"
|
|
31
|
+
},
|
|
32
|
+
"bin": {
|
|
33
|
+
"turn-record": "./bin/turn-record.mjs"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"bin/",
|
|
37
|
+
"lib/",
|
|
38
|
+
"docs/",
|
|
39
|
+
"test/vectors/",
|
|
40
|
+
"README.md"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node --test test/*.test.mjs"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Turn-record conformance vectors
|
|
2
|
+
|
|
3
|
+
Canonical home of the `turn.jsonl` v1 vectors, beside the spec they pin:
|
|
4
|
+
[`docs/turn-record-sot.md`](../../docs/turn-record-sot.md). Generated by the
|
|
5
|
+
Python awid stack (canonicalization and Ed25519 signing from
|
|
6
|
+
`awid/src/awid/signing.py`, in the aweb repo); validated by an independent
|
|
7
|
+
Node re-implementation, so a green run is a two-language agreement on every
|
|
8
|
+
byte.
|
|
9
|
+
|
|
10
|
+
Provenance: these files were first drafted in the aweb-oss working tree
|
|
11
|
+
(2026-08-19, never committed there) and moved here 2026-08-21 as the
|
|
12
|
+
canonical home. The mover verified byte-identity against the drafts before
|
|
13
|
+
removing them, but the drafts left no tracked trace, so that equality is
|
|
14
|
+
not independently re-verifiable — what carries the canonical claim from
|
|
15
|
+
here on is this repo's history plus the cross-language agreement the
|
|
16
|
+
validator proves on every run.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
node validate.mjs # Node >= 18, no dependencies
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| File | Pins |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `turn-id-v1.json` | canonical core bytes, `t1:` content ids, the `<`/`>`/`&` + unicode canonicalization trap, unknown-field coverage, envelope signatures; negatives: id mismatch, bad signature, float in core |
|
|
25
|
+
| `aweb-projection-v1.json` | aweb mail/chat/legacy rows → turns and back: byte-verbatim `signed_payload`, exact ids, deterministic projection, signature verification against `from_did`, signed-consistency rule, field-level round-trip; negative: consistency violation |
|
|
26
|
+
| `journal-merge-v1.json` | stream prefix rule, set-union dedup by id, tombstone dominance and the v1 authority rule; negative: non-prefix stream copies quarantined |
|
|
27
|
+
|
|
28
|
+
The signed cases use the published test identity from
|
|
29
|
+
`docs/vectors/message-signing-v1.json` in the aweb repo
|
|
30
|
+
(github.com/awebai/aweb)
|
|
31
|
+
(seed `000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f`,
|
|
32
|
+
`did:key:z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd`); the two mail
|
|
33
|
+
projection cases embed that file's vectors verbatim, so a real signed message
|
|
34
|
+
is proven to survive project → validate → re-project byte-identically.
|