@saadnvd1/agent-os 0.3.524 → 0.3.527
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 +25 -3
- package/app/api/doctor/route.ts +8 -0
- package/app/api/workspaces/[id]/orchestrator/needs-you/dismiss/route.ts +31 -0
- package/app/api/workspaces/[id]/orchestrator/questions/[questionId]/route.ts +15 -0
- package/app/page.tsx +8 -3
- package/components/Chat/ChatPanel.tsx +96 -8
- package/components/Chat/EventRow.tsx +2 -2
- package/components/Chat/EventTurns.test.ts +56 -0
- package/components/Chat/EventTurns.tsx +141 -0
- package/components/Chat/useFind.ts +5 -3
- package/components/Chat/useGoToItem.ts +71 -0
- package/components/Orchestrator/AsksList.tsx +16 -11
- package/components/Orchestrator/NeedsYouStrip.tsx +148 -0
- package/components/Orchestrator/OrchestratorBar.tsx +27 -2
- package/components/Settings/SettingsDialog.tsx +2 -2
- package/components/Settings/SetupSection.tsx +6 -1
- package/components/Simulator/ScreenshotStrip.test.ts +91 -0
- package/components/Simulator/ScreenshotStrip.tsx +102 -0
- package/components/Simulator/index.ts +1 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Welcome/DevicesStep.tsx +90 -0
- package/components/Welcome/HealthList.tsx +69 -0
- package/components/Welcome/ProjectStep.tsx +2 -8
- package/components/Welcome/WelcomeDialog.tsx +31 -7
- package/data/doctor/index.ts +29 -0
- package/data/orchestrators/index.ts +59 -0
- package/data/push/topics.ts +2 -0
- package/data/simulator/index.test.ts +14 -0
- package/data/simulator/index.ts +33 -0
- package/data/simulator/keys.ts +5 -0
- package/hooks/useNeedsYou.ts +53 -0
- package/lib/chat/events.ts +2 -0
- package/lib/chat/find.ts +25 -4
- package/lib/chat/group.ts +50 -2
- package/lib/chat/turns.ts +111 -0
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations.ts +32 -0
- package/lib/doctor/checks.ts +222 -0
- package/lib/orchestrator/ask-view.ts +13 -0
- package/lib/orchestrator/brief.ts +5 -1
- package/lib/orchestrator/deliver.ts +3 -0
- package/lib/orchestrator/events.ts +19 -0
- package/lib/orchestrator/needs-you-items.ts +104 -0
- package/lib/orchestrator/overview.ts +3 -0
- package/lib/orchestrator/questions.ts +120 -0
- package/lib/orchestrator/serve.ts +11 -0
- package/lib/orchestrator/tool-names.ts +2 -0
- package/lib/orchestrator/tool-schemas.ts +12 -0
- package/lib/orchestrator/tools.ts +4 -0
- package/lib/welcome-url.ts +6 -1
- package/package.json +1 -1
- package/stores/needsYouFocus.ts +13 -0
- package/stores/welcomeUi.test.ts +15 -0
- package/stores/welcomeUi.ts +6 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { memo } from "react";
|
|
3
|
+
import { memo, useState } from "react";
|
|
4
4
|
import { useSnapshot } from "valtio";
|
|
5
5
|
import { Check } from "lucide-react";
|
|
6
6
|
import {
|
|
@@ -16,6 +16,9 @@ import { cn } from "@/lib/utils";
|
|
|
16
16
|
import { WELCOME_STEPS, type WelcomeStep } from "@/lib/welcome-url";
|
|
17
17
|
import { welcomeUi, welcomeUiActions } from "@/stores/welcomeUi";
|
|
18
18
|
import { AgentList } from "./AgentList";
|
|
19
|
+
import type { BrowserNotifications } from "@/components/Settings/NotificationsSection";
|
|
20
|
+
import { HostsDialog } from "@/components/Hosts";
|
|
21
|
+
import { DevicesStep } from "./DevicesStep";
|
|
19
22
|
import { ProjectStep } from "./ProjectStep";
|
|
20
23
|
|
|
21
24
|
const STEPS: Record<
|
|
@@ -32,6 +35,11 @@ const STEPS: Record<
|
|
|
32
35
|
title: "Add your first project",
|
|
33
36
|
hint: "A folder or repo for the first session to work in.",
|
|
34
37
|
},
|
|
38
|
+
devices: {
|
|
39
|
+
label: "Devices",
|
|
40
|
+
title: "Use it from your phone",
|
|
41
|
+
hint: "All optional. Each can be done later from Settings.",
|
|
42
|
+
},
|
|
35
43
|
finish: {
|
|
36
44
|
label: "Start",
|
|
37
45
|
title: "You're set",
|
|
@@ -61,8 +69,14 @@ function FinishStep() {
|
|
|
61
69
|
* First-run setup: agents, then a first project, then a session. It stays
|
|
62
70
|
* open until finished or skipped, and every step after the first can be skipped.
|
|
63
71
|
*/
|
|
64
|
-
export const WelcomeDialog = memo(function WelcomeDialog(
|
|
72
|
+
export const WelcomeDialog = memo(function WelcomeDialog({
|
|
73
|
+
notifications,
|
|
74
|
+
}: {
|
|
75
|
+
notifications: BrowserNotifications;
|
|
76
|
+
}) {
|
|
65
77
|
useWelcomeUrl();
|
|
78
|
+
// Held here so the machines dialog outlives a step change.
|
|
79
|
+
const [hostsOpen, setHostsOpen] = useState(false);
|
|
66
80
|
const { open, step, projectId } = useSnapshot(welcomeUi);
|
|
67
81
|
const end = useEndWelcome();
|
|
68
82
|
const at = WELCOME_STEPS.indexOf(step);
|
|
@@ -77,7 +91,10 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
|
77
91
|
onEscapeKeyDown={(e) => e.preventDefault()}
|
|
78
92
|
className="max-h-[100dvh] overflow-y-auto max-sm:h-[100dvh] max-sm:max-w-full max-sm:rounded-none sm:max-h-[90dvh] sm:max-w-xl"
|
|
79
93
|
>
|
|
80
|
-
<nav
|
|
94
|
+
<nav
|
|
95
|
+
aria-label="Setup steps"
|
|
96
|
+
className="flex min-w-0 items-center gap-1"
|
|
97
|
+
>
|
|
81
98
|
{WELCOME_STEPS.map((s, i) => (
|
|
82
99
|
<button
|
|
83
100
|
key={s}
|
|
@@ -86,7 +103,7 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
|
86
103
|
onClick={() => welcomeUiActions.go(s)}
|
|
87
104
|
aria-current={i === at ? "step" : undefined}
|
|
88
105
|
className={cn(
|
|
89
|
-
"flex min-h-11 flex-1 items-center justify-center gap-1
|
|
106
|
+
"flex min-h-11 min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-1 text-xs font-medium sm:min-h-8 sm:gap-1.5 sm:px-2",
|
|
90
107
|
i === at
|
|
91
108
|
? "bg-foreground/[0.08] text-foreground"
|
|
92
109
|
: "text-muted-foreground hover:bg-foreground/[0.06]"
|
|
@@ -99,7 +116,7 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
|
99
116
|
<Button
|
|
100
117
|
variant="ghost"
|
|
101
118
|
size="sm"
|
|
102
|
-
className="min-h-11 sm:min-h-8"
|
|
119
|
+
className="min-h-11 shrink-0 px-2 sm:min-h-8"
|
|
103
120
|
onClick={() => end()}
|
|
104
121
|
>
|
|
105
122
|
Skip setup
|
|
@@ -111,6 +128,12 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
|
111
128
|
</div>
|
|
112
129
|
{step === "agents" && <AgentList />}
|
|
113
130
|
{step === "project" && <ProjectStep />}
|
|
131
|
+
{step === "devices" && (
|
|
132
|
+
<DevicesStep
|
|
133
|
+
notifications={notifications}
|
|
134
|
+
onLinkMachine={() => setHostsOpen(true)}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
114
137
|
{step === "finish" && <FinishStep />}
|
|
115
138
|
<div className="flex items-center justify-between gap-2">
|
|
116
139
|
<Button
|
|
@@ -122,11 +145,11 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
|
122
145
|
Back
|
|
123
146
|
</Button>
|
|
124
147
|
<div className="flex items-center gap-2">
|
|
125
|
-
{step === "project" && (
|
|
148
|
+
{(step === "project" || step === "devices") && (
|
|
126
149
|
<Button
|
|
127
150
|
variant="ghost"
|
|
128
151
|
className="min-h-11 sm:min-h-9"
|
|
129
|
-
onClick={() => welcomeUiActions.go(
|
|
152
|
+
onClick={() => welcomeUiActions.go(WELCOME_STEPS[at + 1])}
|
|
130
153
|
>
|
|
131
154
|
Skip
|
|
132
155
|
</Button>
|
|
@@ -148,6 +171,7 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
|
|
|
148
171
|
</div>
|
|
149
172
|
</div>
|
|
150
173
|
</DialogContent>
|
|
174
|
+
<HostsDialog open={hostsOpen} onClose={() => setHostsOpen(false)} />
|
|
151
175
|
</Dialog>
|
|
152
176
|
);
|
|
153
177
|
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { DoctorCheck } from "@/lib/doctor/checks";
|
|
3
|
+
|
|
4
|
+
export const doctorKeys = {
|
|
5
|
+
all: ["doctor"] as const,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
async function fetchChecks(fresh: boolean) {
|
|
9
|
+
const res = await fetch(fresh ? "/api/doctor?fresh" : "/api/doctor");
|
|
10
|
+
if (!res.ok) throw new Error("Couldn't run the health checks");
|
|
11
|
+
return ((await res.json()) as { checks: DoctorCheck[] }).checks;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useDoctorQuery() {
|
|
15
|
+
return useQuery({
|
|
16
|
+
queryKey: doctorKeys.all,
|
|
17
|
+
queryFn: () => fetchChecks(false),
|
|
18
|
+
staleTime: 30_000,
|
|
19
|
+
refetchOnMount: "always",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useRecheckDoctor() {
|
|
24
|
+
const client = useQueryClient();
|
|
25
|
+
return useMutation({
|
|
26
|
+
mutationFn: () => fetchChecks(true),
|
|
27
|
+
onSuccess: (checks) => client.setQueryData(doctorKeys.all, checks),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -162,3 +162,62 @@ export function usePauseOrchestrator(workspaceId: string) {
|
|
|
162
162
|
queryClient.invalidateQueries({ queryKey: orchestratorKeys.all }),
|
|
163
163
|
});
|
|
164
164
|
}
|
|
165
|
+
|
|
166
|
+
function patchOverview(
|
|
167
|
+
queryClient: ReturnType<typeof useQueryClient>,
|
|
168
|
+
workspaceId: string,
|
|
169
|
+
change: (o: OrchestratorOverview) => OrchestratorOverview
|
|
170
|
+
) {
|
|
171
|
+
queryClient.setQueryData<OrchestratorOverview[]>(
|
|
172
|
+
orchestratorKeys.all,
|
|
173
|
+
(prev) => prev?.map((o) => (o.workspaceId === workspaceId ? change(o) : o))
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Hiding an item from the Needs-you strip (and showing it again). It never
|
|
178
|
+
// answers an ask or a question; it is shared by his devices.
|
|
179
|
+
export function useDismissNeedsYou(workspaceId: string) {
|
|
180
|
+
const queryClient = useQueryClient();
|
|
181
|
+
const url = `/api/workspaces/${workspaceId}/orchestrator/needs-you/dismiss`;
|
|
182
|
+
return useMutation({
|
|
183
|
+
mutationFn: async ({ key, undo }: { key: string; undo?: boolean }) => {
|
|
184
|
+
if (!undo) return post(url, { key });
|
|
185
|
+
return json(
|
|
186
|
+
await fetch(url, {
|
|
187
|
+
method: "DELETE",
|
|
188
|
+
headers: { "Content-Type": "application/json" },
|
|
189
|
+
body: JSON.stringify({ key }),
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
onMutate: async ({ key, undo }) => {
|
|
194
|
+
await queryClient.cancelQueries({ queryKey: orchestratorKeys.all });
|
|
195
|
+
patchOverview(queryClient, workspaceId, (o) => ({
|
|
196
|
+
...o,
|
|
197
|
+
dismissed: undo
|
|
198
|
+
? o.dismissed.filter((k) => k !== key)
|
|
199
|
+
: [...o.dismissed, key],
|
|
200
|
+
}));
|
|
201
|
+
},
|
|
202
|
+
onSettled: () =>
|
|
203
|
+
queryClient.invalidateQueries({ queryKey: orchestratorKeys.all }),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Saad answered a question the orchestrator put to him.
|
|
208
|
+
export function useResolveQuestion(workspaceId: string) {
|
|
209
|
+
const queryClient = useQueryClient();
|
|
210
|
+
return useMutation({
|
|
211
|
+
mutationFn: (id: number) =>
|
|
212
|
+
post(`/api/workspaces/${workspaceId}/orchestrator/questions/${id}`, {}),
|
|
213
|
+
onMutate: async (id) => {
|
|
214
|
+
await queryClient.cancelQueries({ queryKey: orchestratorKeys.all });
|
|
215
|
+
patchOverview(queryClient, workspaceId, (o) => ({
|
|
216
|
+
...o,
|
|
217
|
+
questions: o.questions.filter((q) => q.id !== id),
|
|
218
|
+
}));
|
|
219
|
+
},
|
|
220
|
+
onSettled: () =>
|
|
221
|
+
queryClient.invalidateQueries({ queryKey: orchestratorKeys.all }),
|
|
222
|
+
});
|
|
223
|
+
}
|
package/data/push/topics.ts
CHANGED
|
@@ -55,6 +55,8 @@ function keysFor(topic: string): QueryKey[] {
|
|
|
55
55
|
case "schedule_runs":
|
|
56
56
|
return [scheduleKeys.all];
|
|
57
57
|
case "orchestrator_asks":
|
|
58
|
+
case "orchestrator_questions":
|
|
59
|
+
case "needs_you_dismissed":
|
|
58
60
|
return [orchestratorKeys.all];
|
|
59
61
|
case "orchestrator_rules":
|
|
60
62
|
return [ruleKeys.all];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { simulatorPollInterval } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("simulatorPollInterval", () => {
|
|
5
|
+
it("polls while a lease is coming up or booted", () => {
|
|
6
|
+
for (const s of ["creating", "starting", "ready"] as const)
|
|
7
|
+
expect(simulatorPollInterval(s)).toBeGreaterThan(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("does not poll with no lease, an idle-shutdown one, a failed one, or before the first read", () => {
|
|
11
|
+
for (const s of ["none", "shutdown", "failed", undefined] as const)
|
|
12
|
+
expect(simulatorPollInterval(s)).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type { SimulatorStatus } from "@/lib/simulator";
|
|
3
|
+
import { simulatorKeys } from "./keys";
|
|
4
|
+
|
|
5
|
+
export { simulatorKeys };
|
|
6
|
+
export type { SimulatorStatus };
|
|
7
|
+
|
|
8
|
+
const POLL_MS = 10_000;
|
|
9
|
+
|
|
10
|
+
// A lease being brought up or booted changes under us (idle shutdown, a new
|
|
11
|
+
// screenshot); anything else only changes when the agent opens one, which the
|
|
12
|
+
// next mount picks up.
|
|
13
|
+
export function simulatorPollInterval(
|
|
14
|
+
state: SimulatorStatus["state"] | undefined
|
|
15
|
+
): number | false {
|
|
16
|
+
return state === "creating" || state === "starting" || state === "ready"
|
|
17
|
+
? POLL_MS
|
|
18
|
+
: false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useSimulatorStatus(sessionId: string, enabled = true) {
|
|
22
|
+
return useQuery({
|
|
23
|
+
queryKey: simulatorKeys.status(sessionId),
|
|
24
|
+
queryFn: async (): Promise<SimulatorStatus> => {
|
|
25
|
+
const res = await fetch(`/api/sessions/${sessionId}/simulator`);
|
|
26
|
+
if (!res.ok) throw new Error("Could not read the simulator");
|
|
27
|
+
return res.json();
|
|
28
|
+
},
|
|
29
|
+
enabled,
|
|
30
|
+
staleTime: 30_000,
|
|
31
|
+
refetchInterval: (query) => simulatorPollInterval(query.state.data?.state),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { useQuery } from "@tanstack/react-query";
|
|
5
|
+
import { useSessionsQuery } from "@/data/sessions/queries";
|
|
6
|
+
import { useProjectsQuery } from "@/data/projects/queries";
|
|
7
|
+
import { fetchStatuses } from "@/data/statuses/queries";
|
|
8
|
+
import { statusKeys } from "@/data/sessions/keys";
|
|
9
|
+
import { useOrchestratorOverview } from "@/data/orchestrators";
|
|
10
|
+
import {
|
|
11
|
+
needsYouItems,
|
|
12
|
+
type NeedsYouItem,
|
|
13
|
+
type TaskNeed,
|
|
14
|
+
} from "@/lib/orchestrator/needs-you-items";
|
|
15
|
+
|
|
16
|
+
// Everything waiting on Saad in a workspace: its open asks and questions, the
|
|
17
|
+
// tasks the sidebar marks as needing him, and its chat waiting on an answer.
|
|
18
|
+
export function useNeedsYou(
|
|
19
|
+
workspaceId: string,
|
|
20
|
+
chatWaiting: boolean
|
|
21
|
+
): NeedsYouItem[] {
|
|
22
|
+
const overview = useOrchestratorOverview(workspaceId);
|
|
23
|
+
const { data: sessions } = useSessionsQuery();
|
|
24
|
+
const { data: projects = [] } = useProjectsQuery();
|
|
25
|
+
const { data: statuses } = useQuery({
|
|
26
|
+
queryKey: statusKeys.all,
|
|
27
|
+
queryFn: fetchStatuses,
|
|
28
|
+
staleTime: 2000,
|
|
29
|
+
});
|
|
30
|
+
const tasks = useMemo(() => {
|
|
31
|
+
const members = new Set(
|
|
32
|
+
projects.filter((p) => p.workspace_id === workspaceId).map((p) => p.id)
|
|
33
|
+
);
|
|
34
|
+
return (sessions?.sessions ?? []).flatMap((s): TaskNeed[] => {
|
|
35
|
+
const need = statuses?.statuses[s.id]?.need;
|
|
36
|
+
return !s.role && s.project_id && members.has(s.project_id) && need
|
|
37
|
+
? [{ sessionId: s.id, name: s.name, need }]
|
|
38
|
+
: [];
|
|
39
|
+
});
|
|
40
|
+
}, [sessions, projects, statuses, workspaceId]);
|
|
41
|
+
return useMemo(
|
|
42
|
+
() =>
|
|
43
|
+
needsYouItems({
|
|
44
|
+
asks: overview?.asks ?? [],
|
|
45
|
+
questions: overview?.questions ?? [],
|
|
46
|
+
dismissed: overview?.dismissed ?? [],
|
|
47
|
+
tasks,
|
|
48
|
+
chatWaiting,
|
|
49
|
+
chatKey: `approval:${workspaceId}`,
|
|
50
|
+
}),
|
|
51
|
+
[overview, tasks, chatWaiting, workspaceId]
|
|
52
|
+
);
|
|
53
|
+
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -81,6 +81,8 @@ export interface ChatOrigin {
|
|
|
81
81
|
body?: string;
|
|
82
82
|
// Lines of an event batch that are the reader's answers to asks.
|
|
83
83
|
decided?: string[];
|
|
84
|
+
// The tasks it is about, by name, set where the event is delivered.
|
|
85
|
+
subjects?: { id: string; name: string }[];
|
|
84
86
|
// Only Away mode's own lines: the turn it starts isn't anyone's activity
|
|
85
87
|
// (lib/away/activity).
|
|
86
88
|
away?: true;
|
package/lib/chat/find.ts
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import type { ChatItem } from "./events";
|
|
9
9
|
import { lineDiff } from "./diff";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
groupTimeline,
|
|
12
|
+
STANDALONE_TOOLS,
|
|
13
|
+
type GroupOptions,
|
|
14
|
+
type ToolItem,
|
|
15
|
+
} from "./group";
|
|
11
16
|
import { eventParts, originOf } from "./origin";
|
|
12
17
|
|
|
13
18
|
export const MIN_QUERY = 2;
|
|
@@ -170,7 +175,11 @@ function rowKeys(item: ChatItem): string[] {
|
|
|
170
175
|
* so it's on screen: a step inside its folded run, a message inside an undo.
|
|
171
176
|
* Null when it isn't loaded (or the timeline never shows it).
|
|
172
177
|
*/
|
|
173
|
-
export function locate(
|
|
178
|
+
export function locate(
|
|
179
|
+
items: ChatItem[],
|
|
180
|
+
id: string,
|
|
181
|
+
opts: GroupOptions = {}
|
|
182
|
+
): Located | null {
|
|
174
183
|
const at = items.findIndex((i) => i.id === id);
|
|
175
184
|
if (at === -1) return null;
|
|
176
185
|
const inside = (
|
|
@@ -182,16 +191,28 @@ export function locate(items: ChatItem[], id: string): Located | null {
|
|
|
182
191
|
return { open: [...open, ...rowKeys(b.item)] };
|
|
183
192
|
if (b.type === "tools" && b.tools.some((t) => t.id === id))
|
|
184
193
|
return { open: [...open, `steps:${b.id}`, `tool:${id}`] };
|
|
194
|
+
if (b.type === "events") {
|
|
195
|
+
const turn = b.turns.find(
|
|
196
|
+
(t) => t.id === id || t.replies.some((i) => i.id === id)
|
|
197
|
+
);
|
|
198
|
+
if (!turn) continue;
|
|
199
|
+
const opened = [...open, `events:${b.id}`, `eventturn:${turn.id}`];
|
|
200
|
+
if (turn.id === id) return { open: [...opened, ...rowKeys(turn.user)] };
|
|
201
|
+
return inside(groupTimeline(turn.replies), opened) ?? { open: opened };
|
|
202
|
+
}
|
|
185
203
|
if (b.type === "undone" && b.items.some((i) => i.id === id))
|
|
186
204
|
return (
|
|
187
|
-
inside(groupTimeline(b.items), [
|
|
205
|
+
inside(groupTimeline(b.items, opts), [
|
|
206
|
+
...open,
|
|
207
|
+
`undone:${b.undo.id}`,
|
|
208
|
+
]) ?? {
|
|
188
209
|
open: [...open, `undone:${b.undo.id}`],
|
|
189
210
|
}
|
|
190
211
|
);
|
|
191
212
|
}
|
|
192
213
|
return null;
|
|
193
214
|
};
|
|
194
|
-
const blocks = groupTimeline(items);
|
|
215
|
+
const blocks = groupTimeline(items, opts);
|
|
195
216
|
const blockOf = (b: (typeof blocks)[number]) =>
|
|
196
217
|
b.type === "item" ? b.item.id : b.id;
|
|
197
218
|
for (const b of blocks) {
|
package/lib/chat/group.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChatItem } from "./events";
|
|
2
|
+
import { eventTurnAt, type EventTurn } from "./turns";
|
|
2
3
|
|
|
3
4
|
export type ToolItem = Extract<ChatItem, { kind: "tool" }>;
|
|
4
5
|
export type UndoItem = Extract<ChatItem, { kind: "undo" }>;
|
|
@@ -6,7 +7,14 @@ export type UndoItem = Extract<ChatItem, { kind: "undo" }>;
|
|
|
6
7
|
export type TimelineBlock =
|
|
7
8
|
| { type: "item"; item: ChatItem }
|
|
8
9
|
| { type: "tools"; id: string; tools: ToolItem[] }
|
|
9
|
-
| { type: "undone"; id: string; undo: UndoItem; items: ChatItem[] }
|
|
10
|
+
| { type: "undone"; id: string; undo: UndoItem; items: ChatItem[] }
|
|
11
|
+
// Consecutive turns an event started, with the reply to each.
|
|
12
|
+
| { type: "events"; id: string; turns: EventTurn[] };
|
|
13
|
+
|
|
14
|
+
export interface GroupOptions {
|
|
15
|
+
// Fold events and the replies to them (the orchestrator's chat).
|
|
16
|
+
foldEvents?: boolean;
|
|
17
|
+
}
|
|
10
18
|
|
|
11
19
|
// Tools worth seeing on their own rather than as one step among many.
|
|
12
20
|
export const STANDALONE_TOOLS = new Set(["Skill", "Task", "Agent"]);
|
|
@@ -14,17 +22,45 @@ export const STANDALONE_TOOLS = new Set(["Skill", "Task", "Agent"]);
|
|
|
14
22
|
// Consecutive tool calls (and the reasoning between them) fold into one
|
|
15
23
|
// collapsible block, so a long turn reads as what was said, not every step.
|
|
16
24
|
// Undone messages fold into one block behind the undo that took them back.
|
|
17
|
-
export function groupTimeline(
|
|
25
|
+
export function groupTimeline(
|
|
26
|
+
items: ChatItem[],
|
|
27
|
+
opts: GroupOptions = {}
|
|
28
|
+
): TimelineBlock[] {
|
|
18
29
|
const blocks: TimelineBlock[] = [];
|
|
19
30
|
let tools: ToolItem[] = [];
|
|
20
31
|
const flush = () => {
|
|
21
32
|
if (tools.length) blocks.push({ type: "tools", id: tools[0].id, tools });
|
|
22
33
|
tools = [];
|
|
23
34
|
};
|
|
35
|
+
let turns: EventTurn[] = [];
|
|
36
|
+
const flushTurns = () => {
|
|
37
|
+
if (turns.length)
|
|
38
|
+
blocks.push({ type: "events", id: `ev-${turns[0].id}`, turns });
|
|
39
|
+
turns = [];
|
|
40
|
+
};
|
|
24
41
|
const ends = undoneRanges(items);
|
|
42
|
+
// Where the next undone range starts: a turn stops short of it.
|
|
43
|
+
const starts = [...ends.keys()].sort((a, b) => a - b);
|
|
25
44
|
for (let i = 0; i < items.length; i++) {
|
|
26
45
|
const item = items[i];
|
|
27
46
|
const end = ends.get(i);
|
|
47
|
+
if (opts.foldEvents && end === undefined && item.kind === "user") {
|
|
48
|
+
const limit = starts.find((s) => s > i) ?? items.length;
|
|
49
|
+
const found = eventTurnAt(items, i, limit);
|
|
50
|
+
if (found) {
|
|
51
|
+
flush();
|
|
52
|
+
turns.push(found.turn);
|
|
53
|
+
i = found.end - 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// What the timeline never shows doesn't break a run of events.
|
|
58
|
+
const hidden =
|
|
59
|
+
item.kind === "task" ||
|
|
60
|
+
(item.kind === "approval" &&
|
|
61
|
+
item.status !== "pending" &&
|
|
62
|
+
!(item.status === "answered" && item.questions));
|
|
63
|
+
if (!hidden) flushTurns();
|
|
28
64
|
if (end !== undefined) {
|
|
29
65
|
flush();
|
|
30
66
|
blocks.push({
|
|
@@ -57,6 +93,7 @@ export function groupTimeline(items: ChatItem[]): TimelineBlock[] {
|
|
|
57
93
|
}
|
|
58
94
|
}
|
|
59
95
|
flush();
|
|
96
|
+
flushTurns();
|
|
60
97
|
return blocks;
|
|
61
98
|
}
|
|
62
99
|
|
|
@@ -89,6 +126,17 @@ export function sameBlock(a: TimelineBlock, b: TimelineBlock): boolean {
|
|
|
89
126
|
return sameItems(a.tools, b.tools);
|
|
90
127
|
if (a.type === "undone" && b.type === "undone")
|
|
91
128
|
return a.undo === b.undo && sameItems(a.items, b.items);
|
|
129
|
+
if (a.type === "events" && b.type === "events")
|
|
130
|
+
return (
|
|
131
|
+
a.id === b.id &&
|
|
132
|
+
a.turns.length === b.turns.length &&
|
|
133
|
+
a.turns.every(
|
|
134
|
+
(t, i) =>
|
|
135
|
+
t.user === b.turns[i].user &&
|
|
136
|
+
t.live === b.turns[i].live &&
|
|
137
|
+
sameItems(t.replies, b.turns[i].replies)
|
|
138
|
+
)
|
|
139
|
+
);
|
|
92
140
|
return false;
|
|
93
141
|
}
|
|
94
142
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ChatItem, ChatOrigin } from "./events";
|
|
2
|
+
import { eventParts, originOf } from "./origin";
|
|
3
|
+
|
|
4
|
+
type UserItem = Extract<ChatItem, { kind: "user" }>;
|
|
5
|
+
|
|
6
|
+
// What the orchestrator's reply to an event ends up as in the timeline.
|
|
7
|
+
export interface EventTurn {
|
|
8
|
+
// The event's own item id.
|
|
9
|
+
id: string;
|
|
10
|
+
user: UserItem;
|
|
11
|
+
origin: ChatOrigin;
|
|
12
|
+
// Everything after the event up to the end of the turn.
|
|
13
|
+
replies: ChatItem[];
|
|
14
|
+
// The start of the reply (or of the event, before it has one).
|
|
15
|
+
summary: string;
|
|
16
|
+
// The task or source it is about.
|
|
17
|
+
task: string;
|
|
18
|
+
at: number;
|
|
19
|
+
// Still being answered.
|
|
20
|
+
live: boolean;
|
|
21
|
+
// The reply opened a question for the reader.
|
|
22
|
+
hasQuestion: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const FOLDED_KINDS = new Set(["event", "peer", "schedule", "system"]);
|
|
26
|
+
|
|
27
|
+
// The origin of a user item when it is activity (an event, a task's message,
|
|
28
|
+
// a schedule) rather than something the reader said or decided. Items
|
|
29
|
+
// without a recorded origin are read by their text only if they predate it
|
|
30
|
+
// (originOf).
|
|
31
|
+
export function activityOrigin(item: UserItem): ChatOrigin | null {
|
|
32
|
+
const origin = originOf(item);
|
|
33
|
+
if (!origin || !FOLDED_KINDS.has(origin.kind)) return null;
|
|
34
|
+
// A batch carrying the reader's own answers: the reply is to them.
|
|
35
|
+
if (origin.decided?.length) return null;
|
|
36
|
+
return origin;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const isQuestionTool = (item: ChatItem): boolean =>
|
|
40
|
+
item.kind === "tool" && /(^|__)question$/.test(item.name);
|
|
41
|
+
|
|
42
|
+
export function plainSummary(text: string, max = 140): string {
|
|
43
|
+
const line =
|
|
44
|
+
text
|
|
45
|
+
.split("\n")
|
|
46
|
+
.map((l) => l.trim())
|
|
47
|
+
.find(Boolean) ?? "";
|
|
48
|
+
const plain = line
|
|
49
|
+
.replace(/^(#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)+/, "")
|
|
50
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
51
|
+
.replace(/[*_`~]+/g, "")
|
|
52
|
+
.replace(/\s+/g, " ")
|
|
53
|
+
.trim();
|
|
54
|
+
return plain.length > max ? `${plain.slice(0, max - 1)}…` : plain;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function taskName(origin: ChatOrigin): string {
|
|
58
|
+
const names = origin.subjects?.map((s) => s.name) ?? [];
|
|
59
|
+
if (!names.length) return origin.label;
|
|
60
|
+
return names.length > 1 ? `${names[0]} +${names.length - 1}` : names[0];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The turn an activity item starts, or null when it should render in full:
|
|
64
|
+
// anything that needs the reader's eyes (a pending approval, a plan, an
|
|
65
|
+
// error) stays out of the fold. `limit` is where a turn must stop (the next
|
|
66
|
+
// undone range).
|
|
67
|
+
export function eventTurnAt(
|
|
68
|
+
items: ChatItem[],
|
|
69
|
+
start: number,
|
|
70
|
+
limit = items.length
|
|
71
|
+
): { turn: EventTurn; end: number } | null {
|
|
72
|
+
const user = items[start];
|
|
73
|
+
if (user.kind !== "user") return null;
|
|
74
|
+
const origin = activityOrigin(user);
|
|
75
|
+
if (!origin) return null;
|
|
76
|
+
let end = start + 1;
|
|
77
|
+
let ended = false;
|
|
78
|
+
while (end < limit) {
|
|
79
|
+
const item = items[end];
|
|
80
|
+
if (item.kind === "user" || item.kind === "undo") break;
|
|
81
|
+
end++;
|
|
82
|
+
if (item.kind === "turn_end") {
|
|
83
|
+
ended = true;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const replies = items.slice(start + 1, end);
|
|
88
|
+
const needsReader = replies.some(
|
|
89
|
+
(i) =>
|
|
90
|
+
i.kind === "plan" ||
|
|
91
|
+
i.kind === "error" ||
|
|
92
|
+
(i.kind === "approval" && i.status === "pending")
|
|
93
|
+
);
|
|
94
|
+
if (needsReader) return null;
|
|
95
|
+
const reply = replies.find((i) => i.kind === "assistant" && i.text.trim());
|
|
96
|
+
const body = eventParts(user, origin).body;
|
|
97
|
+
return {
|
|
98
|
+
end,
|
|
99
|
+
turn: {
|
|
100
|
+
id: user.id,
|
|
101
|
+
user,
|
|
102
|
+
origin,
|
|
103
|
+
replies,
|
|
104
|
+
summary: plainSummary(reply?.kind === "assistant" ? reply.text : body),
|
|
105
|
+
task: taskName(origin),
|
|
106
|
+
at: user.createdAt,
|
|
107
|
+
live: !ended && end >= items.length,
|
|
108
|
+
hasQuestion: replies.some(isQuestionTool),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
package/lib/db/changes.ts
CHANGED
|
@@ -29,12 +29,18 @@ export const TABLES_WATCHED_56 = ["orchestrator_rules"] as const;
|
|
|
29
29
|
|
|
30
30
|
export const TABLES_WATCHED_58 = ["away_periods"] as const;
|
|
31
31
|
|
|
32
|
+
export const TABLES_WATCHED_78 = [
|
|
33
|
+
"orchestrator_questions",
|
|
34
|
+
"needs_you_dismissed",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
32
37
|
// Every table any migration watches, for the browser's topic map to cover.
|
|
33
38
|
export const WATCHED_TABLES: readonly string[] = [
|
|
34
39
|
...TABLES_WATCHED_45,
|
|
35
40
|
...TABLES_WATCHED_50,
|
|
36
41
|
...TABLES_WATCHED_56,
|
|
37
42
|
...TABLES_WATCHED_58,
|
|
43
|
+
...TABLES_WATCHED_78,
|
|
38
44
|
];
|
|
39
45
|
|
|
40
46
|
export function installChangeTriggers(
|
package/lib/db/migrations.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
TABLES_WATCHED_50,
|
|
5
5
|
TABLES_WATCHED_56,
|
|
6
6
|
TABLES_WATCHED_58,
|
|
7
|
+
TABLES_WATCHED_78,
|
|
7
8
|
} from "./changes";
|
|
8
9
|
import type Database from "better-sqlite3";
|
|
9
10
|
|
|
@@ -1787,6 +1788,37 @@ const migrations: Migration[] = [
|
|
|
1787
1788
|
);
|
|
1788
1789
|
},
|
|
1789
1790
|
},
|
|
1791
|
+
{
|
|
1792
|
+
id: 78,
|
|
1793
|
+
name: "orchestrator_questions",
|
|
1794
|
+
up: (db) => {
|
|
1795
|
+
// A question the orchestrator put to Saad in chat, open until he
|
|
1796
|
+
// answers it or the orchestrator clears it. needs_you_dismissed: items
|
|
1797
|
+
// he hid from the Needs-you strip (an ask's or question's key); hiding
|
|
1798
|
+
// never resolves anything.
|
|
1799
|
+
db.exec(`
|
|
1800
|
+
CREATE TABLE IF NOT EXISTS orchestrator_questions (
|
|
1801
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1802
|
+
workspace_id TEXT NOT NULL,
|
|
1803
|
+
text TEXT NOT NULL,
|
|
1804
|
+
task TEXT,
|
|
1805
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
1806
|
+
resolved_by TEXT,
|
|
1807
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1808
|
+
resolved_at TEXT
|
|
1809
|
+
);
|
|
1810
|
+
CREATE INDEX IF NOT EXISTS idx_orchestrator_questions_open
|
|
1811
|
+
ON orchestrator_questions(workspace_id, status);
|
|
1812
|
+
CREATE TABLE IF NOT EXISTS needs_you_dismissed (
|
|
1813
|
+
workspace_id TEXT NOT NULL,
|
|
1814
|
+
key TEXT NOT NULL,
|
|
1815
|
+
dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1816
|
+
PRIMARY KEY (workspace_id, key)
|
|
1817
|
+
);
|
|
1818
|
+
`);
|
|
1819
|
+
installChangeTriggers(db, TABLES_WATCHED_78);
|
|
1820
|
+
},
|
|
1821
|
+
},
|
|
1790
1822
|
];
|
|
1791
1823
|
|
|
1792
1824
|
// `upTo`: stop after this id (tests that start from an older database).
|