@dougharness/cli 1.1.1 → 1.2.0

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 (50) hide show
  1. package/dist/board-serve.js +40 -6
  2. package/dist/board-serve.js.map +1 -1
  3. package/dist/init.js +1 -1
  4. package/dist/init.js.map +1 -1
  5. package/dist/memory-setup.d.ts +7 -0
  6. package/dist/memory-setup.js +70 -46
  7. package/dist/memory-setup.js.map +1 -1
  8. package/node_modules/@dougharness/flow/.claude-plugin/plugin.json +6 -2
  9. package/node_modules/@dougharness/flow/agents/coder.md +23 -0
  10. package/node_modules/@dougharness/flow/agents/spec-writer.md +23 -0
  11. package/node_modules/@dougharness/flow/agents/tester.md +21 -0
  12. package/node_modules/@dougharness/flow/hooks/hooks.json +4 -1
  13. package/node_modules/@dougharness/flow/hooks/register.tsx +75 -0
  14. package/node_modules/@dougharness/flow/lib/autopilot-core.mjs +117 -0
  15. package/node_modules/@dougharness/flow/lib/autopilot-view.mjs +17 -0
  16. package/node_modules/@dougharness/flow/lib/board.mjs +91 -10
  17. package/node_modules/@dougharness/flow/lib/card-land.mjs +115 -0
  18. package/node_modules/@dougharness/flow/lib/card-loop.mjs +746 -0
  19. package/node_modules/@dougharness/flow/lib/coordinator.mjs +368 -0
  20. package/node_modules/@dougharness/flow/lib/cost.mjs +15 -5
  21. package/node_modules/@dougharness/flow/lib/embeddings.mjs +144 -3
  22. package/node_modules/@dougharness/flow/lib/land.mjs +27 -8
  23. package/node_modules/@dougharness/flow/lib/memory.mjs +48 -12
  24. package/node_modules/@dougharness/flow/lib/plan.mjs +32 -4
  25. package/node_modules/@dougharness/flow/lib/worktrees.mjs +3 -3
  26. package/node_modules/@dougharness/flow/package.json +1 -1
  27. package/node_modules/@dougharness/flow/scripts/autopilot.mjs +407 -0
  28. package/node_modules/@dougharness/flow/scripts/board.mjs +107 -32
  29. package/node_modules/@dougharness/flow/scripts/cost.mjs +16 -3
  30. package/node_modules/@dougharness/flow/scripts/memory.mjs +20 -9
  31. package/node_modules/@dougharness/flow/scripts/plan.mjs +100 -30
  32. package/node_modules/@dougharness/flow/skills/doug-hand/SKILL.md +8 -2
  33. package/node_modules/@dougharness/flow/skills/doug-next/SKILL.md +30 -10
  34. package/node_modules/@dougharness/flow/skills/doug-start/SKILL.md +80 -0
  35. package/node_modules/@dougharness/flow/skills/doug-swarm/SKILL.md +1 -1
  36. package/node_modules/@dougharness/flow/skills/run-report/SKILL.md +6 -6
  37. package/node_modules/@dougharness/flow/workflows/card-queen.js +807 -0
  38. package/node_modules/@dougharness/flow/workflows/doug-implement.js +7 -7
  39. package/node_modules/@dougharness/gates/.claude-plugin/plugin.json +1 -1
  40. package/node_modules/@dougharness/gates/lib/anchor.mjs +29 -4
  41. package/node_modules/@dougharness/gates/lib/bash-rules.mjs +196 -6
  42. package/node_modules/@dougharness/gates/lib/gate-lock.mjs +40 -8
  43. package/node_modules/@dougharness/gates/lib/proposals.mjs +41 -5
  44. package/node_modules/@dougharness/gates/lib/scope.mjs +43 -4
  45. package/node_modules/@dougharness/gates/package.json +1 -1
  46. package/node_modules/@dougharness/gates/scripts/reanchor.mjs +2 -1
  47. package/node_modules/@dougharness/gates/scripts/stop-gate.mjs +138 -10
  48. package/node_modules/@dougharness/gates/scripts/verify.mjs +8 -4
  49. package/package.json +3 -3
  50. package/templates/board-app.js +46 -0
