bare-agent 0.12.2 → 0.13.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/README.md CHANGED
@@ -66,13 +66,13 @@ Every piece works alone — take what you need, ignore the rest.
66
66
 
67
67
  | Component | What it does |
68
68
  |---|---|
69
- | **Loop** | Think → act → observe → repeat. Calls any LLM, executes your tools, loops until done. Returns estimated USD cost per run. Governance via `Loop({ policy })` — wire bareguard's `Gate` through `wireGate(gate)` and every tool call (native, MCP, browsing, mobile) traverses one chokepoint with per-caller `ctx` routing. Bareguard owns the audit log, budget caps, and halt decisions; Loop respects the verdict. `onError` + `loop:error` surface every silent-ish failure (callback throw, Checkpoint timeout) |
69
+ | **Loop** | Think → act → observe → repeat. Calls any LLM, executes your tools, loops until done. Returns estimated USD cost per run. Governance via `Loop({ policy })` — wire bareguard's `Gate` through `wireGate(gate)` and every tool call (native, MCP, browsing, mobile) traverses one chokepoint with per-caller `ctx` routing. Bareguard owns the audit log, budget caps, and halt decisions; Loop respects the verdict. Context engineering via `Loop({ assemble })` — a per-round `assemble(msgs, ctx)` chokepoint to recall/compress/trim the window sent to the model (the seam litectx plugs into); returns a view, the canonical transcript stays intact, fail-open. The exported `unitAssembler`/`toUnits`/`fromUnits` adapter lets a consumer work over a neutral unit `{id, role, content, kind, pinned, atomic, tokensApprox}` — bareagent owns the grammar (atomic tool-pair bundling, pinned system/task, a pairing seatbelt), the consumer owns content + relevance. The CE function reads its inputs from the per-run `ctx` — litectx's budget-fitter uses `ctx.budget` (and `ctx.task`), so you **must** populate it via `run(msgs, tools, { ctx })`: an unset `ctx.budget` means the fitter has no budget, keeps everything, and returns the window unchanged — a silent no-op, not a bug (see `examples/litectx-assemble.mjs`). `onError` + `loop:error` surface every silent-ish failure (callback throw, Checkpoint timeout) |
70
70
  | **Planner** | Break a goal into a step DAG via LLM. Built-in caching (`cacheTTL`) |
71
71
  | **runPlan** | Execute steps in parallel waves. Dependency-aware, failure propagation, per-step retry |
72
72
  | **Retry** | Exponential/linear backoff with jitter. Respects `err.retryable` |
73
73
  | **CircuitBreaker** | Fail fast after N errors. Auto-recovers after cooldown. Per-key isolation |
74
74
  | **Fallback** | Try providers in order — if one is down, next one picks up. Transparent to Loop |
