agent-relay-server 0.12.4 → 0.13.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.
package/src/cli.ts CHANGED
@@ -74,6 +74,7 @@ Usage:
74
74
  agent-relay /guide
75
75
  agent-relay /label [LABEL]
76
76
  agent-relay /tags [TAG ...]
77
+ agent-relay /introspect [--thin TEXT] [--worked-around TEXT] [--would-have-helped TEXT] [--stdin]
77
78
  agent-relay --help
78
79
 
79
80
  Pair examples:
@@ -231,6 +232,14 @@ Rules of thumb
231
232
  If you need to know who you are in Relay, run:
232
233
  agent-relay /status --json
233
234
 
235
+ Recording session friction (optional, helps improve your standing context)
236
+ agent-relay /introspect --thin "..." --worked-around "..." --would-have-helped "..."
237
+ When a session involved real work, you can record a short 3-field note about
238
+ where context was thin, what tooling/instruction gaps you routed around, and
239
+ what would have saved the read-up. Keep each field to a sentence or two. This
240
+ feeds the relay's self-improvement signal so the operator can close the gaps —
241
+ it is not a reply and needs no message id. Skip it for trivial sessions.
242
+
234
243
  Use the HTTP API for integrations and debugging. For normal agent-to-agent
235
244
  communication, use the CLI commands above.
236
245
  `.trim();
@@ -322,6 +331,10 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
322
331
  await handleReactCommand(args.slice(1));
323
332
  return "handled";
324
333
  }
334
+ if (command === "introspect" || command === "/introspect") {
335
+ await handleIntrospectCommand(args.slice(1));
336
+ return "handled";
337
+ }
325
338
  if (command === "/status" || command === "status") {
326
339
  await handleStatusCommand(args.slice(1));
327
340
  return "handled";
@@ -1551,6 +1564,75 @@ async function handleReactCommand(args: string[]): Promise<void> {
1551
1564
  else console.log(`${action === "remove" ? "Reaction removed" : "Reaction sent"}: ${emoji} on #${messageId}`);
1552
1565
  }
1553
1566
 
