@bahulam/code 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +39 -0
  3. package/package.json +8 -9
  4. package/pulse/lib/tool-categories.ts +13 -0
  5. package/src/commands/device.mjs +121 -0
  6. package/src/commands/pair.mjs +190 -0
  7. package/src/commands/remote.mjs +110 -0
  8. package/src/config/env.mjs +2 -2
  9. package/src/core/event-log.mjs +393 -0
  10. package/src/core/headless.mjs +198 -0
  11. package/src/core/loop.mjs +276 -0
  12. package/src/core/memory-disk.mjs +210 -0
  13. package/src/core/paths.mjs +36 -0
  14. package/src/core/stream-client.mjs +28 -9
  15. package/src/core/tool-executor.mjs +64 -16
  16. package/src/daemon/approval-store.mjs +253 -0
  17. package/src/daemon/attach-client.mjs +361 -0
  18. package/src/daemon/daemonize.mjs +151 -0
  19. package/src/daemon/event-tap.mjs +197 -0
  20. package/src/daemon/input-lock.mjs +191 -0
  21. package/src/daemon/relay-client.mjs +258 -0
  22. package/src/daemon/session-core.mjs +179 -0
  23. package/src/daemon/session-list.mjs +26 -0
  24. package/src/daemon/session-publisher.mjs +78 -0
  25. package/src/daemon/socket-server.mjs +329 -0
  26. package/src/daemon/stop-daemon.mjs +18 -0
  27. package/src/permissions/checker.mjs +6 -6
  28. package/src/permissions/prompt.mjs +8 -7
  29. package/src/skills/installer.mjs +8 -0
  30. package/src/terminal/ansi.mjs +85 -9
  31. package/src/terminal/main.mjs +97 -3
  32. package/src/terminal/repl.mjs +389 -6
  33. package/src/terminal/skills-picker.mjs +121 -0
  34. package/src/terminal/skills.mjs +3 -3
  35. package/src/tools/analyze-code.mjs +39 -0
  36. package/src/tools/bash.mjs +1 -1
  37. package/src/tools/edit.mjs +18 -18
  38. package/src/tools/git-diff.mjs +34 -0
  39. package/src/tools/git-status.mjs +30 -0
  40. package/src/tools/glob.mjs +5 -2
  41. package/src/tools/grep.mjs +1 -1
  42. package/src/tools/meta-tools.mjs +85 -0
  43. package/src/tools/read-files.mjs +37 -0
  44. package/src/tools/read.mjs +20 -10
  45. package/src/tools/registry.mjs +20 -0
  46. package/src/tools/remember.mjs +147 -0
  47. package/src/tools/search-files.mjs +41 -0
  48. package/src/tools/write-project.mjs +62 -0
  49. package/src/tools/write.mjs +1 -1
  50. package/src/ui/banner.mjs +1 -1
  51. package/src/ui/slash-commands.mjs +16 -0
  52. package/src/ui/sub-agent.mjs +8 -2
  53. package/src/ui/transcript-block.mjs +4 -1
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Disk-backed cross-session memory for the CLI runtime.
3
+ *
4
+ * Cross-session memory lives on the user's disk (not Supabase) whenever a
5
+ * request originates from the CLI. Chat, cloud-IDE, and workspace surfaces
6
+ * continue to use the Supabase `agent_memory` table via the existing
7
+ * SupabaseMemoryBackend — this module is CLI-only.
8
+ *
9
+ * Files:
10
+ * ~/.bahulam/memory.md — user-global. Loaded for every session.
11
+ * <cwd>/.bahulam/memory.md — project-scoped. Merged on top of global
12
+ * when the CLI is running inside a
13
+ * directory that has one.
14
+ *
15
+ * Format (round-trippable with the Supabase agent_memory schema):
16
+ *
17
+ * # Bahulam memory · <optional title>
18
+ *
19
+ * <!-- fact:<slug> type:<fact_type> conf:<0..1> scope:<global|project>
20
+ * source:<origin> tags:<a,b,c> project:<id-or-null>
21
+ * created:<iso> updated:<iso> -->
22
+ * <content body — one or more prose paragraphs until the next `<!-- fact:`
23
+ * header or end of file>
24
+ *
25
+ * HTML comments carry the metadata so GitHub renders the file cleanly.
26
+ * Body text is the `content` field. Unknown metadata keys pass through
27
+ * verbatim (round-trip preserves anything the backend added).
28
+ *
29
+ * Reads are idempotent + tolerant: missing files return an empty list, a
30
+ * malformed fact block is skipped with a warning rather than throwing.
31
+ * Writes are append-only (new facts) or overwrite-in-place (updates to an
32
+ * existing fact_id) — see appendFacts() and its callers.
33
+ */
34
+
35
+ import * as fs from 'node:fs';
36
+ import * as os from 'node:os';
37
+ import * as path from 'node:path';
38
+
39
+ const FACT_HEADER_RE = /<!--\s*fact:([A-Za-z0-9._-]+)\s*([^>]*)-->/g;
40
+
41
+ /** Where the global memory file lives. */
42
+ export function globalMemoryPath() {
43
+ return path.join(os.homedir(), '.bahulam', 'memory.md');
44
+ }
45
+
46
+ /** Where the project memory file lives, if the cwd has a .bahulam dir. */
47
+ export function projectMemoryPath(cwd = process.cwd()) {
48
+ return path.join(cwd, '.bahulam', 'memory.md');
49
+ }
50
+
51
+ /**
52
+ * Ensure `.bahulam/` exists at the requested root, creating it if missing.
53
+ * `scope='global'` → ~/.bahulam/; `scope='project'` → <cwd>/.bahulam/.
54
+ * Returns the directory path. Idempotent — safe to call on every access.
55
+ */
56
+ export function ensureBahulamDir(scope = 'global', cwd = process.cwd()) {
57
+ const dir = scope === 'project'
58
+ ? path.join(cwd, '.bahulam')
59
+ : path.join(os.homedir(), '.bahulam');
60
+ fs.mkdirSync(dir, { recursive: true });
61
+ return dir;
62
+ }
63
+
64
+ // Parse `key:value key:value` from the header comment. Values are strings;
65
+ // callers coerce as needed. `tags:a,b,c` → array, `project:null` → null.
66
+ function _parseMeta(raw) {
67
+ const meta = {};
68
+ const trimmed = String(raw || '').trim();
69
+ if (!trimmed) return meta;
70
+ // Simple space-separated key:value tokenizer. Values cannot contain
71
+ // spaces — matches how appendFacts() serializes below.
72
+ for (const tok of trimmed.split(/\s+/)) {
73
+ const idx = tok.indexOf(':');
74
+ if (idx < 0) continue;
75
+ const key = tok.slice(0, idx);
76
+ let value = tok.slice(idx + 1);
77
+ if (value === 'null' || value === '') value = null;
78
+ else if (key === 'tags') value = value.split(',').filter(Boolean);
79
+ else if (key === 'conf' || key === 'confidence') {
80
+ const n = Number(value);
81
+ value = Number.isFinite(n) ? n : null;
82
+ }
83
+ meta[key] = value;
84
+ }
85
+ return meta;
86
+ }
87
+
88
+ /**
89
+ * Parse one memory.md file into an array of Fact records matching the
90
+ * Supabase schema shape. Missing file → []. Malformed blocks are skipped.
91
+ */
92
+ export function parseMemoryFile(filePath) {
93
+ let text;
94
+ try {
95
+ text = fs.readFileSync(filePath, 'utf-8');
96
+ } catch {
97
+ return [];
98
+ }
99
+
100
+ const facts = [];
101
+ // Reset regex state — using .exec in a loop.
102
+ FACT_HEADER_RE.lastIndex = 0;
103
+ const headers = [];
104
+ let m;
105
+ while ((m = FACT_HEADER_RE.exec(text)) !== null) {
106
+ headers.push({
107
+ slug: m[1],
108
+ metaRaw: m[2],
109
+ commentStart: m.index,
110
+ commentEnd: m.index + m[0].length,
111
+ });
112
+ }
113
+
114
+ for (let i = 0; i < headers.length; i++) {
115
+ const h = headers[i];
116
+ const bodyStart = h.commentEnd;
117
+ const bodyEnd = i + 1 < headers.length ? headers[i + 1].commentStart : text.length;
118
+ const body = text.slice(bodyStart, bodyEnd).trim();
119
+ const meta = _parseMeta(h.metaRaw);
120
+ facts.push({
121
+ fact_id: h.slug,
122
+ content: body,
123
+ fact_type: meta.type || 'other',
124
+ confidence: typeof meta.conf === 'number'
125
+ ? meta.conf
126
+ : (typeof meta.confidence === 'number' ? meta.confidence : null),
127
+ source: meta.source || 'disk',
128
+ tags: Array.isArray(meta.tags) ? meta.tags : [],
129
+ metadata: {},
130
+ project_id: meta.project || null,
131
+ memory_scope: meta.scope || (meta.project ? 'project' : 'global'),
132
+ created_at: meta.created || null,
133
+ updated_at: meta.updated || null,
134
+ _source_file: filePath,
135
+ });
136
+ }
137
+ return facts;
138
+ }
139
+
140
+ /**
141
+ * Load global + project memory, merging by fact_id. Project entries
142
+ * shadow global entries with the same fact_id, matching how the
143
+ * Supabase project_only scope shadows global scope.
144
+ */
145
+ export function loadDiskMemory(cwd = process.cwd()) {
146
+ // Self-heal: create ~/.bahulam/ on first read so subsequent writes
147
+ // don't race on the mkdir. Silent if it already exists.
148
+ try { ensureBahulamDir('global'); } catch { /* ignore mkdir errors */ }
149
+ const globalFacts = parseMemoryFile(globalMemoryPath());
150
+ const projectFacts = parseMemoryFile(projectMemoryPath(cwd));
151
+ const merged = new Map();
152
+ for (const f of globalFacts) merged.set(f.fact_id, f);
153
+ for (const f of projectFacts) merged.set(f.fact_id, f);
154
+ return Array.from(merged.values());
155
+ }
156
+
157
+ // Serialize one fact to the wire format described at the top of this file.
158
+ export function serializeFact(fact) {
159
+ const parts = [];
160
+ if (fact.fact_type) parts.push(`type:${fact.fact_type}`);
161
+ if (typeof fact.confidence === 'number') parts.push(`conf:${fact.confidence}`);
162
+ if (fact.memory_scope) parts.push(`scope:${fact.memory_scope}`);
163
+ if (fact.source) parts.push(`source:${fact.source}`);
164
+ if (Array.isArray(fact.tags) && fact.tags.length) parts.push(`tags:${fact.tags.join(',')}`);
165
+ parts.push(`project:${fact.project_id || 'null'}`);
166
+ if (fact.created_at) parts.push(`created:${fact.created_at}`);
167
+ if (fact.updated_at) parts.push(`updated:${fact.updated_at}`);
168
+ const header = `<!-- fact:${fact.fact_id} ${parts.join(' ')} -->`;
169
+ return `${header}\n${String(fact.content || '').trim()}\n`;
170
+ }
171
+
172
+ /**
173
+ * Append or overwrite one or more facts on disk. Global scope → global
174
+ * file; project scope → project file (creates .bahulam/ if needed).
175
+ * Overwrites in place when a fact_id already exists in the target file.
176
+ */
177
+ export function upsertFacts(facts, cwd = process.cwd()) {
178
+ const byFile = new Map(); // filePath → Map(fact_id → fact)
179
+
180
+ const globalPath = globalMemoryPath();
181
+ const projectPath = projectMemoryPath(cwd);
182
+
183
+ // Seed from existing files so we can round-trip untouched facts.
184
+ for (const f of parseMemoryFile(globalPath)) {
185
+ if (!byFile.has(globalPath)) byFile.set(globalPath, new Map());
186
+ byFile.get(globalPath).set(f.fact_id, f);
187
+ }
188
+ for (const f of parseMemoryFile(projectPath)) {
189
+ if (!byFile.has(projectPath)) byFile.set(projectPath, new Map());
190
+ byFile.get(projectPath).set(f.fact_id, f);
191
+ }
192
+
193
+ for (const raw of facts) {
194
+ if (!raw || !raw.fact_id) continue;
195
+ const scope = raw.memory_scope || (raw.project_id ? 'project' : 'global');
196
+ const target = scope === 'project' ? projectPath : globalPath;
197
+ if (!byFile.has(target)) byFile.set(target, new Map());
198
+ byFile.get(target).set(String(raw.fact_id), { ...raw, memory_scope: scope });
199
+ }
200
+
201
+ for (const [filePath, factMap] of byFile.entries()) {
202
+ if (factMap.size === 0) continue;
203
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
204
+ const title = filePath.endsWith(projectPath)
205
+ ? '# Bahulam memory · project scope\n\n'
206
+ : '# Bahulam memory · global\n\n';
207
+ const body = Array.from(factMap.values()).map(serializeFact).join('\n');
208
+ fs.writeFileSync(filePath, `${title}${body}`, 'utf-8');
209
+ }
210
+ }
@@ -162,6 +162,42 @@ export function historyPath() {
162
162
  return path.join(bahulamHome(), 'history.jsonl');
163
163
  }