@@ -0,0 +1,368 @@
1
+ // The parallel-card coordinator (card parallel-card-queens, ADR 0015): the pure decisions main makes while
2
+ // several card queens run in one session. Which cards may run together (D1 disjointness), how many slots are
3
+ // free, what a queen's finished run means, which done card lands next, which autopilot caps are reached.
4
+ // Pure and import-free: the decisions are testable with fakes, and a host that is not Claude Code can reuse them.
5
+
6
+ export const WORKERS_MAX_DEFAULT = 3;
7
+ export const WORKERS_MAX_LIMIT = 16;
8
+
9
+ // The configured workers.max when it is an integer from 1 to 16; the default silently when the key (or
10
+ // workers) is absent; the default plus one warn() call otherwise.
11
+ export function workersMax(config, warn = () => {}) {
12
+ const v = config && config.workers ? config.workers.max : undefined;
13
+ if (v === undefined) return WORKERS_MAX_DEFAULT;
14
+ if (Number.isInteger(v) && v >= 1 && v <= WORKERS_MAX_LIMIT) return v;
15
+ warn(`workers.max must be an integer from 1 to ${WORKERS_MAX_LIMIT}; using ${WORKERS_MAX_DEFAULT}`);
16
+ return WORKERS_MAX_DEFAULT;
17
+ }
18
+
19
+ // Paths every card records into; they never make two cards conflict.
20
+ const IGNORED_PATHS = [".doug/board.json", "docs/live-runs.md", "board.json", "live-runs.md"];
21
+ // A goal naming one of these runs alone: parallel cards would collide on the file (by basename), or on the
22
+ // config (by full path).
23
+ const SOLO_BASENAMES = ["package.json", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "CLAUDE.md"];
24
+ const SOLO_PATHS = [".doug/config.json"];
25
+
26
+ function norm(p) {
27
+ return String(p)
28
+ .replace(/\\/g, "/")
29
+ .replace(/\/{2,}/g, "/")
30
+ .replace(/^(\.\/)+/, "")
31
+ .replace(/\/+$/, "");
32
+ }
33
+
34
+ function baseName(p) {
35
+ const parts = norm(p).split("/");
36
+ return parts[parts.length - 1];
37
+ }
38
+
39
+ // Extensions seen in this repository; a token whose last segment has another one (Date.now, claude.ai, v1.2)
40
+ // is prose, not a path.
41
+ const FILE_EXTS = ["mjs", "js", "ts", "tsx", "json", "md", "yaml", "yml", "sh", "toml", "lock", "txt", "jsonl", "py", "db", "html", "css"];
42
+ const PATH_CHARS = /^[A-Za-z0-9_.\-/*?@]+$/;
43
+ // Prose pairs that look like a/b when no roots list is given.
44
+ const PROSE_PAIRS = ["and/or", "either/or", "yes/no", "input/output", "read/write", "true/false", "he/she"];
45
+
46
+ // A name that is only a dot and a word (.state, .git): a dot-directory, not a file with an extension.
47
+ function isDotName(p) {
48
+ return /^\.[^.]+$/.test(baseName(p));
49
+ }
50
+
51
+ function hasFileExt(t) {
52
+ if (isDotName(t)) return false;
53
+ const m = /\.([A-Za-z0-9]+)$/.exec(baseName(t));
54
+ return !!m && FILE_EXTS.includes(m[1].toLowerCase());
55
+ }
56
+
57
+ // a/{b,c} -> a/b, a/c (first group, recursively). An unclosed brace is left as it is.
58
+ function expandBraces(t) {
59
+ const open = t.indexOf("{");
60
+ const close = open < 0 ? -1 : t.indexOf("}", open);
61
+ if (open < 0 || close < 0) return [t];
62
+ const out = [];
63
+ for (const alt of t.slice(open + 1, close).split(",")) out.push(...expandBraces(t.slice(0, open) + alt + t.slice(close + 1)));
64
+ return out;
65
+ }
66
+
67
+ function cleanToken(raw) {
68
+ let t = raw;
69
+ for (let i = 0; i < 4; i++) {
70
+ const before = t;
71
+ t = t.replace(/^[([<"'`]+/, "").replace(/['\u2019]s$/, "").replace(/:\d+(-\d+)?$/, "");
72
+ t = t.replace(/[)\]>"'`,;:!?.]+$/, "");
73
+ if (t.endsWith("}") && !t.includes("{")) t = t.slice(0, -1);
74
+ if (t === before) break;
75
+ }
76
+ return t;
77
+ }
78
+
79
+ // The file and directory paths a goal names. Sentence punctuation, a trailing :line or :line-line and a
80
+ // possessive 's are dropped; a/{b,c} expands to a/b and a/c; a trailing slash (a directory) is kept. Slash
81
+ // commands, @scopes and prose (and/or, Date.now) are not paths. A slash token with no extension and no
82
+ // trailing slash counts only when its first segment is in opts.roots; with no roots given it is kept.
83
+ export function goalPaths(goal, opts) {
84
+ if (typeof goal !== "string") return [];
85
+ const roots = opts && Array.isArray(opts.roots) ? opts.roots : null;
86
+ const out = [];
87
+ for (const raw of goal.split(/\s+/)) {
88
+ const cleaned = cleanToken(raw);
89
+ if (!cleaned || cleaned.startsWith("/") || cleaned.startsWith("@") || cleaned.includes("://")) continue;
90
+ for (const t of expandBraces(cleaned)) {
91
+ if (!t || !PATH_CHARS.test(t) || !/[A-Za-z]/.test(t)) continue;
92
+ let ok;
93
+ if (!t.includes("/")) ok = hasFileExt(t);
94
+ else if (t.endsWith("/") || hasFileExt(t)) ok = true;
95
+ else if (roots) ok = roots.includes(t.split("/")[0]);
96
+ else ok = !PROSE_PAIRS.includes(t.toLowerCase()) && (isDotName(t) || !baseName(t).includes("."));
97
+ if (ok && !out.includes(t)) out.push(t);
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function handPaths(card, opts) {
104
+ return goalPaths(card.goal, opts).filter((p) => !IGNORED_PATHS.includes(norm(p)));
105
+ }
106
+
107
+ function soloFile(card, opts) {
108
+ for (const p of goalPaths(card.goal, opts)) {
109
+ const n = norm(p);
110
+ if (SOLO_PATHS.includes(n) || SOLO_BASENAMES.includes(baseName(n))) return n;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function isDirEntry(p) {
116
+ return /\/$/.test(String(p)) || isDotName(p) || !baseName(p).includes(".");
117
+ }
118
+
119
+ function covers(dir, file) {
120
+ const d = norm(dir);
121
+ const f = norm(file);
122
+ return d !== "" && (f === d || f.startsWith(d + "/"));
123
+ }
124
+
125
+ // Two goal paths overlap: the same path, one inside the other, or the same file name.
126
+ function pathsOverlap(p, q) {
127
+ if (covers(p, q) || covers(q, p)) return true;
128
+ return !isDirEntry(p) && !isDirEntry(q) && baseName(p) === baseName(q);
129
+ }
130
+
131
+ const GLOB_CHARS = /[*?{]/;
132
+
133
+ function globToRegExp(glob) {
134
+ let re = "";
135
+ const g = norm(glob);
136
+ let inBrace = false;
137
+ for (let i = 0; i < g.length; i++) {
138
+ const c = g[i];
139
+ if (c === "*") {
140
+ if (g[i + 1] === "*") {
141
+ i++;
142
+ if (g[i + 1] === "/") {
143
+ i++;
144
+ re += "(?:.*/)?";
145
+ } else re += ".*";
146
+ } else re += "[^/]*";
147
+ } else if (c === "?") re += "[^/]";
148
+ else if (c === "{" && g.indexOf("}", i) >= 0) {
149
+ inBrace = true;
150
+ re += "(?:";
151
+ } else if (c === "}" && inBrace) {
152
+ inBrace = false;
153
+ re += ")";
154
+ } else if (c === "," && inBrace) re += "|";
155
+ else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
156
+ }
157
+ return new RegExp("^" + re + "$");
158
+ }
159
+
160
+ // The directory a glob is rooted in: its literal prefix up to the last slash before the first glob character.
161
+ function globRoot(glob) {
162
+ const g = norm(glob);
163
+ const at = g.search(GLOB_CHARS);
164
+ const lead = at < 0 ? g : g.slice(0, at);
165
+ const cut = lead.lastIndexOf("/");
166
+ return cut < 0 ? "" : lead.slice(0, cut);
167
+ }
168
+
169
+ // Do two plan ownership entries (exact file, directory or glob) overlap? Same semantics as scope.mjs's
170
+ // ownsPath, tried both ways; a glob against anything also counts when their directories nest.
171
+ function entriesOverlap(a, b) {
172
+ const x = norm(a);
173
+ const y = norm(b);
174
+ if (!x || !y) return false;
175
+ const gx = GLOB_CHARS.test(x);
176
+ const gy = GLOB_CHARS.test(y);
177
+ if (!gx && !gy) return covers(x, y) || covers(y, x);
178
+ if (gx && !gy && globToRegExp(x).test(y)) return true;
179
+ if (gy && !gx && globToRegExp(y).test(x)) return true;
180
+ const rx = gx ? globRoot(x) : x;
181
+ const ry = gy ? globRoot(y) : y;
182
+ if (rx === "" || ry === "") return true;
183
+ return covers(rx, ry) || covers(ry, rx);
184
+ }
185
+
186
+ function ownedFiles(card) {
187
+ const out = [];
188
+ const tasks = card.plan && Array.isArray(card.plan.tasks) ? card.plan.tasks : [];
189
+ for (const t of tasks) for (const f of (t && t.files) || []) if (typeof f === "string") out.push(f);
190
+ return out;
191
+ }
192
+
193
+ function taskIds(card) {
194
+ const tasks = card.plan && Array.isArray(card.plan.tasks) ? card.plan.tasks : [];
195
+ return tasks.map((t) => t && t.id).filter((id) => typeof id === "string");
196
+ }
197
+
198
+ function conflict(b, why) {
199
+ return { with: b.id, why };
200
+ }
201
+
202
+ function flowWithFlow(a, b) {
203
+ if (!a.plan || !b.plan) return conflict(b, `the plan of ${a.plan ? b.id : a.id} is not known yet`);
204
+ const ids = taskIds(b);
205
+ const shared = taskIds(a).find((id) => ids.includes(id));
206
+ if (shared) return conflict(b, `both plans have a task id "${shared}"`);
207
+ const mine = ownedFiles(a);
208
+ const theirs = ownedFiles(b);
209
+ for (const f of mine) for (const g of theirs) if (entriesOverlap(f, g)) return conflict(b, `both plans own ${norm(f) === norm(g) ? norm(f) : `${norm(f)} and ${norm(g)}`}`);
210
+ return null;
211
+ }
212
+
213
+ function flowWithHand(f, h, opts) {
214
+ if (!f.plan) return conflict(h, `the plan of ${f.id} is not known yet`);
215
+ if (soloFile(h, opts)) return conflict(h, `${h.id} names ${soloFile(h, opts)}, so it runs alone`);
216
+ for (const p of handPaths(h, opts)) {
217
+ for (const o of ownedFiles(f)) {
218
+ const bare = !norm(p).includes("/");
219
+ if (entriesOverlap(o, p) || (bare && !GLOB_CHARS.test(o) && baseName(o) === norm(p))) {
220
+ return conflict(h, `the plan owns ${norm(o)}, which the goal names (${norm(p)})`);
221
+ }
222
+ }
223
+ }
224
+ if (!f.component || !h.component) return conflict(h, "a card with no component conflicts with everything");
225
+ if (f.component === h.component) return conflict(h, `same component ${f.component}`);
226
+ return null;
227
+ }
228
+
229
+ function handWithHand(a, b, opts) {
230
+ const sa = soloFile(a, opts);
231
+ if (sa) return conflict(b, `${a.id} names ${sa}, so it runs alone`);
232
+ const sb = soloFile(b, opts);
233
+ if (sb) return conflict(b, `${b.id} names ${sb}, so it runs alone`);
234
+ const pa = handPaths(a, opts);
235
+ const pb = handPaths(b, opts);
236
+ for (const p of pa) for (const q of pb) if (pathsOverlap(p, q)) return conflict(b, `both goals name ${norm(p) === norm(q) ? norm(p) : `${norm(p)} and ${norm(q)}`}`);
237
+ if (!a.component || !b.component) return conflict(b, "a card with no component conflicts with everything");
238
+ if (a.component === b.component) return conflict(b, `same component ${a.component}`);
239
+ return null;
240
+ }
241
+
242
+ // null when the two cards may run together, else { with: b.id, why }. A flow card carries its plan as `.plan`.
243
+ export function cardConflict(a, b, opts) {
244
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return { with: b && b.id !== undefined ? b.id : null, why: "a card is missing" };
245
+ if (a.track === "flow" && b.track === "flow") return flowWithFlow(a, b);
246
+ if (a.track === "flow") return flowWithHand(a, b, opts);
247
+ if (b.track === "flow") {
248
+ const c = flowWithHand(b, a, opts);
249
+ return c && conflict(b, c.why);
250
+ }
251
+ return handWithHand(a, b, opts);
252
+ }
253
+
254
+ // "hand" or "flow" for a card a queen can run; "alone" for a swarm or a decision card, which runs by itself.
255
+ export function queenKind(card) {
256
+ if (!card) return "alone";
257
+ if (card.swarm || card.class === "decision") return "alone";
258
+ return card.track === "flow" ? "flow" : "hand";
259
+ }
260
+
261
+ // Which ready cards to start now. A flow card without a plan comes back as needsPlan (its owned files are
262
+ // unknown until a planner runs); with a plan it is checked like any card. A card is launched when it conflicts
263
+ // with nothing in flight and nothing launched before it; otherwise it is held against what it met first.
264
+ // needsPlan and launch together never exceed `slots`; a held card takes no slot.
265
+ export function pickCards(arg) {
266
+ const { ready = [], inFlight = [], slots = 0, plans = {}, roots } = arg && typeof arg === "object" ? arg : {};
267
+ const out = { launch: [], needsPlan: [], held: [], alone: [] };
268
+ const opts = { roots };
269
+ const planOf = (c) => (plans && typeof plans === "object" && Object.hasOwn(plans, c.id) ? plans[c.id] : null);
270
+ const withPlan = (c) => (c.track === "flow" && planOf(c) ? { ...c, plan: planOf(c) } : c);
271
+ const running = (inFlight || []).filter((c) => c && typeof c === "object").map(withPlan);
272
+ const launched = [];
273
+ let blocked = false;
274
+ for (const raw of ready || []) {
275
+ if (!raw || typeof raw !== "object") continue;
276
+ if (queenKind(raw) === "alone") {
277
+ // An alone card does not starve: the first one runnable in board order stops later launches. It is returned
278
+ // only when nothing is in flight, launched in this pick, or waiting for a plan.
279
+ if (running.length === 0 && out.launch.length === 0 && out.needsPlan.length === 0) out.alone.push(raw.id);
280
+ blocked = true;
281
+ continue;
282
+ }
283
+ if (blocked) continue;
284
+ if (out.launch.length + out.needsPlan.length >= slots) continue;
285
+ if (raw.track === "flow" && !planOf(raw)) {
286
+ out.needsPlan.push(raw.id);
287
+ continue;
288
+ }
289
+ const card = withPlan(raw);
290
+ let met = null;
291
+ for (const other of [...running, ...launched]) {
292
+ met = cardConflict(card, other, opts);
293
+ if (met) break;
294
+ }
295
+ if (met) out.held.push({ id: raw.id, with: met.with, why: met.why });
296
+ else {
297
+ out.launch.push(raw.id);
298
+ launched.push(card);
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+
304
+ function parseResult(result) {
305
+ if (typeof result === "string") {
306
+ try {
307
+ return JSON.parse(result);
308
+ } catch {
309
+ return null;
310
+ }
311
+ }
312
+ return result && typeof result === "object" ? result : null;
313
+ }
314
+
315
+ // What a queen's finished run means for main: land it, resume it (the run died, or its result is unreadable, or
316
+ // a seat's agent returned null), or ask the user (the other queens keep running meanwhile).
317
+ export function classifyNotification(kind, arg) {
318
+ const { status, result } = arg || {};
319
+ if (status !== "completed") return { action: "resume", reason: `run status ${status}` };
320
+ const r = parseResult(result);
321
+ if (!r) return { action: "resume", reason: "result unreadable" };
322
+ if (kind === "flow") {
323
+ if (r.ok === true) return { action: "land" };
324
+ if (r.paused) {
325
+ const p = r.paused;
326
+ return { action: "ask", reason: `paused at a ${p.gate || "human"} gate (level ${p.level})` };
327
+ }
328
+ if (r.ok === false) return { action: "ask", reason: "the run stopped" + (r.stoppedAtLevel !== undefined ? ` at level ${r.stoppedAtLevel}` : "") };
329
+ return { action: "ask", reason: "unrecognised result" };
330
+ }
331
+ // The queen's real result (card-queen.js) carries outcome; the wrapper shape carries kind. Both are read.
332
+ const verdict = r.kind ?? r.outcome;
333
+ if (verdict === "done") return { action: "land" };
334
+ if (verdict === "stopped") {
335
+ if (r.reason === "agent-null") return { action: "resume", reason: "agent-null" };
336
+ return { action: "ask", reason: r.reason };
337
+ }
338
+ return { action: "ask", reason: "unrecognised result" };
339
+ }
340
+
341
+ // The done cards waiting to land, first in first out by doneAt. One lands at a time: while a card is landing
342
+ // nothing else is next.
343
+ export function landQueue(state) {
344
+ const cards = state && Array.isArray(state.cards) ? state.cards : [];
345
+ const done = cards
346
+ .filter((c) => c && typeof c === "object")
347
+ .map((c, i) => ({ c, i }))
348
+ .filter(({ c }) => c.state === "done")
349
+ .sort((x, y) => {
350
+ const a = x.c.doneAt || "￿";
351
+ const b = y.c.doneAt || "￿";
352
+ return a < b ? -1 : a > b ? 1 : x.i - y.i;
353
+ })
354
+ .map(({ c }) => c.id);
355
+ const busy = cards.some((c) => c && c.state === "landing");
356
+ return { next: busy || done.length === 0 ? null : done[0], queue: done };
357
+ }
358
+
359
+ // The names of the caps whose used value is at or over the cap. An unset cap is ignored.
360
+ export function capsReached(caps, used) {
361
+ const out = [];
362
+ for (const name of ["usd", "wallMin", "contextPct"]) {
363
+ const cap = caps ? caps[name] : undefined;
364
+ const v = used ? used[name] : undefined;
365
+ if (typeof cap === "number" && Number.isFinite(cap) && typeof v === "number" && v >= cap) out.push(name);
366
+ }
367
+ return out;
368
+ }
@@ -77,7 +77,7 @@ export function priceUsage(usage, model) {
77
77
 
78
78
  // The transcript writes one line per content block of an API response, each carrying that response's usage (and
79
79
  // the early blocks a partial output count), so usage is taken once per message id, the largest value seen per field.
80
- export function transcriptUsage(file) {
80
+ export function transcriptUsage(file, { since = null, until = null } = {}) {
81
81
  const perMessage = new Map();
82
82
  const models = new Map();
83
83
  for (const line of readFileSync(file, "utf8").split("\n")) {
@@ -90,6 +90,13 @@ export function transcriptUsage(file) {
90
90
  }
91
91
  const m = o && o.message;
92
92
  if (o.type !== "assistant" || !m || !m.usage) continue;
93
+ if (since !== null || until !== null) {
94
+ // A window (card usd-budget-size-class) keeps only the lines whose timestamp falls in it, inclusive at both
95
+ // ends. A line with no readable timestamp cannot be placed and is kept: counting it is the conservative
96
+ // choice for a budget.
97
+ const at = Date.parse(o.timestamp);
98
+ if (!Number.isNaN(at) && ((since !== null && at < since) || (until !== null && at > until))) continue;
99
+ }
93
100
  const id = m.id || o.requestId || o.uuid;
94
101
  const u = m.usage;
95
102
  const cur = perMessage.get(id) || { ...emptyUsage(), model: m.model || null };
@@ -468,7 +475,7 @@ export function sessionTranscriptPath({ session, claudeDir = join(homedir(), ".c
468
475
  // <slug>/<session>/subagents/, recursively (plain agents and workflows/wf_*/ agents). `session` is an id or "latest",
469
476
  // the most recently modified top-level transcript in the project's slug directory. Throws when none is found.
470
477
  // Workflow journal.jsonl files carry no usage lines and add nothing.
471
- export function costSession({ session, claudeDir = join(homedir(), ".claude"), projectDir = process.cwd() } = {}) {
478
+ export function costSession({ session, claudeDir = join(homedir(), ".claude"), projectDir = process.cwd(), since = null, until = null } = {}) {
472
479
  const top = sessionTranscriptPath({ session, claudeDir, projectDir });
473
480
  const slugDir = join(claudeDir, "projects", projectSlug(projectDir));
474
481
  const id = basename(top, ".jsonl");
@@ -485,23 +492,26 @@ export function costSession({ session, claudeDir = join(homedir(), ".claude"), p
485
492
  const usage = emptyUsage();
486
493
  let usd = 0;
487
494
  let unpriced = 0;
495
+ let counted = 0;
496
+ const windowed = since !== null || until !== null;
488
497
  const notes = [];
489
498
  for (const f of files) {
490
- const t = transcriptUsage(f);
499
+ const t = transcriptUsage(f, { since, until });
491
500
  addUsage(usage, t.usage);
492
501
  if (!t.messages) continue;
502
+ counted++;
493
503
  const price = t.usd;
494
504
  if (price === null) {
495
505
  unpriced++;
496
506
  notes.push(`${f}: model ${t.model || "unknown"} is not in the price table; not priced`);
497
507
  } else usd += price;
498
508
  }
499
- return { session: id, files: files.length, total: { usage, usd, unpriced, agents: files.length }, pricesDated: PRICES_DATED, notes };
509
+ return { session: id, files: windowed ? counted : files.length, windowed, total: { usage, usd, unpriced, agents: windowed ? counted : files.length }, pricesDated: PRICES_DATED, notes };
500
510
  }
501
511
 
502
512
  export function renderSession(result) {
503
513
  const t = result.total;
504
- const lines = [`Session ${result.session}: ${result.files} transcript${result.files === 1 ? "" : "s"}.`];
514
+ const lines = [`Session ${result.session}: ${result.files} transcript${result.files === 1 ? "" : "s"}${result.windowed ? " in window" : ""}.`];
505
515
  lines.push(`Session total: ${fmtInt(t.usage.input)} input, ${fmtInt(t.usage.output)} output, ${fmtInt(t.usage.cacheRead)} cache read, ${fmtInt(t.usage.cacheWrite)} cache write tokens; ${fmtUsd(t.usd)}${t.unpriced ? ` (${t.unpriced} transcript(s) unpriced)` : ""} at list prices dated ${result.pricesDated}.`);
506
516
  for (const n of result.notes) lines.push(`Note: ${n}.`);
507
517
  return lines.join("\n") + "\n";
@@ -3,12 +3,39 @@
3
3
  // /v1/embeddings endpoint (Ollama, llama-server, vLLM, LM Studio, a hosted OpenAI-shaped key) and Voyage AI's
4
4
  // own endpoint. Product first: memory.embeddings in .doug/config.json is null by default, and every function
5
5
  // here treats "no provider" or "provider unreachable" as a normal outcome, never a thrown error.
6
- import { readFileSync, existsSync } from "node:fs";
6
+ import { readFileSync, existsSync, mkdirSync } from "node:fs";
7
7
  import { join, dirname } from "node:path";
8
- import { fileURLToPath } from "node:url";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ import { homedir } from "node:os";
10
+ import { createRequire } from "node:module";
11
+ import { spawn } from "node:child_process";
9
12
 
10
13
  const CONFIG_RELPATH = ".doug/config.json";
11
14
  const VALID_PROVIDERS = new Set(["openai-compatible", "voyage"]);
15
+
16
+ // The bundled local model (card memory-bundled-embedding, ADR 0006 amendment 2026-10-08): the runtime is
17
+ // installed into a user-level cache and loaded by dynamic import, never a dependency of any Doug package.
18
+ export const LOCAL_MODEL_ID = "onnx-community/embeddinggemma-300m-ONNX";
19
+ export const LOCAL_DTYPE = "q8";
20
+ export const LOCAL_RUNTIME_PACKAGE = "@huggingface/transformers@4.3.1";
21
+ export const LOCAL_QUERY_PREFIX = "task: search result | query: ";
22
+ export const LOCAL_DOCUMENT_PREFIX = "title: none | text: ";
23
+ const LOCAL_DIMS = 512;
24
+
25
+ export function localCacheDir(env = process.env, home = homedir()) {
26
+ if (env.DOUG_CACHE_DIR) return env.DOUG_CACHE_DIR;
27
+ return join(env.XDG_CACHE_HOME || join(home, ".cache"), "doug");
28
+ }
29
+
30
+ export function localPaths(cacheDir) {
31
+ const modelsDir = join(cacheDir, "models");
32
+ return {
33
+ cacheDir,
34
+ runtimeDir: join(cacheDir, "runtime"),
35
+ modelsDir,
36
+ modelFile: join(modelsDir, "onnx-community", "embeddinggemma-300m-ONNX", "onnx", "model_quantized.onnx"),
37
+ };
38
+ }
12
39
  const DEFAULT_TIMEOUT_MS = 2000;
13
40
  const OLLAMA_BASE_URL = "http://localhost:11434";
14
41
  const DEFAULT_MODEL = "embeddinggemma";
@@ -21,6 +48,7 @@ const PRESETS = {
21
48
  unsloth: { provider: "openai-compatible", baseUrl: "http://127.0.0.1:8888", apiKeyEnv: "UNSLOTH_API_KEY", sendsDims: false, requiresKey: true, modelsList: "v1" },
22
49
  openai: { provider: "openai-compatible", baseUrl: "https://api.openai.com", apiKeyEnv: "OPENAI_API_KEY", sendsDims: true, requiresKey: true, modelsList: "v1" },
23
50
  voyage: { provider: "voyage", baseUrl: "https://api.voyageai.com", apiKeyEnv: "VOYAGE_API_KEY", sendsDims: true, requiresKey: true, modelsList: null },
51
+ local: { provider: "local", baseUrl: undefined, apiKeyEnv: undefined, sendsDims: false, requiresKey: false, modelsList: null },
24
52
  custom: { provider: undefined, baseUrl: undefined, apiKeyEnv: undefined, sendsDims: true, requiresKey: false, modelsList: null },
25
53
  };
26
54
 
@@ -60,7 +88,8 @@ export function readMemoryConfig(dir) {
60
88
  const e = memory && typeof memory === "object" ? memory.embeddings : undefined;
61
89
  if (e === null || e === undefined) return { embeddings: null };
62
90
  const resolved = applyPreset(e);
63
- if (!isValidEmbeddingsConfig(resolved)) {
91
+ const isLocal = e.preset === "local";
92
+ if (isLocal ? !isValidLocalConfig(resolved) : !isValidEmbeddingsConfig(resolved)) {
64
93
  process.stderr.write(`[doug] ${CONFIG_RELPATH} memory.embeddings is not a valid shape; treating as unset (keyword-only)\n`);
65
94
  return { embeddings: null };
66
95
  }
@@ -80,9 +109,18 @@ function applyPreset(e) {
80
109
  for (const k of ["provider", "baseUrl", "apiKeyEnv"]) {
81
110
  if (out[k] === undefined && p[k] !== undefined) out[k] = p[k];
82
111
  }
112
+ if (e.preset === "local") {
113
+ if (out.model === undefined) out.model = LOCAL_MODEL_ID;
114
+ if (out.dims === undefined) out.dims = LOCAL_DIMS;
115
+ }
83
116
  return out;
84
117
  }
85
118
 
119
+ // The local runtime runs one model at one width: a config that names anything else is invalid.
120
+ function isValidLocalConfig(r) {
121
+ return !!(r && r.provider === "local" && r.model === LOCAL_MODEL_ID && r.dims === LOCAL_DIMS);
122
+ }
123
+
86
124
  function isValidIndexConfig(idx) {
87
125
  return !!(
88
126
  idx &&
@@ -452,11 +490,96 @@ async function embedBatched(texts, inputType, sendBatch) {
452
490
  return { ok: true, vectors };
453
491
  }
454
492
 
493
+ async function defaultLoadTransformers(runtimeDir) {
494
+ const req = createRequire(join(runtimeDir, "noop.js"));
495
+ return import(pathToFileURL(req.resolve("@huggingface/transformers")).href);
496
+ }
497
+
498
+ // The in-process provider: the transformers module comes from opts.loadTransformers (the test seam) or the
499
+ // runtime dir under the cache. Loads lazily on the first embed and keeps the model for the provider's life.
500
+ // Recall never reaches the network (allowRemoteModels false); only setup's download passes allowRemote.
501
+ function createLocalProvider(e, opts) {
502
+ const paths = localPaths(opts.cacheDir ?? localCacheDir());
503
+ const loadTransformers = opts.loadTransformers ?? (() => defaultLoadTransformers(paths.runtimeDir));
504
+ let loaded = null;
505
+ const load = async () => {
506
+ if (loaded) return loaded;
507
+ let mod;
508
+ try {
509
+ mod = await loadTransformers();
510
+ } catch {
511
+ return { failure: { ok: false, reason: `local embedding runtime not installed at ${paths.runtimeDir}: run doug memory setup` } };
512
+ }
513
+ mod.env.cacheDir = paths.modelsDir;
514
+ mod.env.allowRemoteModels = opts.allowRemote === true;
515
+ const tokenizer = await mod.AutoTokenizer.from_pretrained(LOCAL_MODEL_ID);
516
+ const model = await mod.AutoModel.from_pretrained(LOCAL_MODEL_ID, { dtype: LOCAL_DTYPE });
517
+ loaded = { tokenizer, model };
518
+ return loaded;
519
+ };
520
+ const sendBatch = async (texts, inputType) => {
521
+ const l = await load();
522
+ if (l.failure) return l.failure;
523
+ const prefix = inputType === "query" ? LOCAL_QUERY_PREFIX : LOCAL_DOCUMENT_PREFIX;
524
+ const inputs = l.tokenizer(texts.map((t) => prefix + t), { padding: true, truncation: true });
525
+ const { sentence_embedding } = await l.model(inputs);
526
+ const vectors = [];
527
+ for (const row of sentence_embedding.tolist()) {
528
+ if (row.length < LOCAL_DIMS) return { ok: false, reason: `wrong dims: got ${row.length}, expected ${LOCAL_DIMS}` };
529
+ vectors.push(l2normalize(Float32Array.from(row.slice(0, LOCAL_DIMS))));
530
+ }
531
+ return { ok: true, vectors };
532
+ };
533
+ return {
534
+ name: "local",
535
+ model: LOCAL_MODEL_ID,
536
+ dims: LOCAL_DIMS,
537
+ async embed(texts, { inputType = "document" } = {}) {
538
+ try {
539
+ return await embedBatched(texts, inputType, sendBatch);
540
+ } catch (err) {
541
+ return { ok: false, reason: `local embedding failed: ${err.message}` };
542
+ }
543
+ },
544
+ };
545
+ }
546
+
547
+ const LOCAL_CONFIG = { embeddings: { preset: "local", provider: "local", model: LOCAL_MODEL_ID, dims: LOCAL_DIMS } };
548
+
549
+ // Installs the pinned runtime into <cacheDir>/runtime with the npm CLI, CPU only (the CUDA download is skipped on
550
+ // every platform). Never throws.
551
+ export function installLocalRuntime({ cacheDir, spawnImpl = spawn, platform = process.platform } = {}) {
552
+ return new Promise((resolve) => {
553
+ try {
554
+ const { runtimeDir } = localPaths(cacheDir ?? localCacheDir());
555
+ mkdirSync(runtimeDir, { recursive: true });
556
+ const child = spawnImpl(
557
+ platform === "win32" ? "npm.cmd" : "npm",
558
+ ["install", "--prefix", runtimeDir, "--no-audit", "--no-fund", LOCAL_RUNTIME_PACKAGE],
559
+ { stdio: "inherit", env: { ...process.env, ONNXRUNTIME_NODE_INSTALL: "skip" } },
560
+ );
561
+ child.on("error", (err) => resolve({ ok: false, reason: `npm install failed to start: ${err.message}` }));
562
+ child.on("close", (code) => resolve(code === 0 ? { ok: true } : { ok: false, reason: `npm install exited with code ${code}` }));
563
+ } catch (err) {
564
+ resolve({ ok: false, reason: `npm install failed: ${err.message}` });
565
+ }
566
+ });
567
+ }
568
+
569
+ // One document embed with remote models allowed: from_pretrained downloads the tokenizer and q8 model into the
570
+ // models dir. Never throws.
571
+ export async function downloadLocalModel({ cacheDir, loadTransformers } = {}) {
572
+ const provider = createProvider(LOCAL_CONFIG, { cacheDir, loadTransformers, allowRemote: true });
573
+ const r = await provider.embed(["probe"], { inputType: "document" });
574
+ return r.ok ? { ok: true } : { ok: false, reason: r.reason };
575
+ }
576
+
455
577
  // cfg is the { embeddings } shape readMemoryConfig returns. Returns null when nothing is configured (the
456
578
  // caller's cue to run keyword-only); otherwise { name, model, dims, embed(texts, { inputType }) }.
457
579
  export function createProvider(cfg, opts = {}) {
458
580
  const e = cfg && cfg.embeddings;
459
581
  if (!e) return null;
582
+ if (e.provider === "local") return createLocalProvider(e, opts);
460
583
  const fetchImpl = opts.fetch || globalThis.fetch;
461
584
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
462
585
  const state = { omitDims: false };
@@ -569,6 +692,14 @@ export async function probeProvider(cfg, opts = {}) {
569
692
  const baseUrl = e && e.baseUrl ? e.baseUrl : process.env.DOUG_OLLAMA_BASE_URL || OLLAMA_BASE_URL;
570
693
  const wantModel = e && e.model ? e.model : DEFAULT_MODEL;
571
694
  const root = baseUrl.replace(/\/$/, "");
695
+ if (e && e.provider === "local") {
696
+ const { cacheDir, modelFile } = localPaths(opts.cacheDir ?? localCacheDir());
697
+ const modelPresent = existsSync(modelFile);
698
+ const base = { kind: "local", cacheDir, modelFile, modelPresent };
699
+ if (!modelPresent) return { ...base, reachable: false, embedOk: false, reason: "model file missing" };
700
+ const result = await createProvider(cfg, { cacheDir, loadTransformers: opts.loadTransformers }).embed(["probe"]);
701
+ return { ...base, reachable: result.ok, embedOk: result.ok, reason: result.ok ? null : result.reason };
702
+ }
572
703
  const preset = presetOf(e);
573
704
  if (preset && preset.modelsList === "v1") return probeV1Models(e, root, wantModel, { fetchImpl, timeoutMs });
574
705
  if (preset && e.provider === "voyage") {
@@ -675,6 +806,16 @@ export function doctorReport(cfg, probe, platform = process.platform) {
675
806
  return lines.join("\n");
676
807
  }
677
808
 
809
+ if (embeddings.provider === "local") {
810
+ lines.push(`memory.embeddings is configured: local ${embeddings.model}/${embeddings.dims} (bundled, in-process).`);
811
+ lines.push(`Cache: ${probe.cacheDir}`);
812
+ lines.push(`Model file: ${probe.modelFile} (${probe.modelPresent ? "present" : "missing"})`);
813
+ lines.push(`Dims: ${embeddings.dims}`);
814
+ if (!probe.modelPresent) lines.push("Run doug memory setup to install it.");
815
+ else if (probe.embedOk) lines.push("Test embed: ok");
816
+ else lines.push(`Test embed failed: ${probe.reason || "unknown error"}`);
817
+ return lines.join("\n");
818
+ }
678
819
  lines.push(`memory.embeddings is configured: ${embeddings.provider} ${embeddings.model}/${embeddings.dims} at ${embeddings.baseUrl}.`);
679
820
  if (presetOf(embeddings) && PRESETS[embeddings.preset].modelsList === "v1") {
680
821
  if (!probe.reachable) lines.push(`Not reachable: ${probe.reason || "unknown error"}.`);