1567
+ // Insights #185: capture the agent's end-of-session self-view as a bounded, structured
1568
+ // artifact (epic #183, docs/self-improvement.md). Manual rail in v0 — the agent or
1569
+ // operator runs this; the auto-trigger is chosen later on real data. The relay drops it
1570
+ // (409) if Insights or the introspection feature is toggled off.
1571
+ const INTROSPECT_FIELD_MAX = 600;
1572
+
1573
+ async function handleIntrospectCommand(args: string[]): Promise<void> {
1574
+ let from = await detectAgentId();
1575
+ let project: string | undefined;
1576
+ let sessionId: string | undefined;
1577
+ let thin: string | undefined;
1578
+ let workedAround: string | undefined;
1579
+ let wouldHaveHelped: string | undefined;
1580
+ let stdin = false;
1581
+ let json = false;
1582
+
1583
+ for (let i = 0; i < args.length; i++) {
1584
+ const arg = args[i];
1585
+ if (arg === "--from" && i + 1 < args.length) from = args[++i];
1586
+ else if (arg === "--project" && i + 1 < args.length) project = args[++i];
1587
+ else if (arg === "--session" && i + 1 < args.length) sessionId = args[++i];
1588
+ else if (arg === "--thin" && i + 1 < args.length) thin = args[++i];
1589
+ else if (arg === "--worked-around" && i + 1 < args.length) workedAround = args[++i];
1590
+ else if (arg === "--would-have-helped" && i + 1 < args.length) wouldHaveHelped = args[++i];
1591
+ else if (arg === "--stdin") stdin = true;
1592
+ else if (arg === "--json") json = true;
1593
+ else throw new Error(`Unknown introspect option "${arg}"`);
1594
+ }
1595
+
1596
+ // --stdin reads a JSON object { thin, workedAround, wouldHaveHelped } — lets agents
1597
+ // pipe a file without shell-quoting three multi-line fields.
1598
+ if (stdin) {
1599
+ const raw = (await readStdin()).trim();
1600
+ if (!raw) throw new Error("--stdin given but no input received.");
1601
+ let parsed: unknown;
1602
+ try {
1603
+ parsed = JSON.parse(raw);
1604
+ } catch {
1605
+ throw new Error('--stdin must be JSON: { "thin": "...", "workedAround": "...", "wouldHaveHelped": "..." }');
1606
+ }
1607
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("--stdin JSON must be an object");
1608
+ const obj = parsed as Record<string, unknown>;
1609
+ if (typeof obj.thin === "string") thin = obj.thin;
1610
+ if (typeof obj.workedAround === "string") workedAround = obj.workedAround;
1611
+ if (typeof obj.wouldHaveHelped === "string") wouldHaveHelped = obj.wouldHaveHelped;
1612
+ }
1613
+
1614
+ const clip = (v: string | undefined): string => (v ?? "").trim().slice(0, INTROSPECT_FIELD_MAX);
1615
+ const value = { thin: clip(thin), workedAround: clip(workedAround), wouldHaveHelped: clip(wouldHaveHelped) };
1616
+ if (!value.thin && !value.workedAround && !value.wouldHaveHelped) {
1617
+ throw new Error(
1618
+ "Usage: agent-relay /introspect [--thin TEXT] [--worked-around TEXT] [--would-have-helped TEXT] [--stdin] [--from AGENT_ID]\n" +
1619
+ "At least one field is required. The three fields: what context was thin, what you worked around, what would've helped.",
1620
+ );
1621
+ }
1622
+ if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
1623
+
1624
+ const observation = await apiRequest("POST", "/api/insights/observations", {
1625
+ sessionId: sessionId || process.env.AGENT_RELAY_PROVIDER_SESSION_ID || `manual-${from}`,
1626
+ project: project || process.cwd(),
1627
+ agentId: from,
1628
+ signal: "introspection",
1629
+ source: "agent",
1630
+ value,
1631
+ });
1632
+ if (json) console.log(JSON.stringify(observation, null, 2));
1633
+ else console.log("Introspection recorded.");
1634
+ }
1635
+
1554
1636
  function parseReplyFormat(value: string): "text" | "markdown" | "markdownv2" | undefined {
1555
1637
  const normalized = value.trim().toLowerCase();
1556
1638
  if (normalized === "text" || normalized === "markdown" || normalized === "markdownv2") return normalized;
@@ -5,6 +5,7 @@ import type {
5
5
  AgentProfileBase,
6
6
  ConfigEntry,
7
7
  ConfigHistoryEntry,
8
+ InsightsConfig,
8
9
  ManagedAgentState,
9
10
  ManagedAgentStatus,
10
11
  SpawnApprovalMode,
@@ -18,6 +19,8 @@ const SPAWN_POLICY_NAMESPACE = "spawn-policy";
18
19
  const AGENT_PROFILE_NAMESPACE = "agent-profile";
19
20
  const STEWARD_NAMESPACE = "steward";
20
21
  const STEWARD_KEY = "default";
22
+ const INSIGHTS_NAMESPACE = "insights";
23
+ const INSIGHTS_KEY = "default";
21
24
  const VALID_PROVIDERS = ["claude", "codex"] as const;
22
25
  const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
23
26
  const VALID_PROFILE_BASES = ["host", "minimal", "isolated"] as const;
@@ -425,11 +428,56 @@ function validateStewardConfig(value: unknown): StewardConfig {
425
428
  return config;
426
429
  }
427
430
 
431
+ // Insights / self-improvement module config — feature toggles for the dogfooding
432
+ // flywheel (epic #183, docs/self-improvement.md). Passive read-only observation, so it
433
+ // defaults on; each feature toggles independently.
434
+ const INSIGHTS_CONFIG_DEFAULTS: InsightsConfig = {
435
+ enabled: true,
436
+ contextRatio: { enabled: true },
437
+ introspection: { enabled: true, minTurns: 4, minContextRemaining: 0.15 },
438
+ };
439
+
440
+ function cleanFraction(value: unknown, field: string, fallback: number): number {
441
+ if (value === undefined || value === null) return fallback;
442
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
443
+ throw new ValidationError(`${field} must be a number between 0 and 1`);
444
+ }
445
+ return value;
446
+ }
447
+
448
+ function validateInsightsConfig(value: unknown): InsightsConfig {
449
+ if (!isRecord(value)) throw new ValidationError("insights config value must be an object");
450
+ const contextRatio = isRecord(value.contextRatio) ? value.contextRatio : {};
451
+ const introspection = isRecord(value.introspection) ? value.introspection : {};
452
+ return {
453
+ enabled: value.enabled === undefined ? INSIGHTS_CONFIG_DEFAULTS.enabled : cleanBoolean(value.enabled, "enabled"),
454
+ contextRatio: {
455
+ enabled: contextRatio.enabled === undefined
456
+ ? INSIGHTS_CONFIG_DEFAULTS.contextRatio.enabled
457
+ : cleanBoolean(contextRatio.enabled, "contextRatio.enabled"),
458
+ },
459
+ introspection: {
460
+ enabled: introspection.enabled === undefined
461
+ ? INSIGHTS_CONFIG_DEFAULTS.introspection.enabled
462
+ : cleanBoolean(introspection.enabled, "introspection.enabled"),
463
+ minTurns: introspection.minTurns === undefined || introspection.minTurns === null
464
+ ? INSIGHTS_CONFIG_DEFAULTS.introspection.minTurns
465
+ : cleanNumber(introspection.minTurns, "introspection.minTurns", { min: 0, max: 1000 }),
466
+ minContextRemaining: cleanFraction(
467
+ introspection.minContextRemaining,
468
+ "introspection.minContextRemaining",
469
+ INSIGHTS_CONFIG_DEFAULTS.introspection.minContextRemaining,
470
+ ),
471
+ },
472
+ };
473
+ }
474
+
428
475
  function normalizeValue(namespace: string, key: string, value: unknown): unknown {
429
476
  if (value === undefined) throw new ValidationError("value required");
430
477
  if (namespace === SPAWN_POLICY_NAMESPACE) return validateSpawnPolicy(key, value);
431
478
  if (namespace === AGENT_PROFILE_NAMESPACE) return validateAgentProfile(key, value);
432
479
  if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
480
+ if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
433
481
  if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
434
482
  return value;
435
483
  }
@@ -543,6 +591,29 @@ export function setStewardConfig(value: unknown, updatedBy?: string): ConfigEntr
543
591
  return setConfig(STEWARD_NAMESPACE, STEWARD_KEY, value as StewardConfig, updatedBy);
544
592
  }
