@monster-orange/den 0.4.8 → 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.
@@ -20,6 +20,7 @@ import { validateOrgSources, type WorkOrgsConfig } from "./org-sources.js";
20
20
  import { ConfigError, isConfigError } from "./config-error.js";
21
21
  import { validateCatalog, type CatalogEntry } from "./agents/catalog.js";
22
22
  import type { Guardrails } from "./agents/guardrails.js";
23
+ import { DEFAULT_ROTATE, type RotateSettings } from "./agents/rotation.js";
23
24
  import type { TestAccountsConfig } from "./auth/test-accounts.js";
24
25
 
25
26
  import { product } from "./product.js";
@@ -879,6 +880,13 @@ export interface AgentsConfig {
879
880
  * Den doesn't ship, with the same fields and checks as a built-in one.
880
881
  */
881
882
  runtime_catalog?: unknown[];
883
+ /**
884
+ * Session rotation (den#4), `{ after_compactions, standing }`: a session
885
+ * ends after `after_compactions` compactions (default 3) or once its pull
886
+ * request merged, and its role relaunches from its handoff. `standing:
887
+ * false` keeps standing personas from rotating (default true).
888
+ */
889
+ rotate?: { after_compactions?: unknown; standing?: unknown };
882
890
  /**
883
891
  * Any other key is an agent id: `agents.<id>.grants` widens that agent's
884
892
  * MCP scope to other workspaces (AG spec §5b). Config only: an agent
@@ -902,6 +910,7 @@ export const AGENTS_RESERVED_KEYS = [
902
910
  "persona_packs",
903
911
  "runtime_catalog",
904
912
  "default_pack",
913
+ "rotate",
905
914
  ];
906
915
 
907
916
  /** One agent's own block, `agents.<id>`. */
@@ -923,6 +932,37 @@ export interface ResolvedAgentsConfig {
923
932
  agents: Record<string, AgentGrantsConfig>;
924
933
  /** The distribution's runtime catalog entries (AG-0.5), checked. */
925
934
  runtimeCatalog: CatalogEntry[];
935
+ /** agents.rotate with its defaults (den#4); unset: DEFAULT_ROTATE. */
936
+ rotate?: RotateSettings;
937
+ }
938
+
939
+ /** `agents.rotate` checked; any other key, or a bad value, throws naming it. */
940
+ function rotateBlock(v: unknown): RotateSettings {
941
+ const out: RotateSettings = { ...DEFAULT_ROTATE };
942
+ if (v === undefined || v === null) return out;
943
+ if (!isMapping(v))
944
+ throw new Error(
945
+ "agents.rotate: must be a mapping ({ after_compactions: <n>, standing: true|false })",
946
+ );
947
+ for (const [k, x] of Object.entries(v)) {
948
+ if (k === "after_compactions") {
949
+ if (typeof x !== "number" || !Number.isInteger(x) || x < 1 || x > 100)
950
+ throw new Error(
951
+ `agents.rotate.after_compactions: must be a whole number from 1 to 100 (got ${JSON.stringify(x)})`,
952
+ );
953
+ out.afterCompactions = x;
954
+ } else if (k === "standing") {
955
+ if (typeof x !== "boolean")
956
+ throw new Error(
957
+ `agents.rotate.standing: must be true or false (got ${JSON.stringify(x)})`,
958
+ );
959
+ out.standing = x;
960
+ } else
961
+ throw new Error(
962
+ `agents.rotate.${k}: unknown key (takes after_compactions, standing)`,
963
+ );
964
+ }
965
+ return out;
926
966
  }
927
967
 
928
968
  /** An agent id or workspace name: what WORKSPACE_NAME_RE accepts. */
@@ -985,6 +1025,7 @@ export function readAgentsConfig(
985
1025
  raw.runtime_catalog,
986
1026
  "agents.runtime_catalog",
987
1027
  ),
1028
+ rotate: rotateBlock(raw.rotate),
988
1029
  };
