auto-model-router 0.14.0 → 0.16.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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.14.0",
10
+ "version": "0.16.0",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.14.0",
17
+ "version": "0.16.0",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -710,8 +710,12 @@ escalation signal, error. Three views aggregate it, all from the same
710
710
  and model (dispatches, tokens, spend, escalations, errors) as CSV. Also
711
711
  `GET /v1/router/export?days=&harness=[&format=json]`; `GET /v1/router/spend?sinceMs=&harness=`
712
712
  gives spend over a harness set since an instant, and `GET /v1/router/feedback?days=&harness=`
713
- lists verdicts by model and the recent ones with the harness that gave them. These are what
714
- a front door such as the team edition reads instead of the ledger file.
713
+ lists verdicts by model and the recent ones with the harness that gave them, and
714
+ `GET /v1/router/decisions?harness=&days=|since=&slug=&tier=&limit=` is the decision trail
715
+ itself, newest first, each turn with its reasons, the classifier's view, forecast against
716
+ bill, escalation signal and verdicts (`?session=` narrows to one omp session, as `/router
717
+ why` does). These are what a front door such as the team edition reads instead of the
718
+ ledger file.
715
719
  - `GET /v1/router/report?days=7&harness=<id>` for dashboards (`harness` may be
716
720
  a comma-separated set of ids, for a group).