545
593
 
594
+ /** Insights module config, merged over defaults (always returns a usable value). */
595
+ export function getInsightsConfig(): InsightsConfig {
596
+ const entry = getConfig<Partial<InsightsConfig>>(INSIGHTS_NAMESPACE, INSIGHTS_KEY);
597
+ if (!entry) return { ...INSIGHTS_CONFIG_DEFAULTS };
598
+ return validateInsightsConfig({ ...INSIGHTS_CONFIG_DEFAULTS, ...entry.value });
599
+ }
600
+
601
+ export function getInsightsConfigEntry(): ConfigEntry<InsightsConfig> {
602
+ const entry = getConfig<InsightsConfig>(INSIGHTS_NAMESPACE, INSIGHTS_KEY);
603
+ return entry ?? {
604
+ namespace: INSIGHTS_NAMESPACE,
605
+ key: INSIGHTS_KEY,
606
+ value: { ...INSIGHTS_CONFIG_DEFAULTS },
607
+ version: 0,
608
+ updatedAt: "default",
609
+ updatedBy: "system",
610
+ };
611
+ }
612
+
613
+ export function setInsightsConfig(value: unknown, updatedBy?: string): ConfigEntry<InsightsConfig> {
614
+ return setConfig(INSIGHTS_NAMESPACE, INSIGHTS_KEY, value as InsightsConfig, updatedBy);
615
+ }
616
+
546
617
  function builtInProfileEntry(profile: AgentProfile): ConfigEntry<AgentProfile> {
547
618
  return {
548
619
  namespace: AGENT_PROFILE_NAMESPACE,
package/src/db.ts CHANGED
@@ -770,6 +770,21 @@ export function initDb(path: string = "agent-relay.db"): Database {
770
770
  last_auth_success_at INTEGER,
771
771
  last_auth_failure_at INTEGER
772
772
  );
773
+
774
+ CREATE TABLE IF NOT EXISTS insights_observations (
775
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
776
+ session_id TEXT NOT NULL,
777
+ agent_id TEXT,
778
+ project TEXT NOT NULL DEFAULT 'unknown',
779
+ signal TEXT NOT NULL,
780
+ value TEXT NOT NULL DEFAULT '{}',
781
+ outcome TEXT,
782
+ source TEXT NOT NULL DEFAULT 'server',
783
+ created_at INTEGER NOT NULL
784
+ );
785
+ CREATE INDEX IF NOT EXISTS idx_insights_obs_project_signal ON insights_observations(project, signal, created_at DESC);
786
+ CREATE INDEX IF NOT EXISTS idx_insights_obs_signal ON insights_observations(signal, created_at DESC);
787
+ CREATE INDEX IF NOT EXISTS idx_insights_obs_session ON insights_observations(session_id);
773
788
  `);
774
789
  normalizeExistingMessageReactions();
775
790
 
@@ -0,0 +1,179 @@
1
+ // Insights — continuous self-improvement store (epic #183, docs/self-improvement.md).
2
+ //
3
+ // One generic table backs every signal. New instrumentation appends rows with a new
4
+ // `signal` discriminator — never a new table — so the module grows by data, not schema.
5
+ // Each row pairs a metric (`value`) with an optional outcome proxy (`outcome`) so an
6
+ // efficiency number is never read without its anti-Goodhart leash. Everything is scoped
7
+ // per-project (and rolled up globally) so the real signal can be delta-from-baseline,
8
+ // never an absolute threshold.
9
+
10
+ import { getDb, ValidationError } from "./db";
11
+ import type { InsightObservation, InsightsStats } from "./types";
12
+
13
+ interface ObservationRow {
14
+ id: number;
15
+ session_id: string;
16
+ agent_id: string | null;
17
+ project: string;
18
+ signal: string;
19
+ value: string;
20
+ outcome: string | null;
21
+ source: string;
22
+ created_at: number;
23
+ }
24
+
25
+ function parseJson(raw: string | null): Record<string, unknown> | undefined {
26
+ if (raw === null) return undefined;
27
+ try {
28
+ const parsed = JSON.parse(raw);
29
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ }
34
+
35
+ function rowToObservation(row: ObservationRow): InsightObservation {
36
+ return {
37
+ id: row.id,
38
+ sessionId: row.session_id,
39
+ agentId: row.agent_id ?? undefined,
40
+ project: row.project,
41
+ signal: row.signal,
42
+ value: parseJson(row.value) ?? {},
43
+ outcome: parseJson(row.outcome),
44
+ source: row.source === "agent" ? "agent" : "server",
45
+ createdAt: row.created_at,
46
+ };
47
+ }
48
+
49
+ export interface RecordObservationInput {
50
+ sessionId: string;
51
+ agentId?: string;
52
+ project?: string;
53
+ signal: string;
54
+ value: Record<string, unknown>;
55
+ outcome?: Record<string, unknown>;
56
+ source?: "server" | "agent";
57
+ createdAt?: number;
58
+ }
59
+
60
+ export function recordObservation(input: RecordObservationInput): InsightObservation {
61
+ const sessionId = (input.sessionId ?? "").trim();
62
+ if (!sessionId) throw new ValidationError("sessionId required");
63
+ const signal = (input.signal ?? "").trim();
64
+ if (!signal) throw new ValidationError("signal required");
65
+ if (signal.length > 80) throw new ValidationError("signal must be 80 characters or fewer");
66
+ if (!input.value || typeof input.value !== "object" || Array.isArray(input.value)) {
67
+ throw new ValidationError("value must be an object");
68
+ }
69
+ // Bounded by design — the artifact stays a few lines, never an essay (the format
70
+ // constraint is what keeps the introspection signal from becoming a tax).
71
+ const valueJson = JSON.stringify(input.value);
72
+ if (valueJson.length > 16_384) throw new ValidationError("value too large (max 16KB)");
73
+ const outcomeJson = input.outcome ? JSON.stringify(input.outcome) : null;
74
+ if (outcomeJson && outcomeJson.length > 16_384) throw new ValidationError("outcome too large (max 16KB)");
75
+ const project = (input.project ?? "").trim() || "unknown";
76
+ const source = input.source === "agent" ? "agent" : "server";
77
+ const now = input.createdAt ?? Date.now();
78
+
79
+ const result = getDb()
80
+ .prepare(
81
+ `INSERT INTO insights_observations (session_id, agent_id, project, signal, value, outcome, source, created_at)
82
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
83
+ )
84
+ .run(
85
+ sessionId,
86
+ input.agentId ?? null,
87
+ project,
88
+ signal,
89
+ valueJson,
90
+ outcomeJson,
91
+ source,
92
+ now,
93
+ );
94
+ return getObservation(Number(result.lastInsertRowid))!;
95
+ }
96
+
97
+ export function getObservation(id: number): InsightObservation | null {
98
+ const row = getDb().prepare("SELECT * FROM insights_observations WHERE id = ?").get(id) as ObservationRow | undefined;
99
+ return row ? rowToObservation(row) : null;
100
+ }
101
+
102
+ export interface ListObservationsQuery {
103
+ project?: string;
104
+ signal?: string;
105
+ sessionId?: string;
106
+ limit?: number;
107
+ }
108
+
109
+ export function listObservations(query: ListObservationsQuery = {}): InsightObservation[] {
110
+ const clauses: string[] = [];
111
+ const args: (string | number)[] = [];
112
+ if (query.project) {
113
+ clauses.push("project = ?");
114
+ args.push(query.project);
115
+ }
116
+ if (query.signal) {
117
+ clauses.push("signal = ?");
118
+ args.push(query.signal);
119
+ }
120
+ if (query.sessionId) {
121
+ clauses.push("session_id = ?");
122
+ args.push(query.sessionId);
123
+ }
124
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
125
+ const limit = Math.min(Math.max(query.limit ?? 200, 1), 1000);
126
+ const rows = getDb()
127
+ .prepare(`SELECT * FROM insights_observations ${where} ORDER BY created_at DESC, id DESC LIMIT ?`)
128
+ .all(...args, limit) as ObservationRow[];
129
+ return rows.map(rowToObservation);
130
+ }
131
+
132
+ /**
133
+ * Per-project rollups plus a global (project = null) rollup for each signal. Average
134
+ * ratio is computed over rows that carry a numeric `value.ratio` (context_ratio); other
135
+ * signals report null. Raw values are preserved on the rows — this is a convenience view,
136
+ * never the place absolute thresholds get baked in.
137
+ */
138
+ export function getObservationStats(signal?: string): InsightsStats[] {
139
+ const signalFilter = signal ? "WHERE signal = ?" : "";
140
+ const args = signal ? [signal] : [];
141
+ const perProject = getDb()
142
+ .prepare(
143
+ `SELECT signal, project,
144
+ COUNT(*) AS count,
145
+ AVG(CAST(json_extract(value, '$.ratio') AS REAL)) AS avg_ratio,
146
+ MAX(created_at) AS last_at
147
+ FROM insights_observations
148
+ ${signalFilter}
149
+ GROUP BY signal, project
150
+ ORDER BY signal ASC, project ASC`,
151
+ )
152
+ .all(...args) as Array<{ signal: string; project: string; count: number; avg_ratio: number | null; last_at: number | null }>;
153
+
154
+ const global = getDb()
155
+ .prepare(
156
+ `SELECT signal,
157
+ COUNT(*) AS count,
158
+ AVG(CAST(json_extract(value, '$.ratio') AS REAL)) AS avg_ratio,
159
+ MAX(created_at) AS last_at
160
+ FROM insights_observations
161
+ ${signalFilter}
162
+ GROUP BY signal
163
+ ORDER BY signal ASC`,
164
+ )
165
+ .all(...args) as Array<{ signal: string; count: number; avg_ratio: number | null; last_at: number | null }>;
166
+
167
+ return [
168
+ ...global.map((r) => ({ signal: r.signal, project: null, count: r.count, avgRatio: r.avg_ratio, lastAt: r.last_at })),
169
+ ...perProject.map((r) => ({ signal: r.signal, project: r.project, count: r.count, avgRatio: r.avg_ratio, lastAt: r.last_at })),
170
+ ];
171
+ }
172
+
173
+ /** Distinct projects that have produced at least one observation — feeds per-project baselines. */
174
+ export function listObservationProjects(): string[] {
175
+ const rows = getDb()
176
+ .prepare("SELECT DISTINCT project FROM insights_observations ORDER BY project ASC")
177
+ .all() as Array<{ project: string }>;
178
+ return rows.map((r) => r.project);
179
+ }
package/src/routes.ts CHANGED
@@ -106,6 +106,8 @@ import {
106
106
  getManagedAgentState,
107
107
  getSpawnPolicy,
108
108
  getStewardConfigEntry,
109
+ getInsightsConfigEntry,
110
+ setInsightsConfig,
109
111
  listAgentProfiles,
110
112
  listSpawnPolicies,
111
113
  listConfig,
@@ -116,6 +118,7 @@ import {
116
118
  updateManagedAgentState,
117
119
  } from "./config-store";
118
120
  import { createCommand, deleteCommand, getCommand, listCommands, updateCommand } from "./commands-db";
121
+ import { recordObservation, listObservations, getObservationStats, listObservationProjects } from "./insights-db";
119
122
  import { getLifecycleManager } from "./lifecycle-manager";
120
123
  import { getCompactionWatch } from "./compaction-watch";
121
124
  import { getRecipe, listRecipes } from "./recipe-loader";
@@ -4596,6 +4599,71 @@ const putStewardConfigRoute: Handler = async (req) => {
4596
4599
  }
4597
4600
  };