164
164
 
165
+ // ── daemon session paths ─────────────────────────────────────
166
+ //
167
+ // Daemon-owned sessions (bahulamd, detach/attach) live at:
168
+ // ~/.bahulam/sessions/<sess_id>/ per-session dir
169
+ // meta.json cwd, model, opened_at, ...
170
+ // events.jsonl (+ events-1.jsonl, ...) append-only event log
171
+ // snapshot-<seq>.json periodic compacted snapshot
172
+ // approvals/ pending + decided approvals
173
+ // input-lock.json who holds input right now
174
+ // daemon.pid pid of the owning daemon
175
+ // ~/.bahulam/sockets/<sess_id>.sock Unix socket (0600)
176
+ //
177
+ // These are DIFFERENT from the projects/<hash>/sessions/ archive above.
178
+ // The archive is a historical index keyed on project path; daemon sessions
179
+ // are keyed on session id and are the live source of truth while running.
180
+
181
+ /** ~/.bahulam/sessions/ — root for daemon-owned sessions. */
182
+ export function daemonSessionsRoot() {
183
+ return path.join(bahulamHome(), 'sessions');
184
+ }
185
+
186
+ /** ~/.bahulam/sessions/<sess_id>/ — per-session dir. */
187
+ export function daemonSessionDir(sessionId) {
188
+ return path.join(daemonSessionsRoot(), sessionId);
189
+ }
190
+
191
+ /** ~/.bahulam/sockets/ — root for daemon Unix sockets (Phase 1). */
192
+ export function daemonSocketsDir() {
193
+ return path.join(bahulamHome(), 'sockets');
194
+ }
195
+
196
+ /** ~/.bahulam/sockets/<sess_id>.sock — Unix socket path for a session. */
197
+ export function daemonSocketPath(sessionId) {
198
+ return path.join(daemonSocketsDir(), `${sessionId}.sock`);
199
+ }
200
+
165
201
  // ── Project-local config directory (.bahulam/ next to CLAUDE.md/etc) ────
