@monster-orange/den 0.4.7 → 0.4.9

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 (44) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +243 -1
  4. package/package.json +1 -1
  5. package/schemas/den.plugin.v1.json +3 -2
  6. package/src/shared/agents/den-claude.ts +57 -3
  7. package/src/shared/agents/guardrails/hooks.json +23 -0
  8. package/src/shared/agents/guardrails/instructions.md +23 -0
  9. package/src/shared/agents/guardrails/rotation-hook.ts +127 -0
  10. package/src/shared/agents/guardrails/stop-hook.ts +48 -2
  11. package/src/shared/agents/guardrails.ts +38 -7
  12. package/src/shared/agents/handoff.ts +118 -0
  13. package/src/shared/agents/personas.ts +11 -1
  14. package/src/shared/agents/records.ts +122 -1
  15. package/src/shared/agents/rotation.ts +362 -0
  16. package/src/shared/agents/runtime.ts +27 -0
  17. package/src/shared/den-config.ts +72 -1
  18. package/src/shared/git/gh-pr-state.ts +32 -0
  19. package/src/shared/plugins/manifest.ts +3 -0
  20. package/src/shared/usage/paths.ts +16 -0
  21. package/src/shared/usage/report.ts +377 -0
  22. package/src/shared/usage/sources.ts +127 -0
  23. package/src/shared/usage/transcript.ts +319 -0
  24. package/src/ui/cli/agent-ops.ts +678 -51
  25. package/src/ui/cli/agents-command.ts +192 -22
  26. package/src/ui/cli/index.ts +13 -1
  27. package/src/ui/cli/usage-command.ts +323 -0
  28. package/src/ui/mcp/api.ts +10 -1
  29. package/src/ui/mcp/server.ts +34 -0
  30. package/src/ui/mcp/tools.ts +50 -0
  31. package/src/ui/server/agent-resume-grant.ts +53 -0
  32. package/src/ui/server/agent-routes.ts +247 -9
  33. package/src/ui/server/audit.ts +68 -1
  34. package/src/ui/server/index.ts +16 -5
  35. package/src/ui/server/public/agents-page.js +400 -13
  36. package/src/ui/server/public/agents-section.js +17 -3
  37. package/src/ui/server/public/agents.html +44 -0
  38. package/src/ui/server/public/i18n/es.js +40 -0
  39. package/src/ui/server/public/i18n/ja.js +36 -0
  40. package/src/ui/server/public/shell.js +79 -0
  41. package/src/ui/server/request-guard.ts +21 -1
  42. package/src/ui/server/route-classes.ts +29 -0
  43. package/src/ui/server/session-routes.ts +60 -5
  44. package/src/ui/server/sudo-store.ts +36 -7