4598
4601
 
4602
+ // --- Insights / self-improvement (epic #183, docs/self-improvement.md) ---
4603
+
4604
+ const getInsightsConfigRoute: Handler = () => json(getInsightsConfigEntry());
4605
+
4606
+ const putInsightsConfigRoute: Handler = async (req) => {
4607
+ const parsed = await parseBody<unknown>(req);
4608
+ if (!parsed.ok) return error(parsed.error, parsed.status);
4609
+ try {
4610
+ const value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
4611
+ ? parsed.body.value
4612
+ : parsed.body;
4613
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
4614
+ const entry = setInsightsConfig(value, updatedBy);
4615
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
4616
+ return json(entry, entry.version === 1 ? 201 : 200);
4617
+ } catch (e) {
4618
+ if (e instanceof ValidationError) return error(e.message, 400);
4619
+ throw e;
4620
+ }
4621
+ };
4622
+
4623
+ const getInsightsObservationsRoute: Handler = (req) => {
4624
+ const url = new URL(req.url);
4625
+ const project = url.searchParams.get("project") ?? undefined;
4626
+ const signal = url.searchParams.get("signal") ?? undefined;
4627
+ const sessionId = url.searchParams.get("session") ?? undefined;
4628
+ const limitRaw = url.searchParams.get("limit");
4629
+ const limit = limitRaw ? Number(limitRaw) : undefined;
4630
+ const observations = listObservations({ project, signal, sessionId, limit: Number.isFinite(limit) ? limit : undefined });
4631
+ return json({
4632
+ observations,
4633
+ stats: getObservationStats(signal),
4634
+ projects: listObservationProjects(),
4635
+ });
4636
+ };
4637
+
4638
+ const postInsightsObservationRoute: Handler = async (req) => {
4639
+ const parsed = await parseBody<unknown>(req);
4640
+ if (!parsed.ok) return error(parsed.error, parsed.status);
4641
+ if (!isRecord(parsed.body)) return error("JSON object body required", 400);
4642
+ // Master switch + per-signal toggle gate ingestion, so flipping a feature off in the
4643
+ // dashboard actually stops data landing — not just hides it.
4644
+ const config = getInsightsConfigEntry().value;
4645
+ if (!config.enabled) return error("insights disabled", 409);
4646
+ const body = parsed.body;
4647
+ const signal = typeof body.signal === "string" ? body.signal.trim() : "";
4648
+ if (signal === "introspection" && !config.introspection.enabled) return error("introspection disabled", 409);
4649
+ if (signal === "context_ratio" && !config.contextRatio.enabled) return error("contextRatio disabled", 409);
4650
+ try {
4651
+ const observation = recordObservation({
4652
+ sessionId: typeof body.sessionId === "string" ? body.sessionId : "",
4653
+ agentId: typeof body.agentId === "string" ? body.agentId : undefined,
4654
+ project: typeof body.project === "string" ? body.project : undefined,
4655
+ signal,
4656
+ value: isRecord(body.value) ? body.value : {},
4657
+ outcome: isRecord(body.outcome) ? body.outcome : undefined,
4658
+ source: body.source === "server" ? "server" : "agent",
4659
+ });
4660
+ return json(observation, 201);
4661
+ } catch (e) {
4662
+ if (e instanceof ValidationError) return error(e.message, 400);
4663
+ throw e;
4664
+ }
4665
+ };
4666
+
4599
4667
  // --- Config routes ---
