@saadnvd1/agent-os 0.3.715 → 0.3.723

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.
Files changed (142) hide show
  1. package/README.md +1 -0
  2. package/app/api/agent-roles/route.test.ts +67 -0
  3. package/app/api/agent-roles/route.ts +61 -0
  4. package/app/api/analyst/[id]/recommendations/[recId]/again/route.ts +15 -0
  5. package/app/api/analyst/[id]/recommendations/[recId]/dismiss/route.ts +13 -0
  6. package/app/api/analyst/[id]/recommendations/[recId]/send/route.ts +13 -0
  7. package/app/api/analyst/[id]/review/route.ts +22 -0
  8. package/app/api/analyst/[id]/route.ts +44 -0
  9. package/app/api/analyst/route.ts +10 -0
  10. package/app/api/bus/spawn/route.ts +22 -1
  11. package/app/api/models/availability/route.ts +9 -0
  12. package/app/api/models/choices/route.ts +9 -0
  13. package/app/api/sessions/[id]/analyst/route.ts +11 -0
  14. package/app/api/sessions/[id]/crew/route.ts +38 -0
  15. package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
  16. package/app/api/sessions/[id]/export/route.test.ts +125 -0
  17. package/app/api/sessions/[id]/export/route.ts +34 -0
  18. package/app/api/sessions/[id]/model/route.ts +49 -0
  19. package/app/api/sessions/route.ts +16 -0
  20. package/app/page.tsx +5 -0
  21. package/components/Chat/Messages.tsx +104 -10
  22. package/components/Draft/DraftChips.tsx +6 -1
  23. package/components/Draft/DraftPanel.tsx +51 -2
  24. package/components/Draft/ModelUnavailableDialog.tsx +78 -0
  25. package/components/Draft/RoleChips.tsx +110 -0
  26. package/components/Export/ExportActions.tsx +136 -0
  27. package/components/Export/ExportSheet.tsx +217 -0
  28. package/components/Export/RangePicker.tsx +144 -0
  29. package/components/Export/Seg.tsx +38 -0
  30. package/components/Export/TurnSelectBar.tsx +45 -0
  31. package/components/Models/AgentRoleCard.tsx +114 -0
  32. package/components/Models/AgentRolesSection.tsx +132 -0
  33. package/components/Models/ModelBadges.tsx +48 -0
  34. package/components/Models/ModelsSection.tsx +4 -2
  35. package/components/Models/RoleModelSelect.tsx +87 -0
  36. package/components/Pane/DesktopTabBar.tsx +2 -0
  37. package/components/Pane/MobileTabBar.tsx +12 -0
  38. package/components/Pane/PaneMenu.tsx +10 -0
  39. package/components/SessionList/SessionRow.tsx +18 -7
  40. package/components/SessionList/SessionRowMenu.tsx +6 -0
  41. package/components/Sessions/RolePill.tsx +196 -0
  42. package/components/Settings/SettingsDialog.tsx +9 -0
  43. package/components/Share/ShareDialog.tsx +13 -0
  44. package/data/analyst/index.ts +3 -0
  45. package/data/analyst/keys.ts +6 -0
  46. package/data/analyst/mutations.ts +116 -0
  47. package/data/analyst/queries.ts +35 -0
  48. package/data/exports/index.ts +2 -0
  49. package/data/exports/keys.ts +3 -0
  50. package/data/exports/queries.ts +51 -0
  51. package/data/models/index.ts +98 -0
  52. package/data/push/topics.ts +5 -0
  53. package/data/sessions/queries.ts +26 -0
  54. package/lib/agents/brief.ts +1 -0
  55. package/lib/analyst/__fixtures__/items.ts +57 -0
  56. package/lib/analyst/__fixtures__/mocks.ts +49 -0
  57. package/lib/analyst/brief.ts +17 -0
  58. package/lib/analyst/buildDigest.ts +204 -0
  59. package/lib/analyst/createLink.ts +98 -0
  60. package/lib/analyst/endLink.ts +31 -0
  61. package/lib/analyst/forward.ts +109 -0
  62. package/lib/analyst/index.ts +16 -0
  63. package/lib/analyst/linkView.ts +38 -0
  64. package/lib/analyst/onTurnEnd.ts +119 -0
  65. package/lib/analyst/parseReview.ts +41 -0
  66. package/lib/analyst/pushDigest.ts +143 -0
  67. package/lib/analyst/resume.ts +35 -0
  68. package/lib/analyst/route.ts +50 -0
  69. package/lib/analyst/signals.ts +121 -0
  70. package/lib/analyst/store.ts +303 -0
  71. package/lib/analyst/types.ts +140 -0
  72. package/lib/bus/condense.ts +5 -1
  73. package/lib/bus/index.ts +5 -0
  74. package/lib/bus/read-scope.ts +12 -0
  75. package/lib/bus/role-alias.ts +70 -0
  76. package/lib/bus/targets.ts +3 -0
  77. package/lib/chat/drivers/opencode-mapper.ts +55 -1
  78. package/lib/chat/drivers/pi-approvals.ts +7 -4
  79. package/lib/chat/drivers/pi-mapper.ts +38 -0
  80. package/lib/chat/drivers/pi-text-tools.ts +104 -0
  81. package/lib/chat/drivers/pi.ts +8 -3
  82. package/lib/chat/drivers/turn-items.ts +36 -11
  83. package/lib/chat/drivers/turn-outcome.ts +108 -0
  84. package/lib/chat/events.ts +8 -0
  85. package/lib/chat/registry.ts +13 -0
  86. package/lib/chat/store.ts +19 -0
  87. package/lib/chat/turns.ts +1 -0
  88. package/lib/chat/worker/extras.ts +4 -2
  89. package/lib/claims/collectLocal.ts +3 -1
  90. package/lib/db/changes.ts +6 -0
  91. package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
  92. package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
  93. package/lib/db/session-parts/columns.ts +2 -0
  94. package/lib/db/types.ts +3 -0
  95. package/lib/done/archive.ts +2 -0
  96. package/lib/drafts.ts +4 -0
  97. package/lib/export/build.ts +470 -0
  98. package/lib/export/compact.ts +156 -0
  99. package/lib/export/estimate.ts +83 -0
  100. package/lib/export/fixtures.ts +45 -0
  101. package/lib/export/html.tsx +195 -0
  102. package/lib/export/json.ts +39 -0
  103. package/lib/export/load.ts +64 -0
  104. package/lib/export/markdown.ts +127 -0
  105. package/lib/export/request.ts +36 -0
  106. package/lib/export/service.ts +142 -0
  107. package/lib/export/summary.ts +86 -0
  108. package/lib/export/turns.ts +63 -0
  109. package/lib/export/view.ts +173 -0
  110. package/lib/models/agent-binding.ts +102 -0
  111. package/lib/models/agent-roles-view.ts +157 -0
  112. package/lib/models/agent-roles.ts +87 -0
  113. package/lib/models/availability-refs.ts +17 -0
  114. package/lib/models/availability.ts +91 -0
  115. package/lib/models/catalog.ts +156 -0
  116. package/lib/models/config.ts +27 -0
  117. package/lib/models/locality.ts +33 -0
  118. package/lib/models/providers.ts +11 -0
  119. package/lib/models/resolve.ts +50 -12
  120. package/lib/models/roles.ts +12 -0
  121. package/lib/needs-you.ts +1 -1
  122. package/lib/orchestrator/facts.ts +7 -2
  123. package/lib/orchestrator/read.ts +5 -1
  124. package/lib/orchestrator/untrusted.ts +5 -0
  125. package/lib/projects.ts +1 -0
  126. package/lib/session-artifacts/agentos-files.ts +94 -0
  127. package/lib/session-artifacts/collect.ts +17 -0
  128. package/lib/sessions/crew.ts +72 -0
  129. package/lib/sessions/launch.ts +49 -9
  130. package/lib/settings-url.ts +1 -0
  131. package/lib/share/markdown.ts +2 -2
  132. package/lib/share/redact.ts +16 -10
  133. package/lib/share/render.tsx +1 -1
  134. package/lib/share/snapshot.ts +2 -2
  135. package/lib/sidebar/row-menu.ts +6 -1
  136. package/lib/tasks/index.ts +6 -0
  137. package/lib/worktree-deps/caches.ts +3 -2
  138. package/lib/worktree-deps/clone.ts +3 -1
  139. package/lib/worktree-deps/paths.ts +26 -0
  140. package/package.json +1 -1
  141. package/server.ts +9 -1
  142. package/stores/exportUi.ts +87 -0