75
- | **Memory** | Persist and search context. SQLite with FTS (default) or zero-dep JSON file |
75
+ | **Memory** | Persist and search context across turns/sessions through a swappable `Store`. Zero-dep JSON file by default, or mount [litectx](https://npmjs.com/package/litectx) for ranked, graph-aware recall in one line — the host code never changes ([example](examples/litectx-as-store.mjs)). A minimal `SQLite` FTS5 store also ships, though litectx supersedes it for SQLite-backed memory |
76
76
  | **StateMachine** | Task lifecycle tracking with event hooks. `pending → running → done / failed / waiting / cancelled` |
77
77
  | **Checkpoint** | Human approval gate. You provide the transport — terminal, Telegram, Slack, whatever |
78
78
  | **Scheduler** | Cron (`0 9 * * 1-5`) or relative (`2h`, `30m`). Persisted jobs survive restarts |
@@ -82,11 +82,11 @@ Every piece works alone — take what you need, ignore the rest.
82
82
  | **Browsing** | Web navigation, clicking, typing, reading via `barebrowse` (17 tools). Two modes: library tools (inline snapshots, pass to Loop) or CLI session (disk-based snapshots, token-efficient for multi-step flows). Optional `assess` tool (privacy scan) when `wearehere` is installed |
83
83
  | **Mobile** | Android + iOS device control via `baremobile`. Same two modes: library tools (`createMobileTools` — action tools auto-return snapshots) or CLI session (`baremobile` CLI — disk-based snapshots) |
84
84
  | **Shell** | Cross-platform `shell_read`, `shell_grep`, `shell_run` (argv, no shell), `shell_exec` (raw shell). Pure Node — no `grep`/`rg`/`findstr` dependency. Injection-proof `shell_run` for policy-gated use |
85
- | **MCP Bridge** | Auto-discover MCP servers from IDE configs (Claude Code, Cursor, etc.), expose as bareagent tools. Static allow/deny via `.mcp-bridge.json`, `systemContext` for LLM awareness. Runtime policy lives in `Loop({ policy })` — one hook for MCP + native tools alike. Returns both bulk `tools` (one per MCP tool) and `metaTools` (`mcp_discover` + `mcp_invoke` for token-thrifty access to large catalogs). Zero deps |
85
+ | **MCP Bridge** | Auto-discover MCP servers from IDE configs (Claude Code, Cursor, etc.), expose as bareagent tools. Static allow/deny via `.mcp-bridge.json`, `systemContext` for LLM awareness. Runtime policy lives in `Loop({ policy })` — one hook for MCP + native tools alike. Returns both bulk `tools` (one per MCP tool) and `metaTools` (`mcp_discover` + `mcp_invoke` for token-thrifty access to large catalogs). Connecting runs a server's `command` (which may come from a cwd `.mcp.json`): pass `confirmServer` to vet each before it spawns — otherwise the bridge warns naming every command it runs. Every RPC is time-bounded (`timeout` for the handshake, `callTimeout` for `tools/call`), and a server that breaks its stdin pipe fails the connection instead of crashing the host. Zero deps |
86
86
  | **Spawn** | Fork a child bareagent process as a specialist agent. LLM-callable form blocks until child exits; library form returns a handle (`wait`, `onLine`, `kill`). One JSONL channel per child — child stderr captured and re-emitted as `child:stderr` events on the parent stream. Threads `BAREGUARD_AUDIT_PATH` / `BAREGUARD_PARENT_RUN_ID` / `BAREGUARD_BUDGET_FILE` / `BAREGUARD_SPAWN_DEPTH` so the family stitches into one audit + budget. `bareguard ^0.2.0` adds `spawn.ratePerMinute` + `limits.maxDepth` per-family caps |
87
87
  | **Defer** | Append a `{action, when}` record to a JSONL queue for a separate waker (cron / systemd timer / `examples/wake.sh`) to fire later. Two-phase governance: emit-time `gate.check` on the `defer` action; fire-time `gate.check` on the inner action when the waker re-invokes. `bareguard ^0.2.0` adds `defer.ratePerMinute` family-wide cap |
88
88
 
89
- **Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Ollama, CLIPipe (any CLI tool via stdin/stdout with real-time streaming), Fallback, or bring your own (one method: `generate`). All return the same shape — swap freely.
89
+ **Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Ollama, CLIPipe (any CLI tool via stdin/stdout with real-time streaming), Fallback, or bring your own (one method: `generate`). All return the same shape — swap freely. The OpenAI provider warns if it would send your key over plaintext `http://` to a non-loopback host (use `https`, or drop `apiKey` for keyless local endpoints).
90
90
 
91
91
  **Tools:** Any function is a tool. REST APIs, MCP servers, CLI commands, shell scripts — if it's a function, it works. Built-in: `barebrowse` for web browsing, `baremobile` for Android + iOS device control (both optional) — library tools for inline results or CLI session mode for token-efficient disk-based snapshots.
92
92
 
@@ -182,6 +182,8 @@ Runnable scripts in [`examples/`](examples/) — each is self-contained and the
182
182
  | [`orchestrator/`](examples/orchestrator/) | Multi-agent dispatch via `spawn`. Three configs, one system prompt — no orchestrator class, no role types. Roles are JSON files. |
183
183
  | [`wake.sh`](examples/wake.sh) + [`wake.md`](examples/wake.md) | Reference cron + jq script for firing deferred actions. The runtime half of `createDeferTool` — bareagent emits, `wake.sh` fires. |
184
184
  | [`replay-job.js`](examples/replay-job.js) | Supervised replay POC: record a browser task once with the LLM driving, then replay against fresh snapshots with the LLM as locator-only. Falls back to full reasoning when the locator misses, and patches the trace. |
185
+ | [`litectx-as-store.mjs`](examples/litectx-as-store.mjs) | Mount [litectx](https://npmjs.com/package/litectx) as the `Memory` `Store` — one-line swap from `JsonFileStore` to ranked, graph-aware recall; the host code never changes (RT-3). |
186
+ | [`litectx-mcp-child.mjs`](examples/litectx-mcp-child.mjs) | Give a spawned child agent litectx's reasoning verbs as MCP tools, read-only on its own db, via `liteCtxMcpBridgeConfig` + `cfg.mcp` (RT-4). |
185
187
 
186
188
  ---
187
189
 
@@ -1,7 +1,7 @@
1
1
  # bareagent — Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring bareagent into a project.
4
- > v0.12.2 | Node.js >= 18 | one required dep (`bareguard ^0.4.2`) | Apache 2.0
4
+ > v0.13.1 | Node.js >= 18 | one required dep (`bareguard ^0.4.2`) | Apache 2.0
5
5
  >
6
6
  > Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
7
7
 
@@ -14,12 +14,12 @@ npm install bare-agent
14
14
  ```
15
15
 
16
16
  Eight entry points:
17
- - `require('bare-agent')` — Loop, Planner, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, **HaltError**
17
+ - `require('bare-agent')` — Loop, Planner, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, **toUnits, fromUnits, unitAssembler** (the `assemble` context-units adapter, v0.13+), BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, **HaltError**
18
18
  - `require('bare-agent/errors')` — same error classes via a stable subpath (v0.10.1+) for adopters who want to import only the error surface
19
19
  - `require('bare-agent/providers')` — OpenAI, Anthropic, Ollama, CLIPipe, Fallback (the canonical short names; `*Provider` aliases — `OpenAIProvider`, `AnthropicProvider`, etc. — are also exported and match the class names, so either destructure works, v0.12.1+)
20
20
  - `require('bare-agent/stores')` — SQLite (FTS5), JsonFile
21
21
  - `require('bare-agent/transports')` — JsonlTransport
22
- - `require('bare-agent/tools')` — createBrowsingTools, createMobileTools, createShellTools, createSpawnTool, createDeferTool, spawnChild, readDeferQueue
22
+ - `require('bare-agent/tools')` — createBrowsingTools, createMobileTools, createShellTools, createSpawnTool, createDeferTool, spawnChild, readDeferQueue, liteCtxMcpBridgeConfig
23
23
  - `require('bare-agent/mcp')` — createMCPBridge (returns `tools` + `metaTools`), discoverServers, buildMetaTools
24
24
  - `require('bare-agent/bareguard')` — wireGate (one-line bareguard Gate integration), defaultActionTranslator
25
25
 
@@ -69,6 +69,7 @@ Eight entry points:
69
69
  | **Spawn a child specialist agent** | createSpawnTool + bin/cli.js --config (v0.9+) |
70
70
  | **Defer an action for later (cron-fired)** | createDeferTool + examples/wake.sh (v0.9+) |
71
71
  | **Expose a large MCP catalog dynamically** | createMCPBridge → bridge.metaTools (v0.9+) |
72
+ | **Give a child agent litectx memory (read-only, own db)** | liteCtxMcpBridgeConfig → `cfg.mcp` in a spawn child config (RT-4) |
72
73
 
73
74
  **Most projects start with Loop + Provider.** Add components as needed.
74
75
 
@@ -253,7 +254,19 @@ or `content.denyPatterns` over the serialized action.
253
254
  repo) as well as your home/IDE configs. Pass `confirmServer(name, def)
254
255
  => boolean` to `createMCPBridge` to approve each server **before its
255
256
  command is spawned** (return `false` to skip it; a throw fails closed).
256
- Default trusts all discovered servers — unchanged behavior.
257
+ Default trusts all discovered servers — unchanged behavior. **When no
258
+ `confirmServer` is set, the bridge prints a one-time warning naming every
259
+ command it is about to spawn** (before the first spawn, discovery included),
260
+ so a cwd `.mcp.json` can't run a command unannounced — `confirmServer` is
261
+ still how you actually *gate* it.
262
+
263
+ **RPC timeouts (Unreleased).** Every JSON-RPC round-trip is now bounded, so a
264
+ server that never answers can't hang the bridge or the loop. `opts.timeout`
265
+ (default 15 s) bounds the handshake (`initialize` + `tools/list`);
266
+ `opts.callTimeout` (default 120 s, `0` disables) bounds each `tools/call`. A
267
+ timed-out tool call rejects with a `timed out after Nms` `ToolError` rather
268
+ than blocking forever; a server that breaks its stdin pipe surfaces as a
269
+ failed connection, never an uncaught `EPIPE` crash.
257
270
 
258
271
  ## Wiring with bareguard
259
272
 
@@ -543,21 +556,47 @@ All return `{ text, toolCalls, usage: { inputTokens, outputTokens } }`. CLIPipe
543
556
 
544
557
  **Error body (v0.11.0):** on an HTTP error the OpenAI/Anthropic/Ollama providers throw a `ProviderError` whose `message` carries the upstream error string. The full parsed response is **not** attached to `err.body` by default (so an unexpected field can't leak through logs that dump the error object). Pass `{ exposeErrorBody: true }` to attach it for debugging.
545
558
 
559
+ **Plaintext-key warning (Unreleased):** the OpenAI provider's `baseUrl` accepts `http://` (for local/OpenAI-compatible endpoints), but a `Bearer` key sent over plaintext http to a **non-loopback** host is exposed on the wire. The provider now warns once when that happens. Loopback hosts (`localhost`/`127.0.0.0/8`/`::1` — local proxies, Ollama-style endpoints) stay silent, since that's the legitimate keyless-local case. The header is **not** stripped (some local proxies want a key), so use `https` for any remote endpoint, or drop `apiKey` when the local endpoint needs none.
560
+
546
561
  **Cost estimation:** Loop automatically estimates USD cost per run based on model and token usage. The `cost` field appears in every `loop.run()` result and in `loop:done` stream events. Pricing covers OpenAI and Anthropic models; unknown models use a default average. To adjust rates, edit `COST_PER_1K` at the top of `src/loop.js`.
547
562
 
548
563
  ## Store options
549
564
 
550
565
  ```javascript
551
566
  // SQLite FTS5 — full-text search with BM25 ranking (requires: npm install better-sqlite3)
567
+ // Minimal store, kept for back-compat. litectx strictly dominates it (same better-sqlite3
568
+ // requirement, but adds ranked graph-aware recall) — prefer litectx for SQLite-backed memory.
552
569
  new SQLite({ path: './memory.db' })
553
570
 
554
571
  // JSON file — zero deps, substring search
555
572
  new JsonFile({ path: './memory.json' })
556
573
 
574
+ // litectx — ranked, graph-aware recall (RT-3 mount; requires: npm install litectx)
575
+ // One-line swap; the host code (memory.store/search/get/delete) never changes.
576
+ // import { LiteCtx, liteCtxAsStore } from 'litectx';
577
+ // const memory = new Memory({ store: liteCtxAsStore(new LiteCtx({ dbPath: './agent.db' })) });
578
+ // See examples/litectx-as-store.mjs. litectx ships the adapter; bareagent owns the Store socket.
579
+
557
580
  // Custom — implement { store, search, get, delete }
558
581
  ```
559
582
 
560
- **JsonFile scaling:** `search()` is an O(n) substring scan (no index) and every `store()`/`delete()` rewrites the whole file. Fine for hundreds–low-thousands of entries; for larger or write-heavy memory use `SQLite` (FTS5 index, incremental writes). JsonFile warns once past ~10k entries.
583
+ **JsonFile scaling:** `search()` is an O(n) substring scan (no index) and every `store()`/`delete()` rewrites the whole file. Fine for hundreds–low-thousands of entries; for larger or write-heavy memory mount `litectx` for ranked graph-aware recall (the minimal bundled `SQLite` FTS5 store remains for back-compat, but litectx strictly dominates it — same `better-sqlite3` requirement, richer recall). JsonFile warns once past ~10k entries.
584
+
585
+ **Two ways to use litectx, pick by who consumes it (the two are independent):**
586
+ - **As your `Store`** (RT-3, above) — *your* host code recalls via `memory.search/get`. One-line `liteCtxAsStore` swap.
587
+ - **As a child agent's MCP toolbox** (RT-4) — give a *spawned sub-agent* litectx's own reasoning verbs (`litectx_recall`, `litectx_get`, …) so the model calls them in its loop. Use `liteCtxMcpBridgeConfig` to build the curated mount and hand it to the child via `cfg.mcp`:
588
+
589
+ ```js
590
+ const { liteCtxMcpBridgeConfig } = require('bare-agent/tools');
591
+ // Read-only by default: recall/get/impact/recent allowed; remember/forget denied
592
+ // (writable:true to opt in — writes stay in the child's OWN --root db); index/promotions always denied.
593
+ const mcp = liteCtxMcpBridgeConfig({ root: './child-mem' }); // own-db isolation via --root
594
+ // In the spawn child config (bin/cli.js --config): { provider, model, tools, mcp, gate }
595
+ // mcp: <the config above> → child's MCPBridge mounts litectx-mcp; tools join BEFORE gating
596
+ // cfg.mcp also accepts a directory-confined { bridgePath } pointing at a .mcp-bridge.json on disk.
597
+ ```
598
+
599
+ Requires litectx's `litectx-mcp` binary on PATH. Isolation is **physical** (each child a distinct `--root` db) — promotion to the parent is an explicit parent-side `recall`→`remember`, never automatic. See `examples/litectx-mcp-child.mjs`. bareagent imports nothing from litectx; the helper is pure config curation.
561
600
 
562
601
  ## Tool format
563
602
 
package/bin/cli.js CHANGED
@@ -66,11 +66,54 @@ async function runConfigMode(cfgPath) {
66
66
  const stream = new Stream({ transport: new JsonlTransport() });
67
67
 
68
68
  // Provider
69
- const provider = createProvider(cfg.provider || 'openai', cfg.model);
69
+ const provider = createProvider(cfg.provider || 'openai', cfg.model, { command: cfg.command, args: cfg.args });
70
70
 
71
71
  // Tools — registry resolved by name from a curated set of built-ins.
72
72
  const tools = await resolveTools(cfg.tools || [], { stream });
73
73
 
74
+ // Optional MCP mount (RT-4) — a child config can mount MCP servers (e.g. litectx-mcp, read-only on
75
+ // its own db) via `cfg.mcp`. Accepts an inline bridge config (`{ servers, ttl }`, as built by
76
+ // `liteCtxMcpBridgeConfig`) or `{ bridgePath }` pointing at one (confined to the config directory,
77
+ // same rule as gate.humanChannel). Mounted tools join the set BEFORE gating, so they traverse the
78
+ // same policy as native tools. The server `command` runs unsandboxed — same trust as `cfg.tools`.
79
+ /** @type {{ tools: ToolDef[], close: Function } | null} */
80
+ let mcpBridge = null;
81
+ if (cfg.mcp) {
82
+ const { createMCPBridge } = require('../src/mcp-bridge');
83
+ const os = require('node:os');
84
+ const cfgDir = path.resolve(path.dirname(cfgPath));
85
+ let bridgePath;
86
+ let tmpBridge = null;
87
+ if (cfg.mcp && typeof cfg.mcp === 'object' && cfg.mcp.servers) {
88
+ tmpBridge = path.join(os.tmpdir(), `bareagent-mcp-${process.pid}.json`);
89
+ fs.writeFileSync(tmpBridge, JSON.stringify(cfg.mcp));
90
+ bridgePath = tmpBridge;
91
+ } else if (cfg.mcp && typeof cfg.mcp.bridgePath === 'string') {
92
+ const p = path.resolve(cfgDir, cfg.mcp.bridgePath);
93
+ if (p !== cfgDir && !p.startsWith(cfgDir + path.sep)) {
94
+ process.stderr.write(`[cli] cfg.mcp.bridgePath must resolve inside the config directory (${cfgDir}); refusing ${p}\n`);
95
+ process.exit(1);
96
+ }
97
+ bridgePath = p;
98
+ } else {
99
+ process.stderr.write('[cli] cfg.mcp must be an inline bridge config ({ servers }) or { bridgePath }\n');
100
+ process.exit(1);
101
+ }
102
+ try {
103
+ mcpBridge = await createMCPBridge({
104
+ bridgePath,
105
+ servers: cfg.mcp.servers ? Object.keys(cfg.mcp.servers) : undefined,
106
+ timeout: cfg.mcp.timeout || 15000,
107
+ });
108
+ tools.push(...mcpBridge.tools);
109
+ } catch (err) {
110
+ process.stderr.write(`[cli] failed to mount MCP (cfg.mcp): ${err.message}\n`);
111
+ process.exit(1);
112
+ } finally {
113
+ if (tmpBridge) { try { fs.unlinkSync(tmpBridge); } catch { /* best-effort */ } }
114
+ }
115
+ }
116
+
74
117
  // Bareguard Gate (optional but strongly recommended for spawn children).
75
118
  // Fail-closed: if the config asks for a gate but wiring fails, exit non-zero
76
119
  // rather than run an ungoverned child agent.
@@ -163,6 +206,7 @@ async function runConfigMode(cfgPath) {
163
206
  });
164
207
 
165
208
  await loop.run([initialMessage], gatedTools);
209
+ if (mcpBridge) await mcpBridge.close();
166
210
  // Stream's loop:done event has already been emitted; exit clean.
167
211
  process.exit(0);
168
212
  }
@@ -299,7 +343,15 @@ function runStdioMode() {
299
343
  * @param {string} [model]
300
344
  * @returns {Provider}
301
345
  */
302
- function createProvider(name, model) {
346
+ function createProvider(name, model, opts = {}) {
347
+ if (name === 'clipipe') {
348
+ const { CLIPipeProvider } = require('../src/provider-clipipe');
349
+ if (!opts.command) {
350
+ process.stderr.write('[cli] provider "clipipe" requires a `command` in the config (or --command).\n');
351
+ process.exit(1);
352
+ }
353
+ return new CLIPipeProvider({ command: opts.command, args: opts.args || [], ...(model && { model }) });
354
+ }
303
355
  if (name === 'openai') {
304
356
  const { OpenAIProvider } = require('../src/provider-openai');
305
357
  return new OpenAIProvider({
@@ -10,5 +10,6 @@ Runnable reference scripts for bare-agent. Each is self-contained — the top-of
10
10
  | [`orchestrator/`](orchestrator/) | Multi-agent dispatch via `spawn`. Three configs, one system prompt — no orchestrator class, no role types. Roles are JSON files. See its [README](orchestrator/README.md). |
11
11
  | [`wake.sh`](wake.sh) + [`wake.md`](wake.md) | Reference cron + jq script for firing deferred actions. The runtime half of `createDeferTool` — bareagent emits, `wake.sh` fires. |
12
12
  | [`replay-job.js`](replay-job.js) | Supervised replay POC: record a browser task once with the LLM driving, then replay against fresh snapshots with the LLM as locator-only. Falls back to full reasoning when the locator misses, and patches the trace. |
13
+ | [`litectx-as-store.mjs`](litectx-as-store.mjs) | RT-3 Store mount: swap the zero-dep `JsonFileStore` for litectx's ranked, graph-aware recall in one line — the host code never changes. Runs the JsonFileStore half always; runs the litectx half if `litectx` is installed, else prints the one-line swap. |
13
14
 
14
15
  For wiring recipes and API details see the [Integration Guide](../bareagent.context.md); for usage patterns and design philosophy see the [Usage Guide](../docs/02-features/usage-guide.md).
@@ -0,0 +1,78 @@
1
+ // examples/litectx-as-store.mjs
2
+ //
3
+ // RT-3 — mount litectx as a bareagent `Memory` backend (the rich `Store`).
4
+ //
5
+ // Run: node examples/litectx-as-store.mjs
6
+ // (zero-dep: runs the JsonFileStore half always; runs the litectx half only if `litectx`
7
+ // is installed — `npm install litectx` — otherwise it prints the one-line swap and skips.)
8
+ //
9
+ // What this demonstrates:
10
+ // - The `Store` socket (`{ store, search, get, delete }`) is litectx's documented mount point.
11
+ // Swapping the zero-dep JsonFileStore for litectx's ranked, graph-aware recall is a ONE-LINE
12
+ // change — the host code (everything in `hostWorkflow` below) never changes.
13
+ // - litectx ships the adapter (`liteCtxAsStore`); bareagent ships the socket. No bareagent import
14
+ // in litectx, no litectx import in bareagent — the dependency direction stays one-way.
15
+ //
16
+ // The five points where the schemaless socket and litectx's typed model are reconciled (PRD §3.2),
17
+ // all handled INSIDE litectx's adapter — the host never sees them:
18
+ // #1 the adapter mints the id (`kind:uuid`); the host supplies none.
19
+ // #2 `search` returns content inline via `recall({ body: true })`.
20
+ // #3 arbitrary host metadata round-trips through a sealed `meta` passthrough (kind/by are typed).
21
+ // #4 an un-kinded write defaults to `kind:"fact"` (durable agent memory); `metadata.kind` overrides.
22
+ // #5 `search` targets one kind so scores stay comparable across hits.
23
+
24
+ import { createRequire } from 'node:module';
25
+ import { mkdtempSync, rmSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { tmpdir } from 'node:os';
28
+ const require = createRequire(import.meta.url);
29
+ const { Memory } = require('bare-agent');
30
+ const { JsonFile: JsonFileStore } = require('bare-agent/stores');
31
+
32
+ // --- the HOST workflow: written once, runs against ANY Store. This is the code that does NOT change
33
+ // when you swap the backend. Note `await` works whether the store is sync (JsonFileStore) or
34
+ // async (litectx) — Memory delegates the return value without awaiting. ---
35
+ async function hostWorkflow(memory, label) {
36
+ const id = await memory.store(
37
+ 'the auth service uses a token-bucket rate limiter on /login',
38
+ { sessionId: 'sess-1', tag: 'architecture' }, // arbitrary metadata — must survive the round-trip
39
+ );
40
+ const hits = await memory.search('rate limiter');
41
+ const fetched = memory.get(id);
42
+
43
+ console.log(`\n[${label}]`);
44
+ console.log(` store() → id: ${id}`);
45
+ console.log(` search('rate limiter') → ${hits.length} hit(s); top score=${hits[0]?.score?.toFixed?.(3) ?? hits[0]?.score}`);
46
+ console.log(` get(id).content: ${JSON.stringify(fetched?.content)}`);
47
+ console.log(` get(id).metadata: ${JSON.stringify(fetched?.metadata)} ← host metadata round-tripped`);
48
+ }
49
+
50
+ async function main() {
51
+ // 1) Zero-dep baseline: JsonFileStore (always runs).
52
+ const dir = mkdtempSync(join(tmpdir(), 'litectx-as-store-'));
53
+ try {
54
+ await hostWorkflow(new Memory({ store: new JsonFileStore({ path: join(dir, 'mem.json') }) }), 'JsonFileStore (zero-dep)');
55
+ } finally {
56
+ rmSync(dir, { recursive: true, force: true });
57
+ }
58
+
59
+ // 2) The ONE-LINE swap to litectx — identical hostWorkflow, ranked graph-aware recall.
60
+ let liteCtxAsStore, LiteCtx;
61
+ try {
62
+ ({ LiteCtx, liteCtxAsStore } = require('litectx')); // both from the main entry (litectx 0.10+)
63
+ } catch {
64
+ console.log('\n[litectx] not installed — the swap is one line:');
65
+ console.log(" import { LiteCtx, liteCtxAsStore } from 'litectx';");
66
+ console.log(' const lc = new LiteCtx({ dbPath: \'./agent.db\' }); await lc.ready();');
67
+ console.log(' const memory = new Memory({ store: liteCtxAsStore(lc) }); // ← only this line changes');
68
+ console.log('\n Install it (`npm install litectx`) to run the litectx half of this example.');
69
+ return;
70
+ }
71
+
72
+ const lc = new LiteCtx({ dbPath: join(tmpdir(), `litectx-as-store-${process.pid}.db`) });
73
+ if (typeof lc.ready === 'function') await lc.ready();
74
+ await hostWorkflow(new Memory({ store: liteCtxAsStore(lc) }), 'litectx (ranked, graph-aware)');
75
+ if (typeof lc.close === 'function') lc.close();
76
+ }
77
+
78
+ main().catch((err) => { console.error(err); process.exit(1); });
@@ -0,0 +1,78 @@
1
+ // examples/litectx-assemble.mjs
2
+ //
3
+ // RT-1 — wire litectx's budget-fit `assemble` verb into bareagent's Loop context-assembly seam,
4
+ // and show the ONE footgun: you must populate `ctx.budget`, or the fit is a silent no-op.
5
+ //
6
+ // Run: node examples/litectx-assemble.mjs
7
+ // (runs litectx's real verb if installed — `npm install litectx` — otherwise an inline
8
+ // stand-in with identical budget semantics, so the lesson runs zero-dep.)
9
+ //
10
+ // How the seam works:
11
+ // - Loop({ assemble }) calls `assemble(msgs, ctx)` before EVERY provider call, sending the
12
+ // returned view (the canonical transcript is never mutated).
13
+ // - bareagent's `unitAssembler()` wraps a litectx-shaped `assemble(units, ctx)` into that
14
+ // msgs-level seam — bareagent owns the grammar (atomic tool-pair bundling, pinned system/task),
15
+ // litectx owns content + relevance.
16
+ // - litectx reads its inputs from the per-run `ctx`: `ctx.budget` (token budget) and `ctx.task`
17
+ // (recall intent). You pass that ctx via `loop.run(msgs, tools, { ctx })`.
18
+ //
19
+ // THE FOOTGUN: an unset `ctx.budget` is NOT a litectx bug. With no budget the fit defaults to
20
+ // Infinity, keeps everything, and returns the window unchanged — so litectx's core verb LOOKS
21
+ // broken when it is really a wiring omission. Always pass `ctx.budget`.
22
+
23
+ import { createRequire } from 'node:module';
24
+ const require = createRequire(import.meta.url);
25
+ const { unitAssembler } = require('bare-agent');
26
+
27
+ // litectx's real assemble verb if installed; else an inline stand-in with the same budget semantics
28
+ // (best-effort, recency-anchored, never drops `pinned`, returns the { units, dropped, tokens } envelope).
29
+ let assembleVerb;
30
+ try {
31
+ ({ assemble: assembleVerb } = require('litectx')); // free function on the main entry (litectx 0.11+)
32
+ console.log("using litectx's real assemble() verb\n");
33
+ } catch {
34
+ console.log('litectx not installed — using an inline stand-in with identical budget semantics\n');
35
+ const tok = (u) => (Number.isFinite(u.tokensApprox) ? u.tokensApprox : Math.ceil((u.content?.length ?? 0) / 4));
36
+ assembleVerb = (units, ctx = {}) => {
37
+ const budget = Number.isFinite(ctx.budget) ? ctx.budget : Infinity;
38
+ const keep = new Set();
39
+ let used = 0;
40
+ for (const u of units) if (u.pinned) { keep.add(u.id); used += tok(u); } // pinned always kept
41
+ // newest-first, skip-and-continue greedy over the un-pinned remainder
42
+ const rest = units.map((u, i) => ({ u, i })).filter(({ u }) => !u.pinned).sort((a, b) => b.i - a.i);
43
+ for (const { u } of rest) if (used + tok(u) <= budget) { keep.add(u.id); used += tok(u); }
44
+ const kept = units.filter((u) => keep.has(u.id));
45
+ const dropped = units.filter((u) => !keep.has(u.id)).map((u) => ({ id: u.id, reason: 'budget' }));
46
+ return { units: kept, dropped, tokens: used };
47
+ };
48
+ }
49
+
50
+ // the seam bareagent's Loop calls: assemble(msgs, ctx) => msgs
51
+ const assemble = unitAssembler(assembleVerb);
52
+
53
+ // a transcript grown past budget: a pinned system prompt + the task, then several tool rounds.
54
+ const msgs = [
55
+ { role: 'system', content: 'You are a helpful coding agent. '.repeat(20) },
56
+ { role: 'user', content: 'Find and fix the rate-limiter bug in the auth service.' },
57
+ ];
58
+ for (let i = 1; i <= 8; i++) {
59
+ const id = `call_${i}`;
60
+ msgs.push({ role: 'assistant', content: `Round ${i}: inspecting.`, tool_calls: [{ id, type: 'function', function: { name: 'read_file', arguments: `{"path":"src/auth/round${i}.js"}` } }] });
61
+ msgs.push({ role: 'tool', tool_call_id: id, content: `// round ${i} file contents — `.repeat(40) });
62
+ }
63
+ const before = msgs.length;
64
+
65
+ // (1) ctx.budget SET — the fit drops the oldest un-pinned rounds to fit the budget.
66
+ const fitted = await assemble(msgs, { budget: 400, task: 'rate-limiter bug' });
67
+ console.log(`with ctx.budget=400 : ${before} msgs -> ${fitted.length} msgs (fit dropped ${before - fitted.length})`);
68
+
69
+ // (2) ctx.budget UNSET — the footgun. No budget => Infinity => nothing drops => window unchanged.
70
+ const noop = await assemble(msgs, { task: 'rate-limiter bug' }); // <-- budget missing
71
+ console.log(`with ctx.budget unset: ${before} msgs -> ${noop.length} msgs (fit dropped ${before - noop.length}) <-- silent no-op!`);
72
+
73
+ // the pinned system prompt + task always survive the fit (pin, don't hide):
74
+ console.log(`\nsystem prompt survives the tight fit: ${fitted.some((m) => m.role === 'system')}`);
75
+ console.log(`task (first user turn) survives the tight fit: ${fitted.some((m) => m.role === 'user')}`);
76
+
77
+ console.log('\nLesson: wire it as loop.run(msgs, tools, { ctx: { budget, task } }).');
78
+ console.log('An unset ctx.budget is not a litectx bug — the fitter correctly keeps everything when given no budget.');
@@ -0,0 +1,57 @@
1
+ // examples/litectx-mcp-child.mjs
2
+ //
3
+ // RT-4 — give a child/sub-agent litectx memory, READ-ONLY, on its own db (own-db isolation).
4
+ //
5
+ // Run: node examples/litectx-mcp-child.mjs [--root <indexed-litectx-root>]
6
+ // Prints the curated .mcp-bridge.json always; launches the real mount if `litectx-mcp` is on
7
+ // PATH (`npm install -g litectx`), otherwise prints the one-line recipe and exits 0.
8
+ //
9
+ // What this demonstrates:
10
+ // - `liteCtxMcpBridgeConfig({ root })` builds the curated bridge config: the read-only default
11
+ // (recall/get/impact/recent allow; remember/forget/index/promotions deny) so a child can reason
12
+ // over memory but can't mutate durable shared state. Flip with `{ writable: true }` to opt a child
13
+ // into writes — which still land in ITS OWN db, never the parent's.
14
+ // - `createMCPBridge` launches `litectx-mcp --root <child-db>` over stdio and exposes only the
15
+ // allowed verbs as bareagent tools. Pass `bridge.tools` to a child Loop.
16
+ // - Isolation is the child's own `--root`, not a shared store — which is what keeps RT-5's scope
17
+ // column deferred. Promotion of a child-learned fact to the parent is an explicit, parent-side
18
+ // `recall`(child db) → `remember`(parent db); never automatic.
19
+
20
+ import { createRequire } from 'node:module';
21
+ const require = createRequire(import.meta.url);
22
+ const { createMCPBridge } = require('../src/mcp-bridge');
23
+ const { liteCtxMcpBridgeConfig } = require('bare-agent/tools');
24
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { tmpdir } from 'node:os';
27
+
28
+ const rootFlag = process.argv.indexOf('--root');
29
+ const childRoot = rootFlag !== -1 ? process.argv[rootFlag + 1] : process.cwd();
30
+
31
+ // 1) Build the curated config — the artifact a parent drops in to compose the child's toolbox.
32
+ const cfg = liteCtxMcpBridgeConfig({ root: childRoot });
33
+ console.log('Curated .mcp-bridge.json (read-only litectx mount):');
34
+ console.log(JSON.stringify(cfg, null, 2));
35
+ console.log('\n allow: recall, get, impact, recent deny: remember, forget, index, promotions');
36
+ console.log(' (writable:true opts into remember/forget — still child-db-local)\n');
37
+
38
+ // 2) Attempt the real mount. Connects iff `litectx-mcp` is on PATH and the root is an indexed litectx db.
39
+ const dir = mkdtempSync(join(tmpdir(), 'litectx-mcp-child-'));
40
+ const bridgePath = join(dir, '.mcp-bridge.json');
41
+ writeFileSync(bridgePath, JSON.stringify(cfg));
42
+ try {
43
+ const bridge = await createMCPBridge({ bridgePath, servers: ['litectx'], timeout: 8000 });
44
+ if (bridge.servers.includes('litectx')) {
45
+ console.log(`[mounted] child tools: ${bridge.tools.map((t) => t.name).join(', ')}`);
46
+ console.log(`[mounted] withheld: ${bridge.denied.map((d) => d.tool).join(', ')}`);
47
+ // hand bridge.tools to a child Loop here: new Loop({ provider, ... }).run(msgs, bridge.tools)
48
+ await bridge.close();
49
+ } else {
50
+ console.log('[litectx-mcp not on PATH] the mount above is the recipe. To run it live:');
51
+ console.log(' npm install -g litectx # provides the `litectx-mcp` command');
52
+ console.log(` litectx-mcp --root ${childRoot} # (index the root first; see litectx docs)`);
53
+ console.log(' then re-run this example. The parent code never changes.');
54
+ }
55
+ } finally {
56
+ rmSync(dir, { recursive: true, force: true });
57
+ }
package/examples/wake.sh CHANGED
@@ -63,6 +63,14 @@ echo "$PENDING" | while IFS= read -r record; do
63
63
  ID=$(echo "$record" | jq -r '.id')
64
64
  ACTION=$(echo "$record" | jq -c '.action')
65
65
 
66
+ # The defer tool generates ids as def_<base36>_<hex>. Anything else means a
67
+ # hand-edited / untrusted queue line — reject before $ID reaches a file path
68
+ # below (defence-in-depth against path traversal via a crafted id).
69
+ case "$ID" in
70
+ def_[a-z0-9]*_[a-f0-9]*) ;;
71
+ *) echo "[wake $NOW] skipping record with unexpected id: $ID" >&2; continue ;;
72
+ esac
73
+
66
74
  # Append "fired" status line first (defer queue is append-only).
67
75
  printf '{"id":"%s","status":"fired","ts":"%s"}\n' "$ID" "$NOW" >> "$QUEUE"
68
76
 
package/index.d.ts CHANGED
@@ -10,6 +10,9 @@ import { runPlan } from "./src/run-plan";
10
10
  import { CircuitBreaker } from "./src/circuit-breaker";
11
11
  import { wireGate } from "./src/bareguard-adapter";
12
12
  import { defaultActionTranslator } from "./src/bareguard-adapter";
13
+ import { toUnits } from "./src/context-units";
14
+ import { fromUnits } from "./src/context-units";
15
+ import { unitAssembler } from "./src/context-units";
13
16
  import { BareAgentError } from "./src/errors";
14
17
  import { ProviderError } from "./src/errors";
15
18
  import { ToolError } from "./src/errors";
@@ -17,4 +20,4 @@ import { TimeoutError } from "./src/errors";
17
20
  import { ValidationError } from "./src/errors";
18
21
  import { CircuitOpenError } from "./src/errors";
19
22
  import { HaltError } from "./src/errors";
20
- export { Loop, Planner, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, HaltError };
23
+ export { Loop, Planner, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, toUnits, fromUnits, unitAssembler, BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, HaltError };
package/index.js CHANGED
@@ -11,6 +11,7 @@ const { Retry } = require('./src/retry');
11
11
  const { runPlan } = require('./src/run-plan');
12
12
  const { CircuitBreaker } = require('./src/circuit-breaker');
13
13
  const { wireGate, defaultActionTranslator } = require('./src/bareguard-adapter');
14
+ const { toUnits, fromUnits, unitAssembler } = require('./src/context-units');
14
15
  const {
15
16
  BareAgentError,
16
17
  ProviderError,
@@ -34,6 +35,9 @@ module.exports = {
34
35
  CircuitBreaker,
35
36
  wireGate,
36
37
  defaultActionTranslator,
38
+ toUnits,
39
+ fromUnits,
40
+ unitAssembler,
37
41
  BareAgentError,
38
42
  ProviderError,
39
43
  ToolError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -91,7 +91,7 @@
91
91
  }
92
92
  },
93
93
  "scripts": {
94
- "test": "node --test --test-force-exit test/**/*.test.js",
94
+ "test": "node --test test/**/*.test.js",
95
95
  "typecheck": "tsc --noEmit",
96
96
  "prebuild:types": "node scripts/clean-types.js",
97
97
  "build:types": "tsc",
@@ -99,6 +99,7 @@
99
99
  },
100
100
  "devDependencies": {
101
101
  "@types/node": "^22.19.19",
102
+ "litectx": "^0.11.0",
102
103
  "typescript": "^5.7.0"
103
104
  }
104
105
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * msgs → neutral units. Bundles each assistant-tool-call message with the contiguous tool result(s)
3
+ * that answer its ids into ONE atomic unit (so pairing can never be split). system + first user turn
4
+ * are pinned.
5
+ * @param {Array<Record<string, any>>} msgs
6
+ * @returns {Array<Record<string, any>>}
7
+ */
8
+ export function toUnits(msgs: Array<Record<string, any>>): Array<Record<string, any>>;
9
+ /**
10
+ * units → msgs. Honors drop (absent units), reorder (order of the returned array), recall-inject
11
+ * (units with no backing → one synthesised message), and COMPRESS (a unit whose `content` was rewritten
12
+ * is reconstructed from the new content). Atomic units keep their assistant tool-call message verbatim
13
+ * so pairing holds; a content rewrite lands on the tool RESULT. A multi-result atomic bundle whose
14
+ * content was rewritten is kept VERBATIM — a flat string can't be faithfully split back into N
15
+ * results, and splitting is grammar (bareagent's), not litectx's to attempt. This isn't a special
16
+ * case: litectx's compress() is a pure text→text render that returns verbatim when handed no single
17
+ * parseable format (compress.js — "never returns less than the body losslessly"), so a flattened
18
+ * multi-result unit round-trips unchanged on both sides. RATIFIED by litectx (2026-06-12). The pairing
19
+ * seatbelt is the final guard.
20
+ * @param {Array<Record<string, any>>} units
21
+ * @returns {Array<Record<string, any>>}
22
+ */
23
+ export function fromUnits(units: Array<Record<string, any>>): Array<Record<string, any>>;
24
+ /**
25
+ * Wrap litectx's `assemble(units, ctx)` verb into the Loop's msgs-level `assemble(msgs, ctx)` seam.
26
+ * litectx ships the **`AssembleResult` envelope** `{ units, dropped, tokens }` (CE-PRD §8.2: `dropped[]`
27
+ * is load-bearing — it ships in the same slice, never silently truncated). This wrapper accepts that
28
+ * envelope (uses `.units`) OR a bare `units` array (a simpler consumer). `dropped`/`tokens` are litectx's
29
+ * accounting; the Loop's seam is msgs-in/msgs-out, so they're not threaded onward here (the canonical
30
+ * transcript already holds every dropped unit by id — restorable on demand).
31
+ * Fail-OPEN at this layer too: any other return shape → the original msgs are sent unchanged. A thrown
32
+ * error (incl. HaltError) is left to the Loop's own fail-open / HaltError handling — not swallowed here.
33
+ * @param {(units: Array<Record<string, any>>, ctx: any) => (any | Promise<any>)} assembleUnits
34
+ * @returns {(msgs: Array<Record<string, any>>, ctx: any) => Promise<Array<Record<string, any>>>}
35
+ */
36
+ export function unitAssembler(assembleUnits: (units: Array<Record<string, any>>, ctx: any) => (any | Promise<any>)): (msgs: Array<Record<string, any>>, ctx: any) => Promise<Array<Record<string, any>>>;
37
+ /** chars/4 token estimate over a list of messages (matches poc2 / the Loop's own heuristic). */
38
+ export function approxTokens(msgs: any): number;
39
+ /**
40
+ * Drop any tool-result whose tool_call_id has no open assistant tool-call before it, and any assistant
41
+ * tool-call message left with zero surviving results. The final grammar guard: even if litectx hands
42
+ * back something that would orphan a pair, the wire is always valid. Returns a fresh array.
43
+ */
44
+ export function pairingSeatbelt(msgs: any): any[];