@bahulam/code 0.1.2 → 0.1.4

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 (45) hide show
  1. package/package.json +5 -8
  2. package/pulse/lib/tool-categories.ts +13 -0
  3. package/src/commands/device.mjs +121 -0
  4. package/src/commands/pair.mjs +190 -0
  5. package/src/commands/remote.mjs +110 -0
  6. package/src/core/event-log.mjs +393 -0
  7. package/src/core/headless.mjs +198 -0
  8. package/src/core/loop.mjs +276 -0
  9. package/src/core/memory-disk.mjs +210 -0
  10. package/src/core/paths.mjs +36 -0
  11. package/src/core/stream-client.mjs +28 -9
  12. package/src/core/tool-executor.mjs +56 -16
  13. package/src/daemon/approval-store.mjs +253 -0
  14. package/src/daemon/attach-client.mjs +361 -0
  15. package/src/daemon/daemonize.mjs +151 -0
  16. package/src/daemon/event-tap.mjs +197 -0
  17. package/src/daemon/input-lock.mjs +191 -0
  18. package/src/daemon/relay-client.mjs +258 -0
  19. package/src/daemon/session-core.mjs +179 -0
  20. package/src/daemon/session-list.mjs +26 -0
  21. package/src/daemon/session-publisher.mjs +78 -0
  22. package/src/daemon/socket-server.mjs +329 -0
  23. package/src/daemon/stop-daemon.mjs +18 -0
  24. package/src/permissions/checker.mjs +6 -6
  25. package/src/permissions/prompt.mjs +8 -7
  26. package/src/terminal/ansi.mjs +20 -3
  27. package/src/terminal/main.mjs +97 -3
  28. package/src/terminal/repl-render.mjs +21 -8
  29. package/src/terminal/repl.mjs +201 -2
  30. package/src/tools/analyze-code.mjs +39 -0
  31. package/src/tools/bash.mjs +1 -1
  32. package/src/tools/edit.mjs +18 -18
  33. package/src/tools/git-diff.mjs +34 -0
  34. package/src/tools/git-status.mjs +30 -0
  35. package/src/tools/glob.mjs +5 -2
  36. package/src/tools/grep.mjs +1 -1
  37. package/src/tools/meta-tools.mjs +85 -0
  38. package/src/tools/read-files.mjs +37 -0
  39. package/src/tools/read.mjs +20 -10
  40. package/src/tools/registry.mjs +20 -0
  41. package/src/tools/remember.mjs +147 -0
  42. package/src/tools/search-files.mjs +41 -0
  43. package/src/tools/write-project.mjs +62 -0
  44. package/src/tools/write.mjs +1 -1
  45. package/src/ui/sub-agent.mjs +8 -2
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Thin Agent Loop — iterates turns against /v1/agent/* gateway endpoints.
3
+ *
4
+ * PRD-091 §6.2-6.3: CLI owns iteration. Gateway owns LLM calls,
5
+ * prompt assembly, memory management, and sub-agent orchestration.
6
+ *
7
+ * This loop replaces local-agent.mjs's direct LLM calls with a
8
+ * POST /v1/agent/turn to the gateway. Everything else — tool dispatch,
9
+ * message accumulation, event shapes — matches the existing patterns
10
+ * the REPL already consumes.
11
+ *
12
+ * Usage (replacing client.execute()):
13
+ * const { createAgentLoop } = await import('../core/loop.mjs');
14
+ * for await (const event of createAgentLoop({
15
+ * sessionId: 'sess_...',
16
+ * messages: session.agentHistory,
17
+ * toolExecutor: executor,
18
+ * gatewayFetch: (body) => fetch(`${GATEWAY_URL}/v1/agent/turn`, { ... }),
19
+ * })) {
20
+ * // same event types as client.execute() / LocalAgent.execute()
21
+ * }
22
+ */
23
+
24
+ import * as os from 'node:os';
25
+ import * as path from 'node:path';
26
+ import * as fs from 'node:fs';
27
+
28
+ const MAX_TURNS = 999;
29
+
30
+ // ── Auth / URL discovery ────────────────────────────────────────────────
31
+ // Same precedence as bundled-runtime.mjs::_readCliToken so gateway calls
32
+ // use the SAME credential the user's login saved. Kept inline here so
33
+ // loop.mjs stays importable without pulling the bundled-runtime module.
34
+ function _readCliToken() {
35
+ if (process.env.BAHULAM_API_KEY) return process.env.BAHULAM_API_KEY;
36
+ if (process.env.BAHULAM_CLI_TOKEN) return process.env.BAHULAM_CLI_TOKEN;
37
+ if (process.env.B0_TOKEN) return process.env.B0_TOKEN;
38
+ try {
39
+ const raw = fs.readFileSync(path.join(os.homedir(), '.bahulam', 'config.json'), 'utf8');
40
+ const parsed = JSON.parse(raw);
41
+ return (parsed && typeof parsed.token === 'string' && parsed.token.trim()) || null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function _gatewayUrl() {
48
+ return (process.env.BAHULAM_GATEWAY_URL || 'https://gateway.bahulam.ai/v1').replace(/\/+$/, '');
49
+ }
50
+
51
+ /**
52
+ * Create a session on the gateway. Returns the session config the loop
53
+ * uses on every subsequent /v1/agent/turn (server-owned prompt +
54
+ * tool_schemas + model). Called ONCE per REPL session before iterating.
55
+ */
56
+ export async function createGatewaySession({
57
+ workspace = 'kepler-code',
58
+ model = process.env.BAHULAM_MODEL || undefined,
59
+ token = _readCliToken(),
60
+ gateway = _gatewayUrl(),
61
+ } = {}) {
62
+ if (!token) {
63
+ throw new Error(
64
+ 'Not logged in — set BAHULAM_API_KEY or run `bahulam login` first.',
65
+ );
66
+ }
67
+ const base = gateway.endsWith('/v1') ? gateway.slice(0, -3) : gateway;
68
+ const res = await fetch(`${base}/v1/agent/session`, {
69
+ method: 'POST',
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ 'Authorization': `Bearer ${token}`,
73
+ 'X-Bahulam-User-Id': process.env.BAHULAM_USER_ID || 'cli-user',
74
+ 'X-Bahulam-Tier': process.env.BAHULAM_TIER || 'free',
75
+ },
76
+ body: JSON.stringify({ workspace, ...(model ? { model } : {}) }),
77
+ });
78
+ if (!res.ok) {
79
+ const text = await res.text();
80
+ throw new Error(`session create failed (HTTP ${res.status}): ${text.slice(0, 300)}`);
81
+ }
82
+ return res.json(); // { session_id, workspace, prompt, tool_schemas, model, expires_at, ... }
83
+ }
84
+
85
+ /**
86
+ * Execute one turn against /v1/chat/completions (the standard OpenAI-shape
87
+ * gateway endpoint). This reuses the existing metering, entitlement, and
88
+ * provider translation the gateway already does for BYOK — we don't need
89
+ * a new /v1/agent/turn endpoint. Session config (prompt + tools + model)
90
+ * comes from createGatewaySession() once, then every turn just posts
91
+ * standard OpenAI messages + tools + model.
92
+ *
93
+ * The `session` param carries { prompt, tool_schemas, model } from
94
+ * createGatewaySession. We inject them into the request server prefers
95
+ * client to send explicitly so per-tier gating on the gateway side
96
+ * still works (gateway decides what a caller may use; client just
97
+ * echoes what it received).
98
+ */
99
+ async function _callGateway({ session, messages }) {
100
+ const token = _readCliToken();
101
+ if (!token) throw new Error('Missing gateway token (BAHULAM_API_KEY / bahulam login).');
102
+ const base = _gatewayUrl();
103
+ // Support both `<host>` and `<host>/v1` in BAHULAM_GATEWAY_URL.
104
+ const url = base.endsWith('/v1')
105
+ ? `${base}/chat/completions`
106
+ : `${base}/v1/chat/completions`;
107
+
108
+ // Strip Bahulam-specific metadata from tool schemas before sending
109
+ // — server also strips defensively, but keep the wire clean.
110
+ const tools = (session.tool_schemas || []).map(t => ({
111
+ type: t.type || 'function',
112
+ function: t.function,
113
+ }));
114
+
115
+ const body = {
116
+ model: session.model,
117
+ messages, // OpenAI-shape end-to-end — {role, content} or
118
+ // assistant.tool_calls + tool.tool_call_id
119
+ ...(tools.length ? { tools, tool_choice: 'auto' } : {}),
120
+ temperature: 0.0,
121
+ stream: false,
122
+ };
123
+
124
+ const t0 = performance.now();
125
+ const res = await fetch(url, {
126
+ method: 'POST',
127
+ headers: {
128
+ 'Content-Type': 'application/json',
129
+ 'Authorization': `Bearer ${token}`,
130
+ 'X-Bahulam-User-Id': process.env.BAHULAM_USER_ID || 'cli-user',
131
+ 'X-Bahulam-Tier': process.env.BAHULAM_TIER || 'free',
132
+ },
133
+ body: JSON.stringify(body),
134
+ });
135
+ const roundTripMs = performance.now() - t0;
136
+
137
+ if (!res.ok) {
138
+ const text = await res.text();
139
+ throw new Error(`chat/completions failed (HTTP ${res.status}): ${text.slice(0, 400)}`);
140
+ }
141
+ const data = await res.json();
142
+ const choice = (data.choices || [{}])[0];
143
+ return {
144
+ message: choice.message || {}, // OpenAI assistant message shape
145
+ finish_reason: choice.finish_reason,
146
+ usage: data.usage || {},
147
+ timing_ms: { roundTrip: Math.round(roundTripMs * 10) / 10 },
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Create an async generator that iterates turns against the gateway.
153
+ *
154
+ * Uses standard OpenAI /v1/chat/completions shape throughout — the same
155
+ * shape the gateway already speaks to upstream providers. No custom
156
+ * content-block conversion, no /v1/agent/turn endpoint needed.
157
+ *
158
+ * @param {Object} opts
159
+ * @param {Object} opts.session - From createGatewaySession()
160
+ * { session_id, prompt, tool_schemas, model, ... }
161
+ * @param {Array} opts.messages - Conversation history (mutated across turns).
162
+ * Caller seeds with [{role:'user', content:input}].
163
+ * @param {Object} opts.toolExecutor - From createToolExecutor() — .execute(name, input)
164
+ * @param {Function} [opts.gatewayFetch] - Override for testing
165
+ * @param {number} [opts.maxTurns] - Max iterations (default 999)
166
+ * @yields {Object} Events matching the REPL event protocol
167
+ */
168
+ export async function* createAgentLoop({
169
+ session,
170
+ messages,
171
+ toolExecutor,
172
+ gatewayFetch = _callGateway,
173
+ maxTurns = MAX_TURNS,
174
+ } = {}) {
175
+ let toolCount = 0;
176
+ const startTime = Date.now();
177
+ let usage = { input_tokens: 0, output_tokens: 0 };
178
+
179
+ // Prepend the workspace system prompt if not already present. The
180
+ // messages array (caller-owned) may accumulate across REPL turns,
181
+ // so only inject once.
182
+ if (session?.prompt && !messages.some(m => m.role === 'system')) {
183
+ messages.unshift({ role: 'system', content: session.prompt });
184
+ }
185
+
186
+ for (let i = 0; i < maxTurns; i++) {
187
+ // ── Call gateway (/v1/chat/completions) ─────────────────────
188
+ const turn = await gatewayFetch({ session, messages });
189
+ const asst = turn.message || {};
190
+ usage = {
191
+ input_tokens: (usage.input_tokens || 0) + (turn.usage?.prompt_tokens || 0),
192
+ output_tokens: (usage.output_tokens || 0) + (turn.usage?.completion_tokens || 0),
193
+ };
194
+
195
+ // ── Push assistant response into message history (OpenAI shape)
196
+ // content=null when tool_calls exist (OpenAI protocol requirement),
197
+ // else the text string.
198
+ const openAiAsst = { role: 'assistant' };
199
+ if (asst.tool_calls && asst.tool_calls.length > 0) {
200
+ openAiAsst.content = asst.content ?? null;
201
+ openAiAsst.tool_calls = asst.tool_calls;
202
+ } else {
203
+ openAiAsst.content = asst.content ?? '';
204
+ }
205
+ messages.push(openAiAsst);
206
+
207
+ // ── Yield text content ──────────────────────────────────────
208
+ if (typeof asst.content === 'string' && asst.content) {
209
+ yield { type: 'content', data: { text: asst.content } };
210
+ }
211
+
212
+ // ── Handle tool calls ───────────────────────────────────────
213
+ const toolCalls = asst.tool_calls || [];
214
+ if (toolCalls.length > 0) {
215
+ for (const tc of toolCalls) {
216
+ const id = tc.id;
217
+ const name = tc.function?.name || '';
218
+ let input = {};
219
+ try { input = JSON.parse(tc.function?.arguments || '{}'); }
220
+ catch { input = { _raw: tc.function?.arguments }; }
221
+
222
+ yield { type: 'tool_call', data: { call_id: id, tool: name, args: input } };
223
+
224
+ // Execute locally via the existing tool executor
225
+ let result;
226
+ try {
227
+ result = await toolExecutor.execute(name, input || {});
228
+ } catch (err) {
229
+ result = { success: false, output: `Error: ${err.message}` };
230
+ }
231
+
232
+ yield { type: 'tool_done', data: { tool: name, duration_ms: 0 } };
233
+ toolCount++;
234
+
235
+ // Push tool response as a role='tool' message (OpenAI shape).
236
+ // tool_call_id links it to the assistant's tool_calls[i].id.
237
+ // MUST push one message per tool_call in the SAME order as
238
+ // the assistant's tool_calls, or OpenAI rejects the next
239
+ // turn with "tool_call_ids did not have response messages".
240
+ messages.push({
241
+ role: 'tool',
242
+ tool_call_id: id,
243
+ content: typeof result.output === 'string'
244
+ ? result.output
245
+ : JSON.stringify(result.output ?? result),
246
+ });
247
+ }
248
+ // Loop continues to next iteration
249
+ } else {
250
+ // ── No tool calls → turn is done ────────────────────────
251
+ const duration = (Date.now() - startTime) / 1000;
252
+ yield {
253
+ type: 'complete',
254
+ data: {
255
+ summary: 'Done',
256
+ changes: toolCount,
257
+ duration_s: duration,
258
+ usage,
259
+ },
260
+ };
261
+ return;
262
+ }
263
+ }
264
+
265
+ // Max turns reached
266
+ yield { type: 'error', data: { message: `Max turns (${maxTurns}) reached.`, fatal: false } };
267
+ yield {
268
+ type: 'complete',
269
+ data: {
270
+ summary: 'Aborted (max turns)',
271
+ changes: toolCount,
272
+ duration_s: (Date.now() - startTime) / 1000,
273
+ usage,
274
+ },
275
+ };
276
+ }
@@ -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',