@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,35 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ const OATS_MOUSE_BLOCK = `# OATS: scroll agent windows normally with a mouse or trackpad.\nset -g mouse on\n`;
7
+
8
+ /** Choose the tmux user config without creating a second competing config. */
9
+ export function tmuxConfigPath(home = homedir(), env = process.env) {
10
+ const legacy = join(home, ".tmux.conf");
11
+ const xdg = join(env.XDG_CONFIG_HOME || join(home, ".config"), "tmux", "tmux.conf");
12
+ return existsSync(legacy) || !existsSync(xdg) ? legacy : xdg;
13
+ }
14
+
15
+ /** Whether the selected config explicitly enables tmux mouse support. */
16
+ export function tmuxMouseEnabled(text) {
17
+ return text.split("\n").some((line) => {
18
+ const active = line.replace(/\s+#.*$/, "").trim();
19
+ return /^(?:set|set-option)(?:\s+-[a-zA-Z]+)*\s+mouse\s+(?:on|1)\s*$/.test(active);
20
+ });
21
+ }
22
+
23
+ /** Add the setting once and reload a running tmux server when possible. */
24
+ export function enableTmuxMouse(configPath = tmuxConfigPath()) {
25
+ const before = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
26
+ if (tmuxMouseEnabled(before)) return { changed: false, configPath, reloaded: false };
27
+
28
+ mkdirSync(dirname(configPath), { recursive: true });
29
+ const separator = before.length === 0 || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
30
+ appendFileSync(configPath, separator + OATS_MOUSE_BLOCK);
31
+
32
+ const server = spawnSync("tmux", ["has-session"], { stdio: "ignore" });
33
+ const reloaded = server.status === 0 && spawnSync("tmux", ["source-file", configPath], { stdio: "ignore" }).status === 0;
34
+ return { changed: true, configPath, reloaded };
35
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "packages": {
3
+ "oats.okf": {
4
+ "url": "https://github.com/awebai/oats-okf.git",
5
+ "ref": "v1.4.1",
6
+ "path": "oats-package"
7
+ },
8
+ "oats.aweb": {
9
+ "url": "https://github.com/awebai/oats-aweb.git",
10
+ "ref": "v1.8.0",
11
+ "path": "oats-package"
12
+ },
13
+ "oats.jira": {
14
+ "url": "https://github.com/awebai/oats-jira.git",
15
+ "ref": "v1.0.0",
16
+ "path": "oats-package"
17
+ },
18
+ "oats.linear": {
19
+ "url": "https://github.com/awebai/oats-linear.git",
20
+ "ref": "v1.0.0",
21
+ "path": "oats-package"
22
+ },
23
+ "oats.authoring": {
24
+ "url": "https://github.com/awebai/oats-authoring.git",
25
+ "ref": "v1.0.0",
26
+ "path": "oats-package"
27
+ },
28
+ "oats.dev": {
29
+ "url": "https://github.com/awebai/oats-dev.git",
30
+ "ref": "v1.0.0",
31
+ "path": "oats-package"
32
+ }
33
+ },
34
+ "capabilities": {
35
+ "oats.review": "oats.dev",
36
+ "oas.okf": { "package": "oats.okf", "capability": "oats.okf" },
37
+ "oas.aweb": { "package": "oats.aweb", "capability": "oats.aweb" },
38
+ "oas.jira": { "package": "oats.jira", "capability": "oats.jira" },
39
+ "oas.linear": { "package": "oats.linear", "capability": "oats.linear" },
40
+ "oas.authoring": { "package": "oats.authoring", "capability": "oats.authoring" },
41
+ "oas.dev": { "package": "oats.dev", "capability": "oats.dev" },
42
+ "oas.review": { "package": "oats.dev", "capability": "oats.review" }
43
+ }
44
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@awebai/oats",
3
+ "version": "0.22.0",
4
+ "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
+ "keywords": [
6
+ "agents",
7
+ "oats",
8
+ "cli"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/awebai/oats"
13
+ },
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "scripts": {
17
+ "test": "node scripts/run-tests.mjs",
18
+ "check": "node --check lib/core.mjs && node --check lib/packages.mjs && node --check bin/oats.mjs",
19
+ "check:pi": "node --experimental-strip-types --check packages/pi/extension/index.ts",
20
+ "validate": "node scripts/validate-project.mjs",
21
+ "validate:okf": "node scripts/validate-okf.mjs",
22
+ "pack:check": "node scripts/check-package-dry-runs.mjs",
23
+ "smoke:tarball": "node scripts/clean-room-smoke.mjs"
24
+ },
25
+ "exports": {
26
+ ".": "./lib/core.mjs",
27
+ "./core": "./lib/core.mjs",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "bin/",
32
+ "lib/",
33
+ "skills/",
34
+ "injects/",
35
+ "capabilities/",
36
+ "docs/",
37
+ "README.md",
38
+ "package-catalog.json",
39
+ "packages/record/bin/",
40
+ "packages/record/lib/",
41
+ "packages/record/docs/",
42
+ "packages/record/test/vectors/",
43
+ "packages/record/README.md",
44
+ "packages/record/package.json"
45
+ ],
46
+ "dependencies": {
47
+ "@awebai/pi": "^0.2.1"
48
+ },
49
+ "bin": {
50
+ "oats": "./bin/oats.mjs"
51
+ },
52
+ "devDependencies": {
53
+ "ajv": "8.17.1"
54
+ }
55
+ }
@@ -0,0 +1,151 @@
1
+ # @awebai/turn-record
2
+
3
+ The turn record: an append-only, content-addressed, replicated record of
4
+ turns, with the core tools over it — `capture` and `recall`. The normative
5
+ contract is [`docs/turn-record-sot.md`](docs/turn-record-sot.md) in this
6
+ package; the conformance vectors under `test/vectors/` pin the format
7
+ (`node test/vectors/validate.mjs`, dependency-free).
8
+
9
+ Everything is a signed turn in an append-only record. This package is the
10
+ core: gathering every conversation, keeping it durably, and finding it
11
+ again. The experimental tools that select and synthesize over the record
12
+ (dress, spawn, segments, mind) live in `packages/experimental` of the oats
13
+ repo and run as `oats experimental <cmd>`; they are deliberately not part
14
+ of this package.
15
+
16
+ - **`lib/canonical.mjs`** — canonical JSON (integers only in the core),
17
+ `t1:` content ids, did:key Ed25519 verification. Byte-compatible with awid
18
+ message signing.
19
+ - **`lib/store.mjs`** — the store: `streams/<owner>~<source>/journal.jsonl`
20
+ (owner-only append, torn-tail tolerant), `objects/sha256/` (immutable
21
+ blobs), `index/` (derived, never replicated). Merge is prefix extension
22
+ per stream + set union by id; non-prefix copies are quarantined, never
23
+ silently merged. Tombstones hide, v1 authority = author or record owner.
24
+ - **`lib/project-aweb.mjs`** — projections of aweb messages (signed rows,
25
+ legacy rows, client comm logs, interaction logs) into turns. Pure
26
+ functions of the source: the same message projected on two machines gets
27
+ the same id, so replicas dedupe by union.
28
+ - **`lib/capture-cc.mjs`** — session capture (Claude Code, pi, Codex via
29
+ the format registry in `formats.mjs`). Every native transcript record
30
+ becomes ONE turn holding the verbatim line (`body.line`), in a
31
+ per-session stream `<owner>~<source>.<session-id>`; capture is
32
+ incremental by source byte offset, so a growing session appends only its
33
+ new events — storage is linear in conversation size by construction, and
34
+ the original transcript is reconstructible by concatenating `body.line`.
35
+ Reconciliation is the capture (hooks/watchers only decide when to run
36
+ it).
37
+ - **`lib/capture-aw.mjs`** — aw client log capture into `<owner>~aw`.
38
+ - **`lib/index-db.mjs`** — derived SQLite FTS5 index (`node:sqlite`, no
39
+ dependencies). `update()` is incremental; `rebuild()` is reset + update
40
+ from zero (same code path). Session text is extracted per event with
41
+ exact line provenance; tombstoned turns disappear.
42
+ - **`lib/segments.mjs`, `lib/tags.mjs`** — parsers for the note
43
+ conventions (segments, spawn notes, tags, outfits) that experimental
44
+ tools write into the record. They live here because the index reads
45
+ every note in the record; the machinery that writes and uses these
46
+ notes is experimental.
47
+
48
+ ## Install and run
49
+
50
+ One shipped bin, `turn-record`, with three subcommands. From a checkout use
51
+ `node bin/turn-record.mjs ...`; from an npm install (the package has zero
52
+ dependencies and packs clean — `test/packaging.test.mjs` proves the tarball
53
+ runs standalone) just `turn-record ...`. Inside an oats install the same
54
+ commands are `oats capture|recall|setup`.
55
+
56
+ ```bash
57
+ turn-record setup # install Stop/SessionEnd hooks into every
58
+ # ~/.claude*/settings.json, install the background
59
+ # watcher (launchd on macOS, systemd user unit on
60
+ # Linux), then run the first capture pass.
61
+ # Idempotent; --dry-run previews; --owner overrides
62
+ # the machine name.
63
+
64
+ turn-record capture # one reconciliation pass, then index update
65
+ turn-record capture --watch # pass now, on filesystem change, every 15 min
66
+ turn-record capture --status # stream summary
67
+
68
+ turn-record recall "sqlite fts" # search mail + chat + sessions together
69
+ turn-record recall --kind mail --from acme/x q # filters
70
+ turn-record recall --thread aweb:conv:<id> # list a thread chronologically
71
+ turn-record recall --show t1:<hex> # print one turn
72
+ turn-record recall --reindex # full rebuild of the derived index
73
+ ```
74
+
75
+ Store root: `--root`, else `$TURN_RECORD_ROOT`, else `~/.turn-record`.
76
+ Stream owner: `--owner`, else `$TURN_RECORD_OWNER`, else the short hostname.
77
+
78
+ ## Ignoring sessions and paths (privacy)
79
+
80
+ `<root>/ignore` is a plain text file of glob patterns, one per line (blank
81
+ lines and `#` comments skipped). Any capture source file that matches is
82
+ **never captured at all** — it is skipped before being opened, so no turn
83
+ is appended and the offset cache never learns about it.
84
+ This is a capture-time control, stronger than not indexing: the bytes never
85
+ enter the record.
86
+
87
+ ```
88
+ # never capture this session (any format), by session id
89
+ 7fe2a1b4-9c3d-4e21-b0aa-1f2e3d4c5b6a
90
+
91
+ # never capture anything from this project's transcripts
92
+ **/-Users-juanre-private-project/**
93
+
94
+ # never capture one aw account's comm log
95
+ acme-secret
96
+ ```
97
+
98
+ Matching rules (deliberately minimal; no negation, no escapes):
99
+
100
+ - a pattern containing `/` matches against the source file's absolute path;
101
+ - a pattern without `/` matches against the file's basename and against its
102
+ session id (transcripts) or account name (aw comm logs);
103
+ - `*` matches within a path segment, `**` across segments, `?` one
104
+ character; the whole candidate must match.
105
+
106
+ Name-only patterns apply across every capture source: a short or generic
107
+ pattern meant for one aw account can also catch a session file's basename
108
+ or id in any format. Prefer path patterns for anything short or generic.
109
+
110
+ Ignored files are counted in each pass (`N ignored` in capture output;
111
+ `capture --status` shows the active pattern count), never skipped silently.
112
+ The file is per record root and is local policy, not record truth — the
113
+ sync guidance below replicates only `streams/` and `objects/`, so each
114
+ machine decides what its own capture refuses to read; sync the `ignore`
115
+ file yourself if you want one policy everywhere. Two honest limits: an
116
+ ignore file that exists but cannot be read fails the pass loudly (a privacy
117
+ control must not fail open), and ignoring is **forward-looking only** —
118
+ turns already captured stay in the record; hide those with a tombstone.
119
+
120
+ ## Multi-machine
121
+
122
+ Replicate `streams/` and `objects/` with any dumb file sync (syncthing,
123
+ rsync, git); never sync `index/`. Owner-only append means concurrent sync
124
+ cannot conflict; identical projections dedupe by id; a machine that sees a
125
+ non-prefix copy of a stream quarantines it loudly.
126
+
127
+ ## Durability and concurrency
128
+
129
+ Appends and merges run under a per-stream lockfile
130
+ (`streams/<id>/.lock`, exclusive-create, stale after 30 s), because the
131
+ torn-tail repair is a read-truncate-write sequence and hooks, watchers and
132
+ manual passes can fire concurrently for the same owner. Sync tools may copy
133
+ a `.lock` file; that cannot corrupt data, but a synced-in stale lock can
134
+ delay a local `mergeStreamCopy` on that stream by up to the stale threshold
135
+ (30 s), so excluding `.lock` from sync patterns is the right configuration. Journal writes fsync;
136
+ note that on macOS `fsync(2)` does not guarantee media durability (that
137
+ would need `F_FULLFSYNC`, which Node's fs API does not expose) — the
138
+ guarantee is OS-crash-level, not power-loss-level.
139
+
140
+ ## Upgrading
141
+
142
+ The derived index self-heals across schema changes by wiping and
143
+ rebuilding (it is cache; there is no in-place migration). After upgrading
144
+ this package, restart any long-running `capture --watch` process — a
145
+ daemon holding the old database file open would otherwise keep indexing
146
+ into an orphaned inode until it restarts.
147
+
148
+ ## Known costs, accepted for v1
149
+
150
+ - Deletion via tombstone is eventual: an offline replica retains bytes until
151
+ it reconnects. The SOT says this plainly; so do we.
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ // capture — land sessions and aw client logs in the turn record.
3
+ //
4
+ // The flag list lives in USAGE below, once, so `--help` and this header
5
+ // cannot drift apart.
6
+ //
7
+ // An unrecognized flag or a positional argument is a usage error, never a
8
+ // silently ignored option: parsing falls through to pass(), a WRITE, so it
9
+ // must refuse what it does not understand before anything is written.
10
+ //
11
+ // Privacy: `<root>/ignore` lists glob patterns (paths, session ids,
12
+ // accounts) whose sources are never captured — see lib/ignore.mjs.
13
+ // Reconciliation is the capture; hooks and watch only decide when to run it.
14
+
15
+ import { watch } from "node:fs";
16
+ import { homedir, hostname } from "node:os";
17
+ import { join } from "node:path";
18
+ import process from "node:process";
19
+
20
+ import { RecordStore } from "../lib/store.mjs";
21
+ import { captureAllSessions } from "../lib/capture-cc.mjs";
22
+ import { SESSION_FORMATS } from "../lib/formats.mjs";
23
+ import { captureAwLogs, defaultCommLogDir } from "../lib/capture-aw.mjs";
24
+ import { RecordIndex } from "../lib/index-db.mjs";
25
+ import { IgnoreError, ignoreFilePath, loadIgnore } from "../lib/ignore.mjs";
26
+
27
+ // Fail closed but actionably: an unreadable ignore file must stop capture,
28
+ // as one clear line naming the file — never an uncaught stack trace.
29
+ function loadIgnoreOrExit(recordRoot) {
30
+ try {
31
+ return loadIgnore(recordRoot);
32
+ } catch (err) {
33
+ console.error(err instanceof IgnoreError ? err.message : String(err));
34
+ process.exit(1);
35
+ }
36
+ }
37
+
38
+ // Every flag this program understands. An unknown flag is rejected rather
39
+ // than ignored, because the fall-through from argument parsing is pass() —
40
+ // a WRITE. `capture --help` was once a full reconciliation pass under a
41
+ // hostname-derived owner, which forked the whole record into a second owner
42
+ // namespace: 571 duplicate journals from one typo. Parsing must refuse what
43
+ // it does not understand before anything can be written.
44
+ const VALUE_FLAGS = new Set(["root", "owner"]);
45
+ const BOOL_FLAGS = new Set([
46
+ "watch",
47
+ "status",
48
+ "install-hint",
49
+ "help",
50
+ "quiet",
51
+ "sessions-only",
52
+ "aw-only",
53
+ "no-index",
54
+ ]);
55
+
56
+ const USAGE = `capture — land sessions and aw client logs in the turn record.
57
+
58
+ capture one reconciliation pass (sessions + aw logs)
59
+ capture --sessions-only only session transcripts (Claude Code, pi, codex)
60
+ capture --aw-only only aw client comm logs
61
+ capture --watch pass now, then re-pass on filesystem change
62
+ (debounced) and every 15 minutes regardless
63
+ capture --status show store/stream summary, capture nothing
64
+ capture --install-hint print the Claude Code hook snippet
65
+ capture --help this text
66
+ capture --quiet suppress per-pass progress
67
+ capture --no-index append turns without updating the derived index
68
+
69
+ --root <dir> store root (else $TURN_RECORD_ROOT, else ~/.turn-record)
70
+ --owner <name> stream owner (else $TURN_RECORD_OWNER, else short hostname)
71
+
72
+ Every invocation with no explicit subcommand WRITES. Reconciliation is the
73
+ capture; hooks and watch only decide when to run it.`;
74
+
75
+ class UsageError extends Error {}
76
+
77
+ function parseArgs(argv) {
78
+ const args = {};
79
+ for (let i = 0; i < argv.length; i++) {
80
+ const a = argv[i];
81
+ // capture takes no positional arguments. Accepting one silently is the
82
+ // same defect as accepting an unknown flag: `capture status`, meaning
83
+ // `capture --status`, would run a full write pass instead.
84
+ if (!a.startsWith("--")) throw new UsageError(`unexpected argument "${a}" (capture takes no positional arguments)`);
85
+ const name = a.slice(2);
86
+ if (VALUE_FLAGS.has(name)) {
87
+ const value = argv[++i];
88
+ if (value === undefined) throw new UsageError(`${a} needs a value`);
89
+ args[name] = value;
90
+ } else if (BOOL_FLAGS.has(name)) {
91
+ args[name] = true;
92
+ } else {
93
+ throw new UsageError(`unknown flag ${a}`);
94
+ }
95
+ }
96
+ return args;
97
+ }
98
+
99
+ let args;
100
+ try {
101
+ args = parseArgs(process.argv.slice(2));
102
+ } catch (err) {
103
+ if (!(err instanceof UsageError)) throw err;
104
+ console.error(`capture: ${err.message}\n\n${USAGE}`);
105
+ process.exit(2);
106
+ }
107
+
108
+ if (args.help) {
109
+ console.log(USAGE);
110
+ process.exit(0);
111
+ }
112
+
113
+ const root = args.root ?? process.env.TURN_RECORD_ROOT ?? join(homedir(), ".turn-record");
114
+ const ownerExplicit = args.owner !== undefined || process.env.TURN_RECORD_OWNER !== undefined;
115
+ const owner = args.owner ?? process.env.TURN_RECORD_OWNER ?? hostname().split(".")[0];
116
+ const quiet = Boolean(args.quiet);
117
+ const store = new RecordStore(root, { owner });
118
+
119
+ // The owner decides the stream namespace, and turn ids hash it into the
120
+ // canonical core — so the same conversation captured under two owners yields
121
+ // two sets of ids for identical content, which the index cannot dedupe. When
122
+ // the derived owner is a stranger to a record that already has streams, say
123
+ // so. A WARNING, never a refusal: capture runs on every agent's session hooks,
124
+ // and a guard that misjudges would stop capture everywhere — strictly worse
125
+ // than the duplication it prevents. First run on a new machine sees an empty
126
+ // root, no other owners, and nothing is printed.
127
+ function warnOnStrangerOwner() {
128
+ if (ownerExplicit) return;
129
+ let others;
130
+ try {
131
+ others = new Set(
132
+ store
133
+ .listStreams()
134
+ .map((id) => id.slice(0, id.indexOf("~")))
135
+ .filter(Boolean),
136
+ );
137
+ } catch {
138
+ return; // unreadable or absent root: not this function's business
139
+ }
140
+ if (others.size === 0 || others.has(owner)) return;
141
+ console.error(
142
+ `capture: writing as owner "${owner}" (from hostname), but this record holds ` +
143
+ `streams only under ${[...others].sort().map((o) => `"${o}"`).join(", ")}. ` +
144
+ `Pass --owner or set TURN_RECORD_OWNER if that is not what you meant — ` +
145
+ `capturing under a second owner duplicates the record rather than extending it.`,
146
+ );
147
+ }
148
+
149
+ function log(...parts) {
150
+ if (!quiet) console.log(...parts);
151
+ }
152
+
153
+ function pass() {
154
+ const out = { appended: 0 };
155
+ const ignore = loadIgnoreOrExit(root);
156
+ if (!args["aw-only"]) {
157
+ for (const r of captureAllSessions(store, { owner, ignore })) {
158
+ out.appended += r.appended;
159
+ const extras = [r.ignored ? `${r.ignored} ignored` : "", r.held ? `${r.held} held` : ""]
160
+ .filter(Boolean)
161
+ .join(", ");
162
+ log(
163
+ `sessions: ${r.sessions} scanned, ${r.appended} new turns in ${r.streams} sessions${extras ? ` (${extras})` : ""} -> ${r.stream}`,
164
+ );
165
+ }
166
+ }
167
+ if (!args["sessions-only"]) {
168
+ let awEntries = 0;
169
+ let awAppended = 0;
170
+ let awFailed = 0;
171
+ let awFiles = 0;
172
+ let awIgnored = 0;
173
+ for (const r of captureAwLogs(store, { owner, ignore })) {
174
+ if (r.ignored) {
175
+ awIgnored++;
176
+ continue;
177
+ }
178
+ awFiles++;
179
+ awEntries += r.entries;
180
+ awAppended += r.appended;
181
+ awFailed += r.failed;
182
+ out.appended += r.appended;
183
+ if (r.failed) console.error(`aw-log ${r.account}: ${r.failed} entries failed to project`);
184
+ }
185
+ const ignored = awIgnored ? `, ${awIgnored} ignored` : "";
186
+ log(`aw-logs: ${awFiles} files, ${awEntries} entries, ${awAppended} new, ${awFailed} failed${ignored}`);
187
+ }
188
+ if (out.appended > 0 && !args["no-index"]) {
189
+ const index = new RecordIndex(store);
190
+ try {
191
+ index.update();
192
+ log(`index: updated (${JSON.stringify(index.counts())})`);
193
+ } finally {
194
+ index.close();
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+
200
+ if (args["install-hint"]) {
201
+ const self = new URL(import.meta.url).pathname;
202
+ console.log(`Add to Claude Code settings.json to capture on session stop/end:
203
+
204
+ {
205
+ "hooks": {
206
+ "Stop": [{"hooks": [{"type": "command", "command": "node ${self} --sessions-only --quiet"}]}],
207
+ "SessionEnd": [{"hooks": [{"type": "command", "command": "node ${self} --sessions-only --quiet"}]}]
208
+ }
209
+ }
210
+
211
+ A dropped hook is recovered by any later pass (capture, or capture --watch).`);
212
+ process.exit(0);
213
+ }
214
+
215
+ if (args.status) {
216
+ console.log(`root: ${root}\nowner: ${owner}`);
217
+ const ignore = loadIgnoreOrExit(root);
218
+ if (ignore.size > 0) {
219
+ console.log(`ignore: ${ignore.size} pattern${ignore.size === 1 ? "" : "s"} (${ignoreFilePath(root)})`);
220
+ }
221
+ for (const streamId of store.listStreams()) {
222
+ console.log(` ${streamId}: ${store.readStream(streamId).length} turns`);
223
+ }
224
+ process.exit(0);
225
+ }
226
+
227
+ warnOnStrangerOwner();
228
+ pass();
229
+
230
+ if (args.watch) {
231
+ const roots = [
232
+ ...Object.values(SESSION_FORMATS).flatMap((f) => f.defaultRoots()),
233
+ defaultCommLogDir(),
234
+ ];
235
+ let timer = null;
236
+ const schedule = () => {
237
+ if (timer) clearTimeout(timer);
238
+ timer = setTimeout(() => {
239
+ timer = null;
240
+ try {
241
+ pass();
242
+ } catch (err) {
243
+ console.error(`capture pass failed: ${err.message}`);
244
+ }
245
+ }, 2000);
246
+ };
247
+ for (const dir of roots) {
248
+ try {
249
+ watch(dir, { recursive: true }, schedule);
250
+ log(`watching ${dir}`);
251
+ } catch (err) {
252
+ console.error(`cannot watch ${dir}: ${err.message}`);
253
+ }
254
+ }
255
+ setInterval(schedule, 15 * 60 * 1000); // reconcile even if events were missed
256
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ // recall — search the turn record.
3
+ //
4
+ // recall <query...> FTS5 query over mail, chat, sessions
5
+ // recall --kind mail <query...> filter by kind (mail|chat|session|note)
6
+ // recall --thread <thread> [query] filter/list by thread
7
+ // recall --from <name> <query...> filter by speaker
8
+ // recall --limit N max results (default 20)
9
+ // recall --show <turn-id> print one turn as JSON
10
+ // recall --reindex rebuild the derived index and exit
11
+ //
12
+ // Store root: --root, else $TURN_RECORD_ROOT, else ~/.turn-record
13
+
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import process from "node:process";
17
+
18
+ import { RecordStore } from "../lib/store.mjs";
19
+ import { RecordIndex } from "../lib/index-db.mjs";
20
+
21
+ function parseArgs(argv) {
22
+ const args = { _: [] };
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const a = argv[i];
25
+ if (["--root", "--kind", "--thread", "--from", "--role", "--limit", "--show"].includes(a)) {
26
+ args[a.slice(2)] = argv[++i];
27
+ } else if (a.startsWith("--")) args[a.slice(2)] = true;
28
+ else args._.push(a);
29
+ }
30
+ return args;
31
+ }
32
+
33
+ const args = parseArgs(process.argv.slice(2));
34
+ const root = args.root ?? process.env.TURN_RECORD_ROOT ?? join(homedir(), ".turn-record");
35
+ const store = new RecordStore(root, {});
36
+ const index = new RecordIndex(store);
37
+
38
+ try {
39
+ if (args.reindex) {
40
+ index.rebuild();
41
+ console.log(JSON.stringify(index.counts()));
42
+ process.exit(0);
43
+ }
44
+
45
+ if (args.show) {
46
+ const { resolveTurn } = await import("../lib/index-db.mjs");
47
+ const byId = store.readAll();
48
+ const turn = resolveTurn(store, index, args.show, byId);
49
+ if (!turn) {
50
+ console.error(`no turn ${args.show}`);
51
+ process.exit(1);
52
+ }
53
+ // Tombstoned turns are hidden from tool output, id lookup included.
54
+ if (store.hiddenIds(byId).has(args.show)) {
55
+ console.error(`turn ${args.show} is tombstoned`);
56
+ process.exit(1);
57
+ }
58
+ console.log(JSON.stringify(turn, null, 2));
59
+ process.exit(0);
60
+ }
61
+
62
+ const query = args._.join(" ").trim();
63
+ if (!query && !args.thread) {
64
+ console.error("usage: recall [--kind k] [--thread t] [--from f] [--role r] [--limit n] <query>");
65
+ process.exit(2);
66
+ }
67
+
68
+ const limit = args.limit ? Number(args.limit) : 20;
69
+ let rows;
70
+ if (query) {
71
+ rows = index.search(query, {
72
+ kind: args.kind,
73
+ thread: args.thread,
74
+ from: args.from,
75
+ role: args.role,
76
+ limit,
77
+ });
78
+ } else {
79
+ // Thread listing without a text query: chronological turns of a thread.
80
+ rows = index.db
81
+ .prepare(
82
+ `SELECT id, ts, from_name, to_name, thread, kind, source, stream, '' AS loc, kind AS role, '' AS snip
83
+ FROM turns WHERE thread = ? AND hidden = 0 AND superseded = 0
84
+ ORDER BY ts LIMIT ?`,
85
+ )
86
+ .all(args.thread, limit);
87
+ }
88
+
89
+ if (rows.length === 0) {
90
+ console.error("no matches");
91
+ process.exit(1);
92
+ }
93
+ for (const r of rows) {
94
+ const where = r.loc ? ` @${r.loc}` : "";
95
+ const to = r.to_name ? ` -> ${r.to_name}` : "";
96
+ console.log(`${r.ts} [${r.kind}] ${r.from_name}${to} ${r.thread ?? ""}${where}`);
97
+ if (r.snip) console.log(` ${r.snip.replaceAll("\n", " ")}`);
98
+ console.log(` ${r.id}`);
99
+ }
100
+ } finally {
101
+ index.close();
102
+ }