989
1030
  const max = raw.max_concurrent;
990
1031
  if (max !== undefined) {
@@ -1018,7 +1059,7 @@ export function readAgentsConfig(
1018
1059
  const where = `agents.${id}`;
1019
1060
  if (!AGENT_KEY_RE.test(id) || !isMapping(block))
1020
1061
  throw new Error(
1021
- `${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, default_pack, ` +
1062
+ `${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, default_pack, rotate, ` +
1022
1063
  `or an agent id mapping to { grants: [...] })`,
1023
1064
  );
1024
1065
  for (const k of Object.keys(block))
@@ -0,0 +1,32 @@
1
+ /**
2
+ * A pull request's state from gh, to confirm a merge an agent ran
3
+ * (session rotation's "PR merged" trigger, den#4). It runs gh, which asks
4
+ * GitHub; usage itself stays local (src/shared/usage never sends).
5
+ */
6
+ import { execFile } from "child_process";
7
+
8
+ /**
9
+ * `gh pr view [<ref>] [-R <repo>] --json state -q .state`;
10
+ * rejects on any error. Only the ref and repo go out, to GitHub, by gh.
11
+ */
12
+ export function ghPrState(env: NodeJS.ProcessEnv) {
13
+ return (ref: string, o: { repo?: string; cwd: string }) =>
14
+ new Promise<string | undefined>((res, rej) => {
15
+ const args = [
16
+ "pr",
17
+ "view",
18
+ ...(ref ? [ref] : []),
19
+ ...(o.repo ? ["-R", o.repo] : []),
20
+ "--json",
21
+ "state",
22
+ "-q",
23
+ ".state",
24
+ ];
25
+ execFile(
26
+ "gh",
27
+ args,
28
+ { cwd: o.cwd, env, timeout: 15_000, maxBuffer: 64 * 1024 },
29
+ (e, stdout) => (e ? rej(e) : res(String(stdout).trim())),
30
+ );
31
+ });
32
+ }
@@ -8,6 +8,17 @@
8
8
  * share of attributed sessions is the scorecard metric: before this, no
9
9
  * agent's tokens or outcome were joined to it (~0).
10
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
+ *
11
22
  * Everything is read and written on this machine; nothing is sent.
12
23
  */
13
24
  import type {
@@ -30,6 +41,10 @@ export interface AgentUsageRow {
30
41
  usage?: AgentUsage;
31
42
  outcome?: AgentOutcome;
32
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;
33
48
  }
34
49
 
35
50
  /** The sum of transcripts' counts, as the record keeps them. */
@@ -79,7 +94,6 @@ export function outcomeOf(
79
94
  }
80
95
  const pr = [...all].reverse().find((s) => s.prOpened)?.prOpened;
81
96
  if (pr) out.prOpened = pr;
82
- if (all.some((s) => s.prMerged)) out.prMerged = true;
83
97
  // The last test run: of the transcript that ended last and ran tests.
84
98
  const tested = all
85
99
  .filter((s) => s.testsGreen !== undefined)
@@ -88,6 +102,40 @@ export function outcomeOf(
88
102
  return out;
89
103
  }
90
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
+
91
139
  /**
92
140
  * Each record's usage and outcome, from its runtime's transcripts (by its
93
141
  * resumeToken). `source` picks the reader (default: the one for its runtime).
@@ -97,6 +145,15 @@ export async function agentUsage(
97
145
  opts: {
98
146
  env?: NodeJS.ProcessEnv;
99
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>;
100
157
  } = {},
101
158
  ): Promise<AgentUsageRow[]> {
102
159
  const pick = opts.source ?? sourceFor;
@@ -107,6 +164,7 @@ export async function agentUsage(
107
164
  workspace: rec.workspace,
108
165
  persona: rec.persona,
109
166
  runtime: rec.runtime,
167
+ rotations: rec.rotations?.length ?? 0,
110
168
  };
111
169
  const src = pick(rec.runtime);
112
170
  if (!src) {
@@ -140,11 +198,14 @@ export async function agentUsage(
140
198
  }
141
199
  const usage = sumUsage(stats);
142
200
  const outcome = outcomeOf(rec, stats);
201
+ if (await mergeVerified(rec, stats, outcome, opts.prState))
202
+ outcome.prMerged = true;
143
203
  rows.push({
144
204
  ...base,
145
205
  transcripts: files.length,
146
206
  usage,
147
207
  outcome,
208
+ compactions: usage.compactions,
148
209
  attributed: usage.calls > 0 && outcome.wallClockMs !== undefined,
149
210
  });
150
211
  }
@@ -176,6 +237,23 @@ export interface UsageMetrics {
176
237
  subagent_opus_pct: number;
177
238
  /** den#6: the share of agent sessions with attributed tokens and outcome. */
178
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;
179
257
  }
180
258
 
181
259
  const round = (n: number, d = 0) => {
@@ -186,6 +264,8 @@ const round = (n: number, d = 0) => {
186
264
  export function weeklyMetrics(
187
265
  stats: readonly TranscriptStats[],
188
266
  agents?: readonly AgentUsageRow[],
267
+ /** The rotation limit (agents.rotate.after_compactions, default 3). */
268
+ after = 3,
189
269
  ): UsageMetrics {
190
270
  const sum = (
191
271
  xs: readonly TranscriptStats[],
@@ -239,14 +319,28 @@ export function weeklyMetrics(
239
319
  1,
240
320
  ),
241
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
+ ),
242
333
  };
243
- if (agents) m.attributed_pct = attributedPct(agents);
334
+ if (agents) {
335
+ m.attributed_pct = attributedPct(agents);
336
+ m.rotations = agents.reduce((a, r) => a + r.rotations, 0);
337
+ }
244
338
  return m;
245
339
  }
246
340
 
247
341
  /** Higher is better for these; context only for calls, output and prompts. */
248
342
  const BETTER_HIGH = new Set(["attributed_pct"]);
249
- const CONTEXT = new Set(["calls", "output_M", "prompts"]);
343
+ const CONTEXT = new Set(["calls", "output_M", "prompts", "rotations"]);
250
344
 
251
345
  /** The report as markdown: this week, last week, and the change. */
252
346
  export function renderReport(
@@ -277,7 +371,7 @@ export function renderReport(
277
371
  }
278
372
  lines.push(
279
373
  "",
280
- "Read from this machine's transcripts; nothing was sent anywhere.",
374
+ "Read from this machine's transcripts; none of this was sent anywhere.",
281
375
  );
282
376
  return lines.join("\n") + "\n";
283
377
  }
@@ -49,11 +49,66 @@ export interface TranscriptStats {
49
49
  activeSeconds: number;
50
50
  /** The last pull request URL it opened. */
51
51
  prOpened?: string;
52
- prMerged: boolean;
52
+ /**
53
+ * `gh pr merge` commands that exited 0: claims, which `usage` checks
54
+ * with `gh pr view` before it marks the pull request merged.
55
+ */
56
+ prMerges: PrMergeClaim[];
53
57
  /** The last test run's result; undefined when it ran none. */
54
58
  testsGreen?: boolean;
55
59
  }
56
60
 
61
+ /** A `gh pr merge` that ran: its PR (number, URL, or "" for the branch's). */
62
+ export interface PrMergeClaim {
63
+ ref: string;
64
+ repo?: string;
65
+ }
66
+
67
+ /** Words of one simple command, with quotes removed (no expansion). */
68
+ function words(part: string): string[] {
69
+ const out: string[] = [];
70
+ for (const m of part.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g))
71
+ out.push(m[1] ?? m[2] ?? m[3]);
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * The merge a shell command runs: one of its parts (split on `&&`, `||`,
77
+ * `;`, `|`, newlines), after `VAR=value` words, `env` or `command`, starts
78
+ * with `gh pr merge`, and is not asking for help. Null otherwise:
79
+ * `echo gh pr merge 1`, a grep for it, or a comment are not merges.
80
+ */
81
+ export function ghPrMergeRef(cmd: string): PrMergeClaim | null {
82
+ for (const part of cmd.split(/&&|\|\||;|\||\n/)) {
83
+ const w = words(part.trim());
84
+ let i = 0;
85
+ while (
86
+ i < w.length &&
87
+ (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w[i]) ||
88
+ w[i] === "env" ||
89
+ w[i] === "command")
90
+ )
91
+ i++;
92
+ if (w[i] !== "gh" || w[i + 1] !== "pr" || w[i + 2] !== "merge") continue;
93
+ const rest = w.slice(i + 3);
94
+ if (rest.includes("--help") || rest.includes("-h")) return null;
95
+ const claim: PrMergeClaim = { ref: "" };
96
+ for (let j = 0; j < rest.length; j++) {
97
+ const a = rest[j];
98
+ if (a === "-R" || a === "--repo") {
99
+ if (rest[j + 1]) claim.repo = rest[++j];
100
+ } else if (a.startsWith("--repo=")) claim.repo = a.slice(7);
101
+ else if (
102
+ !claim.ref &&
103
+ /^(\d+|https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+)$/.test(a)
104
+ )
105
+ claim.ref = a;
106
+ }
107
+ return claim;
108
+ }
109
+ return null;
110
+ }
111
+
57
112
  /** A correction, as the miner reads one (emphatic ones; polite ones slip by). */
58
113
  const CORRECTION =
59
114
  /\b(wtf|fuck|shit|damn|not what i (said|asked)|that('s| is) wrong|you (missed|forgot|broke)|again\??$|still (not|broken|failing|404)|where (is|the)|why (did|didn't|are|is)|i told you|stop|no,|nope|ugh|boo)\b/i;
@@ -130,13 +185,13 @@ export async function mineTranscript(
130
185
  interrupts: 0,
131
186
  agentSpawns: 0,
132
187
  activeSeconds: 0,
133
- prMerged: false,
188
+ prMerges: [],
134
189
  };
135
190
  const seen = new Set<string>();
136
191
  const toolNames = new Map<string, string>();
137
192
  /** Tool-use ids of the commands whose result decides an outcome. */
138
193
  const tests = new Map<string, number>();
139
- const merges = new Set<string>();
194
+ const merges = new Map<string, PrMergeClaim>();
140
195
  const creates = new Set<string>();
141
196
  let order = 0;
142
197
  let lastTest: { at: number; green: boolean } | undefined;
@@ -217,7 +272,8 @@ export async function mineTranscript(
217
272
  for (const s of cmd.matchAll(/\bsleep\s+(\d+)/g))
218
273
  r.sleepSeconds += Number(s[1]);
219
274
  if (TEST_RUN.test(cmd)) tests.set(useId, order++);
220
- if (/\bgh pr merge\b/.test(cmd)) merges.add(useId);
275
+ const merge = ghPrMergeRef(cmd);
276
+ if (merge) merges.set(useId, merge);
221
277
  if (/\bgh pr create\b/.test(cmd)) creates.add(useId);
222
278
  }
223
279
  continue;
@@ -240,7 +296,8 @@ export async function mineTranscript(
240
296
  const t = tests.get(useId);
241
297
  if (t !== undefined && (!lastTest || t >= lastTest.at))
242
298
  lastTest = { at: t, green: !err };
243
- if (merges.has(useId) && !err) r.prMerged = true;
299
+ const merge = merges.get(useId);
300
+ if (merge && !err) r.prMerges.push(merge);
244
301
  if (creates.has(useId) && !err) {
245
302
  const u = PR_URL.exec(textOf(tr.content));
246
303
  if (u) r.prOpened = u[0];