brane 0.1.1 → 0.3.1
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/AGENTS.md +367 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +101 -0
- package/DESIGN.md +405 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +213 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +133 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +360 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1101 -0
- package/dist/commands/token.js +154 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6353 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +643 -0
- package/dist/db/tokens.js +624 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/app-origin.js +26 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +84 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +216 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/app-host-literal.test.js +53 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +56 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +299 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +143 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +150 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +128 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/terminal-size.test.js +36 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +646 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +627 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/test/wrangler-route.test.js +27 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +213 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +154 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +104 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +243 -0
- package/docs/token-rotation.md +431 -0
- package/docs/view-urls.md +134 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +79 -27
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +53 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.travis.yml +0 -7
- package/lib/brane.cjs.js +0 -12
- package/lib/brane.cjs.js.map +0 -1
- package/lib/brane.d.ts +0 -11
- package/lib/brane.js +0 -6
- package/lib/brane.js.map +0 -1
- package/lib/tests.d.ts +0 -1
- package/lib/tests.js +0 -8
- package/lib/tests.js.map +0 -1
- package/rollup.config.js +0 -21
- package/scripts/test.sh +0 -8
- package/src/brane.ts +0 -21
- package/src/tests.ts +0 -8
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createTestDb } from "./helpers.js";
|
|
7
|
+
import { appendEvent } from "../db/events.js";
|
|
8
|
+
import { readToolUse, readToolUseWindow, maxToolUseRev } from "../db/tool-uses.js";
|
|
9
|
+
import { newCodexLogCursor, pollCodexToolLog, toolUseFromCodexRecord, } from "../codex-tool-log.js";
|
|
10
|
+
import { findCodexRollout, readCodexUsage, resetCodexCaches } from "../agents.js";
|
|
11
|
+
import { startCodexToolSampler } from "../daemon/server.js";
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Fixture construction — the real layout:
|
|
14
|
+
// <base>/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl, first line a `session_meta`.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
const WHEN = new Date(2026, 2, 5, 10, 0, 0);
|
|
17
|
+
function rolloutPath(base, uuid, when = WHEN) {
|
|
18
|
+
const dir = join(base, String(when.getFullYear()), String(when.getMonth() + 1).padStart(2, "0"), String(when.getDate()).padStart(2, "0"));
|
|
19
|
+
mkdirSync(dir, { recursive: true });
|
|
20
|
+
return join(dir, `rollout-${when.toISOString().replace(/[:.]/g, "-")}-${uuid}.jsonl`);
|
|
21
|
+
}
|
|
22
|
+
function line(obj) {
|
|
23
|
+
return JSON.stringify(obj) + "\n";
|
|
24
|
+
}
|
|
25
|
+
function sessionMeta(uuid, cwd = "/Users/test/dev/proj") {
|
|
26
|
+
return line({
|
|
27
|
+
type: "session_meta",
|
|
28
|
+
// `base_instructions` stands in for codex's inlined system prompt, which is
|
|
29
|
+
// what makes a real first line hundreds of kilobytes.
|
|
30
|
+
payload: { id: uuid, cwd, timestamp: WHEN.toISOString(), base_instructions: "x".repeat(4096) },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function tokenCount(total, extra = {}) {
|
|
34
|
+
return line({
|
|
35
|
+
type: "event_msg",
|
|
36
|
+
payload: {
|
|
37
|
+
type: "token_count",
|
|
38
|
+
info: {
|
|
39
|
+
total_token_usage: {
|
|
40
|
+
input_tokens: total,
|
|
41
|
+
cached_input_tokens: 0,
|
|
42
|
+
cache_write_input_tokens: 0,
|
|
43
|
+
output_tokens: 0,
|
|
44
|
+
total_tokens: total,
|
|
45
|
+
...extra,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function toolCall(callId, name, input, custom = false) {
|
|
52
|
+
return line({
|
|
53
|
+
type: "response_item",
|
|
54
|
+
payload: custom
|
|
55
|
+
? { type: "custom_tool_call", call_id: callId, name, input, status: "completed" }
|
|
56
|
+
: { type: "function_call", call_id: callId, name, arguments: input },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function toolOutput(callId, output, custom = false) {
|
|
60
|
+
return line({
|
|
61
|
+
type: "response_item",
|
|
62
|
+
payload: {
|
|
63
|
+
type: custom ? "custom_tool_call_output" : "function_call_output",
|
|
64
|
+
call_id: callId,
|
|
65
|
+
output,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function newBase() {
|
|
70
|
+
return mkdtempSync(join(tmpdir(), "codex-log-test-"));
|
|
71
|
+
}
|
|
72
|
+
function seedNarrative(db, id = "codex-test0001") {
|
|
73
|
+
db.prepare("INSERT INTO narratives (id, brane_id, agent_type, role, status, fold_path, created_at) VALUES (?, 'brane-test', 'codex', 'worker', 'active', '/tmp', ?)").run(id, Date.now());
|
|
74
|
+
return id;
|
|
75
|
+
}
|
|
76
|
+
/** One narrative's reader: a fresh cursor, bound to a fixture root. A second
|
|
77
|
+
* `reader()` over the same fixture is what a daemon restart looks like — the
|
|
78
|
+
* rows are already in the database and the cursor knows nothing about them. */
|
|
79
|
+
function reader(db, nid, sessionId, base) {
|
|
80
|
+
const cursor = newCodexLogCursor();
|
|
81
|
+
return () => pollCodexToolLog(db, nid, sessionId, cursor, base);
|
|
82
|
+
}
|
|
83
|
+
// The rollout-path and usage caches are process-lifetime by design, keyed on a
|
|
84
|
+
// path that a fresh mkdtemp fixture never reuses — but reset anyway so no test
|
|
85
|
+
// can depend on another having run.
|
|
86
|
+
beforeEach(() => resetCodexCaches());
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
describe("findCodexRollout", () => {
|
|
89
|
+
it("locates a rollout by session id under the YYYY/MM/DD tiers", () => {
|
|
90
|
+
const base = newBase();
|
|
91
|
+
const path = rolloutPath(base, "aaaaaaaa-0000-4000-8000-000000000001");
|
|
92
|
+
writeFileSync(path, sessionMeta("aaaaaaaa-0000-4000-8000-000000000001"));
|
|
93
|
+
assert.equal(findCodexRollout("aaaaaaaa-0000-4000-8000-000000000001", base), path);
|
|
94
|
+
});
|
|
95
|
+
it("returns undefined when no rollout records that session", () => {
|
|
96
|
+
const base = newBase();
|
|
97
|
+
writeFileSync(rolloutPath(base, "aaaa-1"), sessionMeta("aaaa-1"));
|
|
98
|
+
assert.equal(findCodexRollout("bbbb-2", base), undefined);
|
|
99
|
+
});
|
|
100
|
+
it("trusts session_meta over the filename", () => {
|
|
101
|
+
// A file *named* for one session whose meta records another is not a match:
|
|
102
|
+
// the name is a hint, the meta is the authority.
|
|
103
|
+
const base = newBase();
|
|
104
|
+
const path = rolloutPath(base, "cccc-3");
|
|
105
|
+
writeFileSync(path, sessionMeta("dddd-4"));
|
|
106
|
+
assert.equal(findCodexRollout("cccc-3", base), undefined);
|
|
107
|
+
});
|
|
108
|
+
it("does not cache a miss — a session found later still resolves", () => {
|
|
109
|
+
const base = newBase();
|
|
110
|
+
assert.equal(findCodexRollout("eeee-5", base), undefined);
|
|
111
|
+
const path = rolloutPath(base, "eeee-5");
|
|
112
|
+
writeFileSync(path, sessionMeta("eeee-5"));
|
|
113
|
+
assert.equal(findCodexRollout("eeee-5", base), path);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe("readCodexUsage", () => {
|
|
117
|
+
it("reports the last cumulative total as TOK, not USD", () => {
|
|
118
|
+
// pricing.ts prices Anthropic models only and falls through to a
|
|
119
|
+
// $10/$50-per-Mtok default tier, so pricing a GPT session would invoice an
|
|
120
|
+
// OpenAI transcript at Opus rates. Tokens are the honest unit.
|
|
121
|
+
const base = newBase();
|
|
122
|
+
const path = rolloutPath(base, "usage-1");
|
|
123
|
+
writeFileSync(path, sessionMeta("usage-1") + tokenCount(100) + tokenCount(4200));
|
|
124
|
+
const reading = readCodexUsage("usage-1", base);
|
|
125
|
+
assert.equal(reading?.unit, "TOK");
|
|
126
|
+
assert.equal(reading?.amount, 4200);
|
|
127
|
+
});
|
|
128
|
+
it("subtracts cached input so the breakdown is disjoint like claude's", () => {
|
|
129
|
+
// Codex's `cached_input_tokens` is a SUBSET of `input_tokens`; claude
|
|
130
|
+
// reports the two separately. A consumer summing the fields must not count
|
|
131
|
+
// the cached portion twice.
|
|
132
|
+
const base = newBase();
|
|
133
|
+
const path = rolloutPath(base, "usage-2");
|
|
134
|
+
writeFileSync(path, sessionMeta("usage-2") +
|
|
135
|
+
tokenCount(0, {
|
|
136
|
+
input_tokens: 1000,
|
|
137
|
+
cached_input_tokens: 900,
|
|
138
|
+
output_tokens: 50,
|
|
139
|
+
total_tokens: 1050,
|
|
140
|
+
}));
|
|
141
|
+
const reading = readCodexUsage("usage-2", base);
|
|
142
|
+
assert.deepEqual(reading?.tokens, { input: 100, output: 50, cacheRead: 900, cacheWrite: 0 });
|
|
143
|
+
assert.equal(reading?.amount, 1050);
|
|
144
|
+
// The parts of a disjoint breakdown add back up to the whole.
|
|
145
|
+
const t = reading.tokens;
|
|
146
|
+
assert.equal(t.input + t.cacheRead + t.output, reading.amount);
|
|
147
|
+
});
|
|
148
|
+
it("clamps a cached count that exceeds input rather than reporting negative input", () => {
|
|
149
|
+
const base = newBase();
|
|
150
|
+
writeFileSync(rolloutPath(base, "usage-3"), sessionMeta("usage-3") +
|
|
151
|
+
tokenCount(0, { input_tokens: 10, cached_input_tokens: 999, total_tokens: 10 }));
|
|
152
|
+
const reading = readCodexUsage("usage-3", base);
|
|
153
|
+
assert.equal(reading?.tokens?.input, 0);
|
|
154
|
+
});
|
|
155
|
+
it("finds the last counter past a tail window filled with a huge tool output", () => {
|
|
156
|
+
// The window doubles until it catches a `token_count`. A single turn can
|
|
157
|
+
// append far more than the initial 64KB window (one big tool result), and
|
|
158
|
+
// the counter must not be lost behind it.
|
|
159
|
+
const base = newBase();
|
|
160
|
+
const path = rolloutPath(base, "usage-4");
|
|
161
|
+
const huge = line({ type: "response_item", payload: { type: "message", text: "y".repeat(200_000) } });
|
|
162
|
+
writeFileSync(path, sessionMeta("usage-4") + tokenCount(777) + huge);
|
|
163
|
+
assert.equal(readCodexUsage("usage-4", base)?.amount, 777);
|
|
164
|
+
});
|
|
165
|
+
it("returns null for a rollout with no counters, and for an unknown session", () => {
|
|
166
|
+
const base = newBase();
|
|
167
|
+
writeFileSync(rolloutPath(base, "usage-5"), sessionMeta("usage-5"));
|
|
168
|
+
assert.equal(readCodexUsage("usage-5", base), null);
|
|
169
|
+
assert.equal(readCodexUsage("nope", base), null);
|
|
170
|
+
});
|
|
171
|
+
it("picks up a counter appended after a previous read", () => {
|
|
172
|
+
// The (size, mtime) memo must not pin a stale reading once the file grows.
|
|
173
|
+
const base = newBase();
|
|
174
|
+
const path = rolloutPath(base, "usage-6");
|
|
175
|
+
writeFileSync(path, sessionMeta("usage-6") + tokenCount(10));
|
|
176
|
+
assert.equal(readCodexUsage("usage-6", base)?.amount, 10);
|
|
177
|
+
appendFileSync(path, tokenCount(20));
|
|
178
|
+
assert.equal(readCodexUsage("usage-6", base)?.amount, 20);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
describe("toolUseFromCodexRecord", () => {
|
|
182
|
+
it("maps both call shapes, reading input from the field each one uses", () => {
|
|
183
|
+
// function_call carries `arguments`; custom_tool_call carries `input`.
|
|
184
|
+
const fn = toolUseFromCodexRecord(JSON.parse(toolCall("call_a", "shell", '{"cmd":"ls"}')));
|
|
185
|
+
assert.equal(fn?.name, "shell");
|
|
186
|
+
assert.equal(fn?.input, '{"cmd":"ls"}');
|
|
187
|
+
assert.equal(fn?.status, "pending");
|
|
188
|
+
const custom = toolUseFromCodexRecord(JSON.parse(toolCall("call_b", "exec", "text(1)", true)));
|
|
189
|
+
assert.equal(custom?.name, "exec");
|
|
190
|
+
assert.equal(custom?.input, "text(1)");
|
|
191
|
+
});
|
|
192
|
+
it("ignores records that are not tool calls", () => {
|
|
193
|
+
for (const record of [
|
|
194
|
+
{ type: "event_msg", payload: { type: "token_count" } },
|
|
195
|
+
{ type: "response_item", payload: { type: "reasoning" } },
|
|
196
|
+
{ type: "response_item", payload: { type: "function_call" } }, // no call_id
|
|
197
|
+
{ type: "session_meta", payload: { id: "x" } },
|
|
198
|
+
null,
|
|
199
|
+
"not an object",
|
|
200
|
+
]) {
|
|
201
|
+
assert.equal(toolUseFromCodexRecord(record), undefined);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
it("attributes an output from the names map, falling back to unknown", () => {
|
|
205
|
+
const names = new Map();
|
|
206
|
+
toolUseFromCodexRecord(JSON.parse(toolCall("call_c", "apply_patch", "{}")), names);
|
|
207
|
+
const known = toolUseFromCodexRecord(JSON.parse(toolOutput("call_c", "ok")), names);
|
|
208
|
+
assert.equal(known?.name, "apply_patch");
|
|
209
|
+
assert.equal(known?.status, "completed");
|
|
210
|
+
const orphan = toolUseFromCodexRecord(JSON.parse(toolOutput("call_z", "ok")), names);
|
|
211
|
+
assert.equal(orphan?.name, "unknown");
|
|
212
|
+
});
|
|
213
|
+
it("flattens custom_tool_call_output content parts into text", () => {
|
|
214
|
+
// The wire shape is [{type,text},…]. Storing it verbatim would show the
|
|
215
|
+
// operator JSON where the output should be.
|
|
216
|
+
const mapped = toolUseFromCodexRecord(JSON.parse(toolOutput("call_d", [
|
|
217
|
+
{ type: "input_text", text: "Script completed\n" },
|
|
218
|
+
{ type: "input_text", text: "2" },
|
|
219
|
+
], true)));
|
|
220
|
+
assert.equal(mapped?.output, "Script completed\n2");
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
describe("pollCodexToolLog", () => {
|
|
224
|
+
it("records calls, completes them from their output, and returns written rows", () => {
|
|
225
|
+
const db = createTestDb();
|
|
226
|
+
const nid = seedNarrative(db);
|
|
227
|
+
const base = newBase();
|
|
228
|
+
writeFileSync(rolloutPath(base, "tools-1"), sessionMeta("tools-1") +
|
|
229
|
+
toolCall("call_a", "shell", '{"cmd":"ls"}') +
|
|
230
|
+
toolOutput("call_a", "a.txt\n"));
|
|
231
|
+
const written = reader(db, nid, "tools-1", base)();
|
|
232
|
+
// The caller pushes what comes back, so the return value is part of the
|
|
233
|
+
// contract, not a convenience.
|
|
234
|
+
assert.deepEqual(written.map((r) => `${r.dedup_key}:${r.status}`), ["call_a:pending", "call_a:completed"]);
|
|
235
|
+
const row = readToolUse(db, nid, "call_a");
|
|
236
|
+
assert.equal(row?.name, "shell");
|
|
237
|
+
assert.equal(row?.status, "completed");
|
|
238
|
+
assert.equal(row?.input, '{"cmd":"ls"}');
|
|
239
|
+
assert.equal(row?.output, "a.txt\n");
|
|
240
|
+
assert.equal(row?.source, "session_log");
|
|
241
|
+
assert.equal(row?.agent_type, "codex");
|
|
242
|
+
});
|
|
243
|
+
it("leaves a call pending until its output is appended", () => {
|
|
244
|
+
const db = createTestDb();
|
|
245
|
+
const nid = seedNarrative(db);
|
|
246
|
+
const base = newBase();
|
|
247
|
+
const path = rolloutPath(base, "tools-2");
|
|
248
|
+
writeFileSync(path, sessionMeta("tools-2") + toolCall("call_b", "shell", "{}"));
|
|
249
|
+
const poll = reader(db, nid, "tools-2", base);
|
|
250
|
+
poll();
|
|
251
|
+
assert.equal(readToolUse(db, nid, "call_b")?.status, "pending");
|
|
252
|
+
appendFileSync(path, toolOutput("call_b", "done"));
|
|
253
|
+
poll();
|
|
254
|
+
const row = readToolUse(db, nid, "call_b");
|
|
255
|
+
assert.equal(row?.status, "completed");
|
|
256
|
+
assert.equal(row?.output, "done");
|
|
257
|
+
});
|
|
258
|
+
it("attributes an output whose call was consumed by an earlier read", () => {
|
|
259
|
+
// The names map lives on the cursor precisely so this survives the gap
|
|
260
|
+
// between two polls.
|
|
261
|
+
const db = createTestDb();
|
|
262
|
+
const nid = seedNarrative(db);
|
|
263
|
+
const base = newBase();
|
|
264
|
+
const path = rolloutPath(base, "tools-3");
|
|
265
|
+
writeFileSync(path, sessionMeta("tools-3") + toolCall("call_c", "apply_patch", "{}"));
|
|
266
|
+
const poll = reader(db, nid, "tools-3", base);
|
|
267
|
+
poll();
|
|
268
|
+
appendFileSync(path, toolOutput("call_c", "ok"));
|
|
269
|
+
poll();
|
|
270
|
+
// The upsert never overwrites `name`, so the real one survives regardless;
|
|
271
|
+
// this pins that the completing write doesn't even try to replace it.
|
|
272
|
+
assert.equal(readToolUse(db, nid, "call_c")?.name, "apply_patch");
|
|
273
|
+
});
|
|
274
|
+
it("reads only appended bytes — a re-poll with no growth writes nothing", () => {
|
|
275
|
+
const db = createTestDb();
|
|
276
|
+
const nid = seedNarrative(db);
|
|
277
|
+
const base = newBase();
|
|
278
|
+
writeFileSync(rolloutPath(base, "tools-4"), sessionMeta("tools-4") + toolCall("call_d", "shell", "{}") + toolOutput("call_d", "ok"));
|
|
279
|
+
const poll = reader(db, nid, "tools-4", base);
|
|
280
|
+
poll();
|
|
281
|
+
const after = maxToolUseRev(db, nid);
|
|
282
|
+
assert.deepEqual(poll(), []);
|
|
283
|
+
assert.deepEqual(poll(), []);
|
|
284
|
+
assert.equal(maxToolUseRev(db, nid), after);
|
|
285
|
+
});
|
|
286
|
+
it("does not restate unchanged rows when a cold cursor replays the file", () => {
|
|
287
|
+
// The cursor is in memory, so the first poll after a daemon restart (or a
|
|
288
|
+
// revive) re-reads the whole rollout. Every write bumps rev, and rev is the
|
|
289
|
+
// client sync cursor — without the guard that would be a sync storm on
|
|
290
|
+
// every restart.
|
|
291
|
+
const db = createTestDb();
|
|
292
|
+
const nid = seedNarrative(db);
|
|
293
|
+
const base = newBase();
|
|
294
|
+
writeFileSync(rolloutPath(base, "tools-5"), sessionMeta("tools-5") +
|
|
295
|
+
toolCall("call_e", "shell", "{}") +
|
|
296
|
+
toolOutput("call_e", "ok") +
|
|
297
|
+
toolCall("call_f", "read", "{}") +
|
|
298
|
+
toolOutput("call_f", "ok"));
|
|
299
|
+
reader(db, nid, "tools-5", base)();
|
|
300
|
+
const after = maxToolUseRev(db, nid);
|
|
301
|
+
// A brand-new cursor over the same file: what a restart actually does.
|
|
302
|
+
assert.deepEqual(reader(db, nid, "tools-5", base)(), []);
|
|
303
|
+
assert.equal(maxToolUseRev(db, nid), after);
|
|
304
|
+
assert.equal(readToolUseWindow(db, nid).length, 2);
|
|
305
|
+
});
|
|
306
|
+
it("survives a chunk boundary that splits a multi-byte character", () => {
|
|
307
|
+
// This is why `pending` is a Buffer. Decoding each chunk independently
|
|
308
|
+
// would leave U+FFFD inside the JSON and the line would never parse.
|
|
309
|
+
const db = createTestDb();
|
|
310
|
+
const nid = seedNarrative(db);
|
|
311
|
+
const base = newBase();
|
|
312
|
+
const path = rolloutPath(base, "tools-6");
|
|
313
|
+
writeFileSync(path, sessionMeta("tools-6"));
|
|
314
|
+
const poll = reader(db, nid, "tools-6", base);
|
|
315
|
+
const record = Buffer.from(toolCall("call_g", "shell", "héllo — wörld 🌍"), "utf8");
|
|
316
|
+
const cut = record.lastIndexOf(Buffer.from("🌍", "utf8")) + 2; // inside the emoji
|
|
317
|
+
appendFileSync(path, record.subarray(0, cut));
|
|
318
|
+
poll();
|
|
319
|
+
assert.equal(readToolUse(db, nid, "call_g"), null); // incomplete — held back
|
|
320
|
+
appendFileSync(path, record.subarray(cut));
|
|
321
|
+
poll();
|
|
322
|
+
assert.equal(readToolUse(db, nid, "call_g")?.input, "héllo — wörld 🌍");
|
|
323
|
+
});
|
|
324
|
+
it("holds back a partial trailing line and completes it on the next poll", () => {
|
|
325
|
+
const db = createTestDb();
|
|
326
|
+
const nid = seedNarrative(db);
|
|
327
|
+
const base = newBase();
|
|
328
|
+
const path = rolloutPath(base, "tools-7");
|
|
329
|
+
writeFileSync(path, sessionMeta("tools-7"));
|
|
330
|
+
const poll = reader(db, nid, "tools-7", base);
|
|
331
|
+
const record = toolCall("call_h", "shell", "{}");
|
|
332
|
+
appendFileSync(path, record.slice(0, 20));
|
|
333
|
+
poll();
|
|
334
|
+
assert.equal(readToolUse(db, nid, "call_h"), null);
|
|
335
|
+
appendFileSync(path, record.slice(20));
|
|
336
|
+
poll();
|
|
337
|
+
assert.equal(readToolUse(db, nid, "call_h")?.name, "shell");
|
|
338
|
+
});
|
|
339
|
+
it("skips malformed lines without blocking the ones after them", () => {
|
|
340
|
+
const db = createTestDb();
|
|
341
|
+
const nid = seedNarrative(db);
|
|
342
|
+
const base = newBase();
|
|
343
|
+
writeFileSync(rolloutPath(base, "tools-8"), sessionMeta("tools-8") +
|
|
344
|
+
"{not json at all\n" +
|
|
345
|
+
toolCall("call_i", "shell", "{}") +
|
|
346
|
+
toolOutput("call_i", "ok"));
|
|
347
|
+
reader(db, nid, "tools-8", base)();
|
|
348
|
+
assert.equal(readToolUse(db, nid, "call_i")?.status, "completed");
|
|
349
|
+
});
|
|
350
|
+
it("restarts from zero when the rollout shrinks", () => {
|
|
351
|
+
// A shorter file was replaced or truncated, so the stored offset no longer
|
|
352
|
+
// points at anything meaningful and reading from it would yield garbage.
|
|
353
|
+
const db = createTestDb();
|
|
354
|
+
const nid = seedNarrative(db);
|
|
355
|
+
const base = newBase();
|
|
356
|
+
const path = rolloutPath(base, "tools-9");
|
|
357
|
+
writeFileSync(path, sessionMeta("tools-9") + toolCall("call_j", "shell", "{}") + toolOutput("call_j", "ok"));
|
|
358
|
+
const poll = reader(db, nid, "tools-9", base);
|
|
359
|
+
poll();
|
|
360
|
+
writeFileSync(path, sessionMeta("tools-9") + toolCall("call_k", "read", "{}"));
|
|
361
|
+
poll();
|
|
362
|
+
assert.equal(readToolUse(db, nid, "call_k")?.name, "read");
|
|
363
|
+
});
|
|
364
|
+
it("anchors rows to the narrative head that preceded them", () => {
|
|
365
|
+
// The flush runs before the MCP tool dispatches, so calls made since the
|
|
366
|
+
// last narration anchor under it — which is what places them between
|
|
367
|
+
// messages in chat rather than bunched at the end.
|
|
368
|
+
const db = createTestDb();
|
|
369
|
+
const nid = seedNarrative(db);
|
|
370
|
+
const base = newBase();
|
|
371
|
+
const path = rolloutPath(base, "tools-10");
|
|
372
|
+
writeFileSync(path, sessionMeta("tools-10"));
|
|
373
|
+
const poll = reader(db, nid, "tools-10", base);
|
|
374
|
+
appendEvent(db, nid, "narration", { content: "first" });
|
|
375
|
+
appendFileSync(path, toolCall("call_l", "shell", "{}"));
|
|
376
|
+
poll();
|
|
377
|
+
appendEvent(db, nid, "narration", { content: "second" });
|
|
378
|
+
appendFileSync(path, toolCall("call_m", "read", "{}"));
|
|
379
|
+
poll();
|
|
380
|
+
assert.equal(readToolUse(db, nid, "call_l")?.after_seq, 1);
|
|
381
|
+
assert.equal(readToolUse(db, nid, "call_m")?.after_seq, 2);
|
|
382
|
+
});
|
|
383
|
+
it("does nothing for a session with no rollout on disk", () => {
|
|
384
|
+
const db = createTestDb();
|
|
385
|
+
const nid = seedNarrative(db);
|
|
386
|
+
assert.deepEqual(reader(db, nid, "absent", newBase())(), []);
|
|
387
|
+
assert.equal(readToolUseWindow(db, nid).length, 0);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// The daemon-side driver. Codex is read from two directions over one cursor:
|
|
392
|
+
// a timer (so a call shows while it runs) and the agent's own MCP calls (so
|
|
393
|
+
// nothing dangles when it goes quiet). These pin that they cooperate.
|
|
394
|
+
//
|
|
395
|
+
// Asserted behaviourally — through rows appearing or not appearing — rather
|
|
396
|
+
// than by inspecting timer handles, since the interval is `unref`'d and so is
|
|
397
|
+
// deliberately invisible to the event loop.
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
describe("startCodexToolSampler", () => {
|
|
400
|
+
const TICK_MS = 10;
|
|
401
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, TICK_MS * 5));
|
|
402
|
+
/** A stand-in for a connected MCP bridge. Only the fields the sampler reads
|
|
403
|
+
* are real; `ws` needs nothing but a readyState. */
|
|
404
|
+
function bridge(narrativeId, agentType, open = true) {
|
|
405
|
+
return {
|
|
406
|
+
ws: { readyState: open ? 1 : 3 },
|
|
407
|
+
narrativeId,
|
|
408
|
+
agentType,
|
|
409
|
+
pendingListens: new Map(),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function seedSession(db, nid, sessionId) {
|
|
413
|
+
db.prepare("UPDATE narratives SET agent_session_id = ? WHERE id = ?").run(sessionId, nid);
|
|
414
|
+
}
|
|
415
|
+
it("flushes inline for codex and ignores every other agent", () => {
|
|
416
|
+
const db = createTestDb();
|
|
417
|
+
const nid = seedNarrative(db);
|
|
418
|
+
const base = newBase();
|
|
419
|
+
writeFileSync(rolloutPath(base, "samp-1"), sessionMeta("samp-1") + toolCall("call_a", "shell", "{}") + toolOutput("call_a", "ok"));
|
|
420
|
+
seedSession(db, nid, "samp-1");
|
|
421
|
+
const pushed = [];
|
|
422
|
+
const sampler = startCodexToolSampler(db, new Set(), (r) => pushed.push(r), 0, base);
|
|
423
|
+
// A non-codex bridge on the same narrative must not trigger a read.
|
|
424
|
+
sampler.flushFor(bridge(nid, "claude"));
|
|
425
|
+
assert.equal(pushed.length, 0);
|
|
426
|
+
sampler.flushFor(bridge(nid, "codex"));
|
|
427
|
+
assert.deepEqual(pushed.map((r) => r.status), ["pending", "completed"]);
|
|
428
|
+
sampler.stop();
|
|
429
|
+
});
|
|
430
|
+
it("shares one cursor between the timer and the inline flush", () => {
|
|
431
|
+
// Both paths drain through the same cursor, so whichever runs first
|
|
432
|
+
// advances the byte offset and the other finds nothing. A second read of
|
|
433
|
+
// the same bytes would bump rev and re-push rows that never changed.
|
|
434
|
+
const db = createTestDb();
|
|
435
|
+
const nid = seedNarrative(db);
|
|
436
|
+
const base = newBase();
|
|
437
|
+
writeFileSync(rolloutPath(base, "samp-2"), sessionMeta("samp-2") + toolCall("call_b", "shell", "{}") + toolOutput("call_b", "ok"));
|
|
438
|
+
seedSession(db, nid, "samp-2");
|
|
439
|
+
const pushed = [];
|
|
440
|
+
const sampler = startCodexToolSampler(db, new Set([bridge(nid, "codex")]), (r) => pushed.push(r), 0, base);
|
|
441
|
+
sampler.flushFor(bridge(nid, "codex"));
|
|
442
|
+
assert.equal(pushed.length, 2);
|
|
443
|
+
const rev = maxToolUseRev(db, nid);
|
|
444
|
+
sampler.flushFor(bridge(nid, "codex"));
|
|
445
|
+
sampler.flushFor(bridge(nid, "codex"));
|
|
446
|
+
assert.equal(pushed.length, 2);
|
|
447
|
+
assert.equal(maxToolUseRev(db, nid), rev);
|
|
448
|
+
sampler.stop();
|
|
449
|
+
});
|
|
450
|
+
it("does not arm for a non-codex agent", async () => {
|
|
451
|
+
// A daemon that never runs codex does no recurring work at all: arming is
|
|
452
|
+
// one string comparison per bridge and nothing else.
|
|
453
|
+
const db = createTestDb();
|
|
454
|
+
const nid = seedNarrative(db);
|
|
455
|
+
const base = newBase();
|
|
456
|
+
writeFileSync(rolloutPath(base, "samp-3"), sessionMeta("samp-3") + toolCall("call_c", "shell", "{}"));
|
|
457
|
+
seedSession(db, nid, "samp-3");
|
|
458
|
+
const sampler = startCodexToolSampler(db, new Set([bridge(nid, "codex")]), () => { }, TICK_MS, base);
|
|
459
|
+
sampler.armFor("claude");
|
|
460
|
+
sampler.armFor("copilot");
|
|
461
|
+
sampler.armFor(undefined);
|
|
462
|
+
await settle();
|
|
463
|
+
assert.equal(readToolUse(db, nid, "call_c"), null);
|
|
464
|
+
// The same sampler, armed properly, does read it.
|
|
465
|
+
sampler.armFor("codex");
|
|
466
|
+
await settle();
|
|
467
|
+
assert.equal(readToolUse(db, nid, "call_c")?.name, "shell");
|
|
468
|
+
sampler.stop();
|
|
469
|
+
});
|
|
470
|
+
it("picks up calls as they are appended, without an MCP call to prompt it", async () => {
|
|
471
|
+
// This is the whole point of the timer: a burst of tool calls with no
|
|
472
|
+
// narration in it used to stay invisible until the burst ended.
|
|
473
|
+
const db = createTestDb();
|
|
474
|
+
const nid = seedNarrative(db);
|
|
475
|
+
const base = newBase();
|
|
476
|
+
const path = rolloutPath(base, "samp-4");
|
|
477
|
+
writeFileSync(path, sessionMeta("samp-4"));
|
|
478
|
+
seedSession(db, nid, "samp-4");
|
|
479
|
+
const sampler = startCodexToolSampler(db, new Set([bridge(nid, "codex")]), () => { }, TICK_MS, base);
|
|
480
|
+
sampler.armFor("codex");
|
|
481
|
+
appendFileSync(path, toolCall("call_d", "shell", "{}"));
|
|
482
|
+
await settle();
|
|
483
|
+
assert.equal(readToolUse(db, nid, "call_d")?.status, "pending");
|
|
484
|
+
appendFileSync(path, toolOutput("call_d", "ok"));
|
|
485
|
+
await settle();
|
|
486
|
+
assert.equal(readToolUse(db, nid, "call_d")?.status, "completed");
|
|
487
|
+
sampler.stop();
|
|
488
|
+
});
|
|
489
|
+
it("drains every connected codex narrative on one tick", async () => {
|
|
490
|
+
const db = createTestDb();
|
|
491
|
+
const one = seedNarrative(db, "codex-test0001");
|
|
492
|
+
const two = seedNarrative(db, "codex-test0002");
|
|
493
|
+
const base = newBase();
|
|
494
|
+
writeFileSync(rolloutPath(base, "samp-5"), sessionMeta("samp-5") + toolCall("call_e", "shell", "{}"));
|
|
495
|
+
writeFileSync(rolloutPath(base, "samp-6", new Date(2026, 2, 6, 10, 0, 0)), sessionMeta("samp-6") + toolCall("call_f", "read", "{}"));
|
|
496
|
+
seedSession(db, one, "samp-5");
|
|
497
|
+
seedSession(db, two, "samp-6");
|
|
498
|
+
const sampler = startCodexToolSampler(db, new Set([bridge(one, "codex"), bridge(two, "codex")]), () => { }, TICK_MS, base);
|
|
499
|
+
sampler.armFor("codex");
|
|
500
|
+
await settle();
|
|
501
|
+
sampler.stop();
|
|
502
|
+
assert.equal(readToolUse(db, one, "call_e")?.name, "shell");
|
|
503
|
+
assert.equal(readToolUse(db, two, "call_f")?.name, "read");
|
|
504
|
+
});
|
|
505
|
+
it("stops ticking once the last codex bridge is gone", async () => {
|
|
506
|
+
// An ended codex session must not leave recurring work behind. The first
|
|
507
|
+
// tick that finds nothing connected disarms the timer; the next codex
|
|
508
|
+
// bridge to arm restarts it.
|
|
509
|
+
const db = createTestDb();
|
|
510
|
+
const nid = seedNarrative(db);
|
|
511
|
+
const base = newBase();
|
|
512
|
+
const path = rolloutPath(base, "samp-7");
|
|
513
|
+
writeFileSync(path, sessionMeta("samp-7"));
|
|
514
|
+
seedSession(db, nid, "samp-7");
|
|
515
|
+
const clients = new Set([bridge(nid, "codex")]);
|
|
516
|
+
const sampler = startCodexToolSampler(db, clients, () => { }, TICK_MS, base);
|
|
517
|
+
sampler.armFor("codex");
|
|
518
|
+
await settle();
|
|
519
|
+
clients.clear(); // the bridge disconnected
|
|
520
|
+
await settle(); // a tick observes that and disarms
|
|
521
|
+
appendFileSync(path, toolCall("call_g", "shell", "{}"));
|
|
522
|
+
await settle();
|
|
523
|
+
assert.equal(readToolUse(db, nid, "call_g"), null);
|
|
524
|
+
// Re-arming after a new bridge connects picks it straight back up.
|
|
525
|
+
clients.add(bridge(nid, "codex"));
|
|
526
|
+
sampler.armFor("codex");
|
|
527
|
+
await settle();
|
|
528
|
+
assert.equal(readToolUse(db, nid, "call_g")?.name, "shell");
|
|
529
|
+
sampler.stop();
|
|
530
|
+
});
|
|
531
|
+
it("keeps the inline flush working when the timer is disabled", async () => {
|
|
532
|
+
// `codex_tool_log.poll_ms = 0` turns off the timer only. Unlike crush and
|
|
533
|
+
// copilot, where that setting disables capture outright, codex falls back
|
|
534
|
+
// to MCP-call-driven reads — less immediate, but still complete.
|
|
535
|
+
const db = createTestDb();
|
|
536
|
+
const nid = seedNarrative(db);
|
|
537
|
+
const base = newBase();
|
|
538
|
+
writeFileSync(rolloutPath(base, "samp-8"), sessionMeta("samp-8") + toolCall("call_h", "shell", "{}"));
|
|
539
|
+
seedSession(db, nid, "samp-8");
|
|
540
|
+
const sampler = startCodexToolSampler(db, new Set([bridge(nid, "codex")]), () => { }, 0, base);
|
|
541
|
+
sampler.armFor("codex");
|
|
542
|
+
await settle();
|
|
543
|
+
assert.equal(readToolUse(db, nid, "call_h"), null); // no timer ever started
|
|
544
|
+
sampler.flushFor(bridge(nid, "codex"));
|
|
545
|
+
assert.equal(readToolUse(db, nid, "call_h")?.name, "shell");
|
|
546
|
+
sampler.stop();
|
|
547
|
+
});
|
|
548
|
+
it("releases a narrative's cursor when its last bridge closes", () => {
|
|
549
|
+
// A revive re-reads from zero, which the row-level guard makes cheap — the
|
|
550
|
+
// point is not to hold cursors for narratives that are gone.
|
|
551
|
+
const db = createTestDb();
|
|
552
|
+
const nid = seedNarrative(db);
|
|
553
|
+
const base = newBase();
|
|
554
|
+
writeFileSync(rolloutPath(base, "samp-9"), sessionMeta("samp-9") + toolCall("call_i", "shell", "{}") + toolOutput("call_i", "ok"));
|
|
555
|
+
seedSession(db, nid, "samp-9");
|
|
556
|
+
const pushed = [];
|
|
557
|
+
const sampler = startCodexToolSampler(db, new Set(), (r) => pushed.push(r), 0, base);
|
|
558
|
+
sampler.flushFor(bridge(nid, "codex"));
|
|
559
|
+
assert.equal(pushed.length, 2);
|
|
560
|
+
sampler.release(nid);
|
|
561
|
+
// Cursor gone, so the file is replayed — but nothing changed, so the guard
|
|
562
|
+
// rewrites no row and re-pushes nothing.
|
|
563
|
+
sampler.flushFor(bridge(nid, "codex"));
|
|
564
|
+
assert.equal(pushed.length, 2);
|
|
565
|
+
sampler.stop();
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
//# sourceMappingURL=codex-tool-log.test.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createTestDb } from "./helpers.js";
|
|
4
|
+
import { getConfig } from "../commands/config.js";
|
|
5
|
+
import { openDatabase } from "../db/schema.js";
|
|
6
|
+
// Test the config logic directly against the meta table,
|
|
7
|
+
// since the config command's run() relies on openDatabase()
|
|
8
|
+
// which opens the real ~/.brane/brane.db.
|
|
9
|
+
describe("config storage", () => {
|
|
10
|
+
it("stores and retrieves a config value", () => {
|
|
11
|
+
const db = createTestDb();
|
|
12
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("config.app_domain", "my.domain.dev");
|
|
13
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get("config.app_domain");
|
|
14
|
+
assert.equal(row?.value, "my.domain.dev");
|
|
15
|
+
});
|
|
16
|
+
it("overwrites an existing config value", () => {
|
|
17
|
+
const db = createTestDb();
|
|
18
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("config.app_domain", "old.domain.dev");
|
|
19
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("config.app_domain", "new.domain.dev");
|
|
20
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get("config.app_domain");
|
|
21
|
+
assert.equal(row.value, "new.domain.dev");
|
|
22
|
+
});
|
|
23
|
+
it("deletes a config value", () => {
|
|
24
|
+
const db = createTestDb();
|
|
25
|
+
db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("config.app_domain", "test.dev");
|
|
26
|
+
const result = db.prepare("DELETE FROM meta WHERE key = ?").run("config.app_domain");
|
|
27
|
+
assert.equal(result.changes, 1);
|
|
28
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get("config.app_domain");
|
|
29
|
+
assert.equal(row, undefined);
|
|
30
|
+
});
|
|
31
|
+
it("lists config values with config. prefix", () => {
|
|
32
|
+
const db = createTestDb();
|
|
33
|
+
db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("config.app_domain", "test.dev");
|
|
34
|
+
db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("config.theme", "dark");
|
|
35
|
+
// This is NOT a config key
|
|
36
|
+
db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("daemon_pid", "12345");
|
|
37
|
+
const rows = db
|
|
38
|
+
.prepare("SELECT key, value FROM meta WHERE key LIKE 'config.%' ORDER BY key")
|
|
39
|
+
.all();
|
|
40
|
+
assert.equal(rows.length, 2);
|
|
41
|
+
assert.equal(rows[0].key, "config.app_domain");
|
|
42
|
+
assert.equal(rows[1].key, "config.theme");
|
|
43
|
+
});
|
|
44
|
+
it("config keys don't collide with daemon state", () => {
|
|
45
|
+
const db = createTestDb();
|
|
46
|
+
db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("daemon_port", "7890");
|
|
47
|
+
db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("config.port", "8080");
|
|
48
|
+
const daemonPort = db.prepare("SELECT value FROM meta WHERE key = 'daemon_port'").get();
|
|
49
|
+
const configPort = db.prepare("SELECT value FROM meta WHERE key = 'config.port'").get();
|
|
50
|
+
assert.equal(daemonPort.value, "7890");
|
|
51
|
+
assert.equal(configPort.value, "8080");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe("the suite never opens ~/.brane", () => {
|
|
55
|
+
it("getConfig reads the db it is handed", () => {
|
|
56
|
+
const db = createTestDb();
|
|
57
|
+
db.prepare("INSERT INTO meta (key, value) VALUES (?, ?)").run("config.mentions.receipt_limit", "3");
|
|
58
|
+
assert.equal(getConfig("mentions.receipt_limit", db), "3");
|
|
59
|
+
// Unset keys still fall back to the defaults table.
|
|
60
|
+
assert.equal(getConfig("app_domain", db), "brane.organelle.dev");
|
|
61
|
+
});
|
|
62
|
+
it("openDatabase() refuses under node --test", () => {
|
|
63
|
+
// The backstop: a path with no db threaded to it fails the test that
|
|
64
|
+
// reached it, instead of opening the operator's live database (and running
|
|
65
|
+
// migrations on it) from a test run — `brane doctor`'s included.
|
|
66
|
+
assert.ok(process.env.NODE_TEST_CONTEXT, "node --test sets NODE_TEST_CONTEXT in each test process");
|
|
67
|
+
assert.throws(() => openDatabase(), /under node --test/);
|
|
68
|
+
assert.throws(() => getConfig("app_domain"), /under node --test/);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
//# sourceMappingURL=config.test.js.map
|