717
721
  - `GET /v1/router/summary?harness=<id>` — the daily summary as JSON (`auto=1`
@@ -1515,6 +1519,28 @@ them, injecting one project's context into another's work. A single configured t
1515
1519
  grants only the scopes it was minted for; for any other project the bridge degrades to inert
1516
1520
  rather than writing somewhere wrong.
1517
1521
 
1522
+ ### Layers a team names around the project scope
1523
+
1524
+ A front door such as the team edition can wrap the project scope in two more
1525
+ layers, named per request in headers next to `X-Agentdox-Scope`:
1526
+
1527
+ | Header | What agentdox renders |
1528
+ | --- | --- |
1529
+ | `X-Agentdox-Group` | the group's context — its brief and top memory — **first**, as `# Group context: <group>` |
1530
+ | `X-Agentdox-Personal` | the member's own thread in the project — the entry tagged `handoff` whole, then the rest — **last**, as `# Your thread in <scope>` |
1531
+
1532
+ Both go through the same slug rule as the scope (absent or invalid ⇒ empty ⇒ not
1533
+ sent). The member is the harness id (`X-Omp-Harness`, which the team sets from the
1534
+ authenticated key): when either layer is named it is sent as `user` too, and agentdox
1535
+ keeps the project layer's recent tail to that member's own turns. It is never sent on
1536
+ its own — a router without a team still has a harness id (Claude Code's is derived,
1537
+ omp's is configured) and must not have its tail filtered to one harness. Every
1538
+ recorded message carries a `user:<harness id>` ref alongside `model:` and `tier:`
1539
+ whenever there is a harness id, team or not; it is just another ref. A router without
1540
+ a team posts exactly what it always did, so its block is byte-identical; an older
1541
+ agentdox ignores the keys it does not know. `context.layers: false` is the kill
1542
+ switch — the headers are still parsed but nothing new goes to agentdox.
1543
+
1518
1544
  ### It does not cost you a cache miss per turn
1519
1545
 
1520
1546
  The context block sits at the front of the prompt, so re-fetching it every turn
@@ -1545,7 +1571,8 @@ block lands inside the prefix `planCacheBreakpoints` already marks.
1545
1571
  ### Turns are recorded back, attributed to the model that served them
1546
1572
 
1547
1573
  With `context.recordTurns` (default on), each settled turn is written to an
1548
- agentdox session tagged `model:<slug>` and `tier:<tier>` a transcript that
1574
+ agentdox session tagged `model:<slug>` and `tier:<tier>` (plus `user:<harness id>`
1575
+ when the request carried one) — a transcript that
1549
1576
  shows which model produced which turn. Those messages feed back into the next
1550
1577
  `context_assemble`, so the model you switch *to* inherits what the model you
1551
1578
  switched *from* actually did.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -300,6 +300,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
300
300
  { path: "context.docsLimit", label: "Doc items", kind: "number", min: 0 },
301
301
  { path: "context.sessionLimit", label: "Session items", kind: "number", min: 0 },
302
302
  { path: "context.briefChars", label: "Brief chars", kind: "number", min: 0 },
303
+ { path: "context.layers", label: "Send team context layers", kind: "boolean" },
303
304
  { path: "context.recordTurns", label: "Record turns back", kind: "boolean" },
304
305
  { path: "context.injectWithoutTools", label: "Inject into tool-less calls", kind: "boolean" },
305
306
  { path: "context.maxQueue", label: "Write-back queue", kind: "number", min: 1 },
@@ -265,6 +265,10 @@ export const DEFAULT_CONFIG: RouterConfig = {
265
265
  // statics + all 14 decisions) inside a 24k block while leaving ~15k for
266
266
  // the query-relevant memory/docs tail. 0 omits the brief.
267
267
  briefChars: 12_000,
268
+ // Group / personal / user layers named by a team front door. On, because
269
+ // a router that is not behind one has no layer names to send and posts
270
+ // exactly what it did before; off only sends none when a team does.
271
+ layers: true,
268
272
  recordTurns: true,
269
273
  // A request with no tool schemas is a harness utility call — omp asks for
270
274
  // a session title or a complexity rating through the same provider — and
@@ -228,6 +228,7 @@ const context = z.strictObject({
228
228
  docsLimit: z.number().int().nonnegative().optional(),
229
229
  sessionLimit: z.number().int().nonnegative().optional(),
230
230
  briefChars: z.number().int().nonnegative().optional(),
231
+ layers: z.boolean().optional(),
231
232
  recordTurns: z.boolean().optional(),
232
233
  injectWithoutTools: z.boolean().optional(),
233
234
  maxQueue: z.number().int().positive().optional(),
@@ -767,6 +767,15 @@ export interface ContextConfig {
767
767
  * query-relevant tail always fits too.
768
768
  */
769
769
  briefChars: number;
770
+ /**
771
+ * Send the context layers a front door names on the request — the group
772
+ * scope (`X-Agentdox-Group`, rendered first), the personal scope
773
+ * (`X-Agentdox-Personal`, rendered last) and the member (the harness id,
774
+ * which filters the project's recent tail and tags recorded turns). A
775
+ * router without a team has none to send and its block is unchanged; this
776
+ * is the kill switch for a team that wants single-scope blocks back.
777
+ */
778
+ layers: boolean;
770
779
  /** Write settled turns back to agentdox sessions, tagged with the served model. */
771
780
  recordTurns: boolean;
772
781
  /**
@@ -24,13 +24,29 @@ export interface AssembleLimits {
24
24
  briefChars: number;
25
25
  }
26
26
 
27
+ /**
28
+ * The layers around the project scope (project memory, phase one). Each is a
29
+ * name the team derives; empty means "not present" and is NOT sent, so a lone
30
+ * router's request body — and therefore its block — is byte-identical to
31
+ * before. An agentdox that predates layers ignores the keys it does not know.
32
+ */
33
+ export interface AssembleLayers {
34
+ /** Scope whose brief and top memory render first, as group context. */
35
+ group: string;
36
+ /** Scope whose memory (handoff note first) renders last, as the member's own thread. */
37
+ personal: string;
38
+ /** The member: the project layer's recent tail is filtered to messages tagged `user:<user>`. */
39
+ user: string;
40
+ }
41
+
27
42
  export interface AgentDoxClient {
28
43
  /**
29
44
  * Assembles a context slice for `scope`, biased by `query`. Falls back to
30
45
  * the server's pre-assembled baseline when assembly is unavailable (older
31
- * server, or no query-relevant content).
46
+ * server, or no query-relevant content). `layers` adds the group and
47
+ * personal scopes around it; absent or all-empty sends nothing extra.
32
48
  */
33
- assemble(scope: string, query: string, limits: AssembleLimits): Promise<string | null>;
49
+ assemble(scope: string, query: string, limits: AssembleLimits, layers?: AssembleLayers): Promise<string | null>;
34
50
  createSession(scope: string, title: string): Promise<string | null>;
35
51
  append(sessionId: string, role: "user" | "assistant", content: string, refs: string[]): Promise<boolean>;
36
52
  }
@@ -84,11 +100,13 @@ export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClien
84
100
  };
85
101
 
86
102
  return {
87
- async assemble(scope, query, limits) {
103
+ async assemble(scope, query, limits, layers) {
88
104
  // camelCase: the REST endpoint ignores snake_case limit keys entirely,
89
105
  // which silently reads as "unbounded". briefChars is sent even when 0:
90
106
  // an older server ignores the unknown key, and 0 is the documented
91
- // "no brief" value there.
107
+ // "no brief" value there. The layer keys are the opposite: only sent
108
+ // when non-empty, so a router without a team posts exactly what it
109
+ // always did.
92
110
  const res = await request("POST", "/context/assemble", {
93
111
  scope,
94
112
  query,
@@ -96,6 +114,9 @@ export function createAgentDoxClient(opts: AgentDoxClientOptions): AgentDoxClien
96
114
  docsLimit: limits.docsLimit,
97
115
  sessionLimit: limits.sessionLimit,
98
116
  briefChars: limits.briefChars,
117
+ ...(layers === undefined || layers.group === "" ? {} : { group: layers.group }),
118
+ ...(layers === undefined || layers.personal === "" ? {} : { personal: layers.personal }),
119
+ ...(layers === undefined || layers.user === "" ? {} : { user: layers.user }),
99
120
  });
100
121
  if (res !== null && res.status === 200) {
101
122
  const prompt = promptOf(res.json);
@@ -40,6 +40,12 @@ export interface BridgeOptions {
40
40
  sessionLimit: number;
41
41
  /** Character budget for the project brief rendered first in the block; 0 omits it. */
42
42
  briefChars: number;
43
+ /**
44
+ * Send the group / personal / user layers a front door names (project
45
+ * memory, phase one). False is the kill switch: none of the new assemble
46
+ * fields go out, whatever the request headers say.
47
+ */
48
+ layers: boolean;
43
49
  /** Record settled turns back into agentdox sessions. */
44
50
  recordTurns: boolean;
45
51
  /** Bound on queued write-backs; excess is dropped rather than grown unbounded. */
@@ -83,7 +89,7 @@ function appendFragment(prior: string, next: string): string {
83
89
  }
84
90
 
85
91
  export function createContextBridge(opts: BridgeOptions): ContextBridge {
86
- const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, docsLimit, sessionLimit, briefChars, recordTurns, maxQueue } = opts;
92
+ const { client, store, log, maxStalenessMs, maxBlockChars, memoryLimit, docsLimit, sessionLimit, briefChars, layers, recordTurns, maxQueue } = opts;
87
93
 
88
94
  // Serialized write-back queue. Session appends for one conversation must
89
95
  // stay ordered, and agentdox is a local service — one worker is plenty.
@@ -124,12 +130,26 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
124
130
  // Recent sessions only on a conversation's first block: after that they
125
131
  // are this conversation's own recorded turns, duplicating the prompt
126
132
  // and changing on every refresh (measured 1.4-1.9k chars per block).
127
- const raw = await client.assemble(input.scope, input.query, {
128
- memoryLimit,
129
- docsLimit,
130
- sessionLimit: input.firstFetch ? sessionLimit : 0,
131
- briefChars,
132
- });
133
+ // The layers around the project scope ride along only while enabled.
134
+ // The block's version is a hash of its content, so a personal layer
135
+ // pins a different block per member and never shares one across them.
136
+ // `user` goes only behind a front door that named a layer: a router
137
+ // on its own still has a harness id (Claude Code's is derived, omp's
138
+ // is configured), and against a new agentdox that would filter the
139
+ // project's recent tail to that harness — a change the lone install
140
+ // never asked for.
141
+ const fronted = input.group !== "" || input.personal !== "";
142
+ const raw = await client.assemble(
143
+ input.scope,
144
+ input.query,
145
+ {
146
+ memoryLimit,
147
+ docsLimit,
148
+ sessionLimit: input.firstFetch ? sessionLimit : 0,
149
+ briefChars,
150
+ },
151
+ layers ? { group: input.group, personal: input.personal, user: fronted ? input.user : "" } : undefined,
152
+ );
133
153
  if (raw === null) {
134
154
  // agentdox unreachable or empty. Keep serving the pinned block if we
135
155
  // have one: stale shared context beats none, and re-using it also
@@ -204,8 +224,13 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
204
224
  // Model attribution rides on refs, which agentdox already carries
205
225
  // per message. This is what makes the transcript newly useful:
206
226
  // every turn shows WHICH model produced it.
207
- const refs = [`model:${rec.slug}`, `tier:${rec.tier}`];
208
- if (rec.userText !== "") await client.append(sessionId, "user", rec.userText, []);
227
+ // The member rides on refs too, on BOTH messages: agentdox filters
228
+ // a project's recent tail to `user:<id>`, and a tail is user and
229
+ // assistant turns alike. A lone router has no harness id and
230
+ // adds nothing.
231
+ const who = rec.harnessId === "" ? [] : [`user:${rec.harnessId}`];
232
+ const refs = [`model:${rec.slug}`, `tier:${rec.tier}`, ...who];
233
+ if (rec.userText !== "") await client.append(sessionId, "user", rec.userText, who);
209
234
  if (assistantText !== "") await client.append(sessionId, "assistant", assistantText, refs);
210
235
  })
211
236
  .catch((err: unknown) => {
@@ -72,6 +72,7 @@ function buildBridge(cfg: RouterConfig, db: Database): ContextBridge {
72
72
  docsLimit: c.docsLimit,
73
73
  sessionLimit: c.sessionLimit,
74
74
  briefChars: c.briefChars,
75
+ layers: c.layers,
75
76
  recordTurns: c.recordTurns,
76
77
  maxQueue: c.maxQueue,
77
78
  });
@@ -47,12 +47,32 @@ export interface ContextResolveInput {
47
47
  * the prompt already holds.
48
48
  */
49
49
  firstFetch: boolean;
50
+ /**
51
+ * Group-context scope (rendered first) from `X-Agentdox-Group`. Empty ⇒ no
52
+ * group layer is requested and nothing new is sent.
53
+ */
54
+ group: string;
55
+ /** Personal scope (rendered last) from `X-Agentdox-Personal`. Empty ⇒ no personal layer. */
56
+ personal: string;
57
+ /**
58
+ * The member this turn belongs to — the harness id, which a team front door
59
+ * sets per user. agentdox filters the project layer's recent tail to that
60
+ * member's own turns. Sent only when `group` or `personal` is also named:
61
+ * a lone router has a harness id too and must not filter its own tail.
62
+ */
63
+ user: string;
50
64
  }
51
65
 
52
66
  /** One settled turn, recorded to agentdox with the model that served it. */
53
67
  export interface TurnRecord {
54
68
  scope: string;
55
69
  conversationKey: string;
70
+ /**
71
+ * The harness id, which a team front door sets per user. Non-empty ⇒ every
72
+ * recorded message also carries a `user:<harnessId>` ref, so the member's
73
+ * own turns can be told apart in a shared project session.
74
+ */
75
+ harnessId: string;
56
76
  /** Title used if this is the first turn and a session must be created. */
57
77
  title: string;
58
78
  userText: string;
@@ -65,7 +65,7 @@ const CACHE_RELIABILITY_MEMO_MS = 60_000;
65
65
  const DAY_MS = 86_400_000;
66
66
 
67
67
  // Row shapes below are fixed by our own schema in util/sqlite.ts.
68
- interface LedgerRow {
68
+ export interface LedgerRow {
69
69
  id: string;
70
70
  created_at_ms: number;
71
71
  conversation_key: string;
@@ -240,7 +240,7 @@ function toLatency(slug: string, row: LatencyRow): ModelLatency | null {
240
240
  return { slug, samples: row.samples, ttftMs: row.ttft_ms, tokensPerSec };
241
241
  }
242
242
 
243
- function toEntry(row: LedgerRow): LedgerEntry {
243
+ export function toEntry(row: LedgerRow): LedgerEntry {
244
244
  return {
245
245
  id: row.id,
246
246
  createdAtMs: row.created_at_ms,
package/src/cost/views.ts CHANGED
@@ -10,7 +10,9 @@
10
10
 
11
11
  import { providerOfSlug } from "./report.ts";
12
12
  import type { Database } from "bun:sqlite";
13
+ import { toEntry, type LedgerRow } from "./ledger.ts";
13
14
  import { harnessFilter } from "./report.ts";
15
+ import type { LedgerEntry } from "./types.ts";
14
16
 
15
17
  /** `null` means every harness; an empty set matches nothing. */
16
18
  export type HarnessScope = readonly string[] | null;
@@ -61,6 +63,68 @@ function scope(harness: HarnessScope, column: string): { sql: string[]; bind: Re
61
63
  return { sql: f.sql.map((s) => s.replace(/^harness_id/, column)), bind: f.bind };
62
64
  }
63
65
 
66
+ /** A ledger entry as the decision explorer shows it: the entry itself plus the verdicts given on it. */
67
+ export type DecisionEntry = LedgerEntry & { feedback: { verdict: "good" | "bad"; note: string; createdAtMs: number }[] };
68
+
69
+ export interface DecisionFilter {
70
+ /** Entries at or after this instant; 0 for everything the ledger still holds. */
71
+ sinceMs: number;
72
+ /** The harness set; null for every harness, an empty list for none. */
73
+ harness: HarnessScope;
74
+ /** At most this many, newest first; 1..1000. */
75
+ limit?: number;
76
+ /** Only turns dispatched to (or served by) this slug. */
77
+ slug?: string;
78
+ /** Only turns classified at this tier. */
79
+ tier?: string;
80
+ /** Only one omp session (`/router why`). */
81
+ ompSessionId?: string;
82
+ }
83
+
84
+ /**
85
+ * Turns, newest first, over a harness set: what `GET /v1/router/decisions` serves and what a
86
+ * front door reads from the ledger file. Every field the decision trail needs is here — the
87
+ * reasons, the classifier's view, the cost forecast against the bill, the escalation signal —
88
+ * and the verdicts `/router good|bad` recorded against each turn ride along.
89
+ */
90
+ export function decisionEntries(db: Database, filter: DecisionFilter): DecisionEntry[] {
91
+ const s = scope(filter.harness, "harness_id");
92
+ if (s === null) return [];
93
+ const where = ["created_at_ms >= $since", ...s.sql];
94
+ const bind: Record<string, string | number> = { $since: filter.sinceMs, ...s.bind };
95
+ if (filter.slug !== undefined && filter.slug !== "") {
96
+ where.push("(slug = $slug OR served_slug = $slug)");
97
+ bind.$slug = filter.slug;
98
+ }
99
+ if (filter.tier !== undefined && filter.tier !== "") {
100
+ where.push("tier = $tier");
101
+ bind.$tier = filter.tier;
102
+ }
103
+ if (filter.ompSessionId !== undefined && filter.ompSessionId !== "") {
104
+ where.push("omp_session_id = $session");
105
+ bind.$session = filter.ompSessionId;
106
+ }
107
+ const limit = Math.min(Math.max(filter.limit ?? 50, 1), 1_000);
108
+ const rows = db.query(`SELECT * FROM ledger WHERE ${where.join(" AND ")} ORDER BY created_at_ms DESC LIMIT ${limit}`).all(bind) as LedgerRow[];
109
+ const entries = rows.map(toEntry);
110
+ if (entries.length === 0) return [];
111
+ // Verdicts, when the feedback table exists (it does not on a ledger no one has judged).
112
+ const hasFeedback = (db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'feedback'").get() as { name: string } | null) !== null;
113
+ const verdicts = new Map<string, DecisionEntry["feedback"]>();
114
+ if (hasFeedback) {
115
+ const ids = entries.map((e) => e.id);
116
+ const marks = ids.map((_, i) => `$f${i}`).join(", ");
117
+ const fb: Record<string, string> = {};
118
+ ids.forEach((id, i) => (fb[`$f${i}`] = id));
119
+ for (const r of db.query(`SELECT ledger_id, verdict, note, created_at_ms FROM feedback WHERE ledger_id IN (${marks}) ORDER BY created_at_ms ASC`).all(fb) as { ledger_id: string; verdict: string; note: string; created_at_ms: number }[]) {
120
+ const list = verdicts.get(r.ledger_id) ?? [];
121
+ list.push({ verdict: r.verdict === "good" ? "good" : "bad", note: r.note, createdAtMs: r.created_at_ms });
122
+ verdicts.set(r.ledger_id, list);
123
+ }
124
+ }
125
+ return entries.map((e) => ({ ...e, feedback: verdicts.get(e.id) ?? [] }));
126
+ }
127
+
64
128
  /** Spend (reported where present, predicted otherwise) since `sinceMs`, digest calls included as the ledger counts them. */
65
129
  export function spendUsdSince(db: Database, sinceMs: number, harness: HarnessScope): number {
66
130
  const s = scope(harness, "harness_id");
package/src/lib.ts CHANGED
@@ -22,7 +22,7 @@ export type { DeepPartial } from "./config/load.ts";
22
22
  export { buildUsageReport, renderUsageReport, type UsageReport, type ReportTotals } from "./cost/report.ts";
23
23
  export { buildDailySummary, renderDailySummary, type DailySummary } from "./cost/summary.ts";
24
24
  export { openDb } from "./util/sqlite.ts";
25
- export { spendUsdSince, feedbackView, exportRows, exportCsv, harnessScopeParam, type HarnessScope, type ExportRow, type FeedbackRow, type FeedbackByModel, type FeedbackView } from "./cost/views.ts";
25
+ export { spendUsdSince, feedbackView, exportRows, exportCsv, decisionEntries, harnessScopeParam, type HarnessScope, type ExportRow, type FeedbackRow, type FeedbackByModel, type FeedbackView, type DecisionEntry, type DecisionFilter } from "./cost/views.ts";
26
26
  export { createLedger } from "./cost/ledger.ts";
27
27
  export { createFeedbackStore, type FeedbackStore, type FeedbackRecord } from "./cost/feedback.ts";
28
28
  export { buildExecutable, collectPackageFiles, executableFileName, hostTarget, isExecutableTarget, EXECUTABLE_TARGETS, type ExecutableTarget, type BuildExecutableResult } from "./cli/build-executable.ts";
@@ -50,6 +50,8 @@ function requestOf(req: AdviseRequest): NormRequest {
50
50
  harnessId: req.harnessId,
51
51
  ompSessionId: req.ompSessionId,
52
52
  agentdoxScope: "",
53
+ agentdoxGroup: "",
54
+ agentdoxPersonal: "",
53
55
  isSubagent: false,
54
56
  requestedModel: "auto",
55
57
  messages: [
@@ -99,6 +99,8 @@ function syntheticRequest(req: DigestRequest, promptText: string): NormRequest {
99
99
  harnessId: req.harnessId,
100
100
  ompSessionId: req.ompSessionId,
101
101
  agentdoxScope: "",
102
+ agentdoxGroup: "",
103
+ agentdoxPersonal: "",
102
104
  isSubagent: true,
103
105
  requestedModel: "digest",
104
106
  messages: [
@@ -10,7 +10,7 @@ import { createDigester } from "./digest.ts";
10
10
  import { advise } from "./advise.ts";
11
11
  import { TIER_ORDER, type Tier } from "../router/types.ts";
12
12
  import { baselinePrices, buildUsageReport, renderUsageReport } from "../cost/report.ts";
13
- import { exportCsv, exportRows, feedbackView, harnessScopeParam, spendUsdSince } from "../cost/views.ts";
13
+ import { decisionEntries, exportCsv, exportRows, feedbackView, harnessScopeParam, spendUsdSince } from "../cost/views.ts";
14
14
  import { anthropicErrorResponse, countAnthropicTokens, createMessagesWire } from "../wire/anthropic/messages.ts";
15
15
  import { buildDailySummary, createKv, markSummaryShown, renderDailySummary, summaryDue, summaryHasNews, type SummaryOllama } from "../cost/summary.ts";
16
16
  import type { Ledger, ModelTrust } from "../cost/types.ts";
@@ -555,13 +555,24 @@ export function startServer(cfg: RouterConfig): StartedServer {
555
555
  return json({ due: true, summary });
556
556
  }
557
557
  if (req.method === "GET" && url.pathname === "/v1/router/decisions") {
558
+ // The decision trail, newest first. ?session=<omp session id> narrows to one
559
+ // session (/router why); ?harness=a,b to a harness set (a team's user or group),
560
+ // ?since=<ms> or ?days=N to a window, ?slug= and ?tier= to a model or a tier.
558
561
  const rawLimit = url.searchParams.get("limit");
559
562
  const parsed = rawLimit === null ? 50 : Number.parseInt(rawLimit, 10);
560
563
  const limit = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 1), 1_000) : 50;
561
- // ?session=<omp session id> narrows to one session (/router why).
562
- const session = url.searchParams.get("session") ?? "";
563
- const entries = session === "" ? ledger.recentEntries(limit) : (ledger.entriesForSession?.(session, limit) ?? []);
564
- return json({ entries: entries.map((e) => ({ ...e, feedback: feedback.forLedgerId(e.id) })) });
564
+ const sinceRaw = Number.parseInt(url.searchParams.get("since") ?? "", 10);
565
+ const daysRaw = url.searchParams.get("days");
566
+ const sinceMs = Number.isFinite(sinceRaw) ? sinceRaw : daysRaw === null ? 0 : Date.now() - clampDays(daysRaw, 30) * 86_400_000;
567
+ const entries = decisionEntries(db, {
568
+ sinceMs,
569
+ harness: harnessScopeParam(url.searchParams.get("harness")),
570
+ limit,
571
+ slug: url.searchParams.get("slug") ?? "",
572
+ tier: url.searchParams.get("tier") ?? "",
573
+ ompSessionId: url.searchParams.get("session") ?? "",
574
+ });
575
+ return json({ entries });
565
576
  }
566
577
  if (url.pathname === "/v1/router/override") {
567
578
  // Per-session pin / tier overrides from omp. GET shows, POST sets or clears.
@@ -225,6 +225,11 @@ export async function runTurn(
225
225
  retrying: attempt > 0,
226
226
  query: relevanceQuery(req),
227
227
  firstFetch: state.contextVersion === null,
228
+ // Layer names come from the front door's headers; the member is the
229
+ // harness id it set. All empty on a router without a team.
230
+ group: req.agentdoxGroup,
231
+ personal: req.agentdoxPersonal,
232
+ user: req.harnessId,
228
233
  });
229
234
  if (pin !== null) {
230
235
  contextBlock = pin.block;
@@ -673,6 +678,7 @@ export async function runTurn(
673
678
  bridge.recordTurn({
674
679
  scope: doxScope,
675
680
  conversationKey: req.conversationKey,
681
+ harnessId: req.harnessId,
676
682
  title: sessionTitle(req),
677
683
  userText,
678
684
  assistantText,
@@ -335,6 +335,12 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
335
335
  // back to its configured default scope.
336
336
  const agentdoxScope = acceptScope(headers.get("x-agentdox-scope"));
337
337
 
338
+ // Layered project memory (team edition): the group-context scope rendered
339
+ // first and the member's personal scope rendered last. A lone router never
340
+ // sees these headers; empty means "not sent", so the block is unchanged.
341
+ const agentdoxGroup = acceptScope(headers.get("x-agentdox-group"));
342
+ const agentdoxPersonal = acceptScope(headers.get("x-agentdox-personal"));
343
+
338
344
  // Subagent marker from the embed extension (sessions without a UI).
339
345
  const isSubagent = (headers.get("x-omp-subagent") ?? "").trim() === "1";
340
346
 
@@ -401,6 +407,8 @@ export function parseChatRequest(body: unknown, headers: Headers): NormRequest {
401
407
  harnessId,
402
408
  ompSessionId,
403
409
  agentdoxScope,
410
+ agentdoxGroup,
411
+ agentdoxPersonal,
404
412
  isSubagent,
405
413
  ...(policy === undefined ? {} : { policy }),
406
414
  requestedModel,
package/src/wire/types.ts CHANGED
@@ -96,6 +96,19 @@ export interface NormRequest {
96
96
  * and if that is empty too the bridge stays inert for this request.
97
97
  */
98
98
  agentdoxScope: string;
99
+ /**
100
+ * agentdox group-context scope from the `X-Agentdox-Group` request header,
101
+ * set by a front door such as the team edition: the scope whose brief and
102
+ * top memory render FIRST in the block. Empty when absent or not a slug,
103
+ * which sends nothing, so a lone router's block is unchanged.
104
+ */
105
+ agentdoxGroup: string;
106
+ /**
107
+ * agentdox personal scope from the `X-Agentdox-Personal` request header:
108
+ * one member's own thread in the project (their handoff note first), which
109
+ * renders LAST. Empty when absent or not a slug ⇒ no personal layer.
110
+ */
111
+ agentdoxPersonal: string;
99
112
  /** `X-Omp-Subagent: 1`: the caller is an omp subagent (a session without a UI). */
100
113
  isSubagent: boolean;
101
114
  /**
@@ -138,6 +138,22 @@ describe("parseMessagesRequest", () => {
138
138
  expect(anthropicIdentityHeaders({}, new Headers({ "user-agent": "python-requests" })).get("x-omp-harness")).toBe("anthropic");
139
139
  });
140
140
 
141
+ test("the agentdox scope and layer headers reach the request on the Anthropic path too", () => {
142
+ const plain = parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "user-agent": "claude-cli/2.1.263" }));
143
+ expect(plain.agentdoxScope).toBe("");
144
+ expect(plain.agentdoxGroup).toBe("");
145
+ expect(plain.agentdoxPersonal).toBe("");
146
+ const team = parseMessagesRequest(
147
+ CLAUDE_CODE_BODY,
148
+ new Headers({ "user-agent": "claude-cli/2.1.263", "x-omp-harness": "u_ada", "x-agentdox-scope": "proj", "x-agentdox-group": "group.g1", "x-agentdox-personal": "proj.u.u_ada" }),
149
+ );
150
+ expect(team.harnessId).toBe("u_ada");
151
+ expect(team.agentdoxScope).toBe("proj");
152
+ expect(team.agentdoxGroup).toBe("group.g1");
153
+ expect(team.agentdoxPersonal).toBe("proj.u.u_ada");
154
+ expect(parseMessagesRequest(CLAUDE_CODE_BODY, new Headers({ "x-agentdox-group": "Not A Slug" })).agentdoxGroup).toBe("");
155
+ });
156
+
141
157
  test("a request captured from Claude Code 2.1: system inside messages, JSON user_id, adaptive thinking with effort, 23 custom tools", () => {
142
158
  const fixture = JSON.parse(readFileSync("test/fixtures/harness/claude-code.json", "utf8")) as { headers: Record<string, string>; body: Record<string, unknown> };
143
159
  const norm = parseMessagesRequest(fixture.body, new Headers(fixture.headers));
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import type { AgentDoxClient, AssembleLimits } from "../src/context/agentdox.ts";
3
+ import { type AgentDoxClient, type AssembleLayers, type AssembleLimits, createAgentDoxClient } from "../src/context/agentdox.ts";
4
4
  import { createContextBridge } from "../src/context/bridge.ts";
5
5
  import { createContextStore } from "../src/context/store.ts";
6
6
  import type { ContextResolveInput, TurnRecord } from "../src/context/types.ts";
@@ -16,19 +16,24 @@ interface FakeClient extends AgentDoxClient {
16
16
  sessionsCreated: number;
17
17
  prompt: string;
18
18
  lastLimits: AssembleLimits | null;
19
+ /** The layers of the last assemble: undefined when the bridge sent none. */
20
+ lastLayers: AssembleLayers | undefined;
19
21
  }
20
22
 
21
23
  function mkClient(prompt = "MEMORY: player digs in 3/4 top-down"): FakeClient {
22
24
  const c: FakeClient = {
23
25
  assembleCalls: 0,
24
26
  lastLimits: null,
27
+ lastLayers: undefined,
25
28
  appended: [],
26
29
  sessionsCreated: 0,
27
30
  prompt,
28
- async assemble(_scope, _query, limits) {
31
+ async assemble(scope, _query, limits, layers) {
29
32
  c.assembleCalls++;
30
33
  c.lastLimits = limits;
31
- return c.prompt;
34
+ c.lastLayers = layers;
35
+ // Like the server: a personal layer renders last, so members differ.
36
+ return layers !== undefined && layers.personal !== "" ? `${c.prompt}\n\n# Your thread in ${scope}\n${layers.personal}` : c.prompt;
32
37
  },
33
38
  async createSession() {
34
39
  c.sessionsCreated++;
@@ -56,6 +61,7 @@ function mkBridge(client: AgentDoxClient, over: Partial<BridgeOpts> = {}) {
56
61
  docsLimit: 2,
57
62
  sessionLimit: 6,
58
63
  briefChars: 0,
64
+ layers: true,
59
65
  recordTurns: true,
60
66
  maxQueue: 64,
61
67
  ...over,
@@ -72,6 +78,9 @@ function input(over: Partial<ContextResolveInput> = {}): ContextResolveInput {
72
78
  modelSwitching: false,
73
79
  retrying: false,
74
80
  query: "movement rules",
81
+ group: "",
82
+ personal: "",
83
+ user: "",
75
84
  firstFetch: true,
76
85
  ...over,
77
86
  };
@@ -258,6 +267,7 @@ describe("context bridge refresh policy", () => {
258
267
  docsLimit: 2,
259
268
  sessionLimit: 6,
260
269
  briefChars: 0,
270
+ layers: true,
261
271
  recordTurns: true,
262
272
  maxQueue: 64,
263
273
  };
@@ -282,6 +292,7 @@ describe("context bridge write-back", () => {
282
292
  bridge.recordTurn({
283
293
  scope: "ashlands",
284
294
  conversationKey: "k1",
295
+ harnessId: "",
285
296
  title: "movement fix",
286
297
  userText: "fix movement",
287
298
  assistantText: "done",
@@ -292,6 +303,7 @@ describe("context bridge write-back", () => {
292
303
  bridge.recordTurn({
293
304
  scope: "ashlands",
294
305
  conversationKey: "k1",
306
+ harnessId: "",
295
307
  title: "movement fix",
296
308
  userText: "now the camera",
297
309
  assistantText: "ok",
@@ -318,6 +330,7 @@ describe("context bridge write-back", () => {
318
330
  bridge.recordTurn({
319
331
  scope: "ashlands",
320
332
  conversationKey: "k1",
333
+ harnessId: "",
321
334
  title: "t",
322
335
  userText: "u",
323
336
  assistantText: "a",
@@ -337,6 +350,7 @@ describe("context bridge write-back", () => {
337
350
  return {
338
351
  scope: "ashlands",
339
352
  conversationKey: "k1",
353
+ harnessId: "",
340
354
  title: "movement fix",
341
355
  userText: "fix movement",
342
356
  assistantText: "",
@@ -423,6 +437,137 @@ describe("context bridge write-back", () => {
423
437
  });
424
438
  });
425
439
 
440
+ describe("context layers a team names (project memory, phase one)", () => {
441
+ // A router without a team has no group or personal scope and no harness id:
442
+ // the bridge must then send exactly what it did before, so a block on a
443
+ // lone install is byte-identical to 0.15. A team front door names the
444
+ // layers in headers and the member in the harness id.
445
+ test("resolve passes the group, personal and user layers to assemble; a lone router passes empties", async () => {
446
+ const client = mkClient();
447
+ const { bridge, db } = mkBridge(client);
448
+ try {
449
+ await bridge.resolve(input({ group: "group.g1", personal: "ashlands.u.u1", user: "u1" }));
450
+ expect(client.lastLayers).toEqual({ group: "group.g1", personal: "ashlands.u.u1", user: "u1" });
451
+ await bridge.resolve(input({ conversationKey: "k2" }));
452
+ expect(client.lastLayers).toEqual({ group: "", personal: "", user: "" });
453
+ // Either layer alone is a front door; the member goes with it.
454
+ await bridge.resolve(input({ conversationKey: "k3", group: "group.g1", user: "u1" }));
455
+ expect(client.lastLayers).toEqual({ group: "group.g1", personal: "", user: "u1" });
456
+ } finally {
457
+ db.close();
458
+ }
459
+ });
460
+
461
+ test("a router on its own sends no user, even though it has a harness id", async () => {
462
+ // Claude Code's wire path derives a harness id and omp sends one, so a
463
+ // lone router is NOT harness-less. Sending it as `user` would make a new
464
+ // agentdox filter the project's recent tail to that harness, dropping
465
+ // every pre-0.16 message and every other harness's turns.
466
+ const client = mkClient();
467
+ const { bridge, db } = mkBridge(client);
468
+ try {
469
+ await bridge.resolve(input({ user: "claude-code" }));
470
+ expect(client.lastLayers).toEqual({ group: "", personal: "", user: "" });
471
+ } finally {
472
+ db.close();
473
+ }
474
+ });
475
+
476
+ test("layers: false sends none, whatever the request names", async () => {
477
+ const client = mkClient();
478
+ const { bridge, db } = mkBridge(client, { layers: false });
479
+ try {
480
+ const pin = await bridge.resolve(input({ group: "group.g1", personal: "ashlands.u.u1", user: "u1" }));
481
+ expect(pin).not.toBeNull();
482
+ expect(client.assembleCalls).toBe(1);
483
+ expect(client.lastLayers).toBeUndefined();
484
+ } finally {
485
+ db.close();
486
+ }
487
+ });
488
+
489
+ test("a personal layer pins a different block per member, and the same block for the same member", async () => {
490
+ // The version is a hash of the block's content, so two members' blocks
491
+ // never share a version even in one scope; the shared store keys on it.
492
+ const client = mkClient();
493
+ const { bridge, db } = mkBridge(client);
494
+ try {
495
+ const ada = await bridge.resolve(input({ conversationKey: "ada", personal: "ashlands.u.ada", user: "ada" }));
496
+ const bob = await bridge.resolve(input({ conversationKey: "bob", personal: "ashlands.u.bob", user: "bob" }));
497
+ const adaAgain = await bridge.resolve(input({ conversationKey: "ada-2", personal: "ashlands.u.ada", user: "ada" }));
498
+ const nobody = await bridge.resolve(input({ conversationKey: "solo" }));
499
+ expect(ada?.block).toContain("ashlands.u.ada");
500
+ expect(bob?.block).toContain("ashlands.u.bob");
501
+ expect(ada?.version).not.toBe(bob?.version);
502
+ expect(adaAgain?.version).toBe(ada?.version ?? "");
503
+ expect(nobody?.version).not.toBe(ada?.version);
504
+ expect(nobody?.block).not.toContain("Your thread");
505
+ } finally {
506
+ db.close();
507
+ }
508
+ });
509
+
510
+ test("the recorded turn carries user:<harnessId> on both messages only when the harness is set", async () => {
511
+ const client = mkClient();
512
+ const { bridge, db } = mkBridge(client);
513
+ try {
514
+ const rec = {
515
+ scope: "ashlands",
516
+ title: "movement fix",
517
+ userText: "fix movement",
518
+ assistantText: "done",
519
+ slug: "anthropic/claude-haiku-4.5",
520
+ tier: "simple",
521
+ turnEnded: true,
522
+ };
523
+ bridge.recordTurn({ ...rec, conversationKey: "member", harnessId: "u_ada" });
524
+ bridge.recordTurn({ ...rec, conversationKey: "solo", harnessId: "" });
525
+ await bridge.flush();
526
+
527
+ const member = client.appended.filter((m) => m.sessionId === "ses_1");
528
+ expect(member.map((m) => m.role)).toEqual(["user", "assistant"]);
529
+ expect(member[0]?.refs).toEqual(["user:u_ada"]);
530
+ expect(member[1]?.refs).toEqual(["model:anthropic/claude-haiku-4.5", "tier:simple", "user:u_ada"]);
531
+
532
+ // No harness id: the refs are exactly what 0.15 wrote.
533
+ const solo = client.appended.filter((m) => m.sessionId === "ses_2");
534
+ expect(solo[0]?.refs).toEqual([]);
535
+ expect(solo[1]?.refs).toEqual(["model:anthropic/claude-haiku-4.5", "tier:simple"]);
536
+ } finally {
537
+ db.close();
538
+ }
539
+ });
540
+
541
+ test("the REST client posts the layer keys only when they are non-empty", async () => {
542
+ // An older agentdox ignores unknown keys, so sending them is harmless —
543
+ // but a lone router must post the same body as before, key for key, and
544
+ // a half-named layer set must not post empty strings the server would
545
+ // have to special-case.
546
+ const bodies: Record<string, unknown>[] = [];
547
+ const realFetch = globalThis.fetch;
548
+ globalThis.fetch = (async (_url: unknown, init?: RequestInit) => {
549
+ bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
550
+ return new Response(JSON.stringify({ prompt: "P" }), { status: 200, headers: { "content-type": "application/json" } });
551
+ }) as unknown as typeof fetch;
552
+ try {
553
+ const client = createAgentDoxClient({ baseUrl: "http://agentdox.test", token: "t", timeoutMs: 1000, log });
554
+ const limits = { memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0 };
555
+ await client.assemble("ashlands", "q", limits);
556
+ await client.assemble("ashlands", "q", limits, { group: "", personal: "", user: "" });
557
+ await client.assemble("ashlands", "q", limits, { group: "group.g1", personal: "", user: "u1" });
558
+ await client.assemble("ashlands", "q", limits, { group: "group.g1", personal: "ashlands.u.u1", user: "u1" });
559
+
560
+ const before = { scope: "ashlands", query: "q", ...limits };
561
+ expect(bodies[0]).toEqual(before);
562
+ expect(bodies[1]).toEqual(before);
563
+ expect(bodies[2]).toEqual({ ...before, group: "group.g1", user: "u1" });
564
+ expect(bodies[3]).toEqual({ ...before, group: "group.g1", personal: "ashlands.u.u1", user: "u1" });
565
+ } finally {
566
+ globalThis.fetch = realFetch;
567
+ }
568
+ });
569
+ });
570
+
426
571
  describe("context injection into the wire body", () => {
427
572
  test("appends to the last system message, leaving breakpoint indices valid", () => {
428
573
  const body = {
@@ -24,6 +24,8 @@ function req(messages: NormMessage[] = [], over: Partial<NormRequest> = {}): Nor
24
24
  harnessId: "",
25
25
  ompSessionId: "",
26
26
  agentdoxScope: "",
27
+ agentdoxGroup: "",
28
+ agentdoxPersonal: "",
27
29
  isSubagent: false,
28
30
  requestedModel: "auto",
29
31
  messages,
@@ -74,7 +74,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
74
74
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1, confirmUpgradesBelowConfidence: 0.6 },
75
75
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
76
76
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
77
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
77
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, layers: true, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
78
78
  compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
79
79
  budget: { onExceeded: "downgrade" },
80
80
  report: { baselines: [], dailySummary: false },
@@ -96,6 +96,8 @@ function mkReq(): NormRequest {
96
96
  harnessId: "",
97
97
  ompSessionId: "",
98
98
  agentdoxScope: "",
99
+ agentdoxGroup: "",
100
+ agentdoxPersonal: "",
99
101
  isSubagent: false,
100
102
  requestedModel: "auto",
101
103
  messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
@@ -28,4 +28,24 @@ describe("the scope a request may name", () => {
28
28
  expect(parse("omp-router").agentdoxScope).toBe("omp-router");
29
29
  expect(parse(SCOPE_ENV).agentdoxScope).toBe("");
30
30
  });
31
+
32
+ test("the wire parser reads X-Agentdox-Group and X-Agentdox-Personal by the same rule", () => {
33
+ // A team front door names the group-context and personal scopes; a
34
+ // lone router never sees the headers and must end up with empties, so
35
+ // nothing new is sent to agentdox.
36
+ const body = { model: "auto", messages: [{ role: "user", content: "hi" }] };
37
+ const parse = (h: Record<string, string>) => parseChatRequest(body, new Headers(h));
38
+ const none = parse({ "x-agentdox-scope": "omp-router" });
39
+ expect(none.agentdoxGroup).toBe("");
40
+ expect(none.agentdoxPersonal).toBe("");
41
+ const both = parse({ "x-agentdox-scope": "omp-router", "x-agentdox-group": " group.g1 ", "x-agentdox-personal": "omp-router.u.u_ada" });
42
+ expect(both.agentdoxGroup).toBe("group.g1");
43
+ expect(both.agentdoxPersonal).toBe("omp-router.u.u_ada");
44
+ // Not slugs: dropped, never passed through.
45
+ const bad = parse({ "x-agentdox-group": "Has Spaces", "x-agentdox-personal": SCOPE_ENV });
46
+ expect(bad.agentdoxGroup).toBe("");
47
+ expect(bad.agentdoxPersonal).toBe("");
48
+ // Layers without a project scope are still parsed; the turn decides what to do.
49
+ expect(parse({ "x-agentdox-group": "group.g1" }).agentdoxGroup).toBe("group.g1");
50
+ });
31
51
  });
package/test/turn.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createDisabledBridge } from "../src/context/bridge.ts";
3
- import type { ContextBridge, TurnRecord } from "../src/context/types.ts";
3
+ import type { ContextBridge, ContextResolveInput, TurnRecord } from "../src/context/types.ts";
4
4
  import type { CatalogModel, CatalogSource } from "../src/catalog/types.ts";
5
5
  import type { EscalationConfig, RouterConfig } from "../src/config/types.ts";
6
6
  import { EMPTY_USAGE, type Ledger, type LedgerEntry, type UsageCounts } from "../src/cost/types.ts";
@@ -74,7 +74,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
74
74
  hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1, confirmUpgradesBelowConfidence: 0.6 },
75
75
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
76
76
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
77
- context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
77
+ context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, layers: true, recordTurns: false, injectWithoutTools: false, maxQueue: 64 },
78
78
  compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1, digestToolResults: false, digestMaxPerTurn: 2 },
79
79
  budget: { onExceeded: "downgrade" },
80
80
  report: { baselines: [], dailySummary: false },
@@ -96,6 +96,8 @@ function mkReq(): NormRequest {
96
96
  harnessId: "",
97
97
  ompSessionId: "",
98
98
  agentdoxScope: "",
99
+ agentdoxGroup: "",
100
+ agentdoxPersonal: "",
99
101
  isSubagent: false,
100
102
  requestedModel: "auto",
101
103
  messages: [{ role: "user", text: "hi", images: 0, textBytes: 2, toolCalls: [] }],
@@ -722,6 +724,41 @@ describe("agentdox injection sees the shape of the turn", () => {
722
724
  expect(store.load(req.conversationKey).contextVersion).toBe("v1");
723
725
  });
724
726
 
727
+ test("the layer headers and the harness id reach resolve, and the harness id reaches the record", async () => {
728
+ // The team front door names the group and personal scopes in headers and
729
+ // the member in X-Omp-Harness; the turn carries all three through
730
+ // unchanged. A router without a team passes empties (see the other tests
731
+ // in this file, whose mkReq has none).
732
+ const { router, upstream, ledger, store, sink, errors } = oneDispatch();
733
+ const inputs: ContextResolveInput[] = [];
734
+ const records: TurnRecord[] = [];
735
+ const bridge: ContextBridge = {
736
+ enabled: true,
737
+ resolve: (input) => {
738
+ inputs.push(input);
739
+ return Promise.resolve(null);
740
+ },
741
+ recordTurn: (rec) => {
742
+ records.push(rec);
743
+ },
744
+ flush: () => Promise.resolve(),
745
+ pruneBlocks: () => 0,
746
+ close: () => {},
747
+ };
748
+ const req: NormRequest = { ...mkReq(), harnessId: "u_ada", agentdoxScope: "proj", agentdoxGroup: "group.g1", agentdoxPersonal: "proj.u.u_ada", tools: [AGENT_TOOL] };
749
+
750
+ await runTurn(req, sink, { config: mkConfig({ enabled: false }), router, upstream, ledger, conversations: store, catalog, context: bridge }, new AbortController().signal);
751
+
752
+ expect(errors).toHaveLength(0);
753
+ expect(inputs).toHaveLength(1);
754
+ expect(inputs[0]?.scope).toBe("proj");
755
+ expect(inputs[0]?.group).toBe("group.g1");
756
+ expect(inputs[0]?.personal).toBe("proj.u.u_ada");
757
+ expect(inputs[0]?.user).toBe("u_ada");
758
+ expect(records).toHaveLength(1);
759
+ expect(records[0]?.harnessId).toBe("u_ada");
760
+ });
761
+
725
762
  test("context.injectWithoutTools restores injection into tool-less calls", async () => {
726
763
  const { router, upstream, ledger, store, sink, errors } = oneDispatch();
727
764
  const { bridge, resolves } = mkServingBridge();
@@ -148,6 +148,12 @@ describe("view routes", () => {
148
148
  expect((await fetch(`http://127.0.0.1:${handle.server.port}/v1/router/spend?sinceMs=0`)).status).toBe(401);
149
149
  expect((await get("/v1/router/spend")).status).toBe(400);
150
150
  expect(((await (await get(`/v1/router/spend?sinceMs=${Date.now() - DAY}&harness=u_x`)).json()) as { usd: number }).usd).toBeCloseTo(0.25, 6);
151
+ // The decision trail over a harness set: a team asks with its user or group ids and sees only theirs.
152
+ const mine = (await (await get("/v1/router/decisions?harness=u_x&days=1")).json()) as { entries: { id: string; feedback: unknown[] }[] };
153
+ expect(mine.entries.map((e) => e.id)).toEqual(["r1"]);
154
+ expect(mine.entries[0]?.feedback).toEqual([]);
155
+ expect((((await (await get("/v1/router/decisions?harness=u_other&days=1")).json()) as { entries: unknown[] }).entries)).toEqual([]);
156
+ expect((((await (await get("/v1/router/decisions?limit=1")).json()) as { entries: { id: string }[] }).entries.map((e) => e.id))).toEqual(["r1"]); // no filter: everything, as before
151
157
  expect(((await (await get(`/v1/router/spend?sinceMs=${Date.now() - DAY}&harness=u_other`)).json()) as { usd: number }).usd).toBe(0);
152
158
  const fb = (await (await get("/v1/router/feedback?days=7")).json()) as { days: number; byModel: unknown[]; recent: unknown[] };
153
159
  expect(fb).toEqual({ days: 7, byModel: [], recent: [] });
@@ -159,3 +165,28 @@ describe("view routes", () => {
159
165
  expect(js.rows[0]?.harnessId).toBe("u_x");
160
166
  });
161
167
  });
168
+
169
+ describe("decision entries", () => {
170
+ const db = seeded();
171
+ const since = NOW - DAY;
172
+
173
+ test("newest first over a harness set, with the verdicts given on each turn", () => {
174
+ const { decisionEntries } = require("../src/cost/views.ts") as typeof import("../src/cost/views.ts");
175
+ const ada = decisionEntries(db, { sinceMs: since, harness: ["u_ada"] });
176
+ expect(ada.map((e) => e.id)).toEqual(["l1", "l2"]); // same instant in the fixture; insertion order within it is stable
177
+ expect(ada.find((e) => e.id === "l1")?.feedback).toEqual([{ verdict: "good", note: "", createdAtMs: NOW - 1000 }]);
178
+ expect(ada.find((e) => e.id === "l2")?.escalationSignal).toBe("circular");
179
+ // Everyone, within the window: the 40-day-old row stays out; the digest row is a turn like any other.
180
+ expect(decisionEntries(db, { sinceMs: since, harness: null }).map((e) => e.id).sort()).toEqual(["l1", "l2", "l3", "l4"]);
181
+ expect(decisionEntries(db, { sinceMs: 0, harness: null }).length).toBe(5);
182
+ // A model, a tier, nobody, and a cap.
183
+ expect(decisionEntries(db, { sinceMs: since, harness: null, slug: "ollama/glm-5.3-flash" }).map((e) => e.id).sort()).toEqual(["l3", "l4"]);
184
+ expect(decisionEntries(db, { sinceMs: since, harness: null, tier: "hard" })).toEqual([]);
185
+ expect(decisionEntries(db, { sinceMs: since, harness: [] })).toEqual([]);
186
+ expect(decisionEntries(db, { sinceMs: since, harness: null, limit: 1 }).length).toBe(1);
187
+ // The error on l3 and its note ride along, so an explorer can show why a turn went wrong.
188
+ const bob = decisionEntries(db, { sinceMs: since, harness: ["u_bob"], slug: "ollama/glm-5.3-flash" });
189
+ expect(bob.find((e) => e.id === "l3")?.error).toBe("boom");
190
+ expect(bob.find((e) => e.id === "l3")?.feedback[0]?.note).toBe("looped");
191
+ });
192
+ });
@@ -44,6 +44,7 @@ const bridge = createContextBridge({
44
44
  docsLimit: 0,
45
45
  sessionLimit: 6,
46
46
  briefChars: 12_000,
47
+ layers: true,
47
48
  recordTurns: true,
48
49
  maxQueue: 64,
49
50
  });
@@ -58,6 +59,9 @@ function input(over: Partial<ContextResolveInput> = {}): ContextResolveInput {
58
59
  modelSwitching: false,
59
60
  retrying: false,
60
61
  query: "cache prompt context injection",
62
+ group: "",
63
+ personal: "",
64
+ user: "",
61
65
  firstFetch: true,
62
66
  ...over,
63
67
  };
@@ -93,6 +97,7 @@ check(
93
97
  bridge.recordTurn({
94
98
  scope,
95
99
  conversationKey,
100
+ harnessId: "",
96
101
  title: `bridge e2e ${conversationKey}`,
97
102
  userText: "does the bridge record which model served this turn?",
98
103
  assistantText: "yes - refs carry model: and tier:.",
@@ -135,6 +140,7 @@ function loopDispatch(assistantText: string, turnEnded: boolean): void {
135
140
  bridge.recordTurn({
136
141
  scope,
137
142
  conversationKey: loopKey,
143
+ harnessId: "",
138
144
  title: loopTitle,
139
145
  userText: "why did cache read fall?",
140
146
  assistantText,
package/tools/replay.ts CHANGED
@@ -197,6 +197,8 @@ function requestOf(row: Row, f: Features): NormRequest {
197
197
  harnessId: row.harness_id,
198
198
  ompSessionId: "",
199
199
  agentdoxScope: "",
200
+ agentdoxGroup: "",
201
+ agentdoxPersonal: "",
200
202
  isSubagent: false,
201
203
  requestedModel: row.requested_model,
202
204
  messages,