agent-relay-server 0.12.3 → 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/docs/openapi.json +310 -1
- package/package.json +8 -5
- package/public/index.html +764 -56
- package/src/cli.ts +82 -0
- package/src/config-store.ts +71 -0
- package/src/db.ts +15 -0
- package/src/insights-db.ts +179 -0
- package/src/routes.ts +110 -0
- package/src/sse.ts +11 -0
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;
|
package/src/config-store.ts
CHANGED
|
@@ -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),
|
package/src/sse.ts
CHANGED
|
@@ -252,8 +252,19 @@ function isInboundChannelMessage(msg: Message): boolean {
|
|
|
252
252
|
return msg.kind === "channel.event" && direction === "inbound" && event?.type === "message.created";
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
function sessionMirrorType(msg: Message): string | undefined {
|
|
256
|
+
const session = isRecord(msg.payload?.session) ? msg.payload.session : undefined;
|
|
257
|
+
return typeof session?.type === "string" ? session.type : undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
255
260
|
function notificationForMessage(msg: Message): RelayNotification | undefined {
|
|
256
261
|
if (msg.from === "user") return undefined;
|
|
262
|
+
// Session-mirror activity (reasoning/tool/notice) is high-frequency telemetry, not
|
|
263
|
+
// something to notify on. Only the response turn warrants a notification; interactive
|
|
264
|
+
// prompts (AskUserQuestion, permission, plan-mode) notify via providerState in
|
|
265
|
+
// notificationForAgentStatus, not here — so suppressing non-response session events
|
|
266
|
+
// doesn't silence anything the user must act on.
|
|
267
|
+
if (msg.kind === "session" && sessionMirrorType(msg) !== "response") return undefined;
|
|
257
268
|
const isDirectToUser = msg.to === "user";
|
|
258
269
|
const isChannelInbound = isInboundChannelMessage(msg);
|
|
259
270
|
if (!isDirectToUser && !isChannelInbound) return undefined;
|