@saadnvd1/agent-os 0.3.503 → 0.3.506
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 +67 -0
- package/app/api/projects/[id]/session-logs/route.ts +39 -0
- package/app/api/session-logs/[id]/route.ts +18 -0
- package/app/api/workspaces/[id]/session-logs/route.ts +35 -0
- package/components/Pane/DesktopTabBar.tsx +4 -1
- package/components/Pane/MobileTabBar.tsx +12 -1
- package/components/Pane/PaneViewToggle.tsx +14 -2
- package/components/Pane/index.tsx +28 -1
- package/components/PrStageBadge.tsx +15 -6
- package/components/Projects/ProjectSettingsDialog.tsx +11 -0
- package/components/SessionList/LiveLine.tsx +75 -0
- package/components/SessionList/RowParts.tsx +1 -7
- package/components/SessionList/RowShell.tsx +1 -1
- package/components/SessionList/RowSubtitle.tsx +4 -0
- package/components/SessionList/SessionRow.tsx +9 -3
- package/components/SessionLogs/LogDocument.tsx +29 -0
- package/components/SessionLogs/ProjectLogsSection.tsx +140 -0
- package/components/Workspaces/HqFolderForm.tsx +71 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/contexts/PaneContext.tsx +1 -1
- package/data/session-logs/index.ts +110 -0
- package/lib/db/migrations.ts +24 -0
- package/lib/done/index.ts +2 -0
- package/lib/orchestrator/brief.ts +1 -0
- package/lib/orchestrator/logs.ts +81 -0
- package/lib/orchestrator/serve.ts +5 -0
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +13 -0
- package/lib/orchestrator/tools.ts +1 -0
- package/lib/session-logs/build.ts +253 -0
- package/lib/session-logs/folder.ts +142 -0
- package/lib/session-logs/gather.ts +301 -0
- package/lib/session-logs/hq.ts +201 -0
- package/lib/session-logs/index.ts +193 -0
- package/lib/session-logs/paths.ts +37 -0
- package/lib/session-logs/reexport.ts +90 -0
- package/lib/session-logs/store.ts +105 -0
- package/lib/sidebar/live-status.ts +52 -0
- package/lib/tasks/finish.ts +3 -0
- package/lib/tasks/pr-stage.ts +14 -0
- package/lib/tasks/session.ts +4 -0
- package/package.json +1 -1
- package/scripts/backfill-session-logs.ts +101 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ScrollText } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from "@/components/ui/dialog";
|
|
13
|
+
import { Input } from "@/components/ui/input";
|
|
14
|
+
import {
|
|
15
|
+
useProjectLogsQuery,
|
|
16
|
+
useSetLogFolder,
|
|
17
|
+
type LogHeader,
|
|
18
|
+
} from "@/data/session-logs";
|
|
19
|
+
import { LogDocument } from "./LogDocument";
|
|
20
|
+
|
|
21
|
+
const day = (h: LogHeader) => (h.mergedAt ?? h.createdAt).slice(0, 10);
|
|
22
|
+
|
|
23
|
+
function LogRow({ log, onOpen }: { log: LogHeader; onOpen: () => void }) {
|
|
24
|
+
return (
|
|
25
|
+
<li>
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
onClick={onOpen}
|
|
29
|
+
className="hover:bg-accent/50 flex min-h-11 w-full items-center gap-3 px-3 py-2 text-left transition-colors"
|
|
30
|
+
>
|
|
31
|
+
<span className="min-w-0 flex-1">
|
|
32
|
+
<span className="block truncate text-sm font-medium">{log.name}</span>
|
|
33
|
+
<span className="text-muted-foreground block truncate text-xs">
|
|
34
|
+
{day(log)}
|
|
35
|
+
{log.prNumber ? ` · #${log.prNumber}` : ""}
|
|
36
|
+
{` · $${log.costUsd.toFixed(2)}`}
|
|
37
|
+
</span>
|
|
38
|
+
</span>
|
|
39
|
+
<ScrollText className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
40
|
+
</button>
|
|
41
|
+
</li>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A project's logs of merged tasks, newest first, and the folder their
|
|
46
|
+
// files go to (the default is outside every repository).
|
|
47
|
+
export function ProjectLogsSection({
|
|
48
|
+
projectId,
|
|
49
|
+
open,
|
|
50
|
+
}: {
|
|
51
|
+
projectId: string;
|
|
52
|
+
open: boolean;
|
|
53
|
+
}) {
|
|
54
|
+
const { data, isPending, isError, error } = useProjectLogsQuery(
|
|
55
|
+
projectId,
|
|
56
|
+
open
|
|
57
|
+
);
|
|
58
|
+
const save = useSetLogFolder(projectId);
|
|
59
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
60
|
+
const [viewing, setViewing] = useState<LogHeader | null>(null);
|
|
61
|
+
const folder = draft ?? data?.folder ?? "";
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="space-y-2">
|
|
65
|
+
<h3 className="text-sm font-medium">Session logs</h3>
|
|
66
|
+
<p className="text-muted-foreground text-xs">
|
|
67
|
+
When a task merges, AgentOS writes a log of it from what it already
|
|
68
|
+
recorded. No model is involved.
|
|
69
|
+
</p>
|
|
70
|
+
<div className="space-y-1.5">
|
|
71
|
+
<label htmlFor={`log-folder-${projectId}`} className="text-sm">
|
|
72
|
+
Session log folder
|
|
73
|
+
</label>
|
|
74
|
+
<div className="flex gap-2">
|
|
75
|
+
<Input
|
|
76
|
+
id={`log-folder-${projectId}`}
|
|
77
|
+
value={folder}
|
|
78
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
79
|
+
placeholder={data ? `${data.defaultFolder}/…` : "~/path/to/logs"}
|
|
80
|
+
className="h-11 flex-1 md:h-9"
|
|
81
|
+
onKeyDown={(e) => {
|
|
82
|
+
if (e.key !== "Enter") return;
|
|
83
|
+
// Not the project form's submit.
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
if (draft !== null)
|
|
86
|
+
save.mutate(draft, { onSuccess: () => setDraft(null) });
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
<Button
|
|
90
|
+
type="button"
|
|
91
|
+
variant="outline"
|
|
92
|
+
className="h-11 md:h-9"
|
|
93
|
+
disabled={save.isPending || draft === null}
|
|
94
|
+
onClick={() =>
|
|
95
|
+
save.mutate(draft ?? "", { onSuccess: () => setDraft(null) })
|
|
96
|
+
}
|
|
97
|
+
>
|
|
98
|
+
Save
|
|
99
|
+
</Button>
|
|
100
|
+
</div>
|
|
101
|
+
{save.isError && (
|
|
102
|
+
<p className="text-destructive text-sm">{save.error.message}</p>
|
|
103
|
+
)}
|
|
104
|
+
<p className="text-muted-foreground text-xs">
|
|
105
|
+
An absolute or ~ path, never inside the project's repository.
|
|
106
|
+
Empty uses the default.
|
|
107
|
+
</p>
|
|
108
|
+
</div>
|
|
109
|
+
{isPending && (
|
|
110
|
+
<div className="bg-muted/40 h-16 animate-pulse rounded-lg" />
|
|
111
|
+
)}
|
|
112
|
+
{isError && <p className="text-destructive text-sm">{error.message}</p>}
|
|
113
|
+
{data &&
|
|
114
|
+
(data.logs.length ? (
|
|
115
|
+
<ul className="bg-muted/40 divide-border max-h-64 divide-y overflow-y-auto rounded-lg">
|
|
116
|
+
{data.logs.map((l) => (
|
|
117
|
+
<LogRow key={l.id} log={l} onOpen={() => setViewing(l)} />
|
|
118
|
+
))}
|
|
119
|
+
</ul>
|
|
120
|
+
) : (
|
|
121
|
+
<p className="text-muted-foreground text-sm">
|
|
122
|
+
No merged tasks logged yet.
|
|
123
|
+
</p>
|
|
124
|
+
))}
|
|
125
|
+
<Dialog open={!!viewing} onOpenChange={(o) => !o && setViewing(null)}>
|
|
126
|
+
<DialogContent className="max-h-[90vh] max-w-3xl overflow-hidden [&>*]:min-w-0">
|
|
127
|
+
<DialogHeader>
|
|
128
|
+
<DialogTitle className="truncate">
|
|
129
|
+
{viewing?.name ?? "Log"}
|
|
130
|
+
</DialogTitle>
|
|
131
|
+
<DialogDescription>Session log</DialogDescription>
|
|
132
|
+
</DialogHeader>
|
|
133
|
+
<div className="h-[70vh]">
|
|
134
|
+
{viewing && <LogDocument sessionId={viewing.id} />}
|
|
135
|
+
</div>
|
|
136
|
+
</DialogContent>
|
|
137
|
+
</Dialog>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { useSetWorkspaceHq, useWorkspaceHqQuery } from "@/data/session-logs";
|
|
8
|
+
|
|
9
|
+
// The git repo this workspace's session logs are written into and
|
|
10
|
+
// committed to. Blank keeps them in AgentOS's own folder.
|
|
11
|
+
export function HqFolderForm({ workspaceId }: { workspaceId: string }) {
|
|
12
|
+
const { data, isPending, isError, error } = useWorkspaceHqQuery(workspaceId);
|
|
13
|
+
const save = useSetWorkspaceHq(workspaceId);
|
|
14
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
15
|
+
const saved = data?.hq?.folder ?? "";
|
|
16
|
+
const folder = draft ?? saved;
|
|
17
|
+
const id = `hq-folder-${workspaceId}`;
|
|
18
|
+
|
|
19
|
+
if (isPending)
|
|
20
|
+
return <div className="bg-muted/60 h-11 animate-pulse rounded-md" />;
|
|
21
|
+
if (isError)
|
|
22
|
+
return <p className="text-destructive text-xs">{error.message}</p>;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<form
|
|
26
|
+
onSubmit={(e) => {
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
save.mutate(folder, {
|
|
29
|
+
onSuccess: () => {
|
|
30
|
+
setDraft(null);
|
|
31
|
+
toast.success(
|
|
32
|
+
folder.trim() ? "HQ folder saved" : "HQ folder cleared"
|
|
33
|
+
);
|
|
34
|
+
},
|
|
35
|
+
onError: (err) => toast.error(err.message),
|
|
36
|
+
});
|
|
37
|
+
}}
|
|
38
|
+
className="space-y-1.5"
|
|
39
|
+
>
|
|
40
|
+
<label htmlFor={id} className="text-sm">
|
|
41
|
+
HQ folder
|
|
42
|
+
</label>
|
|
43
|
+
<div className="flex gap-2">
|
|
44
|
+
<Input
|
|
45
|
+
id={id}
|
|
46
|
+
value={folder}
|
|
47
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
48
|
+
placeholder="~/path/to/a/private/git/repo"
|
|
49
|
+
className="h-11 flex-1 text-base md:h-9 md:text-sm"
|
|
50
|
+
/>
|
|
51
|
+
<Button
|
|
52
|
+
type="submit"
|
|
53
|
+
size="sm"
|
|
54
|
+
className="h-11 sm:h-9"
|
|
55
|
+
disabled={folder === saved || save.isPending}
|
|
56
|
+
>
|
|
57
|
+
Save
|
|
58
|
+
</Button>
|
|
59
|
+
</div>
|
|
60
|
+
<p className="text-muted-foreground text-xs">
|
|
61
|
+
Session logs go to docs/sessions/<project>/ in this repo and are
|
|
62
|
+
committed there, one file per commit.{" "}
|
|
63
|
+
{data.hq?.visibility === "private"
|
|
64
|
+
? "The repo is private, so each commit is pushed."
|
|
65
|
+
: data.hq
|
|
66
|
+
? "Commits stay local: the repo isn't a private GitHub repository."
|
|
67
|
+
: "It must be a private git repo outside the workspace's projects."}
|
|
68
|
+
</p>
|
|
69
|
+
</form>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -6,6 +6,7 @@ import { TaskLimitForm } from "./TaskLimitForm";
|
|
|
6
6
|
import { RulesForm } from "./RulesForm";
|
|
7
7
|
import { FreshStartForm } from "./FreshStartForm";
|
|
8
8
|
import { McpServersForm } from "./McpServersForm";
|
|
9
|
+
import { HqFolderForm } from "./HqFolderForm";
|
|
9
10
|
import { StallForm } from "./StallForm";
|
|
10
11
|
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
11
12
|
import { AwayWindowForm } from "@/components/Away/AwayWindowForm";
|
|
@@ -47,6 +48,7 @@ export function WorkspaceSettings() {
|
|
|
47
48
|
<RulesForm workspaceId={w.id} />
|
|
48
49
|
<WorkspaceModelsForm workspaceId={w.id} />
|
|
49
50
|
<McpServersForm workspace={w} />
|
|
51
|
+
<HqFolderForm workspaceId={w.id} />
|
|
50
52
|
<SettingToggle
|
|
51
53
|
title="Wake tasks on CI failures and reviews"
|
|
52
54
|
detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
|
package/contexts/PaneContext.tsx
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from "@/lib/panes";
|
|
27
27
|
import { useViewport } from "@/hooks/useViewport";
|
|
28
28
|
|
|
29
|
-
export type ViewMode = "terminal" | "files" | "git" | "workers";
|
|
29
|
+
export type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
|
|
30
30
|
|
|
31
31
|
export interface PaneViewState {
|
|
32
32
|
viewMode: ViewMode;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { LogHeader } from "@/lib/session-logs/store";
|
|
3
|
+
|
|
4
|
+
export type { LogHeader };
|
|
5
|
+
|
|
6
|
+
export const sessionLogKeys = {
|
|
7
|
+
all: ["session-logs"] as const,
|
|
8
|
+
task: (id: string) => [...sessionLogKeys.all, "task", id] as const,
|
|
9
|
+
project: (id: string) => [...sessionLogKeys.all, "project", id] as const,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface TaskLog {
|
|
13
|
+
header: LogHeader;
|
|
14
|
+
markdown: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ProjectLogs {
|
|
18
|
+
folder: string | null;
|
|
19
|
+
defaultFolder: string;
|
|
20
|
+
logs: LogHeader[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// null: this task has no log (it didn't merge, or it predates logs).
|
|
24
|
+
export function useTaskLogQuery(sessionId: string | undefined, enabled = true) {
|
|
25
|
+
return useQuery({
|
|
26
|
+
queryKey: sessionLogKeys.task(sessionId ?? ""),
|
|
27
|
+
queryFn: async (): Promise<TaskLog | null> => {
|
|
28
|
+
const res = await fetch(
|
|
29
|
+
`/api/session-logs/${encodeURIComponent(sessionId!)}`
|
|
30
|
+
);
|
|
31
|
+
if (res.status === 404) return null;
|
|
32
|
+
const data = await res.json();
|
|
33
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
34
|
+
return data;
|
|
35
|
+
},
|
|
36
|
+
enabled: enabled && !!sessionId,
|
|
37
|
+
// A written log doesn't change; a task that just merged may not have
|
|
38
|
+
// one for a moment.
|
|
39
|
+
staleTime: (query) => (query.state.data ? Infinity : 10_000),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function projectLogs(res: Response): Promise<ProjectLogs> {
|
|
44
|
+
const data = await res.json();
|
|
45
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useProjectLogsQuery(projectId: string, enabled = true) {
|
|
50
|
+
return useQuery({
|
|
51
|
+
queryKey: sessionLogKeys.project(projectId),
|
|
52
|
+
queryFn: async () =>
|
|
53
|
+
projectLogs(await fetch(`/api/projects/${projectId}/session-logs`)),
|
|
54
|
+
enabled,
|
|
55
|
+
staleTime: 30_000,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useSetLogFolder(projectId: string) {
|
|
60
|
+
const queryClient = useQueryClient();
|
|
61
|
+
return useMutation({
|
|
62
|
+
mutationFn: async (folder: string) =>
|
|
63
|
+
projectLogs(
|
|
64
|
+
await fetch(`/api/projects/${projectId}/session-logs`, {
|
|
65
|
+
method: "PUT",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: JSON.stringify({ folder }),
|
|
68
|
+
})
|
|
69
|
+
),
|
|
70
|
+
onSuccess: (data) =>
|
|
71
|
+
queryClient.setQueryData(sessionLogKeys.project(projectId), data),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface WorkspaceHq {
|
|
76
|
+
hq: { folder: string; visibility: "private" | "internal" | "none" } | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const hqKey = (workspaceId: string) =>
|
|
80
|
+
[...sessionLogKeys.all, "hq", workspaceId] as const;
|
|
81
|
+
|
|
82
|
+
async function hqState(res: Response): Promise<WorkspaceHq> {
|
|
83
|
+
const data = await res.json();
|
|
84
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
85
|
+
return data;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function useWorkspaceHqQuery(workspaceId: string) {
|
|
89
|
+
return useQuery({
|
|
90
|
+
queryKey: hqKey(workspaceId),
|
|
91
|
+
queryFn: async () =>
|
|
92
|
+
hqState(await fetch(`/api/workspaces/${workspaceId}/session-logs`)),
|
|
93
|
+
staleTime: 30_000,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function useSetWorkspaceHq(workspaceId: string) {
|
|
98
|
+
const queryClient = useQueryClient();
|
|
99
|
+
return useMutation({
|
|
100
|
+
mutationFn: async (folder: string) =>
|
|
101
|
+
hqState(
|
|
102
|
+
await fetch(`/api/workspaces/${workspaceId}/session-logs`, {
|
|
103
|
+
method: "PUT",
|
|
104
|
+
headers: { "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify({ folder }),
|
|
106
|
+
})
|
|
107
|
+
),
|
|
108
|
+
onSuccess: (data) => queryClient.setQueryData(hqKey(workspaceId), data),
|
|
109
|
+
});
|
|
110
|
+
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1593,6 +1593,30 @@ const migrations: Migration[] = [
|
|
|
1593
1593
|
})();
|
|
1594
1594
|
},
|
|
1595
1595
|
},
|
|
1596
|
+
{
|
|
1597
|
+
id: 69,
|
|
1598
|
+
name: "session_logs",
|
|
1599
|
+
up: (db) => {
|
|
1600
|
+
// One log per merged task, written by AgentOS from data it already
|
|
1601
|
+
// holds (lib/session-logs). Never deleted: the database is the record,
|
|
1602
|
+
// the markdown file on disk is a copy.
|
|
1603
|
+
db.exec(`
|
|
1604
|
+
CREATE TABLE IF NOT EXISTS session_logs (
|
|
1605
|
+
session_id TEXT PRIMARY KEY,
|
|
1606
|
+
project_id TEXT,
|
|
1607
|
+
workspace_id TEXT,
|
|
1608
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1609
|
+
markdown TEXT NOT NULL,
|
|
1610
|
+
data TEXT NOT NULL,
|
|
1611
|
+
file_path TEXT
|
|
1612
|
+
);
|
|
1613
|
+
CREATE INDEX IF NOT EXISTS idx_session_logs_project
|
|
1614
|
+
ON session_logs(project_id, created_at);
|
|
1615
|
+
CREATE INDEX IF NOT EXISTS idx_session_logs_workspace
|
|
1616
|
+
ON session_logs(workspace_id, created_at);
|
|
1617
|
+
`);
|
|
1618
|
+
},
|
|
1619
|
+
},
|
|
1596
1620
|
];
|
|
1597
1621
|
|
|
1598
1622
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/done/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { sessionMergePolicy } from "../tasks/finish";
|
|
|
25
25
|
import { releasePorts } from "../ports";
|
|
26
26
|
import { dropSessionDatabase } from "../project-config/database";
|
|
27
27
|
import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
|
|
28
|
+
import { captureMergedTask } from "../session-logs";
|
|
28
29
|
|
|
29
30
|
export interface DoneOptions {
|
|
30
31
|
// Who asked: the orchestrator's gate failures count toward Saad, as its
|
|
@@ -102,6 +103,7 @@ function mark(s: Session, status: "merged" | "done", pr: TaskPR | null) {
|
|
|
102
103
|
: `UPDATE sessions SET task_status = 'done' WHERE id = ?`
|
|
103
104
|
).run(s.id);
|
|
104
105
|
syncTaskCardInBackground(s, status, pr);
|
|
106
|
+
if (status === "merged") void captureMergedTask(s.id, { head: pr?.head });
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
export async function doneSession(
|
|
@@ -95,6 +95,7 @@ Reading:
|
|
|
95
95
|
- \`${TOOL_NAMES.sessions}\`: every session in the workspace with its project, view, status, what it's doing, task/PR/CI state and stack position. After them, external sessions: tmux sessions AgentOS didn't start (dispatch, a person's own terminal), on this machine or another, working in one of the workspace's repositories or a worktree of it, each with its branch's open PR. They're read-only: \`read\` works on them; \`send\`, \`stop\` and \`done\` don't. Gate their PRs with \`review\` and \`sign_off\` by number.
|
|
96
96
|
- \`${TOOL_NAMES.read}\` (session, lines?): the end of a session's terminal or chat, or an external session's screen.
|
|
97
97
|
- \`${TOOL_NAMES.cards}\` (board?): the cards on the workspace's LumifyHub boards.
|
|
98
|
+
- \`${TOOL_NAMES.logs}\` (project?, task?, n?): the logs AgentOS wrote when tasks merged. Without task, the latest headers; with it, that task's whole log (brief, plan, timeline, usage, outcome). Check it before repeating work or re-planning a job a merged task already did.
|
|
98
99
|
- \`${TOOL_NAMES.stack_status}\` (id): one stack's items, PRs and progress.
|
|
99
100
|
- \`${TOOL_NAMES.orchestrators}\`: every workspace, and whether it has an orchestrator.
|
|
100
101
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The orchestrator's `logs` tool: session logs read from the database as
|
|
3
|
+
* they were written. Reading only; nothing here asks a model to summarise.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { formatDuration } from "../session-logs/build";
|
|
7
|
+
import { getLog, listLogs, type LogHeader } from "../session-logs";
|
|
8
|
+
import { db } from "../db";
|
|
9
|
+
import { workspaceProject } from "./targets";
|
|
10
|
+
import { untrusted } from "./untrusted";
|
|
11
|
+
|
|
12
|
+
// About 8k tokens: what one full log may return.
|
|
13
|
+
export const LOG_CHAR_CAP = 30000;
|
|
14
|
+
|
|
15
|
+
const headerLine = (h: LogHeader) =>
|
|
16
|
+
[
|
|
17
|
+
(h.mergedAt ?? h.createdAt).slice(0, 10),
|
|
18
|
+
`${h.name} [${h.id.slice(0, 8)}]`,
|
|
19
|
+
h.project ?? "no project",
|
|
20
|
+
h.prNumber ? `#${h.prNumber}` : "no PR",
|
|
21
|
+
h.durationMs !== null ? formatDuration(h.durationMs) : "?",
|
|
22
|
+
`$${h.costUsd.toFixed(2)}`,
|
|
23
|
+
h.hasPlan ? "plan" : h.planKept ? "no plan" : "plan not kept",
|
|
24
|
+
].join(" · ");
|
|
25
|
+
|
|
26
|
+
function findOne(
|
|
27
|
+
workspaceId: string,
|
|
28
|
+
projectId: string | null,
|
|
29
|
+
ref: string
|
|
30
|
+
): string {
|
|
31
|
+
const r = ref.trim().toLowerCase();
|
|
32
|
+
const pr = /^#?(\d+)$/.exec(r);
|
|
33
|
+
const rows = db
|
|
34
|
+
.prepare(
|
|
35
|
+
`SELECT session_id AS id, json_extract(data, '$.name') AS name,
|
|
36
|
+
json_extract(data, '$.prNumber') AS pr
|
|
37
|
+
FROM session_logs WHERE workspace_id = ? AND (? IS NULL OR project_id = ?)
|
|
38
|
+
ORDER BY json_extract(data, '$.mergedAt') DESC`
|
|
39
|
+
)
|
|
40
|
+
.all(workspaceId, projectId, projectId) as {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
pr: number | null;
|
|
44
|
+
}[];
|
|
45
|
+
const hit = rows.filter(
|
|
46
|
+
(x) =>
|
|
47
|
+
x.id === ref ||
|
|
48
|
+
x.id.startsWith(r) ||
|
|
49
|
+
x.name.toLowerCase() === r ||
|
|
50
|
+
(pr !== null && x.pr === Number(pr[1]))
|
|
51
|
+
);
|
|
52
|
+
if (hit.length === 1) return hit[0].id;
|
|
53
|
+
if (hit.length > 1)
|
|
54
|
+
throw new Error(
|
|
55
|
+
`"${ref}" matches ${hit.length} logs (${hit.map((x) => x.id.slice(0, 8)).join(", ")}); use an id`
|
|
56
|
+
);
|
|
57
|
+
throw new Error(`No log for "${ref}" in this workspace.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function readLogs(
|
|
61
|
+
workspaceId: string,
|
|
62
|
+
a: { project?: string; task?: string; n?: number }
|
|
63
|
+
): string {
|
|
64
|
+
const projectId = a.project
|
|
65
|
+
? workspaceProject(workspaceId, a.project).id
|
|
66
|
+
: null;
|
|
67
|
+
if (a.task) {
|
|
68
|
+
const row = getLog(findOne(workspaceId, projectId, a.task))!;
|
|
69
|
+
const md =
|
|
70
|
+
row.markdown.length > LOG_CHAR_CAP
|
|
71
|
+
? `${row.markdown.slice(0, LOG_CHAR_CAP)}\n… (trimmed; ${row.markdown.length} characters in all)`
|
|
72
|
+
: row.markdown;
|
|
73
|
+
return untrusted("session log", md);
|
|
74
|
+
}
|
|
75
|
+
const logs = listLogs(
|
|
76
|
+
{ workspaceId, ...(projectId ? { projectId } : {}) },
|
|
77
|
+
a.n ?? 10
|
|
78
|
+
);
|
|
79
|
+
if (!logs.length) return "No session logs yet.";
|
|
80
|
+
return untrusted("session logs", logs.map(headerLine).join("\n"));
|
|
81
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
stop,
|
|
13
13
|
} from "./act";
|
|
14
14
|
import { readCards } from "./cards";
|
|
15
|
+
import { readLogs } from "./logs";
|
|
15
16
|
import { describeSessions } from "./describe";
|
|
16
17
|
import { sessionFacts } from "./facts";
|
|
17
18
|
import { describeExternal, externalSessions } from "./external-sessions";
|
|
@@ -85,6 +86,10 @@ export async function runTool(
|
|
|
85
86
|
}
|
|
86
87
|
case "cards":
|
|
87
88
|
return readCards(w, p(tool, raw).board);
|
|
89
|
+
case "logs": {
|
|
90
|
+
const a = p(tool, raw);
|
|
91
|
+
return readLogs(w, a);
|
|
92
|
+
}
|
|
88
93
|
case "send": {
|
|
89
94
|
const a = p(tool, raw);
|
|
90
95
|
return send(w, a.session, a.message, {
|
|
@@ -57,6 +57,19 @@ export const TOOL_SHAPES = {
|
|
|
57
57
|
cards: {
|
|
58
58
|
board: ref("One board, by board or project name").optional(),
|
|
59
59
|
},
|
|
60
|
+
logs: {
|
|
61
|
+
project: project.optional(),
|
|
62
|
+
task: ref(
|
|
63
|
+
"One merged task's log, by task name, id (or its first characters) or PR (#12); without it, the latest headers"
|
|
64
|
+
).optional(),
|
|
65
|
+
n: z
|
|
66
|
+
.number()
|
|
67
|
+
.int()
|
|
68
|
+
.min(1)
|
|
69
|
+
.max(50)
|
|
70
|
+
.optional()
|
|
71
|
+
.describe("How many headers, newest first (default 10)"),
|
|
72
|
+
},
|
|
60
73
|
send: {
|
|
61
74
|
session,
|
|
62
75
|
message: z.string().trim().min(1).max(8000),
|
|
@@ -55,6 +55,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
55
55
|
"Every session in this workspace: project, view, status, what it's doing, task/PR/CI state and stack position; then external sessions (tmux sessions AgentOS didn't start, here or on other machines, working in a workspace repository), read-only, with their branch's PR.",
|
|
56
56
|
read: "The end of one session's terminal or chat, or an external session's screen, as plain text (capped at about 4k tokens).",
|
|
57
57
|
cards: "Cards on this workspace's linked LumifyHub boards, by list.",
|
|
58
|
+
logs: "Session logs AgentOS wrote when tasks merged: the last N headers (task, PR, cost, duration) of the workspace or one project, or one log in full (brief, plan, timeline, usage, outcome). Read straight from the database; nothing is summarised.",
|
|
58
59
|
send: "Message a session in this workspace over the bus. It arrives as its next prompt. When Saad changes a running task's scope, tell it with scope_change set, so its review judges the PR against the brief as amended.",
|
|
59
60
|
orchestrators:
|
|
60
61
|
"Every workspace, and whether it has an orchestrator you can message.",
|