4600
4668
 
4601
4669
  function normalizeConfigPathParam(raw: string | undefined, field: string): string {
@@ -5608,6 +5676,43 @@ const putConnectorConfig: Handler = async (req, params) => {
5608
5676
  }
5609
5677
  };
5610
5678
 
5679
+ // Endpoints a connector daemon may expose for the dashboard to call through the
5680
+ // relay (single-origin — no CORS, no extra port exposure). Kept to a small,
5681
+ // non-mutating allowlist; the connector advertises its HTTP base via its status.
5682
+ const PROXYABLE_CONNECTOR_CALLS = new Set(["transcribe", "utterance"]);
5683
+
5684
+ function connectorAdvertisedEndpoint(connector: NonNullable<ReturnType<typeof getConnector>>): string | null {
5685
+ const raw = connector.state?.raw;
5686
+ if (isRecord(raw) && typeof raw.endpoint === "string" && raw.endpoint) return raw.endpoint;
5687
+ const cfg = connector.config;
5688
+ if (isRecord(cfg) && typeof cfg.endpoint === "string" && cfg.endpoint) return cfg.endpoint;
5689
+ return null;
5690
+ }
5691
+
5692
+ const postConnectorCall: Handler = async (req, params) => {
5693
+ const connector = getConnector(params.id!);
5694
+ if (!connector) return error("connector not found", 404);
5695
+ const name = params.name!;
5696
+ if (!PROXYABLE_CONNECTOR_CALLS.has(name)) return error("unsupported connector call", 404);
5697
+ const endpoint = connectorAdvertisedEndpoint(connector);
5698
+ if (!endpoint) return error("connector is not running (no advertised endpoint)", 422);
5699
+ try {
5700
+ const body = await req.arrayBuffer();
5701
+ const res = await fetch(`${endpoint.replace(/\/$/, "")}/${name}`, {
5702
+ method: "POST",
5703
+ headers: { "content-type": req.headers.get("content-type") || "application/octet-stream" },
5704
+ body,
5705
+ signal: AbortSignal.timeout(120_000),
5706
+ });
5707
+ return new Response(res.body, {
5708
+ status: res.status,
5709
+ headers: { "content-type": res.headers.get("content-type") || "application/json" },
5710
+ });
5711
+ } catch (e) {
5712
+ return error(`failed to reach connector: ${(e as Error).message}`, 502);
5713
+ }
5714
+ };
5715
+
5611
5716
  // --- Tasks and integrations ---