166
202
  //
167
203
  // Project-scoped stuff (agents/*.yaml, memory/*.md, hooks/, settings.json,
@@ -143,23 +143,19 @@ export class TarangStreamClient {
143
143
  this._toolAbort = null;
144
144
 
145
145
  // Transport mode:
146
- // 'bundled' → local Python runtime (PRD-091 §6). Framework calls
147
- // the Bahulam Gateway directly. Metering runs. THIS IS THE
148
- // PUBLIC CLI DEFAULT.
149
146
  // 'remote' → cloud backend runs the agent loop server-side.
150
147
  // Backend calls Bahulam Gateway with service-token attribution;
151
- // metering still runs at the gateway boundary.
148
+ // metering runs at the gateway boundary.
149
+ // 'bundled' → legacy: local Python runtime (PRD-091 §6, deprecated).
150
+ // Only used when BAHULAM_RUNTIME_MODE=bundled is explicitly set.
152
151
  //
153
- // Explicit opt precedence: constructor arg > env vars > sniff runtime
154
- // package availability > default 'bundled'.
152
+ // Explicit opt precedence: constructor arg > env vars > default 'remote'.
155
153
  this.mode = mode
156
154
  || (process.env.BAHULAM_RUNTIME_MODE === 'remote' ? 'remote' : null)
157
155
  || (process.env.BAHULAM_RUNTIME_MODE === 'bundled' ? 'bundled' : null)
158
156
  || (process.env.TARANG_ENV === 'remote' ? 'remote' : null)
159
157
  || (process.env.TARANG_ENV === 'bundled' ? 'bundled' : null)
160
- || 'bundled';
161
- // Bundled runtime binds to a random localhost port on first use. Cached
162
- // here so every method sees the same baseUrl without re-spawning.
158
+ || 'remote';
163
159
  this._bundledReady = false;
164
160
  }
