@awebai/oats 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/bin/oats.mjs +3294 -0
  4. package/capabilities/oats-authoring/oats.json +12 -0
  5. package/capabilities/oats-aweb/bin/oats-aweb.mjs +297 -0
  6. package/capabilities/oats-aweb/injects/aweb.md +55 -0
  7. package/capabilities/oats-aweb/oats.json +47 -0
  8. package/capabilities/oats-aweb/package.json +20 -0
  9. package/capabilities/oats-jira/bin/oats-jira.mjs +40 -0
  10. package/capabilities/oats-jira/injects/jira.md +10 -0
  11. package/capabilities/oats-jira/oats.json +22 -0
  12. package/capabilities/oats-jira/package.json +25 -0
  13. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +176 -0
  14. package/capabilities/oats-linear/README.md +234 -0
  15. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +32 -0
  16. package/capabilities/oats-linear/bin/oats-linear.mjs +344 -0
  17. package/capabilities/oats-linear/injects/linear.md +8 -0
  18. package/capabilities/oats-linear/oats.json +24 -0
  19. package/capabilities/oats-linear/package.json +29 -0
  20. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +217 -0
  21. package/capabilities/oats-linear/test/oats-linear.test.mjs +168 -0
  22. package/capabilities/oats-okf/agents/memory-harvest.md +25 -0
  23. package/capabilities/oats-okf/bin/oats-okf.mjs +283 -0
  24. package/capabilities/oats-okf/injects/okf.md +62 -0
  25. package/capabilities/oats-okf/oats.json +20 -0
  26. package/capabilities/oats-okf/package.json +22 -0
  27. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +100 -0
  28. package/capabilities/oats-okf/skills/okf/SKILL.md +107 -0
  29. package/capabilities/oats-okf/skills/okf/scripts/okf-validate.mjs +123 -0
  30. package/capabilities/oats-review/agents/reviewer/AGENTS.md +53 -0
  31. package/capabilities/oats-review/agents/reviewer/soul.yaml +6 -0
  32. package/capabilities/oats-review/injects/review.md +68 -0
  33. package/capabilities/oats-review/oats.json +10 -0
  34. package/capabilities/oats-review/skills/code-review/SKILL.md +44 -0
  35. package/capabilities/oats-review/skills/security-review/SKILL.md +59 -0
  36. package/docs/capabilities.md +505 -0
  37. package/docs/capability-manifest.schema.json +223 -0
  38. package/docs/configuration.md +482 -0
  39. package/docs/conventions.md +73 -0
  40. package/docs/design/desktop-ux-plan.md +362 -0
  41. package/docs/design/package-engine-contract.md +813 -0
  42. package/docs/design/package-runtime-api.md +414 -0
  43. package/docs/desktop-cli-api.md +89 -0
  44. package/docs/desktop-succession.md +51 -0
  45. package/docs/desktop.md +187 -0
  46. package/docs/implementation.md +282 -0
  47. package/docs/integrations.md +123 -0
  48. package/docs/knowledge-theory.md +97 -0
  49. package/docs/knowledge.md +139 -0
  50. package/docs/layers.md +110 -0
  51. package/docs/migration-from-oas.md +122 -0
  52. package/docs/oats-config.schema.json +140 -0
  53. package/docs/oats-lock.schema.json +175 -0
  54. package/docs/oats-package.schema.json +129 -0
  55. package/docs/packages.md +442 -0
  56. package/docs/release-lane.md +132 -0
  57. package/docs/release-notes/v0.18.2.md +79 -0
  58. package/docs/release-notes/v0.18.3.md +63 -0
  59. package/docs/release-notes/v0.18.4.md +65 -0
  60. package/docs/release-notes/v0.18.5.md +66 -0
  61. package/docs/release-notes/v0.18.6.md +87 -0
  62. package/docs/release-notes/v0.19.0.md +186 -0
  63. package/docs/release-notes/v0.19.1.md +24 -0
  64. package/docs/release-notes/v0.19.2.md +32 -0
  65. package/docs/release-notes/v0.19.3.md +16 -0
  66. package/docs/release-notes/v0.19.4.md +12 -0
  67. package/docs/release-notes/v0.20.0.md +89 -0
  68. package/docs/release-notes/v0.22.0.md +180 -0
  69. package/docs/souls-and-instances.md +344 -0
  70. package/injects/framework-workspace.md +7 -0
  71. package/injects/instance-boundary.md +43 -0
  72. package/injects/local-soul.md +19 -0
  73. package/injects/oats.md +11 -0
  74. package/injects/work-attached.md +16 -0
  75. package/injects/work-checkout.md +12 -0
  76. package/injects/work-workspace.md +22 -0
  77. package/injects/work-worktree.md +13 -0
  78. package/lib/core.mjs +6792 -0
  79. package/lib/packages.mjs +1375 -0
  80. package/lib/tmux-config.mjs +35 -0
  81. package/package-catalog.json +44 -0
  82. package/package.json +55 -0
  83. package/packages/record/README.md +151 -0
  84. package/packages/record/bin/capture.mjs +256 -0
  85. package/packages/record/bin/recall.mjs +102 -0
  86. package/packages/record/bin/setup.mjs +281 -0
  87. package/packages/record/bin/turn-record.mjs +47 -0
  88. package/packages/record/docs/turn-record-sot.md +350 -0
  89. package/packages/record/lib/canonical.mjs +151 -0
  90. package/packages/record/lib/capture-aw.mjs +179 -0
  91. package/packages/record/lib/capture-cc.mjs +322 -0
  92. package/packages/record/lib/formats.mjs +296 -0
  93. package/packages/record/lib/ignore.mjs +155 -0
  94. package/packages/record/lib/index-db.mjs +524 -0
  95. package/packages/record/lib/project-aweb.mjs +251 -0
  96. package/packages/record/lib/segments.mjs +186 -0
  97. package/packages/record/lib/store.mjs +447 -0
  98. package/packages/record/lib/tags.mjs +114 -0
  99. package/packages/record/package.json +45 -0
  100. package/packages/record/test/vectors/README.md +34 -0
  101. package/packages/record/test/vectors/aweb-projection-v1.json +231 -0
  102. package/packages/record/test/vectors/journal-merge-v1.json +261 -0
  103. package/packages/record/test/vectors/turn-id-v1.json +152 -0
  104. package/packages/record/test/vectors/validate.mjs +391 -0
  105. package/skills/integration-authoring/SKILL.md +76 -0
  106. package/skills/oats/SKILL.md +151 -0
  107. package/skills/oats-config/SKILL.md +162 -0
  108. package/skills/oats-getting-started/SKILL.md +159 -0
  109. package/skills/oats-packages/SKILL.md +180 -0
  110. package/skills/oats-support/SKILL.md +79 -0
  111. package/skills/skill-craft/SKILL.md +109 -0
  112. package/skills/soul-craft/SKILL.md +109 -0
