@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,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
|
+
}
|