@@ -0,0 +1,143 @@
1
+ import type { ChatItem } from "../chat/events";
2
+ import { hasItem, itemsAfter, lastItems } from "../chat/store";
3
+ import { listQueue } from "../chat/queued";
4
+ import { sendChat } from "../chat/runner";
5
+ import { db } from "../db";
6
+ import { buildDigest } from "./buildDigest";
7
+ import { dropPendingDigest, getLink, markDigestPending } from "./store";
8
+ import type { AnalystLink } from "./types";
9
+
10
+ // How far back a first review, or a review of the same turn again, reads.
11
+ const LAST_TURN_ITEMS = 400;
12
+
13
+ export const digestPrefix = (linkId: string) => `user-analyst-${linkId}-`;
14
+
15
+ const sessionName = (id: string) =>
16
+ (
17
+ db.prepare(`SELECT name FROM sessions WHERE id = ?`).get(id) as
18
+ { name: string } | undefined
19
+ )?.name ?? "coding agent";
20
+
21
+ const lastTurnEnd = (items: ChatItem[]) =>
22
+ items.findLastIndex((i) => i.kind === "turn_end");
23
+
24
+ // The latest finished turn: from after the turn_end before it.
25
+ function lastTurn(sessionId: string): ChatItem[] {
26
+ const items = lastItems(sessionId, LAST_TURN_ITEMS);
27
+ const end = lastTurnEnd(items);
28
+ if (end < 0) return [];
29
+ const before = items
30
+ .slice(0, end)
31
+ .findLastIndex((i) => i.kind === "turn_end");
32
+ return items.slice(before + 1, end + 1);
33
+ }
34
+
35
+ // The coding agent's finished turns the analyst hasn't reviewed.
36
+ export function unreviewedTurns(link: AnalystLink): ChatItem[] {
37
+ if (!link.reviewedThrough) return lastTurn(link.coderSessionId);
38
+ const items = itemsAfter(link.coderSessionId, link.reviewedThrough);
39
+ return items.slice(0, lastTurnEnd(items) + 1);
40
+ }
41
+
42
+ // The turns a pending digest covers: after the cursor, up to its end.
43
+ function pendingTurns(link: AnalystLink): ChatItem[] {
44
+ const items = link.reviewedThrough
45
+ ? itemsAfter(link.coderSessionId, link.reviewedThrough)
46
+ : lastItems(link.coderSessionId, LAST_TURN_ITEMS);
47
+ const end = items.findIndex((i) => i.id === link.pendingThrough);
48
+ if (end < 0) return [];
49
+ if (link.reviewedThrough) return items.slice(0, end + 1);
50
+ const before = items
51
+ .slice(0, end)
52
+ .findLastIndex((i) => i.kind === "turn_end");
53
+ return items.slice(before + 1, end + 1);
54
+ }
55
+
56
+ const taken = (sessionId: string, id: string) =>
57
+ hasItem(sessionId, id) || listQueue(sessionId).some((q) => q.id === id);
58
+
59
+ // A pending digest the analyst will never answer: lost before it arrived,
60
+ // or answered by a turn whose end this server didn't see (a crash).
61
+ export function pendingIsStale(link: AnalystLink): boolean {
62
+ const id = link.pendingDigestId;
63
+ if (!id) return false;
64
+ if (!taken(link.analystSessionId, id)) return true;
65
+ // Still queued: it waits its turn.
66
+ if (!hasItem(link.analystSessionId, id)) return false;
67
+ return itemsAfter(link.analystSessionId, id, 200).some(
68
+ (i) => i.kind === "turn_end"
69
+ );
70
+ }
71
+
72
+ async function send(link: AnalystLink, id: string, items: ChatItem[]) {
73
+ const through = items[items.length - 1].id;
74
+ const digest = buildDigest(items, {
75
+ coderName: sessionName(link.coderSessionId),
76
+ through,
77
+ });
78
+ markDigestPending(link.id, id, through, digest.signals);
79
+ await sendChat(link.analystSessionId, {
80
+ text: digest.text,
81
+ id,
82
+ queue: true,
83
+ origin: {
84
+ kind: "event",
85
+ label: "Coding agent",
86
+ sessionId: link.coderSessionId,
87
+ body: digest.label,
88
+ },
89
+ });
90
+ }
91
+
92
+ export type PushResult =
93
+ "sent" | "nothing" | "paused" | "busy" | "ended" | "interrupted";
94
+
95
+ /**
96
+ * Sends the analyst the coding agent's turns since it last reviewed, once:
97
+ * turns that end while it is still on a digest wait for the next one, and
98
+ * a turn the user stopped rides along with the turn after it. `force`
99
+ * (Review now) sends even when paused, busy or stopped, and with nothing
100
+ * new reviews the last turn again.
101
+ */
102
+ export async function pushDigest(
103
+ linkId: string,
104
+ opts: { force?: boolean } = {}
105
+ ): Promise<PushResult> {
106
+ let link = getLink(linkId);
107
+ if (!link || link.endedAt) return "ended";
108
+ if (link.pendingDigestId && (opts.force || pendingIsStale(link))) {
109
+ dropPendingDigest(link.id);
110
+ link = getLink(linkId)!;
111
+ }
112
+ if (!opts.force) {
113
+ if (link.paused) return "paused";
114
+ if (link.pendingDigestId) return "busy";
115
+ }
116
+ const items = unreviewedTurns(link);
117
+ const end = items[items.length - 1];
118
+ if (end?.kind === "turn_end") {
119
+ if (end.interrupted && !opts.force) return "interrupted";
120
+ await send(link, `${digestPrefix(link.id)}${end.id}`, items);
121
+ return "sent";
122
+ }
123
+ if (!opts.force) return "nothing";
124
+ const again = lastTurn(link.coderSessionId);
125
+ if (!again.length) return "nothing";
126
+ await send(link, `${digestPrefix(link.id)}again-${Date.now()}`, again);
127
+ return "sent";
128
+ }
129
+
130
+ // After a restart: a digest marked pending is sent again under its id (a
131
+ // no-op when it landed).
132
+ export async function resendPending(linkId: string): Promise<boolean> {
133
+ const link = getLink(linkId);
134
+ if (!link?.pendingDigestId || link.endedAt) return false;
135
+ if (taken(link.analystSessionId, link.pendingDigestId)) return false;
136
+ const items = pendingTurns(link);
137
+ if (!items.length) {
138
+ dropPendingDigest(link.id);
139
+ return false;
140
+ }
141
+ await send(link, link.pendingDigestId, items);
142
+ return true;
143
+ }
@@ -0,0 +1,35 @@
1
+ import { onTurnEnd } from "./onTurnEnd";
2
+ import { pushDigest, resendPending } from "./pushDigest";
3
+ import { liveLinks } from "./store";
4
+ import type { ChatItem } from "../chat/events";
5
+ import { FLUSH_MS } from "../chat/store";
6
+
7
+ // The worker writes a turn's items a moment after reporting its end.
8
+ export const SETTLE_MS = 4 * FLUSH_MS;
9
+
10
+ // The chat registry's hook for every finished turn: read once the items
11
+ // have landed. Never throws into the chat path.
12
+ export function analystTurnEndHook(
13
+ sessionId: string,
14
+ item: Extract<ChatItem, { kind: "turn_end" }>
15
+ ): void {
16
+ setTimeout(() => {
17
+ onTurnEnd(sessionId, item).catch((error) =>
18
+ console.error("[analyst] turn end:", error)
19
+ );
20
+ }, SETTLE_MS);
21
+ }
22
+
23
+ // After a restart: digests marked pending go again under their ids (a
24
+ // no-op where they landed), and turns that ended while the server was down
25
+ // are sent.
26
+ export async function resumeAnalystLinks(): Promise<void> {
27
+ for (const link of liveLinks()) {
28
+ try {
29
+ if (link.pendingDigestId) await resendPending(link.id);
30
+ else await pushDigest(link.id);
31
+ } catch (error) {
32
+ console.error(`[analyst] resuming ${link.id}:`, error);
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,50 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { z } from "zod";
3
+ import { ModelUnavailableError } from "../models/agent-binding";
4
+ import { AnalystError } from "./types";
5
+
6
+ // An analyst action for a route: a body that fails its schema is a 400, an
7
+ // AnalystError its status, a role model that's down a 409 with its fallback.
8
+ export async function analystRoute<S extends z.ZodType>(
9
+ request: Request,
10
+ schema: S,
11
+ act: (body: z.infer<S>) => unknown
12
+ ): Promise<NextResponse> {
13
+ const raw = await request.text().catch(() => "");
14
+ let json: unknown = {};
15
+ try {
16
+ json = raw.trim() ? JSON.parse(raw) : {};
17
+ } catch {
18
+ return NextResponse.json({ error: "Bad JSON" }, { status: 400 });
19
+ }
20
+ const parsed = schema.safeParse(json);
21
+ if (!parsed.success)
22
+ return NextResponse.json(
23
+ { error: parsed.error.issues[0]?.message ?? "Bad request" },
24
+ { status: 400 }
25
+ );
26
+ return run(() => act(parsed.data));
27
+ }
28
+
29
+ export async function run(act: () => unknown): Promise<NextResponse> {
30
+ try {
31
+ return NextResponse.json(await act());
32
+ } catch (err) {
33
+ if (err instanceof AnalystError)
34
+ return NextResponse.json({ error: err.message }, { status: err.status });
35
+ if (err instanceof ModelUnavailableError)
36
+ return NextResponse.json(
37
+ {
38
+ code: err.code,
39
+ role: err.role,
40
+ ref: err.ref,
41
+ fallback: err.fallback ?? null,
42
+ error: err.message,
43
+ },
44
+ { status: 409 }
45
+ );
46
+ const message = err instanceof Error ? err.message : String(err);
47
+ console.error("[analyst]", message);
48
+ return NextResponse.json({ error: message }, { status: 400 });
49
+ }
50
+ }
@@ -0,0 +1,121 @@
1
+ import type { ChatItem } from "../chat/events";
2
+ import type { DigestSignals } from "./types";
3
+
4
+ type Tool = Extract<ChatItem, { kind: "tool" }>;
5
+
6
+ // A command that checks the work: tests, a build, a typecheck, a lint.
7
+ const CHECK =
8
+ /\b(?:vitest|jest|pytest|mocha|playwright|rspec|phpunit|tsc|eslint|ruff|mypy|clippy|go\s+(?:test|vet|build)|cargo\s+(?:test|build|check)|swift\s+(?:test|build)|xcodebuild|gradle\w*\s+(?:test|build)|mvn\s+(?:test|verify)|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|typecheck|lint|check)|make\s+(?:test|check|build)|scripts\/check|rails\s+test|rake\s+test)\b/;
9
+
10
+ const WEB_TOOLS = new Set(["WebFetch", "WebSearch"]);
11
+ const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
12
+ // A curl or wget to anything but this machine.
13
+ const REMOTE_FETCH =
14
+ /\b(?:curl|wget)\b[^|;&]*\bhttps?:\/\/(?!localhost\b|127\.0\.0\.1\b|\[::1\])/;
15
+
16
+ const input = (t: Tool) =>
17
+ (t.input && typeof t.input === "object" ? t.input : {}) as Record<
18
+ string,
19
+ unknown
20
+ >;
21
+
22
+ export const commandOf = (t: Tool): string | null =>
23
+ t.name === "Bash" && typeof input(t).command === "string"
24
+ ? (input(t).command as string)
25
+ : null;
26
+
27
+ export const isCheckCommand = (command: string) => CHECK.test(command);
28
+
29
+ function pathOf(t: Tool): string | null {
30
+ if (t.diff?.path) return t.diff.path;
31
+ if (!EDIT_TOOLS.has(t.name)) return null;
32
+ const i = input(t);
33
+ const p = i.file_path ?? i.notebook_path;
34
+ return typeof p === "string" ? p : null;
35
+ }
36
+
37
+ // What the turns did, counted from their items: the same answer whatever
38
+ // the analyst writes about them.
39
+ export function turnSignals(items: ChatItem[]): DigestSignals {
40
+ const s: DigestSignals = {
41
+ turns: 0,
42
+ toolCalls: 0,
43
+ failedTools: 0,
44
+ checks: [],
45
+ filesChanged: [],
46
+ durationMs: 0,
47
+ costUsd: 0,
48
+ interrupted: false,
49
+ errors: 0,
50
+ noToolTurns: 0,
51
+ readWeb: false,
52
+ };
53
+ const files = new Set<string>();
54
+ let toolsThisTurn = 0;
55
+ for (const item of items) {
56
+ if (item.kind === "tool") {
57
+ s.toolCalls++;
58
+ toolsThisTurn++;
59
+ if (item.status === "error") s.failedTools++;
60
+ const command = commandOf(item);
61
+ if (command && isCheckCommand(command) && item.status !== "running")
62
+ s.checks.push({
63
+ command: command.trim().split("\n")[0].slice(0, 200),
64
+ passed: item.status === "done",
65
+ });
66
+ if (
67
+ WEB_TOOLS.has(item.name) ||
68
+ item.name.startsWith("mcp__") ||
69
+ (command && REMOTE_FETCH.test(command))
70
+ )
71
+ s.readWeb = true;
72
+ const path = item.status !== "error" ? pathOf(item) : null;
73
+ if (path) files.add(path);
74
+ } else if (item.kind === "error") s.errors++;
75
+ else if (item.kind === "turn_end") {
76
+ s.turns++;
77
+ s.durationMs += item.durationMs ?? 0;
78
+ s.costUsd += item.costUsd ?? 0;
79
+ if (item.interrupted) s.interrupted = true;
80
+ if (!toolsThisTurn && !item.interrupted) s.noToolTurns++;
81
+ toolsThisTurn = 0;
82
+ }
83
+ }
84
+ s.filesChanged = [...files];
85
+ return s;
86
+ }
87
+
88
+ // The last check of each command decides it: a test that failed and then
89
+ // passed counts as passing.
90
+ export function latestChecks(s: DigestSignals) {
91
+ const by = new Map<string, boolean>();
92
+ for (const c of s.checks) by.set(c.command, c.passed);
93
+ return [...by].map(([command, passed]) => ({ command, passed }));
94
+ }
95
+
96
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
97
+
98
+ // One line heading the digest, and the chips the card shows.
99
+ export function signalLine(s: DigestSignals): string {
100
+ const checks = latestChecks(s);
101
+ const failed = checks.filter((c) => !c.passed);
102
+ const parts = [
103
+ plural(s.toolCalls, "tool call"),
104
+ s.failedTools ? `${s.failedTools} failed` : "",
105
+ checks.length
106
+ ? failed.length
107
+ ? `checks FAILED (${failed.map((c) => c.command).join("; ")})`
108
+ : `checks passed (${checks.length})`
109
+ : "no checks run",
110
+ s.filesChanged.length
111
+ ? plural(s.filesChanged.length, "file") + " changed"
112
+ : "",
113
+ s.noToolTurns ? `${plural(s.noToolTurns, "turn")} with no tool calls` : "",
114
+ s.interrupted ? "stopped by the user" : "",
115
+ s.errors ? plural(s.errors, "error") : "",
116
+ s.readWeb ? "read web content" : "",
117
+ s.durationMs ? `${Math.round(s.durationMs / 1000)}s` : "",
118
+ s.costUsd ? `$${s.costUsd.toFixed(2)}` : "",
119
+ ];
120
+ return parts.filter(Boolean).join(" · ");
121
+ }
@@ -0,0 +1,303 @@
1
+ import { randomUUID } from "crypto";
2
+ import { db } from "../db";
3
+ import {
4
+ AnalystError,
5
+ type AnalystLink,
6
+ type DigestSignals,
7
+ type Issue,
8
+ type Recommendation,
9
+ type RecommendationState,
10
+ type Verdict,
11
+ } from "./types";
12
+
13
+ interface LinkRow {
14
+ id: string;
15
+ coder_session_id: string;
16
+ analyst_session_id: string;
17
+ paused: number;
18
+ reviewed_through: string | null;
19
+ pending_digest_id: string | null;
20
+ pending_through: string | null;
21
+ digest_signals: string | null;
22
+ created_at: number;
23
+ ended_at: number | null;
24
+ }
25
+
26
+ interface RecRow {
27
+ id: string;
28
+ link_id: string;
29
+ analyst_item_id: string;
30
+ coder_through: string | null;
31
+ verdict: string | null;
32
+ summary: string;
33
+ issues: string;
34
+ signals: string | null;
35
+ prompt: string | null;
36
+ state: string;
37
+ error: string | null;
38
+ sent_text: string | null;
39
+ sent_item_id: string | null;
40
+ edited: number;
41
+ created_at: number;
42
+ decided_at: number | null;
43
+ }
44
+
45
+ const parse = <T>(s: string | null, fallback: T): T => {
46
+ if (!s) return fallback;
47
+ try {
48
+ return JSON.parse(s) as T;
49
+ } catch {
50
+ return fallback;
51
+ }
52
+ };
53
+
54
+ const toLink = (r: LinkRow): AnalystLink => ({
55
+ id: r.id,
56
+ coderSessionId: r.coder_session_id,
57
+ analystSessionId: r.analyst_session_id,
58
+ paused: !!r.paused,
59
+ reviewedThrough: r.reviewed_through,
60
+ pendingDigestId: r.pending_digest_id,
61
+ pendingThrough: r.pending_through,
62
+ digestSignals: parse<DigestSignals | null>(r.digest_signals, null),
63
+ createdAt: r.created_at,
64
+ endedAt: r.ended_at,
65
+ });
66
+
67
+ const toRec = (r: RecRow): Recommendation => ({
68
+ id: r.id,
69
+ linkId: r.link_id,
70
+ analystItemId: r.analyst_item_id,
71
+ coderThrough: r.coder_through,
72
+ verdict: r.verdict as Verdict | null,
73
+ summary: r.summary,
74
+ issues: parse<Issue[]>(r.issues, []),
75
+ signals: parse<DigestSignals | null>(r.signals, null),
76
+ prompt: r.prompt,
77
+ state: r.state as RecommendationState,
78
+ error: r.error,
79
+ sentText: r.sent_text,
80
+ sentItemId: r.sent_item_id,
81
+ edited: !!r.edited,
82
+ createdAt: r.created_at,
83
+ decidedAt: r.decided_at,
84
+ });
85
+
86
+ export function getLink(id: string): AnalystLink | null {
87
+ const r = db.prepare(`SELECT * FROM analyst_links WHERE id = ?`).get(id) as
88
+ LinkRow | undefined;
89
+ return r ? toLink(r) : null;
90
+ }
91
+
92
+ export function getLinkOrThrow(id: string): AnalystLink {
93
+ const link = getLink(id);
94
+ if (!link) throw new AnalystError("Pairing not found", 404);
95
+ return link;
96
+ }
97
+
98
+ // The live link a session is in, on either side.
99
+ export function liveLinkFor(sessionId: string): AnalystLink | null {
100
+ const r = db
101
+ .prepare(
102
+ `SELECT * FROM analyst_links WHERE ended_at IS NULL
103
+ AND (coder_session_id = ? OR analyst_session_id = ?)`
104
+ )
105
+ .get(sessionId, sessionId) as LinkRow | undefined;
106
+ return r ? toLink(r) : null;
107
+ }
108
+
109
+ // Any link, live or ended, the session was the analyst of.
110
+ export function linkOfAnalyst(sessionId: string): AnalystLink | null {
111
+ const r = db
112
+ .prepare(`SELECT * FROM analyst_links WHERE analyst_session_id = ?`)
113
+ .get(sessionId) as LinkRow | undefined;
114
+ return r ? toLink(r) : null;
115
+ }
116
+
117
+ export function liveLinks(): AnalystLink[] {
118
+ return (
119
+ db
120
+ .prepare(`SELECT * FROM analyst_links WHERE ended_at IS NULL`)
121
+ .all() as LinkRow[]
122
+ ).map(toLink);
123
+ }
124
+
125
+ export function insertLink(
126
+ coderSessionId: string,
127
+ analystSessionId: string,
128
+ reviewedThrough: string | null
129
+ ): AnalystLink {
130
+ const id = randomUUID();
131
+ db.prepare(
132
+ `INSERT INTO analyst_links (id, coder_session_id, analyst_session_id,
133
+ reviewed_through, created_at) VALUES (?, ?, ?, ?, ?)`
134
+ ).run(id, coderSessionId, analystSessionId, reviewedThrough, Date.now());
135
+ return getLink(id)!;
136
+ }
137
+
138
+ export function setPausedRow(id: string, paused: boolean): void {
139
+ db.prepare(`UPDATE analyst_links SET paused = ? WHERE id = ?`).run(
140
+ paused ? 1 : 0,
141
+ id
142
+ );
143
+ }
144
+
145
+ export function endLinkRow(id: string): boolean {
146
+ return (
147
+ db
148
+ .prepare(
149
+ `UPDATE analyst_links SET ended_at = ? WHERE id = ? AND ended_at IS NULL`
150
+ )
151
+ .run(Date.now(), id).changes > 0
152
+ );
153
+ }
154
+
155
+ // Recorded before the digest is sent, so a restart can send it again.
156
+ export function markDigestPending(
157
+ id: string,
158
+ digestId: string,
159
+ through: string,
160
+ signals: DigestSignals
161
+ ): void {
162
+ db.prepare(
163
+ `UPDATE analyst_links SET pending_digest_id = ?, pending_through = ?,
164
+ digest_signals = ? WHERE id = ?`
165
+ ).run(digestId, through, JSON.stringify(signals), id);
166
+ }
167
+
168
+ // The analyst answered the digest: its turns count as reviewed.
169
+ export function settleDigest(id: string, digestId: string): boolean {
170
+ return (
171
+ db
172
+ .prepare(
173
+ `UPDATE analyst_links SET reviewed_through = pending_through,
174
+ pending_digest_id = NULL, pending_through = NULL
175
+ WHERE id = ? AND pending_digest_id = ?`
176
+ )
177
+ .run(id, digestId).changes > 0
178
+ );
179
+ }
180
+
181
+ // A digest that never arrived: dropped, so the next one covers its turns.
182
+ export function dropPendingDigest(id: string): void {
183
+ db.prepare(
184
+ `UPDATE analyst_links SET pending_digest_id = NULL, pending_through = NULL
185
+ WHERE id = ?`
186
+ ).run(id);
187
+ }
188
+
189
+ export function listRecommendations(
190
+ linkId: string,
191
+ limit = 20
192
+ ): Recommendation[] {
193
+ return (
194
+ db
195
+ .prepare(
196
+ `SELECT * FROM analyst_recommendations WHERE link_id = ?
197
+ ORDER BY created_at DESC, rowid DESC LIMIT ?`
198
+ )
199
+ .all(linkId, limit) as RecRow[]
200
+ ).map(toRec);
201
+ }
202
+
203
+ export function getRecommendation(id: string): Recommendation | null {
204
+ const r = db
205
+ .prepare(`SELECT * FROM analyst_recommendations WHERE id = ?`)
206
+ .get(id) as RecRow | undefined;
207
+ return r ? toRec(r) : null;
208
+ }
209
+
210
+ export function recommendationFor(
211
+ linkId: string,
212
+ analystItemId: string
213
+ ): Recommendation | null {
214
+ const r = db
215
+ .prepare(
216
+ `SELECT * FROM analyst_recommendations WHERE link_id = ? AND analyst_item_id = ?`
217
+ )
218
+ .get(linkId, analystItemId) as RecRow | undefined;
219
+ return r ? toRec(r) : null;
220
+ }
221
+
222
+ export interface NewRecommendation {
223
+ linkId: string;
224
+ analystItemId: string;
225
+ coderThrough: string | null;
226
+ verdict: Verdict | null;
227
+ summary: string;
228
+ issues: Issue[];
229
+ signals: DigestSignals | null;
230
+ prompt: string | null;
231
+ state: "open" | "invalid";
232
+ error?: string | null;
233
+ }
234
+
235
+ // Once per analyst reply: a repeat is a no-op. A new one retires the
236
+ // cards still open, so only one is ever actionable.
237
+ export function insertRecommendation(
238
+ r: NewRecommendation
239
+ ): Recommendation | null {
240
+ const id = randomUUID();
241
+ const inserted = db.transaction(() => {
242
+ const changes = db
243
+ .prepare(
244
+ `INSERT OR IGNORE INTO analyst_recommendations (id, link_id,
245
+ analyst_item_id, coder_through, verdict, summary, issues, signals,
246
+ prompt, state, error, created_at)
247
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
248
+ )
249
+ .run(
250
+ id,
251
+ r.linkId,
252
+ r.analystItemId,
253
+ r.coderThrough,
254
+ r.verdict,
255
+ r.summary,
256
+ JSON.stringify(r.issues),
257
+ r.signals ? JSON.stringify(r.signals) : null,
258
+ r.prompt,
259
+ r.state,
260
+ r.error ?? null,
261
+ Date.now()
262
+ ).changes;
263
+ if (!changes) return false;
264
+ db.prepare(
265
+ `UPDATE analyst_recommendations SET state = 'superseded', decided_at = ?
266
+ WHERE link_id = ? AND id != ? AND state IN ('open', 'invalid')`
267
+ ).run(Date.now(), r.linkId, id);
268
+ return true;
269
+ })();
270
+ return inserted ? getRecommendation(id) : null;
271
+ }
272
+
273
+ // Moves an open card to its decision, once: false when it was decided.
274
+ export function decideRecommendation(
275
+ id: string,
276
+ state: "sent" | "dismissed",
277
+ sent?: { text: string; itemId: string; edited: boolean }
278
+ ): boolean {
279
+ return (
280
+ db
281
+ .prepare(
282
+ `UPDATE analyst_recommendations SET state = ?, decided_at = ?,
283
+ sent_text = ?, sent_item_id = ?, edited = ?
284
+ WHERE id = ? AND state IN ('open', 'invalid')`
285
+ )
286
+ .run(
287
+ state,
288
+ Date.now(),
289
+ sent?.text ?? null,
290
+ sent?.itemId ?? null,
291
+ sent?.edited ? 1 : 0,
292
+ id
293
+ ).changes > 0
294
+ );
295
+ }
296
+
297
+ // Back to open: a send that failed after the card was claimed.
298
+ export function reopenRecommendation(id: string): void {
299
+ db.prepare(
300
+ `UPDATE analyst_recommendations SET state = 'open', decided_at = NULL,
301
+ sent_text = NULL, sent_item_id = NULL, edited = 0 WHERE id = ?`
302
+ ).run(id);
303
+ }