@@ -0,0 +1,524 @@
1
+ // Derived SQLite index over the record: turn metadata + FTS5 text.
2
+ // The index is cache: deleting <root>/index/ loses nothing; rebuild()
3
+ // reconstructs it from the streams. It is never replicated.
4
+ //
5
+ // update() is incremental: it indexes only journal lines appended since the
6
+ // last pass (per-stream position in index_state), so a capture pass costs
7
+ // seconds, not a rescan of the record. rebuild() is reset + update-from-zero
8
+ // — one code path, identical semantics.
9
+ //
10
+ // Session turns are one per native transcript event (body.line holds the
11
+ // verbatim record); text is extracted from that line, so a hit names the
12
+ // turn AND the event's line in the original transcript.
13
+
14
+ import { existsSync, mkdirSync, rmSync, unlinkSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { DatabaseSync } from "node:sqlite";
17
+
18
+ // Bump whenever the derived schema changes shape. The index is cache:
19
+ // a version mismatch self-heals by deleting the database and rebuilding
20
+ // from the record — never by in-place migration.
21
+ const SCHEMA_VERSION = 1;
22
+
23
+ import { extractCcText, extractSessionTextFor } from "./formats.mjs";
24
+ import { parseOutfit, parseTag } from "./tags.mjs";
25
+ import { parseSegment, parseSpawn } from "./segments.mjs";
26
+
27
+ const SCHEMA = `
28
+ CREATE TABLE IF NOT EXISTS turns (
29
+ id TEXT PRIMARY KEY,
30
+ ts TEXT NOT NULL,
31
+ from_name TEXT NOT NULL,
32
+ to_name TEXT,
33
+ thread TEXT,
34
+ kind TEXT NOT NULL,
35
+ source TEXT NOT NULL,
36
+ fidelity TEXT NOT NULL,
37
+ stream TEXT NOT NULL,
38
+ events INTEGER NOT NULL DEFAULT 0,
39
+ hidden INTEGER NOT NULL DEFAULT 0,
40
+ superseded INTEGER NOT NULL DEFAULT 0
41
+ );
42
+ CREATE INDEX IF NOT EXISTS turns_thread ON turns(thread);
43
+ CREATE INDEX IF NOT EXISTS turns_ts ON turns(ts);
44
+ CREATE TABLE IF NOT EXISTS tombstones (
45
+ ref TEXT NOT NULL,
46
+ from_name TEXT NOT NULL,
47
+ PRIMARY KEY (ref, from_name)
48
+ );
49
+ CREATE TABLE IF NOT EXISTS index_state (
50
+ stream TEXT PRIMARY KEY,
51
+ indexed_count INTEGER NOT NULL
52
+ );
53
+ CREATE TABLE IF NOT EXISTS meta (
54
+ key TEXT PRIMARY KEY,
55
+ value TEXT NOT NULL
56
+ );
57
+ CREATE TABLE IF NOT EXISTS tags (
58
+ tag_id TEXT NOT NULL,
59
+ target_ref TEXT NOT NULL,
60
+ about TEXT NOT NULL,
61
+ acts TEXT NOT NULL,
62
+ case_slug TEXT,
63
+ note TEXT NOT NULL,
64
+ PRIMARY KEY (tag_id)
65
+ );
66
+ CREATE INDEX IF NOT EXISTS tags_target ON tags(target_ref);
67
+ CREATE INDEX IF NOT EXISTS tags_case ON tags(case_slug);
68
+ CREATE TABLE IF NOT EXISTS outfits (
69
+ outfit_id TEXT PRIMARY KEY,
70
+ task TEXT NOT NULL,
71
+ status TEXT NOT NULL,
72
+ members TEXT NOT NULL
73
+ );
74
+ CREATE TABLE IF NOT EXISTS segments (
75
+ note_id TEXT PRIMARY KEY,
76
+ thread TEXT NOT NULL,
77
+ start_n INTEGER NOT NULL,
78
+ end_n INTEGER,
79
+ type TEXT NOT NULL,
80
+ outcome TEXT NOT NULL,
81
+ about TEXT NOT NULL,
82
+ established TEXT NOT NULL,
83
+ lesson TEXT,
84
+ ts TEXT NOT NULL,
85
+ ts_ms INTEGER NOT NULL DEFAULT 0
86
+ );
87
+ CREATE INDEX IF NOT EXISTS segments_thread ON segments(thread, start_n);
88
+ CREATE TABLE IF NOT EXISTS spawns (
89
+ note_id TEXT PRIMARY KEY,
90
+ agent_thread TEXT NOT NULL,
91
+ outfit_id TEXT NOT NULL,
92
+ task TEXT,
93
+ harness TEXT,
94
+ grant_ref TEXT,
95
+ ts TEXT NOT NULL
96
+ );
97
+ CREATE INDEX IF NOT EXISTS spawns_agent ON spawns(agent_thread);
98
+ CREATE VIRTUAL TABLE IF NOT EXISTS turn_text USING fts5(
99
+ turn_id UNINDEXED, loc UNINDEXED, role UNINDEXED, text
100
+ );
101
+ `;
102
+
103
+ export class RecordIndex {
104
+ constructor(store) {
105
+ this.store = store;
106
+ const dir = join(store.root, "index");
107
+ mkdirSync(dir, { recursive: true });
108
+ const path = join(dir, "turns.db");
109
+ const open = () => {
110
+ const db = new DatabaseSync(path);
111
+ // Concurrent index users (watcher pass, manual reindex, recall)
112
+ // wait for each other instead of dying on "database is locked".
113
+ db.exec("PRAGMA busy_timeout = 60000");
114
+ return db;
115
+ };
116
+ this.db = open();
117
+ const version = this.db.prepare("PRAGMA user_version").get().user_version;
118
+ const isNew = !this.db
119
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'turns'")
120
+ .get();
121
+ if (!isNew && version !== SCHEMA_VERSION) {
122
+ // Old-schema index: self-heal by starting over. Deleting the cache
123
+ // loses nothing; an in-place ALTER would just be a second way to
124
+ // get this wrong.
125
+ this.db.close();
126
+ for (const suffix of ["", "-wal", "-shm"]) {
127
+ try {
128
+ if (existsSync(path + suffix)) unlinkSync(path + suffix);
129
+ } catch {
130
+ // Two processes self-healing at the same instant: the loser's
131
+ // unlink races the winner's; either way the schema ends fixed.
132
+ }
133
+ }
134
+ this.db = open();
135
+ }
136
+ this.db.exec(SCHEMA);
137
+ this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
138
+ }
139
+
140
+ close() {
141
+ this.db.close();
142
+ }
143
+
144
+ recordOwner() {
145
+ if (this.store.owner) return this.store.owner;
146
+ const row = this.db.prepare("SELECT value FROM meta WHERE key = 'owner'").get();
147
+ return row ? row.value : null;
148
+ }
149
+
150
+ // Index everything appended since the last pass.
151
+ update() {
152
+ const owner = this.store.owner;
153
+ this.db.exec("BEGIN");
154
+ try {
155
+ if (owner) {
156
+ this.db
157
+ .prepare("INSERT INTO meta (key, value) VALUES ('owner', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
158
+ .run(owner);
159
+ }
160
+ const effectiveOwner = this.recordOwner();
161
+ const getState = this.db.prepare("SELECT indexed_count FROM index_state WHERE stream = ?");
162
+ const setState = this.db.prepare(
163
+ "INSERT INTO index_state (stream, indexed_count) VALUES (?, ?) ON CONFLICT(stream) DO UPDATE SET indexed_count = excluded.indexed_count",
164
+ );
165
+ for (const streamId of this.store.listStreams()) {
166
+ const turns = this.store.readStream(streamId);
167
+ const done = Number(getState.get(streamId)?.indexed_count ?? 0);
168
+ for (let i = done; i < turns.length; i++) {
169
+ this.addTurn(turns[i], streamId, effectiveOwner);
170
+ }
171
+ setState.run(streamId, turns.length);
172
+ }
173
+ this.db.exec("COMMIT");
174
+ } catch (err) {
175
+ this.db.exec("ROLLBACK");
176
+ throw err;
177
+ }
178
+ }
179
+
180
+ // Full rebuild = reset + update from zero. Same code path as update().
181
+ rebuild() {
182
+ this.db.exec("BEGIN");
183
+ try {
184
+ this.db.exec("DELETE FROM turns");
185
+ this.db.exec("DELETE FROM turn_text");
186
+ this.db.exec("DELETE FROM tombstones");
187
+ this.db.exec("DELETE FROM tags");
188
+ this.db.exec("DELETE FROM outfits");
189
+ this.db.exec("DELETE FROM segments");
190
+ this.db.exec("DELETE FROM spawns");
191
+ this.db.exec("DELETE FROM index_state");
192
+ this.db.exec("COMMIT");
193
+ } catch (err) {
194
+ this.db.exec("ROLLBACK");
195
+ throw err;
196
+ }
197
+ this.update();
198
+ }
199
+
200
+ // Insert one turn, maintaining the hidden invariant in both arrival
201
+ // orders (tombstone before or after target). Duplicate ids across
202
+ // streams: first seen wins.
203
+ addTurn(turn, streamId, owner) {
204
+ if (typeof turn.id !== "string") return;
205
+ const existing = this.db.prepare("SELECT stream FROM turns WHERE id = ?").get(turn.id);
206
+ if (existing) {
207
+ // Duplicate id across streams. Keep attribution deterministic and in
208
+ // agreement with RecordStore.readAll(): alphabetically first stream,
209
+ // regardless of indexing order.
210
+ if (streamId < existing.stream) {
211
+ this.db.prepare("UPDATE turns SET stream = ? WHERE id = ?").run(streamId, turn.id);
212
+ }
213
+ return;
214
+ }
215
+
216
+ const fromName = String(turn.from ?? "");
217
+ // Hidden if a valid tombstone already arrived for this id.
218
+ const tombs = this.db.prepare("SELECT from_name FROM tombstones WHERE ref = ?").all(turn.id);
219
+ const hidden = tombs.some((t) => t.from_name === fromName || (owner && t.from_name === owner))
220
+ ? 1
221
+ : 0;
222
+
223
+ // Sessions are one turn per native event (no snapshots, nothing to
224
+ // supersede); events/superseded columns remain for schema stability.
225
+ const events = 0;
226
+ const superseded = 0;
227
+
228
+ this.db
229
+ .prepare(
230
+ `INSERT INTO turns (id, ts, from_name, to_name, thread, kind, source, fidelity, stream, events, hidden, superseded)
231
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
232
+ )
233
+ .run(
234
+ turn.id,
235
+ String(turn.ts ?? ""),
236
+ fromName,
237
+ turn.to === undefined ? null : Array.isArray(turn.to) ? turn.to.join(",") : String(turn.to),
238
+ turn.thread ?? null,
239
+ String(turn.kind ?? ""),
240
+ String(turn.provenance?.source ?? ""),
241
+ String(turn.provenance?.fidelity ?? ""),
242
+ streamId,
243
+ events,
244
+ hidden,
245
+ superseded,
246
+ );
247
+
248
+ if (turn.kind === "tombstone") {
249
+ for (const link of turn.links ?? []) {
250
+ if (link.rel !== "tombstones" || typeof link.ref !== "string") continue;
251
+ this.db
252
+ .prepare("INSERT OR IGNORE INTO tombstones (ref, from_name) VALUES (?, ?)")
253
+ .run(link.ref, fromName);
254
+ // Apply to a target that arrived first.
255
+ const target = this.db
256
+ .prepare("SELECT id, from_name FROM turns WHERE id = ? AND hidden = 0")
257
+ .get(link.ref);
258
+ if (target && (target.from_name === fromName || (owner && fromName === owner))) {
259
+ this.db.prepare("UPDATE turns SET hidden = 1 WHERE id = ?").run(target.id);
260
+ this.db.prepare("DELETE FROM turn_text WHERE turn_id = ?").run(target.id);
261
+ this.db.prepare("DELETE FROM segments WHERE note_id = ?").run(target.id);
262
+ this.db.prepare("DELETE FROM spawns WHERE note_id = ?").run(target.id);
263
+ }
264
+ }
265
+ }
266
+
267
+ if (!hidden && !superseded) {
268
+ const insText = this.db.prepare(
269
+ "INSERT INTO turn_text (turn_id, loc, role, text) VALUES (?, ?, ?, ?)",
270
+ );
271
+ for (const doc of this.extractText(turn)) {
272
+ insText.run(turn.id, doc.loc, doc.role, doc.text);
273
+ }
274
+ }
275
+
276
+ // Selection map: tags, outfits, segments, and spawns are note turns
277
+ // with structured bodies; the index makes them queryable. A malformed
278
+ // note turn must never break indexing — the rebuild invariant (one
279
+ // bad turn anywhere cannot make the index unrecoverable) outranks
280
+ // completeness of the selection map.
281
+ let tag = null;
282
+ let outfitParsed = null;
283
+ let segParsed = null;
284
+ let spawnParsed = null;
285
+ if (turn.kind === "note") {
286
+ try {
287
+ tag = parseTag(turn);
288
+ outfitParsed = parseOutfit(turn);
289
+ segParsed = parseSegment(turn);
290
+ spawnParsed = parseSpawn(turn);
291
+ } catch {
292
+ tag = null;
293
+ outfitParsed = null;
294
+ segParsed = null;
295
+ spawnParsed = null;
296
+ }
297
+ }
298
+ if (tag) {
299
+ this.db
300
+ .prepare(
301
+ "INSERT OR IGNORE INTO tags (tag_id, target_ref, about, acts, case_slug, note) VALUES (?, ?, ?, ?, ?, ?)",
302
+ )
303
+ .run(turn.id, tag.target, JSON.stringify(tag.about), JSON.stringify(tag.acts), tag.caseSlug, tag.note);
304
+ }
305
+ const outfit = outfitParsed;
306
+ if (outfit) {
307
+ this.db
308
+ .prepare("INSERT OR IGNORE INTO outfits (outfit_id, task, status, members) VALUES (?, ?, ?, ?)")
309
+ .run(turn.id, outfit.task, outfit.status, JSON.stringify(outfit.members));
310
+ }
311
+ if (segParsed && !hidden) {
312
+ const locN = (r) => Number(/line:(\d+)/.exec(r ?? "")?.[1] ?? null);
313
+ const tsMs = Date.parse(String(turn.ts ?? ""));
314
+ this.db
315
+ .prepare(
316
+ `INSERT OR IGNORE INTO segments (note_id, thread, start_n, end_n, type, outcome, about, established, lesson, ts, ts_ms)
317
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
318
+ )
319
+ .run(
320
+ turn.id,
321
+ segParsed.thread,
322
+ locN(segParsed.start) ?? 0,
323
+ segParsed.end ? locN(segParsed.end) : null,
324
+ segParsed.type,
325
+ segParsed.outcome,
326
+ JSON.stringify(segParsed.about),
327
+ segParsed.established,
328
+ segParsed.lesson,
329
+ String(turn.ts ?? ""),
330
+ Number.isFinite(tsMs) ? tsMs : 0,
331
+ );
332
+ // Full established+lesson searchable under role "segment".
333
+ this.db
334
+ .prepare("INSERT INTO turn_text (turn_id, loc, role, text) VALUES (?, ?, ?, ?)")
335
+ .run(turn.id, "", "segment", segParsed.established + (segParsed.lesson ? "\n" + segParsed.lesson : ""));
336
+ }
337
+ if (spawnParsed && !hidden) {
338
+ this.db
339
+ .prepare(
340
+ "INSERT OR IGNORE INTO spawns (note_id, agent_thread, outfit_id, task, harness, grant_ref, ts) VALUES (?, ?, ?, ?, ?, ?, ?)",
341
+ )
342
+ .run(turn.id, spawnParsed.agent, spawnParsed.outfit, spawnParsed.task, spawnParsed.harness, spawnParsed.grant, String(turn.ts ?? ""));
343
+ }
344
+ }
345
+
346
+ // The segment catalog: latest revision per (thread, start), structural
347
+ // filters + optional FTS over established/lesson. Dead ends and
348
+ // superseded judgments are excluded unless asked for.
349
+ segmentCatalog({ thread, type, outcome, about, q, includeDead = false, limit = 50 } = {}) {
350
+ let rows = this.db
351
+ .prepare(
352
+ `SELECT * FROM segments s WHERE NOT EXISTS (
353
+ SELECT 1 FROM segments s2 WHERE s2.thread = s.thread AND s2.start_n = s.start_n
354
+ AND (s2.ts_ms > s.ts_ms OR (s2.ts_ms = s.ts_ms AND s2.note_id > s.note_id)))`,
355
+ )
356
+ .all();
357
+ if (q) {
358
+ const hits = new Set(this.search(q, { role: "segment", limit: 500 }).map((h) => h.id));
359
+ rows = rows.filter((r) => hits.has(r.note_id));
360
+ }
361
+ rows = rows.filter((r) => {
362
+ if (thread && r.thread !== thread) return false;
363
+ if (type && r.type !== type) return false;
364
+ if (outcome && r.outcome !== outcome) return false;
365
+ if (!outcome && !includeDead && (r.outcome === "dead-end" || r.outcome === "superseded")) return false;
366
+ if (about && !JSON.parse(r.about).includes(about)) return false;
367
+ return true;
368
+ });
369
+ rows.sort((a, b) => a.thread.localeCompare(b.thread) || a.start_n - b.start_n);
370
+ return rows.slice(0, limit).map((r) => ({ ...r, about: JSON.parse(r.about) }));
371
+ }
372
+
373
+ // Creation mapping lookups for quality assessment.
374
+ spawnsFor({ agent, outfit } = {}) {
375
+ const rows = this.db.prepare("SELECT * FROM spawns").all();
376
+ return rows.filter(
377
+ (r) => (!agent || r.agent_thread === agent) && (!outfit || r.outfit_id === outfit),
378
+ );
379
+ }
380
+
381
+ // Turn refs tagged with a case or topic slug (exact slug match).
382
+ // The unfiltered scan is v1 debt: fine while the mind stream is small,
383
+ // needs paging when tags accumulate over the record's lifetime.
384
+ taggedRefs({ caseSlug, about } = {}) {
385
+ const rows = caseSlug
386
+ ? this.db
387
+ .prepare("SELECT target_ref, about, acts, case_slug, note FROM tags WHERE case_slug = ?")
388
+ .all(caseSlug)
389
+ : this.db.prepare("SELECT target_ref, about, acts, case_slug, note FROM tags").all();
390
+ return rows
391
+ .filter((r) => {
392
+ if (caseSlug && r.case_slug !== caseSlug) return false;
393
+ if (about && !JSON.parse(r.about).includes(about)) return false;
394
+ return true;
395
+ })
396
+ .map((r) => ({
397
+ ref: r.target_ref,
398
+ about: JSON.parse(r.about),
399
+ acts: JSON.parse(r.acts),
400
+ caseSlug: r.case_slug,
401
+ note: r.note,
402
+ }));
403
+ }
404
+
405
+ outfitsFor(taskWords) {
406
+ const rows = this.db.prepare("SELECT outfit_id, task, status, members FROM outfits").all();
407
+ const words = taskWords.toLowerCase().split(/\s+/).filter(Boolean);
408
+ return rows
409
+ .filter((r) => words.some((w) => r.task.toLowerCase().includes(w)))
410
+ .map((r) => ({ id: r.outfit_id, task: r.task, status: r.status, members: JSON.parse(r.members) }));
411
+ }
412
+
413
+ // Text documents for one turn. Mail/chat: one doc. Session: one doc per
414
+ // conversational event extracted from the transcript blob.
415
+ extractText(turn) {
416
+ if (turn.kind === "note" && turn.body?.segment) return []; // dedicated role "segment" row
417
+ if (turn.kind === "mail" || turn.kind === "chat" || turn.kind === "note") {
418
+ const subject = turn.body?.subject ?? "";
419
+ const text = turn.body?.text ?? "";
420
+ const joined = subject ? subject + "\n" + text : text;
421
+ return joined.trim() ? [{ loc: "", role: turn.kind, text: joined }] : [];
422
+ }
423
+ if (turn.kind === "session" && typeof turn.body?.line === "string") {
424
+ const loc = `line:${turn.provenance?.origin?.line ?? 0}`;
425
+ return extractSessionTextFor(turn.provenance?.source, Buffer.from(turn.body.line, "utf8")).map(
426
+ (d) => ({ ...d, loc }),
427
+ );
428
+ }
429
+ return [];
430
+ }
431
+
432
+ // FTS query with optional filters. Returns rows with turn metadata and a
433
+ // snippet. Hidden (tombstoned) and superseded turns never match.
434
+ //
435
+ // User text is not FTS5 syntax: a colon, a trailing AND, or an unbalanced
436
+ // quote raises a parse error inside SQLite. First try the query as given
437
+ // (so power users keep operators), then fall back to quoting every term
438
+ // as a literal, then to no matches — never a crash.
439
+ search(query, opts = {}) {
440
+ try {
441
+ return this.searchRaw(query, opts);
442
+ } catch {
443
+ const quoted = String(query)
444
+ .split(/\s+/)
445
+ .filter(Boolean)
446
+ .map((t) => '"' + t.replaceAll('"', '""') + '"')
447
+ .join(" ");
448
+ if (!quoted) return [];
449
+ try {
450
+ return this.searchRaw(quoted, opts);
451
+ } catch {
452
+ return [];
453
+ }
454
+ }
455
+ }
456
+
457
+ searchRaw(query, { kind, thread, from, role, limit = 20 } = {}) {
458
+ let sql = `
459
+ SELECT t.id, t.ts, t.from_name, t.to_name, t.thread, t.kind, t.source, t.stream,
460
+ x.loc, x.role, snippet(turn_text, 3, '', '', ' … ', 24) AS snip
461
+ FROM turn_text x
462
+ JOIN turns t ON t.id = x.turn_id
463
+ WHERE turn_text MATCH ? AND t.hidden = 0 AND t.superseded = 0`;
464
+ const params = [query];
465
+ if (kind) {
466
+ sql += " AND t.kind = ?";
467
+ params.push(kind);
468
+ }
469
+ if (thread) {
470
+ sql += " AND t.thread = ?";
471
+ params.push(thread);
472
+ }
473
+ if (from) {
474
+ sql += " AND t.from_name = ?";
475
+ params.push(from);
476
+ }
477
+ if (role) {
478
+ sql += " AND x.role = ?";
479
+ params.push(role);
480
+ }
481
+ sql += " ORDER BY rank LIMIT ?";
482
+ params.push(limit);
483
+ return this.db.prepare(sql).all(...params);
484
+ }
485
+
486
+ counts() {
487
+ const row = this.db
488
+ .prepare(
489
+ `SELECT COUNT(*) AS turns,
490
+ SUM(hidden) AS hidden,
491
+ SUM(superseded) AS superseded,
492
+ (SELECT COUNT(*) FROM turn_text) AS docs
493
+ FROM turns`,
494
+ )
495
+ .get();
496
+ return {
497
+ turns: Number(row.turns ?? 0),
498
+ hidden: Number(row.hidden ?? 0),
499
+ superseded: Number(row.superseded ?? 0),
500
+ docs: Number(row.docs ?? 0),
501
+ };
502
+ }
503
+ }
504
+
505
+ // Claude Code text extraction, re-exported for compatibility; the
506
+ // per-format extractors live in formats.mjs.
507
+ export const extractSessionText = extractCcText;
508
+
509
+ export function dropIndex(store) {
510
+ rmSync(join(store.root, "index"), { recursive: true, force: true });
511
+ }
512
+
513
+ // Resolve a turn id wherever it lives: bulk streams first, then (for
514
+ // session-event turns, whose streams bulk reads exclude) via the index's
515
+ // stream column and a targeted journal read.
516
+ export function resolveTurn(store, index, id, byId = null) {
517
+ const bulk = byId ?? store.readAll();
518
+ const hit = bulk.get(id);
519
+ if (hit) return hit.turn;
520
+ const row = index.db.prepare("SELECT stream FROM turns WHERE id = ?").get(id);
521
+ if (!row) return null;
522
+ for (const t of store.readStream(row.stream)) if (t.id === id) return t;
523
+ return null;
524
+ }