@@ -0,0 +1,377 @@
1
+ /**
2
+ * The usage report (den#6, ported from the agent-usage report): per agent,
3
+ * its tokens and its outcome, joined on the agent record's resumeToken; and
4
+ * per week, the numbers to move, diffed against the last report.
5
+ *
6
+ * An agent session is "attributed" when its transcripts were found and
7
+ * counted (tokens) and its outcome is known (at least its wall-clock). The
8
+ * share of attributed sessions is the scorecard metric: before this, no
9
+ * agent's tokens or outcome were joined to it (~0).
10
+ *
11
+ * Session rotation (den#4) adds, per agent, its compactions (this
12
+ * conversation's) and its rotations (how many times Den relaunched it from
13
+ * a handoff); and per week, two numbers to move:
14
+ * - long_session_context_k: the highest average context per call (input,
15
+ * cache reads and cache writes, over calls) of any main session in the
16
+ * window, in thousands of tokens;
17
+ * - never_ending_reread_pct: the share of context re-reads (cache reads)
18
+ * that came from never-ending sessions: main sessions that compacted
19
+ * more often than the rotation limit (agents.rotate.after_compactions,
20
+ * default 3).
21
+ *
22
+ * Everything is read and written on this machine; nothing is sent.
23
+ */
24
+ import type {
25
+ AgentOutcome,
26
+ AgentRecord,
27
+ AgentUsage,
28
+ } from "../agents/records.js";
29
+ import { sourceFor, type UsageSource } from "./sources.js";
30
+ import type { TranscriptStats } from "./transcript.js";
31
+
32
+ export interface AgentUsageRow {
33
+ agent: string;
34
+ workspace: string;
35
+ persona: string;
36
+ runtime: string;
37
+ /** No source reads its runtime, or it has no resume token. */
38
+ why?: string;
39
+ /** How many transcripts were found (its own and its subagents'). */
40
+ transcripts: number;
41
+ usage?: AgentUsage;
42
+ outcome?: AgentOutcome;
43
+ attributed: boolean;
44
+ /** Its current conversation's compactions (den#4), when counted. */
45
+ compactions?: number;
46
+ /** How many times Den relaunched it from a handoff (den#4). */
47
+ rotations: number;
48
+ }
49
+
50
+ /** The sum of transcripts' counts, as the record keeps them. */
51
+ export function sumUsage(stats: readonly TranscriptStats[]): AgentUsage {
52
+ const u: AgentUsage = {
53
+ calls: 0,
54
+ input: 0,
55
+ output: 0,
56
+ cacheRead: 0,
57
+ cacheWrite: 0,
58
+ compactions: 0,
59
+ };
60
+ for (const s of stats) {
61
+ u.calls += s.calls;
62
+ u.input += s.tokens.input;
63
+ u.output += s.tokens.output;
64
+ u.cacheRead += s.tokens.cacheRead;
65
+ u.cacheWrite += s.tokens.cacheWrite;
66
+ u.compactions += s.compactions;
67
+ }
68
+ return u;
69
+ }
70
+
71
+ /** An agent's outcome from its transcripts and its record. */
72
+ export function outcomeOf(
73
+ rec: AgentRecord,
74
+ stats: readonly TranscriptStats[],
75
+ ): AgentOutcome {
76
+ const own = stats.filter((s) => !s.subagent);
77
+ const all = own.length ? own : stats;
78
+ const firsts = all
79
+ .map((s) => s.first)
80
+ .filter((x): x is number => x !== undefined);
81
+ const lasts = all
82
+ .map((s) => s.last)
83
+ .filter((x): x is number => x !== undefined);
84
+ const out: AgentOutcome = {};
85
+ if (firsts.length && lasts.length)
86
+ out.wallClockMs = Math.max(...lasts) - Math.min(...firsts);
87
+ else {
88
+ const start = Date.parse(rec.startedAt);
89
+ const end = Date.parse(
90
+ rec.stoppedAt ?? rec.endedAt ?? rec.interruptedAt ?? "",
91
+ );
92
+ if (Number.isFinite(start) && Number.isFinite(end) && end >= start)
93
+ out.wallClockMs = end - start;
94
+ }
95
+ const pr = [...all].reverse().find((s) => s.prOpened)?.prOpened;
96
+ if (pr) out.prOpened = pr;
97
+ // The last test run: of the transcript that ended last and ran tests.
98
+ const tested = all
99
+ .filter((s) => s.testsGreen !== undefined)
100
+ .sort((a, b) => (a.last ?? 0) - (b.last ?? 0));
101
+ if (tested.length) out.testsGreen = tested[tested.length - 1].testsGreen;
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * Whether one of the transcripts' `gh pr merge` claims is a pull request
107
+ * gh says is MERGED. A bare `gh pr merge` (the branch's PR) is checked by
108
+ * the PR the agent opened, else in its folder.
109
+ */
110
+ async function mergeVerified(
111
+ rec: AgentRecord,
112
+ stats: readonly TranscriptStats[],
113
+ outcome: AgentOutcome,
114
+ prState?: (
115
+ ref: string,
116
+ o: { repo?: string; cwd: string },
117
+ ) => Promise<string | undefined>,
118
+ ): Promise<boolean> {
119
+ if (!prState) return false;
120
+ const seen = new Set<string>();
121
+ for (const c of stats.flatMap((s) => s.prMerges)) {
122
+ const ref = c.ref || outcome.prOpened || "";
123
+ const key = `${c.repo ?? ""} ${ref}`;
124
+ if (seen.has(key)) continue;
125
+ seen.add(key);
126
+ try {
127
+ const st = await prState(ref, {
128
+ ...(c.repo ? { repo: c.repo } : {}),
129
+ cwd: rec.cwd,
130
+ });
131
+ if (st?.trim().toUpperCase() === "MERGED") return true;
132
+ } catch {
133
+ // Not checked: not marked.
134
+ }
135
+ }
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * Each record's usage and outcome, from its runtime's transcripts (by its
141
+ * resumeToken). `source` picks the reader (default: the one for its runtime).
142
+ */
143
+ export async function agentUsage(
144
+ records: readonly AgentRecord[],
145
+ opts: {
146
+ env?: NodeJS.ProcessEnv;
147
+ source?: (runtime: string) => UsageSource | undefined;
148
+ /**
149
+ * A pull request's state (`gh pr view <ref> --json state`), run in the
150
+ * agent's folder: "MERGED" marks the outcome merged. Without it, or on
151
+ * any error, nothing is marked: a merge is never taken from text.
152
+ */
153
+ prState?: (
154
+ ref: string,
155
+ o: { repo?: string; cwd: string },
156
+ ) => Promise<string | undefined>;
157
+ } = {},
158
+ ): Promise<AgentUsageRow[]> {
159
+ const pick = opts.source ?? sourceFor;
160
+ const rows: AgentUsageRow[] = [];
161
+ for (const rec of records) {
162
+ const base = {
163
+ agent: rec.id,
164
+ workspace: rec.workspace,
165
+ persona: rec.persona,
166
+ runtime: rec.runtime,
167
+ rotations: rec.rotations?.length ?? 0,
168
+ };
169
+ const src = pick(rec.runtime);
170
+ if (!src) {
171
+ rows.push({
172
+ ...base,
173
+ why: `no usage source reads ${rec.runtime}`,
174
+ transcripts: 0,
175
+ attributed: false,
176
+ });
177
+ continue;
178
+ }
179
+ if (!rec.resumeToken) {
180
+ rows.push({
181
+ ...base,
182
+ why: "no conversation id to join on",
183
+ transcripts: 0,
184
+ attributed: false,
185
+ });
186
+ continue;
187
+ }
188
+ const files = src.agentFiles(rec.resumeToken, opts.env);
189
+ const stats = await Promise.all(files.map((f) => src.mine(f)));
190
+ if (!stats.length) {
191
+ rows.push({
192
+ ...base,
193
+ why: "no transcript found",
194
+ transcripts: 0,
195
+ attributed: false,
196
+ });
197
+ continue;
198
+ }
199
+ const usage = sumUsage(stats);
200
+ const outcome = outcomeOf(rec, stats);
201
+ if (await mergeVerified(rec, stats, outcome, opts.prState))
202
+ outcome.prMerged = true;
203
+ rows.push({
204
+ ...base,
205
+ transcripts: files.length,
206
+ usage,
207
+ outcome,
208
+ compactions: usage.compactions,
209
+ attributed: usage.calls > 0 && outcome.wallClockMs !== undefined,
210
+ });
211
+ }
212
+ return rows;
213
+ }
214
+
215
+ /** The share of agent sessions attributed, in percent (0 with none). */
216
+ export function attributedPct(rows: readonly AgentUsageRow[]): number {
217
+ if (!rows.length) return 0;
218
+ return Math.round(
219
+ (100 * rows.filter((r) => r.attributed).length) / rows.length,
220
+ );
221
+ }
222
+
223
+ /** The week's numbers (lower is better for all but calls, output, prompts). */
224
+ export interface UsageMetrics {
225
+ calls: number;
226
+ cache_read_B: number;
227
+ output_M: number;
228
+ avg_context_k_main: number;
229
+ top15_share_pct: number;
230
+ reread_share_pct: number;
231
+ compactions: number;
232
+ polls: number;
233
+ denials: number;
234
+ tool_error_pct: number;
235
+ prompts: number;
236
+ correction_pct: number;
237
+ subagent_opus_pct: number;
238
+ /** den#6: the share of agent sessions with attributed tokens and outcome. */
239
+ attributed_pct?: number;
240
+ /** den#4: the highest average context per call of a main session (k tokens). */
241
+ long_session_context_k: number;
242
+ /** den#4: the share of cache reads from never-ending main sessions. */
243
+ never_ending_reread_pct: number;
244
+ /** den#4: relaunches from a handoff, over the agents listed. */
245
+ rotations?: number;
246
+ }
247
+
248
+ /** A main session's average context per call, in tokens. */
249
+ export function contextPerCall(s: TranscriptStats): number {
250
+ const t = s.tokens.input + s.tokens.cacheRead + s.tokens.cacheWrite;
251
+ return s.calls ? t / s.calls : 0;
252
+ }
253
+
254
+ /** Never-ending: a main session that compacted more often than the limit. */
255
+ export function isNeverEnding(s: TranscriptStats, after: number): boolean {
256
+ return !s.subagent && s.compactions > after;
257
+ }
258
+
259
+ const round = (n: number, d = 0) => {
260
+ const f = 10 ** d;
261
+ return Math.round(n * f) / f;
262
+ };
263
+
264
+ export function weeklyMetrics(
265
+ stats: readonly TranscriptStats[],
266
+ agents?: readonly AgentUsageRow[],
267
+ /** The rotation limit (agents.rotate.after_compactions, default 3). */
268
+ after = 3,
269
+ ): UsageMetrics {
270
+ const sum = (
271
+ xs: readonly TranscriptStats[],
272
+ f: (s: TranscriptStats) => number,
273
+ ) => xs.reduce((a, s) => a + f(s), 0);
274
+ const calls = sum(stats, (s) => s.calls);
275
+ const reads = sum(stats, (s) => s.tokens.cacheRead);
276
+ const writes = sum(stats, (s) => s.tokens.cacheWrite);
277
+ const out = sum(stats, (s) => s.tokens.output);
278
+ const main = stats.filter((s) => !s.subagent);
279
+ const mainCalls = sum(main, (s) => s.calls);
280
+ const top15 = [...main]
281
+ .sort((a, b) => b.tokens.cacheRead - a.tokens.cacheRead)
282
+ .slice(0, 15);
283
+ const tools = sum(stats, (s) =>
284
+ Object.values(s.tools).reduce((a, b) => a + b, 0),
285
+ );
286
+ const errors = sum(stats, (s) =>
287
+ Object.values(s.toolErrors).reduce((a, b) => a + b, 0),
288
+ );
289
+ let subTotal = 0;
290
+ let subOpus = 0;
291
+ for (const s of stats)
292
+ if (s.subagent)
293
+ for (const [m, n] of Object.entries(s.models)) {
294
+ subTotal += n;
295
+ if (m.includes("opus")) subOpus += n;
296
+ }
297
+ const prompts = sum(stats, (s) => s.prompts);
298
+ const m: UsageMetrics = {
299
+ calls,
300
+ cache_read_B: round(reads / 1e9, 2),
301
+ output_M: round(out / 1e6, 1),
302
+ avg_context_k_main: round(
303
+ sum(main, (s) => s.tokens.cacheRead) / Math.max(1, mainCalls) / 1e3,
304
+ ),
305
+ top15_share_pct: round(
306
+ (100 * sum(top15, (s) => s.tokens.cacheRead)) / Math.max(1, reads),
307
+ ),
308
+ // The usual price ratios: a cache read a tenth of input, a write 1.25x, output 5x.
309
+ reread_share_pct: round(
310
+ (100 * reads * 0.1) / Math.max(1, reads * 0.1 + writes * 1.25 + out * 5),
311
+ ),
312
+ compactions: sum(stats, (s) => s.compactions),
313
+ polls: sum(stats, (s) => s.polls),
314
+ denials: sum(stats, (s) => s.denials),
315
+ tool_error_pct: round((100 * errors) / Math.max(1, tools), 1),
316
+ prompts,
317
+ correction_pct: round(
318
+ (100 * sum(stats, (s) => s.corrections)) / Math.max(1, prompts),
319
+ 1,
320
+ ),
321
+ subagent_opus_pct: round((100 * subOpus) / Math.max(1, subTotal)),
322
+ long_session_context_k: round(
323
+ Math.max(0, ...main.map(contextPerCall)) / 1e3,
324
+ ),
325
+ never_ending_reread_pct: round(
326
+ (100 *
327
+ sum(
328
+ main.filter((s) => isNeverEnding(s, after)),
329
+ (s) => s.tokens.cacheRead,
330
+ )) /
331
+ Math.max(1, reads),
332
+ ),
333
+ };
334
+ if (agents) {
335
+ m.attributed_pct = attributedPct(agents);
336
+ m.rotations = agents.reduce((a, r) => a + r.rotations, 0);
337
+ }
338
+ return m;
339
+ }
340
+
341
+ /** Higher is better for these; context only for calls, output and prompts. */
342
+ const BETTER_HIGH = new Set(["attributed_pct"]);
343
+ const CONTEXT = new Set(["calls", "output_M", "prompts", "rotations"]);
344
+
345
+ /** The report as markdown: this week, last week, and the change. */
346
+ export function renderReport(
347
+ m: UsageMetrics,
348
+ date: string,
349
+ prev?: { date: string; metrics: Partial<UsageMetrics> },
350
+ ): string {
351
+ const lines = [`# Agent usage, week to ${date}`, ""];
352
+ if (prev) lines.push(`Compared with ${prev.date}.`, "");
353
+ lines.push(
354
+ "| Metric | This week | Last | Change |",
355
+ "| --- | --- | --- | --- |",
356
+ );
357
+ for (const [k, v] of Object.entries(m) as [keyof UsageMetrics, number][]) {
358
+ const p = prev?.metrics[k];
359
+ let change = "";
360
+ if (typeof p === "number" && p) {
361
+ const d = round(v - p, 2);
362
+ const mark =
363
+ !d || CONTEXT.has(k)
364
+ ? ""
365
+ : (BETTER_HIGH.has(k) ? d > 0 : d < 0)
366
+ ? "better"
367
+ : "worse";
368
+ change = `${d > 0 ? "+" : ""}${d} ${mark}`.trim();
369
+ }
370
+ lines.push(`| ${k} | ${v} | ${p ?? ""} | ${change} |`);
371
+ }
372
+ lines.push(
373
+ "",
374
+ "Read from this machine's transcripts; none of this was sent anywhere.",
375
+ );
376
+ return lines.join("\n") + "\n";
377
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Usage sources (den#6): plugin kind `usage`, strategy kind `usage`. A
3
+ * source reads one runtime's own transcripts on this machine: which files
4
+ * belong to an agent (by the agent record's resumeToken, the runtime's
5
+ * conversation id), and every file touched in a window (the weekly
6
+ * report). Den's built-in reads Claude Code's (den-claude); a plugin adds
7
+ * one for another runtime the same way, by registering here.
8
+ *
9
+ * A source reads locally and never sends what it reads anywhere.
10
+ */
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+ import {
14
+ getStrategy,
15
+ registerStrategy,
16
+ strategyIds,
17
+ type StrategyEntry,
18
+ } from "../strategies/registry.js";
19
+ import { mineTranscript, type TranscriptStats } from "./transcript.js";
20
+
21
+ export const USAGE_KIND = "usage";
22
+
23
+ export interface UsageSource {
24
+ id: string;
25
+ /** The runtimes whose agents it reads (den-claude). */
26
+ runtimes: readonly string[];
27
+ /** Where it reads (for doctor and the report's header). */
28
+ root(env?: NodeJS.ProcessEnv): string;
29
+ /** An agent's transcripts: its conversation's file, then its subagents'. */
30
+ agentFiles(resumeToken: string, env?: NodeJS.ProcessEnv): string[];
31
+ /** Every transcript changed since `since` (ms since the epoch). */
32
+ files(since: number, env?: NodeJS.ProcessEnv): string[];
33
+ mine(file: string, opts?: { since?: number }): Promise<TranscriptStats>;
34
+ }
35
+
36
+ /** A conversation id as Claude Code names its files: never a path. */
37
+ const TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
38
+
39
+ import { claudeProjectsDir } from "./paths.js";
40
+ export { claudeProjectsDir };
41
+
42
+ function dirs(p: string): string[] {
43
+ try {
44
+ return fs
45
+ .readdirSync(p, { withFileTypes: true })
46
+ .filter((e) => e.isDirectory())
47
+ .map((e) => path.join(p, e.name));
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+
53
+ function jsonl(p: string): string[] {
54
+ try {
55
+ return fs
56
+ .readdirSync(p, { withFileTypes: true })
57
+ .filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
58
+ .map((e) => path.join(p, e.name));
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ /** Claude Code: <projects>/<cwd slug>/<session id>.jsonl, and <session id>/subagents/*.jsonl. */
65
+ export const claudeCodeSource: UsageSource = {
66
+ id: "claude-code",
67
+ runtimes: ["den-claude"],
68
+ root: claudeProjectsDir,
69
+ agentFiles(token, env) {
70
+ if (!TOKEN_RE.test(token)) return [];
71
+ const out: string[] = [];
72
+ for (const proj of dirs(claudeProjectsDir(env))) {
73
+ const f = path.join(proj, `${token}.jsonl`);
74
+ if (fs.existsSync(f)) out.push(f);
75
+ out.push(...jsonl(path.join(proj, token, "subagents")));
76
+ }
77
+ return out;
78
+ },
79
+ files(since, env) {
80
+ const out: string[] = [];
81
+ const walk = (p: string, depth: number) => {
82
+ for (const f of jsonl(p)) {
83
+ try {
84
+ if (fs.statSync(f).mtimeMs >= since) out.push(f);
85
+ } catch {
86
+ /* gone */
87
+ }
88
+ }
89
+ if (depth < 3) for (const d of dirs(p)) walk(d, depth + 1);
90
+ };
91
+ walk(claudeProjectsDir(env), 0);
92
+ return out.sort();
93
+ },
94
+ mine: (file, opts) => mineTranscript(file, opts),
95
+ };
96
+
97
+ /** Register a usage source (Den's built-in, or a `usage` plugin's). */
98
+ export function registerUsageSource(src: UsageSource): void {
99
+ const entry: StrategyEntry<typeof USAGE_KIND, UsageSource> = {
100
+ id: src.id,
101
+ kind: USAGE_KIND,
102
+ detect(env) {
103
+ return fs.existsSync(src.root(env))
104
+ ? { available: true }
105
+ : { available: false, reason: `no transcripts at ${src.root(env)}` };
106
+ },
107
+ capabilities: src,
108
+ };
109
+ registerStrategy(entry);
110
+ }
111
+
112
+ export function usageSources(): UsageSource[] {
113
+ return strategyIds(USAGE_KIND)
114
+ .map(
115
+ (id) =>
116
+ getStrategy<typeof USAGE_KIND, UsageSource>(USAGE_KIND, id)
117
+ ?.capabilities,
118
+ )
119
+ .filter((s): s is UsageSource => Boolean(s));
120
+ }
121
+
122
+ /** The source that reads `runtime`'s agents, if any. */
123
+ export function sourceFor(runtime: string): UsageSource | undefined {
124
+ return usageSources().find((s) => s.runtimes.includes(runtime));
125
+ }
126
+
127
+ registerUsageSource(claudeCodeSource);