5612
5717
 
5613
5718
  function reconcileIntegrationRegistry(configured: Map<string, IntegrationTokenConfig>, observedStats: Map<string, ReturnType<typeof listIntegrationTaskStats>[number]>): Map<string, ReturnType<typeof listIntegrationRegistry>[number]> {
@@ -6464,6 +6569,10 @@ const routes: Route[] = [
6464
6569
  route("DELETE", "/api/agent-profiles/:name", deleteAgentProfileRoute),
6465
6570
  route("GET", "/api/steward-config", getStewardConfigRoute),
6466
6571
  route("PUT", "/api/steward-config", putStewardConfigRoute),
6572
+ route("GET", "/api/insights/config", getInsightsConfigRoute),
6573
+ route("PUT", "/api/insights/config", putInsightsConfigRoute),
6574
+ route("GET", "/api/insights/observations", getInsightsObservationsRoute),
6575
+ route("POST", "/api/insights/observations", postInsightsObservationRoute),
6467
6576
  route("GET", "/api/config/:namespace", getConfigNamespace),
6468
6577
  route("GET", "/api/config/:namespace/:key/history", getConfigKeyHistory),
6469
6578
  route("GET", "/api/config/:namespace/:key", getConfigKey),
@@ -6520,6 +6629,7 @@ const routes: Route[] = [
6520
6629
  route("POST", "/api/connectors/:id/actions", postConnectorAction),
6521
6630
  route("GET", "/api/connectors/:id/config", getConnectorConfig),
6522
6631
  route("PUT", "/api/connectors/:id/config", putConnectorConfig),
6632
+ route("POST", "/api/connectors/:id/call/:name", postConnectorCall),
6523
6633
 
6524
6634
  route("GET", "/api/channels", getChannels),
6525
6635
  route("POST", "/api/channels/:id/events", postChannelEvent),