@saadnvd1/agent-os 0.3.700 → 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.
- package/README.md +2 -0
- package/app/api/agent-roles/route.test.ts +67 -0
- package/app/api/agent-roles/route.ts +61 -0
- package/app/api/analyst/[id]/recommendations/[recId]/again/route.ts +15 -0
- package/app/api/analyst/[id]/recommendations/[recId]/dismiss/route.ts +13 -0
- package/app/api/analyst/[id]/recommendations/[recId]/send/route.ts +13 -0
- package/app/api/analyst/[id]/review/route.ts +22 -0
- package/app/api/analyst/[id]/route.ts +44 -0
- package/app/api/analyst/route.ts +10 -0
- package/app/api/bus/groups/route.ts +44 -0
- package/app/api/bus/spawn/route.ts +34 -2
- package/app/api/claims/[id]/route.ts +20 -0
- package/app/api/claims/check/route.ts +23 -0
- package/app/api/claims/check.test.ts +113 -0
- package/app/api/claims/exchange/route.ts +42 -0
- package/app/api/claims/outside.test.ts +90 -0
- package/app/api/claims/route.test.ts +193 -0
- package/app/api/claims/route.ts +73 -0
- package/app/api/hosts/[id]/route.ts +9 -1
- package/app/api/models/availability/route.ts +9 -0
- package/app/api/models/choices/route.ts +9 -0
- package/app/api/peer/unlink/route.ts +17 -0
- package/app/api/peers/[hostId]/audit/route.ts +14 -0
- package/app/api/peers/[hostId]/route.ts +20 -0
- package/app/api/peers/gate.test.ts +64 -1
- package/app/api/peers/pending/route.test.ts +37 -0
- package/app/api/peers/pending/route.ts +11 -4
- package/app/api/session-groups/[id]/restore/route.ts +11 -0
- package/app/api/session-groups/[id]/route.ts +25 -0
- package/app/api/session-groups/order/route.ts +15 -0
- package/app/api/session-groups/route.ts +21 -0
- package/app/api/sessions/[id]/analyst/route.ts +11 -0
- package/app/api/sessions/[id]/crew/route.ts +38 -0
- package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
- package/app/api/sessions/[id]/export/route.test.ts +125 -0
- package/app/api/sessions/[id]/export/route.ts +34 -0
- package/app/api/sessions/[id]/fork/route.ts +3 -0
- package/app/api/sessions/[id]/group/route.ts +18 -0
- package/app/api/sessions/[id]/model/route.ts +49 -0
- package/app/api/sessions/[id]/route.ts +14 -4
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/sessions/claims.test.ts +122 -0
- package/app/api/sessions/group/route.ts +17 -0
- package/app/api/sessions/route.ts +31 -14
- package/app/page.tsx +10 -0
- package/components/Chat/Messages.tsx +104 -10
- package/components/Claims/ClaimFiles.tsx +38 -0
- package/components/Claims/ClaimRow.tsx +82 -0
- package/components/Claims/ClaimsSheet.tsx +176 -0
- package/components/Claims/OverlapPill.tsx +31 -0
- package/components/Claims/StartClaimsNotice.tsx +70 -0
- package/components/Claims/claims-ui.test.ts +161 -0
- package/components/Claims/format.ts +57 -0
- package/components/Claims/index.ts +3 -0
- package/components/Draft/DraftChips.tsx +6 -1
- package/components/Draft/DraftPanel.tsx +90 -3
- package/components/Draft/ModelUnavailableDialog.tsx +78 -0
- package/components/Draft/RoleChips.tsx +110 -0
- package/components/Export/ExportActions.tsx +136 -0
- package/components/Export/ExportSheet.tsx +217 -0
- package/components/Export/RangePicker.tsx +144 -0
- package/components/Export/Seg.tsx +38 -0
- package/components/Export/TurnSelectBar.tsx +45 -0
- package/components/Hosts/AccountMachines.tsx +32 -18
- package/components/Hosts/HostRow.tsx +155 -14
- package/components/Hosts/IncomingLinkSheet.tsx +28 -10
- package/components/Hosts/PeerActivitySheet.tsx +111 -0
- package/components/Models/AgentRoleCard.tsx +114 -0
- package/components/Models/AgentRolesSection.tsx +132 -0
- package/components/Models/ModelBadges.tsx +48 -0
- package/components/Models/ModelsSection.tsx +4 -2
- package/components/Models/RoleModelSelect.tsx +87 -0
- package/components/Pane/DesktopTabBar.tsx +2 -0
- package/components/Pane/MobileTabBar.tsx +12 -0
- package/components/Pane/PaneMenu.tsx +10 -0
- package/components/SessionList/GroupShelves.tsx +195 -0
- package/components/SessionList/MoveToGroupMenu.tsx +105 -0
- package/components/SessionList/RowSubtitle.tsx +7 -1
- package/components/SessionList/SelectionToolbar.tsx +52 -1
- package/components/SessionList/SessionRow.tsx +19 -7
- package/components/SessionList/SessionRowMenu.tsx +23 -0
- package/components/SessionList/SessionShelves.tsx +5 -0
- package/components/SessionList/SidebarBody.tsx +6 -0
- package/components/SessionList/SidebarContextMenu.test.ts +104 -0
- package/components/SessionList/SidebarContextMenu.tsx +59 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
- package/components/SessionList/index.tsx +18 -14
- package/components/SessionList/useSidebarData.ts +10 -0
- package/components/Sessions/RolePill.tsx +196 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Share/ShareDialog.tsx +13 -0
- package/components/ui/notice.tsx +46 -0
- package/components/ui/status-pill.tsx +39 -0
- package/data/analyst/index.ts +3 -0
- package/data/analyst/keys.ts +6 -0
- package/data/analyst/mutations.ts +116 -0
- package/data/analyst/queries.ts +35 -0
- package/data/claims/index.ts +2 -0
- package/data/claims/keys.ts +5 -0
- package/data/claims/queries.ts +47 -0
- package/data/exports/index.ts +2 -0
- package/data/exports/keys.ts +3 -0
- package/data/exports/queries.ts +51 -0
- package/data/models/index.ts +98 -0
- package/data/peers/keys.ts +1 -0
- package/data/peers/queries.ts +31 -0
- package/data/push/topics.ts +9 -0
- package/data/session-groups/index.ts +156 -0
- package/data/sessions/index.ts +0 -1
- package/data/sessions/pin.ts +2 -6
- package/data/sessions/queries.ts +36 -29
- package/data/sessions/shelf.ts +2 -6
- package/data/tasks/index.ts +2 -0
- package/hooks/useSessions.ts +0 -12
- package/lib/__fixtures__/peer-rpc.ts +65 -33
- package/lib/agents/brief.ts +1 -0
- package/lib/agents/spawn.ts +4 -0
- package/lib/analyst/__fixtures__/items.ts +57 -0
- package/lib/analyst/__fixtures__/mocks.ts +49 -0
- package/lib/analyst/brief.ts +17 -0
- package/lib/analyst/buildDigest.ts +204 -0
- package/lib/analyst/createLink.ts +98 -0
- package/lib/analyst/endLink.ts +31 -0
- package/lib/analyst/forward.ts +109 -0
- package/lib/analyst/index.ts +16 -0
- package/lib/analyst/linkView.ts +38 -0
- package/lib/analyst/onTurnEnd.ts +119 -0
- package/lib/analyst/parseReview.ts +41 -0
- package/lib/analyst/pushDigest.ts +143 -0
- package/lib/analyst/resume.ts +35 -0
- package/lib/analyst/route.ts +50 -0
- package/lib/analyst/signals.ts +121 -0
- package/lib/analyst/store.ts +303 -0
- package/lib/analyst/types.ts +140 -0
- package/lib/bus/condense.ts +5 -1
- package/lib/bus/index.ts +13 -0
- package/lib/bus/read-scope.ts +12 -0
- package/lib/bus/role-alias.ts +70 -0
- package/lib/bus/targets.ts +3 -0
- package/lib/chat/drivers/opencode-mapper.ts +55 -1
- package/lib/chat/drivers/pi-approvals.ts +7 -4
- package/lib/chat/drivers/pi-mapper.ts +38 -0
- package/lib/chat/drivers/pi-text-tools.ts +104 -0
- package/lib/chat/drivers/pi.ts +8 -3
- package/lib/chat/drivers/turn-items.ts +36 -11
- package/lib/chat/drivers/turn-outcome.ts +108 -0
- package/lib/chat/events.ts +8 -0
- package/lib/chat/registry.ts +13 -0
- package/lib/chat/store.ts +19 -0
- package/lib/chat/turns.ts +1 -0
- package/lib/chat/worker/extras.ts +4 -2
- package/lib/claims/collectLocal.ts +123 -0
- package/lib/claims/describe.ts +41 -0
- package/lib/claims/exchange.ts +91 -0
- package/lib/claims/goal.ts +45 -0
- package/lib/claims/index.ts +10 -0
- package/lib/claims/loop.ts +51 -0
- package/lib/claims/machine.ts +21 -0
- package/lib/claims/mine.ts +43 -0
- package/lib/claims/notifyOverlaps.ts +101 -0
- package/lib/claims/onMerged.ts +85 -0
- package/lib/claims/outside.ts +36 -0
- package/lib/claims/overlap.ts +87 -0
- package/lib/claims/overlapCache.ts +86 -0
- package/lib/claims/overlapText.ts +36 -0
- package/lib/claims/peers.ts +56 -0
- package/lib/claims/startCheck.ts +100 -0
- package/lib/claims/startWarning.ts +29 -0
- package/lib/claims/store.ts +366 -0
- package/lib/claims/types.ts +102 -0
- package/lib/claims/view.ts +80 -0
- package/lib/claims/yield.ts +24 -0
- package/lib/db/changes.ts +12 -0
- package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
- package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
- package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
- package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
- package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
- package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
- package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
- package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
- package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
- package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
- package/lib/db/queries.ts +0 -40
- package/lib/db/session-parts/columns.ts +3 -0
- package/lib/db/types.ts +19 -8
- package/lib/doctor/checks.ts +64 -1
- package/lib/done/archive.ts +2 -0
- package/lib/drafts.ts +4 -0
- package/lib/export/build.ts +470 -0
- package/lib/export/compact.ts +156 -0
- package/lib/export/estimate.ts +83 -0
- package/lib/export/fixtures.ts +45 -0
- package/lib/export/html.tsx +195 -0
- package/lib/export/json.ts +39 -0
- package/lib/export/load.ts +64 -0
- package/lib/export/markdown.ts +127 -0
- package/lib/export/request.ts +36 -0
- package/lib/export/service.ts +142 -0
- package/lib/export/summary.ts +86 -0
- package/lib/export/turns.ts +63 -0
- package/lib/export/view.ts +173 -0
- package/lib/git-remote.ts +43 -0
- package/lib/hosts/index.ts +10 -1
- package/lib/models/agent-binding.ts +102 -0
- package/lib/models/agent-roles-view.ts +157 -0
- package/lib/models/agent-roles.ts +87 -0
- package/lib/models/availability-refs.ts +17 -0
- package/lib/models/availability.ts +91 -0
- package/lib/models/catalog.ts +156 -0
- package/lib/models/config.ts +27 -0
- package/lib/models/locality.ts +33 -0
- package/lib/models/providers.ts +11 -0
- package/lib/models/resolve.ts +50 -12
- package/lib/models/roles.ts +12 -0
- package/lib/needs-you.ts +1 -1
- package/lib/orchestration.ts +2 -0
- package/lib/orchestrator/act.ts +54 -4
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/claim-wait.ts +71 -0
- package/lib/orchestrator/conditions.ts +28 -1
- package/lib/orchestrator/describe.ts +6 -1
- package/lib/orchestrator/facts.ts +12 -2
- package/lib/orchestrator/read.ts +5 -1
- package/lib/orchestrator/serve.ts +37 -2
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +31 -1
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/orchestrator/untrusted.ts +5 -0
- package/lib/orchestrator/watcher.ts +19 -1
- package/lib/peer/account.ts +74 -2
- package/lib/peer/allowlist.ts +1 -1
- package/lib/peer/client.ts +26 -1
- package/lib/peer/link-cli.ts +19 -4
- package/lib/peer/links.ts +117 -5
- package/lib/peer/machines.ts +14 -2
- package/lib/peer/name.ts +32 -0
- package/lib/peer/pairing.ts +34 -5
- package/lib/peer/pin.ts +10 -3
- package/lib/peer/reasons.ts +7 -0
- package/lib/peer/revoke.ts +75 -0
- package/lib/peer/rpc-server.ts +53 -5
- package/lib/peer/unlink.ts +62 -0
- package/lib/peer/unlinked-proof.ts +44 -0
- package/lib/projects.ts +6 -2
- package/lib/security/demo.ts +7 -0
- package/lib/session-artifacts/agentos-files.ts +94 -0
- package/lib/session-artifacts/collect.ts +17 -0
- package/lib/session-groups/assignSessions.ts +81 -0
- package/lib/session-groups/cli.ts +61 -0
- package/lib/session-groups/createGroup.ts +28 -0
- package/lib/session-groups/index.ts +21 -0
- package/lib/session-groups/removeGroup.ts +36 -0
- package/lib/session-groups/reorderGroups.ts +31 -0
- package/lib/session-groups/resolveGroup.ts +19 -0
- package/lib/session-groups/route.ts +32 -0
- package/lib/session-groups/store.ts +85 -0
- package/lib/session-groups/types.ts +25 -0
- package/lib/session-groups/updateGroup.ts +35 -0
- package/lib/session-groups/workspace.ts +21 -0
- package/lib/sessions/crew.ts +72 -0
- package/lib/sessions/launch.ts +54 -11
- package/lib/settings-url.ts +1 -0
- package/lib/share/markdown.ts +2 -2
- package/lib/share/redact.ts +16 -10
- package/lib/share/render.tsx +1 -1
- package/lib/share/snapshot.ts +2 -2
- package/lib/sidebar/rollup.ts +1 -0
- package/lib/sidebar/row-menu.ts +8 -1
- package/lib/sidebar/shelves.ts +90 -15
- package/lib/sidebar/visible-order.ts +5 -0
- package/lib/tasks/hold-checks.ts +3 -1
- package/lib/tasks/holds.ts +3 -0
- package/lib/tasks/index.ts +11 -0
- package/lib/tasks/queue-rows.ts +8 -1
- package/lib/tasks/queue-start.ts +1 -0
- package/lib/tasks/queue-types.ts +9 -1
- package/lib/tasks/queue.ts +15 -5
- package/lib/tasks/schedule-types.ts +1 -0
- package/lib/test-temp.ts +19 -3
- package/lib/workspaces.ts +11 -3
- package/lib/worktree-deps/caches.ts +3 -2
- package/lib/worktree-deps/clone.ts +3 -1
- package/lib/worktree-deps/paths.ts +26 -0
- package/lib/worktree-sweep/testTemp.ts +23 -106
- package/package.json +1 -1
- package/scripts/agent-os +1 -1
- package/scripts/autodeploy +2 -0
- package/scripts/autodeploy.test.ts +31 -1
- package/scripts/claim-hook.sh +31 -0
- package/scripts/claim-hook.test.ts +230 -0
- package/scripts/lib/cli-links.sh +36 -0
- package/scripts/lib/commands.sh +3 -0
- package/scripts/lib/machine.sh +27 -2
- package/scripts/lib/machine.test.ts +25 -3
- package/scripts/sync-cli-links +7 -0
- package/scripts/sync-cli-links.test.ts +69 -0
- package/server.ts +12 -1
- package/stores/claimsUi.ts +22 -0
- package/stores/exportUi.ts +87 -0
- package/app/api/groups/[...path]/route.ts +0 -136
- package/app/api/groups/route.ts +0 -93
- package/data/groups/index.ts +0 -1
- package/data/groups/mutations.ts +0 -95
- package/hooks/useGroups.ts +0 -37
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { sendChat } from "../chat/runner";
|
|
2
|
+
import { ASK_AGAIN } from "./brief";
|
|
3
|
+
import { digestPrefix } from "./pushDigest";
|
|
4
|
+
import {
|
|
5
|
+
decideRecommendation,
|
|
6
|
+
getLink,
|
|
7
|
+
getRecommendation,
|
|
8
|
+
markDigestPending,
|
|
9
|
+
reopenRecommendation,
|
|
10
|
+
} from "./store";
|
|
11
|
+
import { AnalystError, type AnalystLink, type Recommendation } from "./types";
|
|
12
|
+
|
|
13
|
+
// A prompt into the coding agent's chat as the reader's decision, by id,
|
|
14
|
+
// so a repeat is a no-op. It waits in the queue while a turn runs.
|
|
15
|
+
export async function forwardToCoder(
|
|
16
|
+
coderSessionId: string,
|
|
17
|
+
input: { text: string; id: string; label: string; fromSessionId: string }
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
await sendChat(coderSessionId, {
|
|
20
|
+
text: input.text,
|
|
21
|
+
id: input.id,
|
|
22
|
+
queue: true,
|
|
23
|
+
origin: {
|
|
24
|
+
kind: "decision",
|
|
25
|
+
label: input.label,
|
|
26
|
+
sessionId: input.fromSessionId,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function liveLink(linkId: string): AnalystLink {
|
|
32
|
+
const link = getLink(linkId);
|
|
33
|
+
if (!link) throw new AnalystError("Pairing not found", 404);
|
|
34
|
+
if (link.endedAt) throw new AnalystError("This pairing has ended", 409);
|
|
35
|
+
return link;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cardOf(link: AnalystLink, recId: string): Recommendation {
|
|
39
|
+
const rec = getRecommendation(recId);
|
|
40
|
+
if (!rec || rec.linkId !== link.id)
|
|
41
|
+
throw new AnalystError("Recommendation not found", 404);
|
|
42
|
+
return rec;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const sentItemId = (recId: string) => `user-analyst-rec-${recId}`;
|
|
46
|
+
|
|
47
|
+
// Send to Coding Agent: the analyst's prompt, or the reader's edit of it.
|
|
48
|
+
export async function sendRecommendation(
|
|
49
|
+
linkId: string,
|
|
50
|
+
recId: string,
|
|
51
|
+
edited?: string
|
|
52
|
+
): Promise<Recommendation> {
|
|
53
|
+
const link = liveLink(linkId);
|
|
54
|
+
const rec = cardOf(link, recId);
|
|
55
|
+
const text = (edited ?? rec.prompt ?? "").trim();
|
|
56
|
+
if (!text) throw new AnalystError("There is no prompt to send", 400);
|
|
57
|
+
const id = sentItemId(rec.id);
|
|
58
|
+
const isEdit = edited !== undefined && edited.trim() !== rec.prompt?.trim();
|
|
59
|
+
// Claimed first, so two presses send once.
|
|
60
|
+
if (
|
|
61
|
+
!decideRecommendation(rec.id, "sent", { text, itemId: id, edited: isEdit })
|
|
62
|
+
)
|
|
63
|
+
throw new AnalystError(`This recommendation was already ${rec.state}`, 409);
|
|
64
|
+
try {
|
|
65
|
+
await forwardToCoder(link.coderSessionId, {
|
|
66
|
+
text,
|
|
67
|
+
id,
|
|
68
|
+
label: "Analyst",
|
|
69
|
+
fromSessionId: link.analystSessionId,
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
reopenRecommendation(rec.id);
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
return getRecommendation(rec.id)!;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function dismissRecommendation(
|
|
79
|
+
linkId: string,
|
|
80
|
+
recId: string
|
|
81
|
+
): Recommendation {
|
|
82
|
+
const link = liveLink(linkId);
|
|
83
|
+
const rec = cardOf(link, recId);
|
|
84
|
+
if (!decideRecommendation(rec.id, "dismissed"))
|
|
85
|
+
throw new AnalystError(`This recommendation was already ${rec.state}`, 409);
|
|
86
|
+
return getRecommendation(rec.id)!;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Ask again, on a card whose reply had no valid block: the analyst gets a
|
|
90
|
+
// short correction, answered like the digest it replaces.
|
|
91
|
+
export async function askAgain(linkId: string, recId: string): Promise<void> {
|
|
92
|
+
const link = liveLink(linkId);
|
|
93
|
+
const rec = cardOf(link, recId);
|
|
94
|
+
if (rec.state !== "invalid")
|
|
95
|
+
throw new AnalystError(
|
|
96
|
+
"Only a reply with no recommendation is asked again",
|
|
97
|
+
409
|
|
98
|
+
);
|
|
99
|
+
const id = `${digestPrefix(link.id)}again-${rec.id}`;
|
|
100
|
+
const through = rec.coderThrough ?? link.reviewedThrough;
|
|
101
|
+
if (through && link.digestSignals)
|
|
102
|
+
markDigestPending(link.id, id, through, link.digestSignals);
|
|
103
|
+
await sendChat(link.analystSessionId, {
|
|
104
|
+
text: ASK_AGAIN,
|
|
105
|
+
id,
|
|
106
|
+
queue: true,
|
|
107
|
+
origin: { kind: "system", label: "AgentOS" },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export * from "./types";
|
|
2
|
+
export { createLink, coderRefusal } from "./createLink";
|
|
3
|
+
export { endLink, endLinkOf, setPaused } from "./endLink";
|
|
4
|
+
export { linkView, sessionLinkView } from "./linkView";
|
|
5
|
+
export { pushDigest } from "./pushDigest";
|
|
6
|
+
export {
|
|
7
|
+
askAgain,
|
|
8
|
+
dismissRecommendation,
|
|
9
|
+
forwardToCoder,
|
|
10
|
+
sendRecommendation,
|
|
11
|
+
} from "./forward";
|
|
12
|
+
export { analystTurnEndHook, resumeAnalystLinks } from "./resume";
|
|
13
|
+
export { buildDigest } from "./buildDigest";
|
|
14
|
+
export { turnSignals, signalLine, latestChecks } from "./signals";
|
|
15
|
+
export { parseReview, REVIEW_FENCE } from "./parseReview";
|
|
16
|
+
export { getLink, getLinkOrThrow } from "./store";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { db } from "../db";
|
|
2
|
+
import { lastItems } from "../chat/store";
|
|
3
|
+
import { getLink, listRecommendations, liveLinkFor } from "./store";
|
|
4
|
+
import type { AnalystLinkView, PaneSession } from "./types";
|
|
5
|
+
|
|
6
|
+
function pane(id: string): PaneSession {
|
|
7
|
+
const r = db
|
|
8
|
+
.prepare(
|
|
9
|
+
`SELECT id, name, agent_type, model FROM session_rows WHERE id = ?`
|
|
10
|
+
)
|
|
11
|
+
.get(id) as
|
|
12
|
+
| { id: string; name: string; agent_type: string; model: string | null }
|
|
13
|
+
| undefined;
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
name: r?.name ?? "Ended session",
|
|
17
|
+
agentType: r?.agent_type ?? "claude",
|
|
18
|
+
model: r?.model ?? null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function linkView(linkId: string): AnalystLinkView | null {
|
|
23
|
+
const link = getLink(linkId);
|
|
24
|
+
if (!link) return null;
|
|
25
|
+
return {
|
|
26
|
+
...link,
|
|
27
|
+
coder: pane(link.coderSessionId),
|
|
28
|
+
analyst: pane(link.analystSessionId),
|
|
29
|
+
coderLastTurn: lastItems(link.coderSessionId, 1, "turn_end")[0]?.id ?? null,
|
|
30
|
+
recommendations: listRecommendations(link.id),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The live pairing a session is in, as either side.
|
|
35
|
+
export function sessionLinkView(sessionId: string): AnalystLinkView | null {
|
|
36
|
+
const link = liveLinkFor(sessionId);
|
|
37
|
+
return link ? linkView(link.id) : null;
|
|
38
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ChatItem } from "../chat/events";
|
|
2
|
+
import { lastItems } from "../chat/store";
|
|
3
|
+
import { parseReview, REVIEW_FENCE } from "./parseReview";
|
|
4
|
+
import { digestPrefix, pushDigest } from "./pushDigest";
|
|
5
|
+
import {
|
|
6
|
+
getLink,
|
|
7
|
+
insertRecommendation,
|
|
8
|
+
liveLinkFor,
|
|
9
|
+
recommendationFor,
|
|
10
|
+
settleDigest,
|
|
11
|
+
} from "./store";
|
|
12
|
+
import type { AnalystLink, Recommendation } from "./types";
|
|
13
|
+
|
|
14
|
+
// Enough of the analyst's chat to hold one reply and the digest before it.
|
|
15
|
+
const REPLY_ITEMS = 80;
|
|
16
|
+
|
|
17
|
+
// The coding agent finished a turn: the analyst gets it, unless it's busy
|
|
18
|
+
// with a digest (then it comes with the next one).
|
|
19
|
+
export async function onCoderTurnEnd(sessionId: string): Promise<void> {
|
|
20
|
+
const link = liveLinkFor(sessionId);
|
|
21
|
+
if (link?.coderSessionId !== sessionId) return;
|
|
22
|
+
await pushDigest(link.id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Reply {
|
|
26
|
+
// The message the turn answered.
|
|
27
|
+
prompt: Extract<ChatItem, { kind: "user" }> | null;
|
|
28
|
+
text: string;
|
|
29
|
+
// The item the review block is in (its card renders there), else the
|
|
30
|
+
// turn's end.
|
|
31
|
+
itemId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function lastReply(sessionId: string, turnEndId: string): Reply | null {
|
|
35
|
+
const items = lastItems(sessionId, REPLY_ITEMS);
|
|
36
|
+
const end = items.findLastIndex((i) => i.id === turnEndId);
|
|
37
|
+
if (end < 0) return null;
|
|
38
|
+
const start = items.slice(0, end).findLastIndex((i) => i.kind === "user");
|
|
39
|
+
const turn = items.slice(start + 1, end);
|
|
40
|
+
const said = turn.filter(
|
|
41
|
+
(i): i is Extract<ChatItem, { kind: "assistant" }> => i.kind === "assistant"
|
|
42
|
+
);
|
|
43
|
+
const prompt = start >= 0 ? items[start] : null;
|
|
44
|
+
return {
|
|
45
|
+
prompt: prompt?.kind === "user" ? prompt : null,
|
|
46
|
+
text: said.map((i) => i.text).join("\n\n"),
|
|
47
|
+
itemId:
|
|
48
|
+
said.findLast((i) => i.text.includes(REVIEW_FENCE))?.id ??
|
|
49
|
+
said[said.length - 1]?.id ??
|
|
50
|
+
turnEndId,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Turns the analyst's reply into a card: a recommendation when it carries a
|
|
55
|
+
// valid block, an invalid one when it answered a digest without one.
|
|
56
|
+
export function recordReply(
|
|
57
|
+
link: AnalystLink,
|
|
58
|
+
reply: Reply
|
|
59
|
+
): Recommendation | null {
|
|
60
|
+
const digest =
|
|
61
|
+
!!reply.prompt?.id.startsWith(digestPrefix(link.id)) ||
|
|
62
|
+
reply.prompt?.id === link.pendingDigestId;
|
|
63
|
+
const parsed = parseReview(reply.text);
|
|
64
|
+
if (!parsed.ok && !digest) return null;
|
|
65
|
+
if (recommendationFor(link.id, reply.itemId)) return null;
|
|
66
|
+
const about = {
|
|
67
|
+
linkId: link.id,
|
|
68
|
+
analystItemId: reply.itemId,
|
|
69
|
+
coderThrough: link.pendingThrough ?? link.reviewedThrough,
|
|
70
|
+
signals: link.digestSignals,
|
|
71
|
+
};
|
|
72
|
+
return parsed.ok
|
|
73
|
+
? insertRecommendation({
|
|
74
|
+
...about,
|
|
75
|
+
verdict: parsed.review.verdict,
|
|
76
|
+
summary: parsed.review.summary,
|
|
77
|
+
issues: parsed.review.issues,
|
|
78
|
+
prompt: parsed.review.prompt ?? null,
|
|
79
|
+
state: "open",
|
|
80
|
+
})
|
|
81
|
+
: insertRecommendation({
|
|
82
|
+
...about,
|
|
83
|
+
verdict: null,
|
|
84
|
+
summary: "",
|
|
85
|
+
issues: [],
|
|
86
|
+
prompt: null,
|
|
87
|
+
state: "invalid",
|
|
88
|
+
error: parsed.error,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The analyst finished a turn: its review becomes a card, the digest it
|
|
93
|
+
// answered counts as reviewed, and turns that ended meanwhile go next.
|
|
94
|
+
export async function onAnalystTurnEnd(
|
|
95
|
+
sessionId: string,
|
|
96
|
+
turnEnd: Extract<ChatItem, { kind: "turn_end" }>
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const link = liveLinkFor(sessionId);
|
|
99
|
+
if (link?.analystSessionId !== sessionId) return;
|
|
100
|
+
const reply = lastReply(sessionId, turnEnd.id);
|
|
101
|
+
if (!reply) return;
|
|
102
|
+
recordReply(link, reply);
|
|
103
|
+
const answered = reply.prompt?.id;
|
|
104
|
+
if (answered && answered === link.pendingDigestId)
|
|
105
|
+
settleDigest(link.id, answered);
|
|
106
|
+
if (getLink(link.id)?.pendingDigestId) return;
|
|
107
|
+
await pushDigest(link.id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// What the chat hook calls for every finished turn.
|
|
111
|
+
export async function onTurnEnd(
|
|
112
|
+
sessionId: string,
|
|
113
|
+
item: Extract<ChatItem, { kind: "turn_end" }>
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const link = liveLinkFor(sessionId);
|
|
116
|
+
if (!link) return;
|
|
117
|
+
if (link.coderSessionId === sessionId) await onCoderTurnEnd(sessionId);
|
|
118
|
+
else await onAnalystTurnEnd(sessionId, item);
|
|
119
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { reviewSchema, type Review } from "./types";
|
|
2
|
+
|
|
3
|
+
export const REVIEW_FENCE = "agentos-review";
|
|
4
|
+
|
|
5
|
+
const FENCE = new RegExp(
|
|
6
|
+
"(`{3,}|~{3,})[ \\t]*" + REVIEW_FENCE + "[^\\n]*\\n([\\s\\S]*?)\\n?\\1",
|
|
7
|
+
"g"
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export type ParsedReview =
|
|
11
|
+
{ ok: true; review: Review } | { ok: false; error: string };
|
|
12
|
+
|
|
13
|
+
// The last agentos-review block in the analyst's reply, validated.
|
|
14
|
+
export function parseReview(text: string): ParsedReview {
|
|
15
|
+
const blocks = [...text.matchAll(FENCE)];
|
|
16
|
+
if (!blocks.length)
|
|
17
|
+
return { ok: false, error: "The reply had no agentos-review block" };
|
|
18
|
+
return parseReviewJson(blocks[blocks.length - 1][2]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseReviewJson(raw: string): ParsedReview {
|
|
22
|
+
let json: unknown;
|
|
23
|
+
try {
|
|
24
|
+
json = JSON.parse(raw.trim());
|
|
25
|
+
} catch {
|
|
26
|
+
return { ok: false, error: "The agentos-review block isn't valid JSON" };
|
|
27
|
+
}
|
|
28
|
+
const got = reviewSchema.safeParse(json);
|
|
29
|
+
if (!got.success) {
|
|
30
|
+
const issue = got.error.issues[0];
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
error: `The agentos-review block is invalid: ${issue.path.join(".") || "block"} ${issue.message}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const review = got.data;
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
review: { ...review, prompt: review.prompt?.trim() || null },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -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
|
+
}
|