golem-kit 0.1.1 → 0.2.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +8 -5
  3. package/docs/agents.md +64 -0
  4. package/docs/app-backend.md +261 -0
  5. package/docs/architecture.md +93 -0
  6. package/docs/builder.md +15 -0
  7. package/docs/knowledge.md +35 -0
  8. package/docs/local-cli.md +22 -12
  9. package/docs/source-development.md +31 -0
  10. package/index.html +9 -0
  11. package/package.json +24 -5
  12. package/src/backend/accounts.ts +287 -0
  13. package/src/backend/app.ts +269 -0
  14. package/src/backend/files.ts +68 -0
  15. package/src/backend/http.ts +276 -0
  16. package/src/backend/index.ts +10 -0
  17. package/src/backend/jobs.ts +302 -0
  18. package/src/backend/jsonl.ts +87 -0
  19. package/src/backend/knowledge.ts +264 -0
  20. package/src/backend/model.ts +129 -0
  21. package/src/backend/rules.ts +53 -0
  22. package/src/backend/sqlite.ts +73 -0
  23. package/src/backend/views.ts +216 -0
  24. package/src/brain.ts +94 -0
  25. package/src/browser/adapters.ts +229 -53
  26. package/src/browser/ansi.ts +104 -0
  27. package/src/browser/app.d.ts +5 -2
  28. package/src/browser/app.tsx +167 -39
  29. package/src/browser/groups.tsx +29 -0
  30. package/src/browser/main.tsx +1 -0
  31. package/src/browser/panekeys.ts +34 -0
  32. package/src/browser/sources.tsx +113 -0
  33. package/src/browser/styles.css +36 -0
  34. package/src/browser/terminal.tsx +89 -0
  35. package/src/browser-build.ts +25 -7
  36. package/src/chat.ts +74 -0
  37. package/src/cli.ts +103 -14
  38. package/src/client.ts +205 -0
  39. package/src/config.ts +139 -5
  40. package/src/dev-server.ts +336 -39
  41. package/src/entry.mjs +23 -0
  42. package/src/eslint.mjs +55 -0
  43. package/src/operations.ts +169 -0
  44. package/src/runtime/assistant.ts +141 -0
  45. package/src/runtime/discovery.ts +13 -7
  46. package/src/runtime/harness/agent-status.js +388 -0
  47. package/src/runtime/harness/claude-tmux.js +573 -0
  48. package/src/runtime/harness/codex-notify.js +95 -0
  49. package/src/runtime/harness/codex-tmux.js +292 -0
  50. package/src/runtime/harness/fake.js +430 -0
  51. package/src/runtime/harness/package.json +1 -0
  52. package/src/runtime/harness/port.js +208 -0
  53. package/src/runtime/harness/tmux-session.js +556 -0
  54. package/src/runtime/harness/tmux.js +285 -0
  55. package/src/runtime/harness/turnend-hook.js +105 -0
  56. package/src/runtime/session.ts +171 -34
  57. package/src/runtime/tmux.ts +173 -0
  58. package/src/runtime/tool-names.ts +19 -0
  59. package/src/source-mode.ts +56 -0
  60. package/vite.config.ts +2 -4
  61. package/src/runtime/codex.ts +0 -119