165
161
 
@@ -238,6 +234,29 @@ export class TarangStreamClient {
238
234
  if (messages && messages.length > 0) body.messages = messages;
239
235
  if (this.sessionId) body.session_id = this.sessionId;
240
236
 
237
+ // daemon cache-guard hook. If BAHULAM_CAPTURE_REQUEST is set to a file
238
+ // path, serialize the exact body that would go to /api/execute, write
239
+ // it there, and exit(0) before making the network call. Zero credits
240
+ // spent, zero backend state changed. Used to capture a byte-exact
241
+ // baseline before the Slice B refactor so we can assert byte identity
242
+ // after the daemon extraction. See the security model note
243
+ // about no daemon-added fields leaking into the payload).
244
+ if (process.env.BAHULAM_CAPTURE_REQUEST) {
245
+ const fs = await import('node:fs');
246
+ const target = process.env.BAHULAM_CAPTURE_REQUEST;
247
+ const serialized = JSON.stringify(body, null, 2);
248
+ try {
249
+ fs.writeFileSync(target, serialized, { mode: 0o600 });
250
+ process.stderr.write(
251
+ `[BAHULAM_CAPTURE_REQUEST] wrote ${target} (${Buffer.byteLength(serialized, 'utf-8')} bytes, ${Object.keys(body).length} top-level keys)\n`
252
+ );
253
+ } catch (err) {
254
+ process.stderr.write(`[BAHULAM_CAPTURE_REQUEST] write failed: ${err.message}\n`);
255
+ process.exit(1);
256
+ }
257
+ process.exit(0);
258
+ }
259
+
241
260
  const headers = this._headers({
242
261
  'Accept': 'text/event-stream',
243
262
  'Content-Type': 'application/json',
@@ -24,9 +24,11 @@ import { sendApprovalDecision, sendCallback } from './callback-client.mjs';
24
24
  import { HookRunner } from '../config/hook-runner.mjs';
25
25
  import { buildFileDiff } from './file-diff.mjs';
26
26
  import { buildWorkScope } from './work-scope.mjs';
27
+ import { loadDiskMemory, ensureBahulamDir, globalMemoryPath, projectMemoryPath } from './memory-disk.mjs';
27
28
  import * as fs from 'node:fs';
28
29
  import * as os from 'node:os';
29
30
  import * as path from 'node:path';
31
+ import * as crypto from 'node:crypto';
30
32
  import { execSync } from 'node:child_process';
31
33
 
32
34
  /**
@@ -43,6 +45,35 @@ export function createToolExecutor({
43
45
  hookRunner = null,
44
46
  interactionHandler = null,
45
47
  } = {}) {
48
+ // Cross-session memory cache. Ships in getAgentContext() on every turn,
49
+ // so we need it to be byte-identical when the underlying disk file hasn't
50
+ // changed — otherwise the backend's prompt cache invalidates on every
51
+ // ExecuteRequest.
52
+ //
53
+ // Strategy: mtime-driven cache. Read the mtimes of the global +
54
+ // project memory files; if unchanged since the last call, return the
55
+ // cached snapshot. The `remember` tool writes to disk directly, which
56
+ // bumps mtime and forces a reload on the next getAgentContext() call.
57
+ //
58
+ // Self-heal ~/.bahulam/ once at construction; loadDiskMemory() also
59
+ // guards it, but doing it here means the first read is a plain fs stat
60
+ // rather than a mkdir round-trip.
61
+ try { ensureBahulamDir('global'); } catch { /* ignore */ }
62
+ let _memoryCache = null; // { key: string, facts: Fact[], digest: string }
63
+ function _readMemorySnapshot() {
64
+ const gPath = globalMemoryPath();
65
+ const pPath = projectMemoryPath(process.cwd());
66
+ const gStat = fs.existsSync(gPath) ? fs.statSync(gPath).mtimeMs : 0;
67
+ const pStat = fs.existsSync(pPath) ? fs.statSync(pPath).mtimeMs : 0;
68
+ const key = `${gStat}|${pStat}|${process.cwd()}`;
69
+ if (_memoryCache && _memoryCache.key === key) return _memoryCache;
70
+ const facts = loadDiskMemory(process.cwd());
71
+ const digest = crypto.createHash('sha256')
72
+ .update(JSON.stringify(facts.map(f => [f.fact_id, f.content, f.updated_at])))
73
+ .digest('hex').slice(0, 16);
74
+ _memoryCache = { key, facts, digest };
75
+ return _memoryCache;
76
+ }
46
77
  const occRegistry = createToolRegistry();
47
78
  const skillTool = occRegistry.get('Skill');
48
79
  if (skillTool) skillTool._skillsLoader = skillsLoader;
@@ -898,7 +929,7 @@ export function createToolExecutor({
898
929
 
899
930
  const observationTimeout = args.timeout == null && isLikelyLongRunningCommand(args.command);
900
931
  const effectiveTimeout = observationTimeout ? longRunningObservationTimeoutMs() : args.timeout;
901
- const result = await occRegistry.call('Bash', {
932
+ const result = await occRegistry.call('shell', {
902
933
  command: args.command,
903
934
  timeout: effectiveTimeout,
904
935
  description: args.description || `Run: ${(args.command || '').slice(0, 50)}`,
@@ -985,7 +1016,7 @@ export function createToolExecutor({
985
1016
  } catch { /* let Read handle the error */ }
986
1017
  }
987
1018
 
988
- const result = await occRegistry.call('Read', {
1019
+ const result = await occRegistry.call('read_file', {
989
1020
  file_path: filePath,
990
1021
  offset,
991
1022
  limit,
@@ -993,12 +1024,20 @@ export function createToolExecutor({
993
1024
  const output = typeof result === 'string' ? result : String(result);
994
1025
  const content = output.replace(/^\s*\d+[→\t]/gm, '');
995
1026
  const actNudge = solutionNudge(filePath);
1027
+ // Set _total_lines so the tool-card display doesn't have
1028
+ // to compute line counts from the display-side `output`
1029
+ // (which contains nudges + line-number prefixes that
1030
+ // throw off the count and can render as "0 lines" when
1031
+ // downstream fallbacks miss the payload). Uses the
1032
+ // same split-by-newline convention as the >50-line
1033
+ // truncation branch above so both paths agree.
996
1034
  return {
997
1035
  success: !isError(output),
998
1036
  content,
999
1037
  output: output + nudge + actNudge,
1000
1038
  _tool: 'read_file',
1001
1039
  _output_type: 'file_content',
1040
+ _total_lines: content ? content.split('\n').length : 0,
1002
1041
  };
1003
1042
  },
1004
1043
  );
@@ -1019,14 +1058,14 @@ export function createToolExecutor({
1019
1058
  // OCC Write requires Read first for existing files — handle gracefully
1020
1059
  try {
1021
1060
  if (fs.existsSync(filePath)) {
1022
- await occRegistry.call('Read', { file_path: filePath, limit: 1 });
1061
+ await occRegistry.call('read_file', { file_path: filePath, limit: 1 });
1023
1062
  }
1024
1063
  } catch { /* file may not exist yet */ }
1025
1064
  // Checkpoint before overwrite so /undo can restore the previous content.
1026
1065
  if (checkpoints && fs.existsSync(filePath)) {
1027
1066
  try { checkpoints.save(filePath); } catch { /* best effort */ }
1028
1067
  }
1029
- const result = await occRegistry.call('Write', {
1068
+ const result = await occRegistry.call('write_file', {
1030
1069
  file_path: filePath,
1031
1070
  content: args.content,
1032
1071
  });
@@ -1084,11 +1123,11 @@ export function createToolExecutor({
1084
1123
  // Read first if exists (OCC Write requirement)
1085
1124
  try {
1086
1125
  if (fs.existsSync(filePath)) {
1087
- await occRegistry.call('Read', { file_path: filePath, limit: 1 });
1126
+ await occRegistry.call('read_file', { file_path: filePath, limit: 1 });
1088
1127
  }
1089
1128
  } catch { /* file may not exist yet */ }
1090
1129
 
1091
- await occRegistry.call('Write', { file_path: filePath, content });
1130
+ await occRegistry.call('write_file', { file_path: filePath, content });
1092
1131
  const after = readTextIfExists(filePath);
1093
1132
  diffs.push(buildResultFileDiff(filePath, before, after));
1094
1133
  updateProjectIndex(filePath);
@@ -1137,7 +1176,7 @@ export function createToolExecutor({
1137
1176
  }
1138
1177
  // OCC Edit requires Read first
1139
1178
  try {
1140
- await occRegistry.call('Read', { file_path: filePath, limit: 1 });
1179
+ await occRegistry.call('read_file', { file_path: filePath, limit: 1 });
1141
1180
  } catch { /* best effort */ }
1142
1181
 
1143
1182
  // Checkpoint before edit so /undo can restore the previous content.
@@ -1147,10 +1186,10 @@ export function createToolExecutor({
1147
1186
 
1148
1187
  let result;
1149
1188
  try {
1150
- result = await occRegistry.call('Edit', {
1189
+ result = await occRegistry.call('edit_file', {
1151
1190
  file_path: filePath,
1152
- old_string: args.search,
1153
- new_string: args.replace,
1191
+ search: args.search,
1192
+ replace: args.replace,
1154
1193
  replace_all: args.replace_all || false,
1155
1194
  });
1156
1195
  } catch (editErr) {
@@ -1231,7 +1270,7 @@ print('OK: replaced')
1231
1270
  _format: 'tree',
1232
1271
  };
1233
1272
  }
1234
- const result = await occRegistry.call('Glob', {
1273
+ const result = await occRegistry.call('list_files', {
1235
1274
  pattern: args.pattern || '**/*',
1236
1275
  path: searchPath,
1237
1276
  });
@@ -1335,7 +1374,7 @@ print('OK: replaced')
1335
1374
  { query, path: searchPath, mode: 'glob' },
1336
1375
  { generation: _readOnlyCacheGeneration },
1337
1376
  async () => {
1338
- const result = await occRegistry.call('Glob', {
1377
+ const result = await occRegistry.call('list_files', {
1339
1378
  pattern: query,
1340
1379
  path: searchPath,
1341
1380
  });
@@ -1356,7 +1395,7 @@ print('OK: replaced')
1356
1395
  { query, path: searchPath, mode: 'grep' },
1357
1396
  { generation: _readOnlyCacheGeneration },
1358
1397
  async () => {
1359
- const result = await occRegistry.call('Grep', {
1398
+ const result = await occRegistry.call('search_code', {
1360
1399
  pattern: query,
1361
1400
  path: searchPath,
1362
1401
  output_mode: 'content',
@@ -1513,7 +1552,7 @@ print('OK: replaced')
1513
1552
  else if (fs.existsSync(path.join(cwd, 'Cargo.toml'))) cmd = 'cargo build';
1514
1553
  else return { success: false, output: 'No build system detected', _tool: 'validate_build' };
1515
1554
  }
1516
- const output = await occRegistry.call('Bash', {
1555
+ const output = await occRegistry.call('shell', {
1517
1556
  command: cmd,
1518
1557
  timeout: Math.min(args.timeout || 120_000, 600_000),
1519
1558
  description: `Validate build: ${cmd.slice(0, 80)}`,
@@ -1563,7 +1602,7 @@ print('OK: replaced')
1563
1602
  else if (['.js', '.mjs', '.ts', '.tsx'].includes(ext)) cmd = `npx eslint "${filePath}" 2>&1 || true`;
1564
1603
  else return { success: true, issues: [], message: 'No linter for this file type', _tool: 'lint_check' };
1565
1604
 
1566
- const output = await occRegistry.call('Bash', {
1605
+ const output = await occRegistry.call('shell', {
1567
1606
  command: cmd,
1568
1607
  timeout: 30_000,
1569
1608
  description: `Lint: ${path.basename(filePath)}`,
@@ -1587,7 +1626,7 @@ print('OK: replaced')
1587
1626
  throwIfAborted(options.signal);
1588
1627
  const cmd = args.command || 'npm test';
1589
1628
  const cwd = await commandCwd(args);
1590
- const output = await occRegistry.call('Bash', {
1629
+ const output = await occRegistry.call('shell', {
1591
1630
  command: cmd,
1592
1631
  timeout: Math.min(args.timeout || 120_000, 600_000),
1593
1632
  description: `Run tests: ${cmd.slice(0, 80)}`,
@@ -2189,10 +2228,19 @@ print('OK: replaced')
2189
2228
 
2190
2229
  getAgentContext() {
2191
2230
  const global = projectRegistry.getGlobalContext();
2231
+ const mem = _readMemorySnapshot();
2192
2232
  return {
2193
2233
  identity: global.identity,
2194
2234
  preferences: global.preferences,
2195
2235
  global_skills: skillsLoader.list(),
2236
+ // Cross-session memory read from disk (CLI-only source of truth).
2237
+ // Backend prefers this over the Supabase agent_memory table when
2238
+ // ctx.agent_ctx.source === 'cli'. `memory_digest` is a stable
2239
+ // sha256 prefix so the backend can hash-compare without
2240
+ // re-serializing — helps keep the prompt cacheable when memory
2241
+ // hasn't changed between turns.
2242
+ memory_facts: mem.facts,
2243
+ memory_digest: mem.digest,
2196
2244
  available_agents: listLocalAgents(process.cwd()).map(agent => ({
2197
2245
  slug: agent.slug,
2198
2246
  name: agent.name,