@monster-orange/den 0.4.8 → 0.4.10

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,44 @@
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 number,state`: the state and the
10
+ * number (a bare ref is the branch's PR, and the number names it). Rejects
11
+ * on any error, or an answer that isn't that JSON. Only the ref and repo
12
+ * go out, to GitHub, by gh.
13
+ */
14
+ export function ghPrState(env: NodeJS.ProcessEnv) {
15
+ return (ref: string, o: { repo?: string; cwd: string }) =>
16
+ new Promise<{ state: string; number?: number }>((res, rej) => {
17
+ const args = [
18
+ "pr",
19
+ "view",
20
+ ...(ref ? [ref] : []),
21
+ ...(o.repo ? ["-R", o.repo] : []),
22
+ "--json",
23
+ "number,state",
24
+ ];
25
+ execFile(
26
+ "gh",
27
+ args,
28
+ { cwd: o.cwd, env, timeout: 15_000, maxBuffer: 64 * 1024 },
29
+ (e, stdout) => {
30
+ if (e) return rej(e);
31
+ try {
32
+ const o = JSON.parse(String(stdout)) as Record<string, unknown>;
33
+ if (typeof o.state !== "string") throw new Error("no state");
34
+ res({
35
+ state: o.state,
36
+ ...(typeof o.number === "number" ? { number: o.number } : {}),
37
+ });
38
+ } catch (err) {
39
+ rej(err);
40
+ }
41
+ },
42
+ );
43
+ });
44
+ }
@@ -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,55 @@ export function outcomeOf(
88
102
  return out;
89
103
  }
90
104
 
105
+ /**
106
+ * What a pull request check answers: its state ("MERGED"), and its number
107
+ * when gh gave one (a bare `gh pr merge` is the branch's PR: the number is
108
+ * how it is kept).
109
+ */
110
+ export type PrStateAnswer = string | { state: string; number?: number };
111
+
112
+ /** Checks one pull request's state (gh pr view), in the agent's folder. */
113
+ export type PrStateCheck = (
114
+ ref: string,
115
+ o: { repo?: string; cwd: string },
116
+ ) => Promise<PrStateAnswer | undefined>;
117
+
118
+ /**
119
+ * Whether one of the transcripts' `gh pr merge` claims is a pull request
120
+ * gh says is MERGED. A bare `gh pr merge` (the branch's PR) is checked by
121
+ * the PR the agent opened, else in its folder.
122
+ */
123
+ async function mergeVerified(
124
+ rec: AgentRecord,
125
+ stats: readonly TranscriptStats[],
126
+ outcome: AgentOutcome,
127
+ prState?: PrStateCheck,
128
+ ): Promise<string | undefined> {
129
+ if (!prState) return undefined;
130
+ const seen = new Set<string>();
131
+ for (const c of stats.flatMap((s) => s.prMerges)) {
132
+ const ref = c.ref || outcome.prOpened || "";
133
+ const key = `${c.repo ?? ""} ${ref}`;
134
+ if (seen.has(key)) continue;
135
+ seen.add(key);
136
+ try {
137
+ const st = await prState(ref, {
138
+ ...(c.repo ? { repo: c.repo } : {}),
139
+ cwd: rec.cwd,
140
+ });
141
+ const state = typeof st === "string" ? st : st?.state;
142
+ if (state?.trim().toUpperCase() !== "MERGED") continue;
143
+ // Kept by its ref, or by the number gh gave; never by "".
144
+ const n = typeof st === "object" ? st.number : undefined;
145
+ const key = ref || (Number.isInteger(n) && n! > 0 ? String(n) : "");
146
+ if (key) return key;
147
+ } catch {
148
+ // Not checked: not marked.
149
+ }
150
+ }
151
+ return undefined;
152
+ }
153
+
91
154
  /**
92
155
  * Each record's usage and outcome, from its runtime's transcripts (by its
93
156
  * resumeToken). `source` picks the reader (default: the one for its runtime).
@@ -97,6 +160,12 @@ export async function agentUsage(
97
160
  opts: {
98
161
  env?: NodeJS.ProcessEnv;
99
162
  source?: (runtime: string) => UsageSource | undefined;
163
+ /**
164
+ * A pull request's state (`gh pr view <ref> --json number,state`), run in the
165
+ * agent's folder: "MERGED" marks the outcome merged. Without it, or on
166
+ * any error, nothing is marked: a merge is never taken from text.
167
+ */
168
+ prState?: PrStateCheck;
100
169
  } = {},
101
170
  ): Promise<AgentUsageRow[]> {
102
171
  const pick = opts.source ?? sourceFor;
@@ -107,6 +176,7 @@ export async function agentUsage(
107
176
  workspace: rec.workspace,
108
177
  persona: rec.persona,
109
178
  runtime: rec.runtime,
179
+ rotations: rec.rotations?.length ?? 0,
110
180
  };
111
181
  const src = pick(rec.runtime);
112
182
  if (!src) {
@@ -140,11 +210,21 @@ export async function agentUsage(
140
210
  }
141
211
  const usage = sumUsage(stats);
142
212
  const outcome = outcomeOf(rec, stats);
213
+ // Confirmed once, kept: GitHub is not asked again.
214
+ const confirmed =
215
+ rec.outcome?.prMerged && rec.outcome.prMergeConfirmed
216
+ ? rec.outcome.prMergeConfirmed
217
+ : await mergeVerified(rec, stats, outcome, opts.prState);
218
+ if (confirmed) {
219
+ outcome.prMerged = true;
220
+ outcome.prMergeConfirmed = confirmed;
221
+ }
143
222
  rows.push({
144
223
  ...base,
145
224
  transcripts: files.length,
146
225
  usage,
147
226
  outcome,
227
+ compactions: usage.compactions,
148
228
  attributed: usage.calls > 0 && outcome.wallClockMs !== undefined,
149
229
  });
150
230
  }
@@ -176,6 +256,23 @@ export interface UsageMetrics {
176
256
  subagent_opus_pct: number;
177
257
  /** den#6: the share of agent sessions with attributed tokens and outcome. */
178
258
  attributed_pct?: number;
259
+ /** den#4: the highest average context per call of a main session (k tokens). */
260
+ long_session_context_k: number;
261
+ /** den#4: the share of cache reads from never-ending main sessions. */
262
+ never_ending_reread_pct: number;
263
+ /** den#4: relaunches from a handoff, over the agents listed. */
264
+ rotations?: number;
265
+ }
266
+
267
+ /** A main session's average context per call, in tokens. */
268
+ export function contextPerCall(s: TranscriptStats): number {
269
+ const t = s.tokens.input + s.tokens.cacheRead + s.tokens.cacheWrite;
270
+ return s.calls ? t / s.calls : 0;
271
+ }
272
+
273
+ /** Never-ending: a main session that compacted more often than the limit. */
274
+ export function isNeverEnding(s: TranscriptStats, after: number): boolean {
275
+ return !s.subagent && s.compactions > after;
179
276
  }
180
277
 
181
278
  const round = (n: number, d = 0) => {
@@ -186,6 +283,8 @@ const round = (n: number, d = 0) => {
186
283
  export function weeklyMetrics(
187
284
  stats: readonly TranscriptStats[],
188
285
  agents?: readonly AgentUsageRow[],
286
+ /** The rotation limit (agents.rotate.after_compactions, default 3). */
287
+ after = 3,
189
288
  ): UsageMetrics {
190
289
  const sum = (
191
290
  xs: readonly TranscriptStats[],
@@ -239,14 +338,28 @@ export function weeklyMetrics(
239
338
  1,
240
339
  ),
241
340
  subagent_opus_pct: round((100 * subOpus) / Math.max(1, subTotal)),
341
+ long_session_context_k: round(
342
+ Math.max(0, ...main.map(contextPerCall)) / 1e3,
343
+ ),
344
+ never_ending_reread_pct: round(
345
+ (100 *
346
+ sum(
347
+ main.filter((s) => isNeverEnding(s, after)),
348
+ (s) => s.tokens.cacheRead,
349
+ )) /
350
+ Math.max(1, reads),
351
+ ),
242
352
  };
243
- if (agents) m.attributed_pct = attributedPct(agents);
353
+ if (agents) {
354
+ m.attributed_pct = attributedPct(agents);
355
+ m.rotations = agents.reduce((a, r) => a + r.rotations, 0);
356
+ }
244
357
  return m;
245
358
  }
246
359
 
247
360
  /** Higher is better for these; context only for calls, output and prompts. */
248
361
  const BETTER_HIGH = new Set(["attributed_pct"]);
249
- const CONTEXT = new Set(["calls", "output_M", "prompts"]);
362
+ const CONTEXT = new Set(["calls", "output_M", "prompts", "rotations"]);
250
363
 
251
364
  /** The report as markdown: this week, last week, and the change. */
252
365
  export function renderReport(
@@ -277,7 +390,7 @@ export function renderReport(
277
390
  }
278
391
  lines.push(
279
392
  "",
280
- "Read from this machine's transcripts; nothing was sent anywhere.",
393
+ "Read from this machine's transcripts; none of this was sent anywhere.",
281
394
  );
282
395
  return lines.join("\n") + "\n";
283
396
  }
@@ -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];