@@ -0,0 +1,388 @@
1
+ // Vendored from bridge-commander 5bf87e4b harness/agent-status.js (zero-dependency). Local patches are marked 'golem:'.
2
+ 'use strict';
3
+ // agent-status — session status (model, context usage, rate limits) read from
4
+ // the files the harnesses ALREADY write; no statusline dependency, nothing new
5
+ // is persisted. Backs the OPTIONAL `status`/`runCommand` capability verbs
6
+ // (port.js): claude-tmux reads the session transcript under ~/.claude/projects,
7
+ // codex-tmux reads the rollout log under ~/.codex/sessions. Everything here is
8
+ // best-effort by contract: a missing/unreadable/foreign-shaped file returns
9
+ // null, never a throw.
10
+ //
11
+ // Status shape (the port's `status(ref)` return value):
12
+ // { model, contextUsed, contextWindow, effort?, rateLimits? }
13
+ // effort (reasoning level, e.g. "high") — from the claude sidecar or the codex
14
+ // turn_context; absent when unknown (e.g. the claude transcript fallback).
15
+ // rateLimits (codex only — claude does not persist them): { primary?,
16
+ // secondary? } each { usedPercent, windowMinutes, resetsAt (epoch secs) }.
17
+ //
18
+ // Files can grow to tens of MB, so reads are TAIL reads (last N bytes, from
19
+ // the end); the interesting lines — claude's last assistant message, codex's
20
+ // last token_count event — always sit near the bottom. claude alone gets one
21
+ // escalation step: a huge tool-result line can push the last assistant line
22
+ // past a small tail window.
23
+
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+
28
+ const TAIL_BYTES = 256 * 1024;
29
+
30
+ // tailRead — the last maxBytes of a file, decoded; null when missing/unreadable.
31
+ // When the file is smaller than maxBytes the whole file comes back.
32
+ function tailRead(file, maxBytes) {
33
+ let fd;
34
+ try {
35
+ fd = fs.openSync(file, 'r');
36
+ } catch {
37
+ return null;
38
+ }
39
+ try {
40
+ const size = fs.fstatSync(fd).size;
41
+ const want = Math.min(size, maxBytes);
42
+ const buf = Buffer.alloc(want);
43
+ fs.readSync(fd, buf, 0, want, size - want);
44
+ return buf.toString('utf8');
45
+ } catch {
46
+ return null;
47
+ } finally {
48
+ fs.closeSync(fd);
49
+ }
50
+ }
51
+
52
+ // ---------- claude ----------
53
+ // Transcript path: ~/.claude/projects/<slug(cwd)>/<sessionId>.jsonl — the slug
54
+ // replaces every non-alphanumeric cwd character with '-' (verified against
55
+ // real transcript dirs: /home/ai/.treehouse/x → -home-ai--treehouse-x).
56
+ function claudeProjectSlug(cwd) {
57
+ return String(cwd).replace(/[^A-Za-z0-9]/g, '-');
58
+ }
59
+
60
+ // Context window per model — matched by substring so versioned ids
61
+ // (claude-fable-5, claude-opus-4-8, …) hit without an exhaustive list.
62
+ // Extend by adding a pair; unknown models get the conservative default.
63
+ const CLAUDE_CONTEXT_WINDOWS = [
64
+ ['fable', 1000000],
65
+ ['opus', 200000],
66
+ ['sonnet', 200000],
67
+ ['haiku', 200000],
68
+ ];
69
+ const CLAUDE_DEFAULT_WINDOW = 200000;
70
+ function claudeContextWindow(model) {
71
+ const m = String(model || '').toLowerCase();
72
+ for (const [needle, window] of CLAUDE_CONTEXT_WINDOWS) {
73
+ if (m.includes(needle)) return window;
74
+ }
75
+ return CLAUDE_DEFAULT_WINDOW;
76
+ }
77
+
78
+ // ---------- claude statusline sidecar ----------
79
+ // harness/statusline.js (the workspace's Claude Code `statusLine` command) tees
80
+ // each stdin payload to <workspace>/.bridge-commander/statusline/<session_id>.json
81
+ // — the ONLY source of the REAL context window (context_window_size), the
82
+ // account rate limits, and the model display name. Lieutenants run with cwd =
83
+ // workspace root, so the sidecar exists for them; workers in worktree cwds have
84
+ // none and fall through to the transcript+map path below (accepted).
85
+ const STATE_DIR_NAME = '.bridge-commander';
86
+ function findBridgeWorkspace(startDir) {
87
+ if (!startDir) return null;
88
+ let dir = path.resolve(startDir);
89
+ for (;;) {
90
+ try {
91
+ if (fs.statSync(path.join(dir, STATE_DIR_NAME)).isDirectory()) return dir;
92
+ } catch { /* keep walking up */ }
93
+ const parent = path.dirname(dir);
94
+ if (parent === dir) return null;
95
+ dir = parent;
96
+ }
97
+ }
98
+
99
+ // Map the statusline payload's rate_limits (five_hour/seven_day, each
100
+ // {used_percentage, resets_at}) onto the shared status rateLimits shape
101
+ // (primary/secondary, each {usedPercent, windowMinutes, resetsAt epoch secs}),
102
+ // so formatStatus labels them 5h / 1w just like codex.
103
+ function claudeSidecarRateLimits(rl) {
104
+ if (!rl || typeof rl !== 'object') return null;
105
+ const toEpochSecs = (v) => {
106
+ if (v == null) return undefined;
107
+ if (typeof v === 'number' && Number.isFinite(v)) return v > 1e11 ? Math.floor(v / 1000) : Math.floor(v);
108
+ if (typeof v === 'string' && v.trim() !== '') {
109
+ const n = Number(v);
110
+ if (Number.isFinite(n)) return n > 1e11 ? Math.floor(n / 1000) : Math.floor(n);
111
+ const p = Date.parse(v);
112
+ if (!Number.isNaN(p)) return Math.floor(p / 1000);
113
+ }
114
+ return undefined;
115
+ };
116
+ const pick = (w, windowMinutes) => {
117
+ if (!w || typeof w !== 'object' || w.used_percentage == null) return undefined;
118
+ const out = { usedPercent: Number(w.used_percentage), windowMinutes };
119
+ const r = toEpochSecs(w.resets_at);
120
+ if (r !== undefined) out.resetsAt = r;
121
+ return out;
122
+ };
123
+ const out = {};
124
+ const primary = pick(rl.five_hour, 300);
125
+ const secondary = pick(rl.seven_day, 10080);
126
+ if (primary) out.primary = primary;
127
+ if (secondary) out.secondary = secondary;
128
+ return Object.keys(out).length ? out : null;
129
+ }
130
+
131
+ // claudeSidecarStatus(ref, opts?) -> status | null
132
+ // Prefer the sidecar when one exists for this session (file name == resumeId)
133
+ // and it carries a real context_window_size. Any missing file / bad JSON /
134
+ // absent window → null, so the caller falls back to the transcript path.
135
+ function claudeSidecarStatus(ref, opts = {}) {
136
+ const dir = opts.sidecarDir
137
+ || (() => {
138
+ const ws = opts.workspace || findBridgeWorkspace(ref.cwd);
139
+ return ws ? path.join(ws, STATE_DIR_NAME, 'statusline') : null;
140
+ })();
141
+ if (!dir) return null;
142
+ let doc;
143
+ try {
144
+ doc = JSON.parse(fs.readFileSync(path.join(dir, ref.resumeId + '.json'), 'utf8'));
145
+ } catch {
146
+ return null;
147
+ }
148
+ const p = doc && doc.payload;
149
+ const cw = p && p.context_window;
150
+ const window = cw && Number(cw.context_window_size);
151
+ if (!p || !cw || !Number.isFinite(window) || window <= 0) return null;
152
+ const out = {
153
+ model: (p.model && (p.model.id || p.model.display_name)) || null,
154
+ contextUsed: Number(cw.total_input_tokens) || 0,
155
+ contextWindow: window,
156
+ };
157
+ const effort = p.effort && p.effort.level;
158
+ if (effort) out.effort = String(effort);
159
+ const rl = claudeSidecarRateLimits(p.rate_limits);
160
+ if (rl) out.rateLimits = rl;
161
+ return out;
162
+ }
163
+
164
+ // claudeStatus(ref, opts?) -> status | null
165
+ // Prefer the statusline sidecar (real window + rate limits); else the last
166
+ // assistant line's message.usage: contextUsed = input + cache_read +
167
+ // cache_creation + output (what the next turn starts from), with the context
168
+ // window guessed from the model→window map.
169
+ function claudeStatus(ref, opts = {}) {
170
+ if (!ref || !ref.cwd || !ref.resumeId) return null;
171
+ const sidecar = claudeSidecarStatus(ref, opts);
172
+ if (sidecar) return sidecar;
173
+ const projectsDir = opts.projectsDir || process.env.BC_CLAUDE_PROJECTS_DIR
174
+ || path.join(os.homedir(), '.claude', 'projects');
175
+ const file = path.join(projectsDir, claudeProjectSlug(ref.cwd), ref.resumeId + '.jsonl');
176
+ let size = 0;
177
+ try {
178
+ size = fs.statSync(file).size;
179
+ } catch {
180
+ return null;
181
+ }
182
+ // One escalation: 256KB tail first, 4MB if no assistant line surfaced.
183
+ for (const maxBytes of [TAIL_BYTES, 16 * TAIL_BYTES]) {
184
+ const text = tailRead(file, maxBytes);
185
+ if (text === null) return null;
186
+ const lines = text.split('\n');
187
+ for (let i = lines.length - 1; i >= 0; i--) {
188
+ if (!lines[i].includes('"type":"assistant"')) continue;
189
+ let doc;
190
+ try {
191
+ doc = JSON.parse(lines[i]);
192
+ } catch {
193
+ continue; // the tail window's first line may be cut mid-JSON
194
+ }
195
+ const msg = doc && doc.type === 'assistant' && doc.message;
196
+ const u = msg && msg.usage;
197
+ if (!u || typeof u !== 'object') continue;
198
+ const used = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0)
199
+ + (u.cache_creation_input_tokens || 0) + (u.output_tokens || 0);
200
+ return {
201
+ model: msg.model || null,
202
+ contextUsed: used,
203
+ contextWindow: claudeContextWindow(msg.model),
204
+ };
205
+ }
206
+ if (size <= maxBytes) break; // whole file scanned — a bigger tail finds nothing new
207
+ }
208
+ return null;
209
+ }
210
+
211
+ // ---------- codex ----------
212
+ // Rollout path: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<threadId>.jsonl
213
+ // (threadId — see codexThreadId). The date dirs are walked newest-first and the
214
+ // first match wins, so a thread resumed on a later day resolves to its newest
215
+ // rollout file.
216
+ function codexRolloutFile(threadId, sessionsDir) {
217
+ const suffix = '-' + threadId + '.jsonl';
218
+ const listDesc = (dir) => {
219
+ try {
220
+ return fs.readdirSync(dir).sort().reverse();
221
+ } catch {
222
+ return [];
223
+ }
224
+ };
225
+ for (const year of listDesc(sessionsDir)) {
226
+ for (const month of listDesc(path.join(sessionsDir, year))) {
227
+ for (const day of listDesc(path.join(sessionsDir, year, month))) {
228
+ const dir = path.join(sessionsDir, year, month, day);
229
+ const hit = listDesc(dir).find((f) => f.startsWith('rollout-') && f.endsWith(suffix));
230
+ if (hit) return path.join(dir, hit);
231
+ }
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+
237
+ function codexRateLimits(rl) {
238
+ if (!rl || typeof rl !== 'object') return null;
239
+ const pick = (w) => (w && typeof w === 'object' ? {
240
+ usedPercent: w.used_percent,
241
+ windowMinutes: w.window_minutes,
242
+ resetsAt: w.resets_at,
243
+ } : undefined);
244
+ const out = {};
245
+ if (pick(rl.primary)) out.primary = pick(rl.primary);
246
+ if (pick(rl.secondary)) out.secondary = pick(rl.secondary);
247
+ return Object.keys(out).length ? out : null;
248
+ }
249
+
250
+ // codexStatus(ref, opts?) -> status | null
251
+ // The thread-id comes from codexThreadId (recorded session-id file, then the
252
+ // ref) — NOT from ref.resumeId alone: a codex ref is born without one.
253
+ // The last token_count event carries current context occupancy
254
+ // (info.last_token_usage.total_tokens = input+cached+output of the last turn)
255
+ // and the model context window; the model rides every turn_context line, so the
256
+ // tail always has a fresh one. rate_limits come from the same token_count.
257
+ // NOTE: info.total_token_usage is the CUMULATIVE session total (grows forever,
258
+ // exceeds the window) — it is NOT occupancy. Selecting on last_token_usage is
259
+ // deliberate: rollouts where info is populated always carry it (verified on
260
+ // real rollouts), and the only ones missing it have info === null, which the
261
+ // null-guards below already reject — so no total_token_usage fallback is needed.
262
+ // codexThreadId(ref, opts) -> the thread-id whose rollout to read, or null.
263
+ // The notify relay rewrites <stateDir>/<key>.session-id at every turn, so that
264
+ // file is ground truth; ref.resumeId is adopted once from a turn-end POST and a
265
+ // ref can live its whole life without one (codex assigns the id itself — see
266
+ // codex-tmux.js). Same order resume() uses: recorded file first, ref second.
267
+ // opts.stateDir comes from the caller that knows where harness state lives
268
+ // (codex-tmux status()); without it only the ref can answer.
269
+ function codexThreadId(ref, opts = {}) {
270
+ if (ref && ref.session && opts.stateDir) {
271
+ const key = ref.window ? ref.session + ':' + ref.window : ref.session;
272
+ try {
273
+ const rec = fs.readFileSync(path.join(opts.stateDir, key + '.session-id'), 'utf8').trim();
274
+ if (rec) return rec;
275
+ } catch {
276
+ // no recorded id — the ref's is all there is
277
+ }
278
+ }
279
+ return (ref && ref.resumeId) || null;
280
+ }
281
+
282
+ function codexStatus(ref, opts = {}) {
283
+ const threadId = codexThreadId(ref, opts);
284
+ if (!threadId) return null;
285
+ const sessionsDir = opts.sessionsDir || process.env.BC_CODEX_SESSIONS_DIR
286
+ || path.join(os.homedir(), '.codex', 'sessions');
287
+ const file = codexRolloutFile(threadId, sessionsDir);
288
+ if (!file) return null;
289
+ const text = tailRead(file, TAIL_BYTES);
290
+ if (text === null) return null;
291
+ const lines = text.split('\n');
292
+ let usage = null;
293
+ let model = null;
294
+ let effort = null;
295
+ for (let i = lines.length - 1; i >= 0 && !(usage && model); i--) {
296
+ const line = lines[i];
297
+ let doc = null;
298
+ if (!usage && line.includes('"token_count"')) {
299
+ try { doc = JSON.parse(line); } catch { continue; }
300
+ const p = doc && doc.payload;
301
+ if (p && p.type === 'token_count' && p.info && p.info.last_token_usage) usage = p;
302
+ } else if (!model && line.includes('"turn_context"')) {
303
+ try { doc = JSON.parse(line); } catch { continue; }
304
+ const p = doc && doc.payload;
305
+ if (doc.type === 'turn_context' && p && p.model) {
306
+ model = String(p.model);
307
+ if (p.effort) effort = String(p.effort);
308
+ }
309
+ }
310
+ }
311
+ if (!usage) return null;
312
+ const out = {
313
+ model,
314
+ contextUsed: usage.info.last_token_usage.total_tokens || 0,
315
+ contextWindow: usage.info.model_context_window || null,
316
+ };
317
+ if (effort) out.effort = effort;
318
+ const rl = codexRateLimits(usage.rate_limits);
319
+ if (rl) out.rateLimits = rl;
320
+ return out;
321
+ }
322
+
323
+ // ---------- shared slash-command surface ----------
324
+ // The commands every status-capable harness answers; runCommand semantics:
325
+ // /status formats status(), /compact rides the verified-submit send path
326
+ // (the harness's OWN /compact runs in-session), /help renders this list.
327
+ const SLASH_COMMANDS = [
328
+ { name: '/status', description: 'model, context usage and rate limits' },
329
+ { name: '/compact', description: 'compact the conversation to free context' },
330
+ { name: '/help', description: 'list the available commands' },
331
+ ];
332
+
333
+ // Replies render as markdown in the chat thread, where a single newline
334
+ // collapses — blank-line separators keep each line its own paragraph.
335
+ function helpText(cmds) {
336
+ return cmds.map((c) => c.name + ' — ' + c.description).join('\n\n');
337
+ }
338
+
339
+ function fmtInt(n) {
340
+ return Number.isFinite(n) ? Math.round(n).toLocaleString('en-US') : '?';
341
+ }
342
+
343
+ function fmtWindowLabel(minutes) {
344
+ if (!Number.isFinite(minutes)) return 'rate';
345
+ if (minutes % 10080 === 0) return (minutes / 10080) + 'w';
346
+ if (minutes % 1440 === 0) return (minutes / 1440) + 'd';
347
+ if (minutes % 60 === 0) return (minutes / 60) + 'h';
348
+ return minutes + 'min';
349
+ }
350
+
351
+ // formatStatus(status) -> the human /status reply.
352
+ function formatStatus(st) {
353
+ const modelLine = 'model: ' + (st.model || 'unknown') + (st.effort ? ' (' + st.effort + ')' : '');
354
+ const lines = [modelLine];
355
+ if (Number.isFinite(st.contextUsed) && st.contextWindow > 0) {
356
+ const pct = Math.round((st.contextUsed / st.contextWindow) * 100);
357
+ lines.push('context: ' + fmtInt(st.contextUsed) + ' / ' + fmtInt(st.contextWindow)
358
+ + ' tokens (' + pct + '%)');
359
+ } else if (Number.isFinite(st.contextUsed)) {
360
+ lines.push('context: ' + fmtInt(st.contextUsed) + ' tokens');
361
+ }
362
+ const rl = st.rateLimits || {};
363
+ for (const key of ['primary', 'secondary']) {
364
+ const w = rl[key];
365
+ if (!w) continue;
366
+ let line = fmtWindowLabel(w.windowMinutes) + ' limit: ' + (Number.isFinite(w.usedPercent) ? Math.round(w.usedPercent) : '?') + '% used';
367
+ if (Number.isFinite(w.resetsAt)) {
368
+ line += ' (resets ' + new Date(w.resetsAt * 1000).toISOString().replace('T', ' ').slice(0, 16) + ' UTC)';
369
+ }
370
+ lines.push(line);
371
+ }
372
+ return lines.join('\n\n');
373
+ }
374
+
375
+ module.exports = {
376
+ tailRead,
377
+ claudeProjectSlug,
378
+ claudeContextWindow,
379
+ findBridgeWorkspace,
380
+ claudeSidecarStatus,
381
+ claudeStatus,
382
+ codexRolloutFile,
383
+ codexThreadId,
384
+ codexStatus,
385
+ SLASH_COMMANDS,
386
+ helpText,
387
+ formatStatus,
388
+ };