@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.
- package/LICENSE +201 -0
- package/NOTICE +39 -0
- package/package.json +8 -9
- package/pulse/lib/tool-categories.ts +13 -0
- package/src/commands/device.mjs +121 -0
- package/src/commands/pair.mjs +190 -0
- package/src/commands/remote.mjs +110 -0
- package/src/config/env.mjs +2 -2
- package/src/core/event-log.mjs +393 -0
- package/src/core/headless.mjs +198 -0
- package/src/core/loop.mjs +276 -0
- package/src/core/memory-disk.mjs +210 -0
- package/src/core/paths.mjs +36 -0
- package/src/core/stream-client.mjs +28 -9
- package/src/core/tool-executor.mjs +64 -16
- package/src/daemon/approval-store.mjs +253 -0
- package/src/daemon/attach-client.mjs +361 -0
- package/src/daemon/daemonize.mjs +151 -0
- package/src/daemon/event-tap.mjs +197 -0
- package/src/daemon/input-lock.mjs +191 -0
- package/src/daemon/relay-client.mjs +258 -0
- package/src/daemon/session-core.mjs +179 -0
- package/src/daemon/session-list.mjs +26 -0
- package/src/daemon/session-publisher.mjs +78 -0
- package/src/daemon/socket-server.mjs +329 -0
- package/src/daemon/stop-daemon.mjs +18 -0
- package/src/permissions/checker.mjs +6 -6
- package/src/permissions/prompt.mjs +8 -7
- package/src/skills/installer.mjs +8 -0
- package/src/terminal/ansi.mjs +85 -9
- package/src/terminal/main.mjs +97 -3
- package/src/terminal/repl.mjs +389 -6
- package/src/terminal/skills-picker.mjs +121 -0
- package/src/terminal/skills.mjs +3 -3
- package/src/tools/analyze-code.mjs +39 -0
- package/src/tools/bash.mjs +1 -1
- package/src/tools/edit.mjs +18 -18
- package/src/tools/git-diff.mjs +34 -0
- package/src/tools/git-status.mjs +30 -0
- package/src/tools/glob.mjs +5 -2
- package/src/tools/grep.mjs +1 -1
- package/src/tools/meta-tools.mjs +85 -0
- package/src/tools/read-files.mjs +37 -0
- package/src/tools/read.mjs +20 -10
- package/src/tools/registry.mjs +20 -0
- package/src/tools/remember.mjs +147 -0
- package/src/tools/search-files.mjs +41 -0
- package/src/tools/write-project.mjs +62 -0
- package/src/tools/write.mjs +1 -1
- package/src/ui/banner.mjs +1 -1
- package/src/ui/slash-commands.mjs +16 -0
- package/src/ui/sub-agent.mjs +8 -2
- 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
|
+
}
|
package/src/core/paths.mjs
CHANGED
|
@@ -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
|
|
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 >
|
|
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
|
-
|| '
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
1189
|
+
result = await occRegistry.call('edit_file', {
|
|
1151
1190
|
file_path: filePath,
|
|
1152
|
-
|
|
1153
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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,
|