@saadnvd1/agent-os 0.3.853 → 0.3.859
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/app/api/leads/adopt/route.ts +35 -0
- package/app/api/leads/proposals/route.ts +52 -0
- package/app/api/leads/route.test.ts +95 -0
- package/app/api/leads/route.ts +60 -0
- package/app/api/sessions/[id]/fresh/route.ts +2 -1
- package/app/api/sessions/[id]/route.ts +9 -1
- package/app/api/tmux/kill-all/route.ts +2 -1
- package/app/api/workspaces/[id]/orchestrator/tools/route.test.ts +43 -0
- package/app/api/workspaces/[id]/orchestrator/tools/route.ts +14 -11
- package/components/Analyst/pair.ts +2 -1
- package/components/Chat/ChatPanel.tsx +1 -0
- package/components/Orchestrator/AskCard.tsx +6 -0
- package/components/Orchestrator/NeedsYouStrip.tsx +7 -2
- package/components/Pane/index.tsx +5 -7
- package/components/Schedules/ScheduleForm.tsx +2 -1
- package/components/SessionList/RowSubtitle.tsx +2 -1
- package/components/SessionList/SessionRow.tsx +2 -1
- package/components/SessionList/useSidebarData.ts +8 -3
- package/content/whats-new.json +16 -0
- package/hooks/useNeedsYou.ts +10 -4
- package/hooks/useOrchestratorHeader.ts +2 -1
- package/lib/analyst/createLink.ts +3 -1
- package/lib/away/report.ts +2 -1
- package/lib/bus/format.ts +4 -1
- package/lib/bus/index.ts +9 -2
- package/lib/bus/read-scope.ts +4 -3
- package/lib/bus/resolve.ts +4 -1
- package/lib/bus/role-alias.ts +2 -1
- package/lib/bus/targets.ts +8 -3
- package/lib/bus/waits/tick.ts +2 -1
- package/lib/chat/compact-context.ts +2 -1
- package/lib/chat/mention-roots.ts +2 -1
- package/lib/chat/runner.ts +3 -2
- package/lib/chat/settings.ts +3 -2
- package/lib/chat/worker/extras.ts +4 -1
- package/lib/chat/worker/host.ts +6 -5
- package/lib/db/migrations/20261102T1944_leads.ts +142 -0
- package/lib/db/migrations/20261102T2300_needs_you_chat_per_session.ts +24 -0
- package/lib/db/session-parts/columns.ts +2 -0
- package/lib/db/types.ts +10 -2
- package/lib/done/archive.ts +3 -2
- package/lib/done/auto-archive.ts +2 -1
- package/lib/done/bulk.ts +4 -3
- package/lib/done/index.ts +3 -2
- package/lib/done/plan.ts +5 -2
- package/lib/experiments/registry.ts +11 -0
- package/lib/experiments/view.ts +3 -1
- package/lib/fork/brief.ts +2 -2
- package/lib/fork/extras.ts +4 -2
- package/lib/fork/guard.ts +4 -2
- package/lib/fork/scratch.ts +102 -12
- package/lib/fork/settle.ts +31 -7
- package/lib/fork/start.ts +5 -4
- package/lib/fork/tick.ts +19 -0
- package/lib/fork/worktree.ts +7 -3
- package/lib/health/env/sendFixNotes.ts +3 -2
- package/lib/hosts/peer-actions.ts +2 -1
- package/lib/hosts/peer-sessions.ts +3 -1
- package/lib/leads/actor.ts +16 -0
- package/lib/leads/adopt.ts +150 -0
- package/lib/leads/area.ts +77 -0
- package/lib/leads/brief-common.ts +122 -0
- package/lib/leads/brief.ts +155 -0
- package/lib/leads/charters.ts +99 -0
- package/lib/leads/deliver.ts +24 -0
- package/lib/leads/digest.ts +166 -0
- package/lib/leads/direction.ts +202 -0
- package/lib/leads/home.ts +196 -0
- package/lib/leads/index.ts +8 -0
- package/lib/leads/memos.ts +70 -0
- package/lib/leads/messages.ts +112 -0
- package/lib/leads/profiles.ts +83 -0
- package/lib/leads/roles.ts +114 -0
- package/lib/leads/settings.ts +55 -0
- package/lib/leads/templates.ts +51 -0
- package/lib/leads/tools.ts +159 -0
- package/lib/leads/types.ts +88 -0
- package/lib/leads/usage.ts +46 -0
- package/lib/learning-cards/input.ts +2 -1
- package/lib/live/drive.ts +2 -1
- package/lib/models/launch.ts +2 -1
- package/lib/models/session-role.ts +2 -1
- package/lib/needs-you.ts +12 -6
- package/lib/notify/index.ts +2 -1
- package/lib/orchestrator/act.ts +3 -1
- package/lib/orchestrator/ask-tool.ts +10 -0
- package/lib/orchestrator/ask-view.ts +2 -0
- package/lib/orchestrator/asks.ts +48 -8
- package/lib/orchestrator/brief.ts +32 -67
- package/lib/orchestrator/digest.ts +3 -2
- package/lib/orchestrator/facts.ts +2 -1
- package/lib/orchestrator/full-access.ts +2 -1
- package/lib/orchestrator/home.ts +8 -7
- package/lib/orchestrator/mcp-servers.ts +7 -6
- package/lib/orchestrator/notes.ts +15 -4
- package/lib/orchestrator/overview.ts +3 -1
- package/lib/orchestrator/recipe-tools.ts +2 -1
- package/lib/orchestrator/reminders.ts +2 -1
- package/lib/orchestrator/tools.ts +43 -13
- package/lib/orchestrator/worker.ts +45 -13
- package/lib/orchestrator/write-guard.ts +2 -2
- package/lib/projects.ts +3 -2
- package/lib/resurrect/find.ts +2 -1
- package/lib/resurrect/index.ts +3 -2
- package/lib/schedules/index.ts +3 -2
- package/lib/schedules/start.ts +2 -1
- package/lib/schedules/store.ts +3 -2
- package/lib/session-audit/sources.ts +4 -3
- package/lib/session-groups/workspace.ts +2 -1
- package/lib/sessions/launch.ts +2 -1
- package/lib/sidebar/machines.ts +2 -1
- package/lib/sidebar/row-actions.ts +2 -1
- package/lib/sidebar/row-badge.ts +3 -1
- package/lib/sidebar/row-menu.ts +6 -3
- package/lib/sidebar/shelves.ts +3 -3
- package/lib/status/collect.ts +4 -3
- package/lib/tasks/held-replies.ts +2 -1
- package/lib/tasks/move-targets.ts +2 -1
- package/lib/tasks/pause.ts +2 -1
- package/lib/tasks/starter.ts +3 -2
- package/lib/workspaces.ts +4 -3
- package/lib/worktree-sweep/findSettled.ts +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a lead carries across a fresh start: its pinned direction (all of it,
|
|
3
|
+
* newest first), its memo, the pins it proposed, unread mail from other
|
|
4
|
+
* leads, and its own asks, work and decision log. Read from the database at
|
|
5
|
+
* every start, no model involved, under the same token budget as the
|
|
6
|
+
* orchestrator's digest.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { db } from "../db";
|
|
10
|
+
import { owner } from "../owner";
|
|
11
|
+
import {
|
|
12
|
+
CHARS_PER_TOKEN,
|
|
13
|
+
clip,
|
|
14
|
+
DIGEST_TOKENS,
|
|
15
|
+
renderDigest,
|
|
16
|
+
type Section,
|
|
17
|
+
} from "../orchestrator/digest";
|
|
18
|
+
import { openAsks } from "../orchestrator/asks";
|
|
19
|
+
import { shortId } from "../orchestrator/describe";
|
|
20
|
+
import { untrusted } from "../orchestrator/untrusted";
|
|
21
|
+
import { currentDirection, openProposals } from "./direction";
|
|
22
|
+
import { latestMemo } from "./memos";
|
|
23
|
+
import { unreadFor } from "./messages";
|
|
24
|
+
import { leadTitle } from "./home";
|
|
25
|
+
import type { LeadKind } from "./types";
|
|
26
|
+
|
|
27
|
+
const QUOTE_CAP = 600;
|
|
28
|
+
const NOTES_SCANNED = 100;
|
|
29
|
+
// What Tech's digest adds once its workspace has leads.
|
|
30
|
+
export const TECH_LEAD_TOKENS = 1500;
|
|
31
|
+
|
|
32
|
+
function directionSection(w: string, kind: LeadKind): Section {
|
|
33
|
+
return {
|
|
34
|
+
title: `Pinned direction, from ${owner()}, newest first`,
|
|
35
|
+
// The server copied each quote from his own message: it's his word.
|
|
36
|
+
lines: currentDirection(w, kind).map(
|
|
37
|
+
(p) =>
|
|
38
|
+
`- [pin ${p.id}, ${p.setAt.slice(0, 10)}] "${clip(p.quote, QUOTE_CAP)}"`
|
|
39
|
+
),
|
|
40
|
+
empty: `- None yet. Only ${owner()} sets direction; propose a pin with propose_pin.`,
|
|
41
|
+
more: "direction",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function memoSection(w: string, kind: LeadKind): Section {
|
|
46
|
+
const memo = latestMemo(w, kind);
|
|
47
|
+
return {
|
|
48
|
+
title: "Your memo",
|
|
49
|
+
// Written by the lead from what it read: it informs, never authorizes.
|
|
50
|
+
lines: memo
|
|
51
|
+
? [
|
|
52
|
+
`(${memo.createdAt.slice(0, 16)} UTC) ${untrusted("your memo", memo.text)}`,
|
|
53
|
+
]
|
|
54
|
+
: [],
|
|
55
|
+
empty: "- None yet. Write one with update_memo.",
|
|
56
|
+
more: "direction",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function proposalsSection(w: string, kind: LeadKind): Section {
|
|
61
|
+
return {
|
|
62
|
+
title: `Pins you proposed, waiting for ${owner()}`,
|
|
63
|
+
lines: openProposals(w, kind).map(
|
|
64
|
+
(p) => `- [proposal ${p.id}] "${clip(p.quote, QUOTE_CAP)}"`
|
|
65
|
+
),
|
|
66
|
+
more: "direction",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mailSection(w: string, kind: LeadKind): Section {
|
|
71
|
+
return {
|
|
72
|
+
title: "Unread mail from other leads",
|
|
73
|
+
lines: unreadFor(w, kind).map(
|
|
74
|
+
(m) =>
|
|
75
|
+
`- [${m.createdAt.slice(0, 16)} UTC, from ${m.fromKind}, thread "${clip(m.thread, 80)}"] ${untrusted(`the ${m.fromKind} lead`, clip(m.text))}`
|
|
76
|
+
),
|
|
77
|
+
more: "",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function asksSection(w: string, kind: LeadKind): Section {
|
|
82
|
+
return {
|
|
83
|
+
title: `Your open asks (waiting on ${owner()})`,
|
|
84
|
+
lines: openAsks(w)
|
|
85
|
+
.filter((a) => a.lead_kind === kind)
|
|
86
|
+
.map(
|
|
87
|
+
(a) =>
|
|
88
|
+
`- [ask ${a.id}] ${a.kind}: ${untrusted("ask title", clip(a.title, 160))}`
|
|
89
|
+
),
|
|
90
|
+
empty: "- None.",
|
|
91
|
+
more: "",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function workSection(sessionId: string): Section {
|
|
96
|
+
const rows = db
|
|
97
|
+
.prepare(
|
|
98
|
+
`SELECT id, name, task_status, pr_number FROM session_rows
|
|
99
|
+
WHERE started_by = ? AND archived_at IS NULL
|
|
100
|
+
AND (task_status IS NULL OR task_status = 'running')
|
|
101
|
+
ORDER BY created_at DESC LIMIT 30`
|
|
102
|
+
)
|
|
103
|
+
.all(sessionId) as {
|
|
104
|
+
id: string;
|
|
105
|
+
name: string;
|
|
106
|
+
task_status: string | null;
|
|
107
|
+
pr_number: number | null;
|
|
108
|
+
}[];
|
|
109
|
+
return {
|
|
110
|
+
title: "Work you started, still open",
|
|
111
|
+
lines: rows.map(
|
|
112
|
+
(r) =>
|
|
113
|
+
`- ${shortId(r.id)} ${untrusted("session name", clip(r.name, 80))}${r.task_status ? " (task)" : ""}${r.pr_number ? ` PR #${r.pr_number}` : ""}`
|
|
114
|
+
),
|
|
115
|
+
empty: "- None.",
|
|
116
|
+
more: "sessions",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function notesSection(w: string, kind: LeadKind): Section {
|
|
121
|
+
const rows = db
|
|
122
|
+
.prepare(
|
|
123
|
+
`SELECT kind, text, created_at FROM orchestrator_notes
|
|
124
|
+
WHERE workspace_id = ? AND lead_kind = ? ORDER BY id DESC LIMIT ?`
|
|
125
|
+
)
|
|
126
|
+
.all(w, kind, NOTES_SCANNED) as {
|
|
127
|
+
kind: string;
|
|
128
|
+
text: string;
|
|
129
|
+
created_at: string;
|
|
130
|
+
}[];
|
|
131
|
+
return {
|
|
132
|
+
title: "Your decision log, newest first",
|
|
133
|
+
lines: rows.map(
|
|
134
|
+
(n) =>
|
|
135
|
+
`- ${n.created_at.slice(0, 16)} UTC${n.kind === "note" ? "" : ` ${n.kind}`}: ${untrusted(n.kind === "note" ? "your note" : `${n.kind} log line`, clip(n.text))}`
|
|
136
|
+
),
|
|
137
|
+
more: "",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A lead's whole digest. Tech's (`tech`) is only what its own digest lacks:
|
|
142
|
+
// direction, memo, proposals and mail.
|
|
143
|
+
export function buildLeadDigest(
|
|
144
|
+
w: string,
|
|
145
|
+
kind: LeadKind,
|
|
146
|
+
opts: { sessionId: string; tech?: boolean },
|
|
147
|
+
budgetTokens = opts.tech ? TECH_LEAD_TOKENS : DIGEST_TOKENS
|
|
148
|
+
): string {
|
|
149
|
+
const head = opts.tech
|
|
150
|
+
? `## Your direction as ${leadTitle(w, kind)}`
|
|
151
|
+
: "## Where things stand (read from AgentOS's database as you started)";
|
|
152
|
+
const sections = [
|
|
153
|
+
directionSection(w, kind),
|
|
154
|
+
memoSection(w, kind),
|
|
155
|
+
proposalsSection(w, kind),
|
|
156
|
+
mailSection(w, kind),
|
|
157
|
+
...(opts.tech
|
|
158
|
+
? []
|
|
159
|
+
: [
|
|
160
|
+
asksSection(w, kind),
|
|
161
|
+
workSection(opts.sessionId),
|
|
162
|
+
notesSection(w, kind),
|
|
163
|
+
]),
|
|
164
|
+
];
|
|
165
|
+
return `${head}\n\n${renderDigest(sections, budgetTokens - Math.ceil((head.length + 2) / CHARS_PER_TOKEN))}`;
|
|
166
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import type { Session } from "@/lib/db/types";
|
|
3
|
+
import { getItem } from "@/lib/chat/store";
|
|
4
|
+
import { originOf } from "@/lib/chat/origin";
|
|
5
|
+
import { leadKindOf } from "./roles";
|
|
6
|
+
import type { DirectionPin, LeadKind, PinProposal } from "./types";
|
|
7
|
+
|
|
8
|
+
const QUOTE_CAP = 2000;
|
|
9
|
+
|
|
10
|
+
interface PinRow {
|
|
11
|
+
id: number;
|
|
12
|
+
workspace_id: string;
|
|
13
|
+
lead_kind: string;
|
|
14
|
+
session_id: string;
|
|
15
|
+
chat_item_id: string;
|
|
16
|
+
quote: string;
|
|
17
|
+
set_at: string;
|
|
18
|
+
superseded_by: number | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ProposalRow {
|
|
22
|
+
id: number;
|
|
23
|
+
workspace_id: string;
|
|
24
|
+
lead_kind: string;
|
|
25
|
+
session_id: string;
|
|
26
|
+
chat_item_id: string;
|
|
27
|
+
quote: string;
|
|
28
|
+
status: PinProposal["status"];
|
|
29
|
+
created_at: string;
|
|
30
|
+
resolved_at: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const pinView = (r: PinRow): DirectionPin => ({
|
|
34
|
+
id: r.id,
|
|
35
|
+
workspaceId: r.workspace_id,
|
|
36
|
+
leadKind: r.lead_kind,
|
|
37
|
+
sessionId: r.session_id,
|
|
38
|
+
chatItemId: r.chat_item_id,
|
|
39
|
+
quote: r.quote,
|
|
40
|
+
setAt: r.set_at,
|
|
41
|
+
supersededBy: r.superseded_by,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const proposalView = (r: ProposalRow): PinProposal => ({
|
|
45
|
+
id: r.id,
|
|
46
|
+
workspaceId: r.workspace_id,
|
|
47
|
+
leadKind: r.lead_kind,
|
|
48
|
+
sessionId: r.session_id,
|
|
49
|
+
chatItemId: r.chat_item_id,
|
|
50
|
+
quote: r.quote,
|
|
51
|
+
status: r.status,
|
|
52
|
+
createdAt: r.created_at,
|
|
53
|
+
resolvedAt: r.resolved_at,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// The words a pin may quote: a message the person typed in this chat, read
|
|
57
|
+
// from the database. An event, a peer, a schedule or the agent's own text
|
|
58
|
+
// is never direction, whatever a lead says about it.
|
|
59
|
+
export function authoredQuote(
|
|
60
|
+
sessionId: string,
|
|
61
|
+
chatItemId: string
|
|
62
|
+
): string | null {
|
|
63
|
+
const item = getItem(sessionId, chatItemId);
|
|
64
|
+
if (!item || item.kind !== "user" || originOf(item) !== null) return null;
|
|
65
|
+
const text = item.text.trim().slice(0, QUOTE_CAP);
|
|
66
|
+
return text || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PinTarget {
|
|
70
|
+
workspaceId: string;
|
|
71
|
+
leadKind: LeadKind;
|
|
72
|
+
sessionId: string;
|
|
73
|
+
chatItemId: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The quote for a pin in a lead's own chat, or why it can't be one.
|
|
77
|
+
function attest(t: PinTarget): { quote: string } | { error: string } {
|
|
78
|
+
const s = db
|
|
79
|
+
.prepare(
|
|
80
|
+
`SELECT role, lead_kind, workspace_id FROM session_rows WHERE id = ?`
|
|
81
|
+
)
|
|
82
|
+
.get(t.sessionId) as
|
|
83
|
+
Pick<Session, "role" | "lead_kind" | "workspace_id"> | undefined;
|
|
84
|
+
if (!s || s.workspace_id !== t.workspaceId || leadKindOf(s) !== t.leadKind)
|
|
85
|
+
return { error: "That chat isn't this lead's" };
|
|
86
|
+
const quote = authoredQuote(t.sessionId, t.chatItemId);
|
|
87
|
+
return quote ? { quote } : { error: "Only your own message can be pinned" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function pinDirection(
|
|
91
|
+
t: PinTarget & { supersedes?: number }
|
|
92
|
+
): DirectionPin | { error: string } {
|
|
93
|
+
const a = attest(t);
|
|
94
|
+
if ("error" in a) return a;
|
|
95
|
+
return db.transaction(() => {
|
|
96
|
+
const { lastInsertRowid } = db
|
|
97
|
+
.prepare(
|
|
98
|
+
`INSERT INTO lead_direction (workspace_id, lead_kind, session_id, chat_item_id, quote)
|
|
99
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
100
|
+
)
|
|
101
|
+
.run(t.workspaceId, t.leadKind, t.sessionId, t.chatItemId, a.quote);
|
|
102
|
+
const id = Number(lastInsertRowid);
|
|
103
|
+
if (t.supersedes)
|
|
104
|
+
db.prepare(
|
|
105
|
+
`UPDATE lead_direction SET superseded_by = ?
|
|
106
|
+
WHERE id = ? AND workspace_id = ? AND lead_kind = ? AND superseded_by IS NULL`
|
|
107
|
+
).run(id, t.supersedes, t.workspaceId, t.leadKind);
|
|
108
|
+
return getPin(id)!;
|
|
109
|
+
})();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function getPin(id: number): DirectionPin | null {
|
|
113
|
+
const r = db.prepare(`SELECT * FROM lead_direction WHERE id = ?`).get(id) as
|
|
114
|
+
PinRow | undefined;
|
|
115
|
+
return r ? pinView(r) : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The pins in force, newest first.
|
|
119
|
+
export function currentDirection(
|
|
120
|
+
workspaceId: string,
|
|
121
|
+
leadKind: LeadKind
|
|
122
|
+
): DirectionPin[] {
|
|
123
|
+
return (
|
|
124
|
+
db
|
|
125
|
+
.prepare(
|
|
126
|
+
`SELECT * FROM lead_direction
|
|
127
|
+
WHERE workspace_id = ? AND lead_kind = ? AND superseded_by IS NULL
|
|
128
|
+
ORDER BY id DESC`
|
|
129
|
+
)
|
|
130
|
+
.all(workspaceId, leadKind) as PinRow[]
|
|
131
|
+
).map(pinView);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function directionHistory(
|
|
135
|
+
workspaceId: string,
|
|
136
|
+
leadKind: LeadKind
|
|
137
|
+
): DirectionPin[] {
|
|
138
|
+
return (
|
|
139
|
+
db
|
|
140
|
+
.prepare(
|
|
141
|
+
`SELECT * FROM lead_direction WHERE workspace_id = ? AND lead_kind = ?
|
|
142
|
+
ORDER BY id DESC`
|
|
143
|
+
)
|
|
144
|
+
.all(workspaceId, leadKind) as PinRow[]
|
|
145
|
+
).map(pinView);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A lead suggests a pin; the quote is still the server's, and nothing is
|
|
149
|
+
// direction until the person confirms it.
|
|
150
|
+
export function proposePin(t: PinTarget): PinProposal | { error: string } {
|
|
151
|
+
const a = attest(t);
|
|
152
|
+
if ("error" in a) return a;
|
|
153
|
+
db.prepare(
|
|
154
|
+
`INSERT INTO lead_pin_proposals (workspace_id, lead_kind, session_id, chat_item_id, quote)
|
|
155
|
+
VALUES (?, ?, ?, ?, ?)
|
|
156
|
+
ON CONFLICT(session_id, chat_item_id) WHERE status = 'open' DO NOTHING`
|
|
157
|
+
).run(t.workspaceId, t.leadKind, t.sessionId, t.chatItemId, a.quote);
|
|
158
|
+
const r = db
|
|
159
|
+
.prepare(
|
|
160
|
+
`SELECT * FROM lead_pin_proposals
|
|
161
|
+
WHERE session_id = ? AND chat_item_id = ? AND status = 'open'`
|
|
162
|
+
)
|
|
163
|
+
.get(t.sessionId, t.chatItemId) as ProposalRow;
|
|
164
|
+
return proposalView(r);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function openProposals(
|
|
168
|
+
workspaceId: string,
|
|
169
|
+
leadKind: LeadKind
|
|
170
|
+
): PinProposal[] {
|
|
171
|
+
return (
|
|
172
|
+
db
|
|
173
|
+
.prepare(
|
|
174
|
+
`SELECT * FROM lead_pin_proposals
|
|
175
|
+
WHERE workspace_id = ? AND lead_kind = ? AND status = 'open' ORDER BY id`
|
|
176
|
+
)
|
|
177
|
+
.all(workspaceId, leadKind) as ProposalRow[]
|
|
178
|
+
).map(proposalView);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// One tap: pins the confirmed proposals and drops the rest of the open
|
|
182
|
+
// ones. The quote is attested again, in case the item changed.
|
|
183
|
+
export function resolveProposals(
|
|
184
|
+
workspaceId: string,
|
|
185
|
+
leadKind: LeadKind,
|
|
186
|
+
confirm: readonly number[]
|
|
187
|
+
): DirectionPin[] {
|
|
188
|
+
const keep = new Set(confirm);
|
|
189
|
+
return db.transaction(() => {
|
|
190
|
+
const pins: DirectionPin[] = [];
|
|
191
|
+
for (const p of openProposals(workspaceId, leadKind)) {
|
|
192
|
+
const pin = keep.has(p.id) ? pinDirection(p) : null;
|
|
193
|
+
const ok = !!pin && !("error" in pin);
|
|
194
|
+
if (ok) pins.push(pin);
|
|
195
|
+
db.prepare(
|
|
196
|
+
`UPDATE lead_pin_proposals SET status = ?, resolved_at = datetime('now')
|
|
197
|
+
WHERE id = ?`
|
|
198
|
+
).run(ok ? "pinned" : "dropped", p.id);
|
|
199
|
+
}
|
|
200
|
+
return pins;
|
|
201
|
+
})();
|
|
202
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A workspace's leads: durable chat sessions run like its orchestrator, each
|
|
3
|
+
* heading one department. The orchestrator is the Tech lead in place (same
|
|
4
|
+
* row, same data); the others are rows with the lead role, made by "Add leads"
|
|
5
|
+
* or adopted from an existing chat (./adopt).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
10
|
+
import { db, type Session } from "../db";
|
|
11
|
+
import { agentosHome } from "../agentos-home";
|
|
12
|
+
import { refuseRealStateUnderTest } from "../test-home-guard";
|
|
13
|
+
import { getWorkspace } from "../workspaces";
|
|
14
|
+
import { resolveRoleOrDefault } from "../models/resolve";
|
|
15
|
+
import {
|
|
16
|
+
ensureOrchestrator,
|
|
17
|
+
getOrchestrator,
|
|
18
|
+
orchestratorDir,
|
|
19
|
+
} from "../orchestrator/home";
|
|
20
|
+
import { isLeadKind, leadKindOf, sqlLead, sqlStanding } from "./roles";
|
|
21
|
+
import { TECH, type LeadKind } from "./types";
|
|
22
|
+
import { markLeadsEnabled } from "./settings";
|
|
23
|
+
import { getCharter, leadAreas, putCharter } from "./charters";
|
|
24
|
+
import { charterTemplate } from "./templates";
|
|
25
|
+
|
|
26
|
+
// Tech first, then the shipped kinds, then custom ones by name.
|
|
27
|
+
const ORDER = [TECH, "marketing", "product"];
|
|
28
|
+
const rank = (kind: string) => {
|
|
29
|
+
const i = ORDER.indexOf(kind);
|
|
30
|
+
return i < 0 ? ORDER.length : i;
|
|
31
|
+
};
|
|
32
|
+
export const byLeadOrder = (a: string, b: string) =>
|
|
33
|
+
rank(a) - rank(b) || a.localeCompare(b);
|
|
34
|
+
|
|
35
|
+
// Where a lead starts and may write freely: the Tech lead keeps the
|
|
36
|
+
// orchestrator's folder.
|
|
37
|
+
export const leadDir = (workspaceId: string, kind: LeadKind) =>
|
|
38
|
+
kind === TECH
|
|
39
|
+
? orchestratorDir(workspaceId)
|
|
40
|
+
: refuseRealStateUnderTest(agentosHome("leads", workspaceId, kind));
|
|
41
|
+
|
|
42
|
+
// A standing session's own folder, whatever its role.
|
|
43
|
+
export const standingDir = (
|
|
44
|
+
s: Pick<Session, "role" | "lead_kind" | "workspace_id">
|
|
45
|
+
) => leadDir(s.workspace_id ?? "", leadKindOf(s) ?? "chief");
|
|
46
|
+
|
|
47
|
+
export function getLead(workspaceId: string, kind: LeadKind): Session | null {
|
|
48
|
+
if (kind === TECH) return getOrchestrator(workspaceId);
|
|
49
|
+
return (
|
|
50
|
+
(db
|
|
51
|
+
.prepare(
|
|
52
|
+
`SELECT * FROM session_rows WHERE ${sqlLead()} AND workspace_id = ? AND lead_kind = ?`
|
|
53
|
+
)
|
|
54
|
+
.get(workspaceId, kind) as Session | undefined) ?? null
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The workspace's heads: its orchestrator (Tech) and its other leads.
|
|
59
|
+
export function listLeads(workspaceId: string): Session[] {
|
|
60
|
+
const rows = db
|
|
61
|
+
.prepare(
|
|
62
|
+
`SELECT * FROM session_rows
|
|
63
|
+
WHERE workspace_id = ? AND ${sqlLead()}`
|
|
64
|
+
)
|
|
65
|
+
.all(workspaceId) as Session[];
|
|
66
|
+
return rows.sort((a, b) =>
|
|
67
|
+
byLeadOrder(leadKindOf(a) ?? "", leadKindOf(b) ?? "")
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function leadTitle(workspaceId: string, kind: LeadKind): string {
|
|
72
|
+
return getCharter(workspaceId, kind)?.title || charterTemplate(kind).title;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A lead starts from the generic charter for its kind; one already written
|
|
76
|
+
// (by the person, in their own database) is never overwritten.
|
|
77
|
+
export function seedCharter(workspaceId: string, kind: LeadKind): void {
|
|
78
|
+
if (getCharter(workspaceId, kind)) return;
|
|
79
|
+
const t = charterTemplate(kind);
|
|
80
|
+
putCharter({ workspaceId, leadKind: kind, ...t });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Marks the orchestrator as the Tech lead, once.
|
|
84
|
+
function claimTech(workspaceId: string): Session {
|
|
85
|
+
const tech = ensureOrchestrator(workspaceId);
|
|
86
|
+
db.prepare(
|
|
87
|
+
`UPDATE sessions SET lead_kind = 'tech', lead_since = COALESCE(lead_since, datetime('now'))
|
|
88
|
+
WHERE id = ?`
|
|
89
|
+
).run(tech.id);
|
|
90
|
+
seedCharter(workspaceId, TECH);
|
|
91
|
+
return getOrchestrator(workspaceId)!;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createLead(workspaceId: string, kind: LeadKind): Session {
|
|
95
|
+
const existing = getLead(workspaceId, kind);
|
|
96
|
+
if (existing) return existing;
|
|
97
|
+
const workspace = getWorkspace(workspaceId)!;
|
|
98
|
+
seedCharter(workspaceId, kind);
|
|
99
|
+
const dir = leadDir(workspaceId, kind);
|
|
100
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
const id = randomUUID();
|
|
102
|
+
// Two at once still make one: the unique (workspace, kind) index decides.
|
|
103
|
+
db.prepare(
|
|
104
|
+
`INSERT OR IGNORE INTO sessions (id, name, tmux_name, working_directory, model,
|
|
105
|
+
group_path, agent_type, auto_approve, project_id, host_id, view, chat_access,
|
|
106
|
+
role, lead_kind, lead_since, workspace_id, pinned)
|
|
107
|
+
VALUES (?, ?, ?, ?, ?, 'sessions', 'claude', 0, NULL, 'local', 'chat', 'ask',
|
|
108
|
+
'lead', ?, datetime('now'), ?, 1)`
|
|
109
|
+
).run(
|
|
110
|
+
id,
|
|
111
|
+
`${workspace.name} ${leadTitle(workspaceId, kind)}`,
|
|
112
|
+
`claude-${id}`,
|
|
113
|
+
dir,
|
|
114
|
+
resolveRoleOrDefault("orchestrator", { workspaceId }).model,
|
|
115
|
+
kind,
|
|
116
|
+
workspaceId
|
|
117
|
+
);
|
|
118
|
+
return getLead(workspaceId, kind)!;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function checkKinds(kinds: unknown): LeadKind[] {
|
|
122
|
+
if (!Array.isArray(kinds) || !kinds.every(isLeadKind))
|
|
123
|
+
throw new Error(
|
|
124
|
+
"Each lead is a short lowercase name: letters, digits and dashes"
|
|
125
|
+
);
|
|
126
|
+
return [...new Set(kinds)].filter((k) => k !== TECH);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// "Add leads": the workspace gets its Tech lead (its orchestrator, made if
|
|
130
|
+
// it has none) and the leads picked. Safe to run again; nothing made twice.
|
|
131
|
+
export function addLeads(workspaceId: string, kinds: LeadKind[]): Session[] {
|
|
132
|
+
if (!getWorkspace(workspaceId)) throw new Error("Unknown workspace");
|
|
133
|
+
const picked = checkKinds(kinds);
|
|
134
|
+
markLeadsEnabled(workspaceId);
|
|
135
|
+
claimTech(workspaceId);
|
|
136
|
+
for (const kind of picked) createLead(workspaceId, kind);
|
|
137
|
+
return listLeads(workspaceId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The secret a standing session's worker sends with each tool call, so no
|
|
141
|
+
// other local process can act as it. The orchestrator's is the one it
|
|
142
|
+
// always had.
|
|
143
|
+
export function standingToken(sessionId: string): string {
|
|
144
|
+
db.prepare(
|
|
145
|
+
`UPDATE sessions SET orch_token = COALESCE(orch_token, ?) WHERE id = ?`
|
|
146
|
+
).run(randomBytes(32).toString("hex"), sessionId);
|
|
147
|
+
const row = db
|
|
148
|
+
.prepare(`SELECT orch_token FROM sessions WHERE id = ?`)
|
|
149
|
+
.get(sessionId) as { orch_token: string | null } | undefined;
|
|
150
|
+
if (!row?.orch_token) throw new Error("No such session");
|
|
151
|
+
return row.orch_token;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Which of the workspace's standing sessions holds this token, if any.
|
|
155
|
+
export function standingByToken(
|
|
156
|
+
workspaceId: string,
|
|
157
|
+
token: string | null
|
|
158
|
+
): Session | null {
|
|
159
|
+
if (!token) return null;
|
|
160
|
+
const b = Buffer.from(token);
|
|
161
|
+
const rows = db
|
|
162
|
+
.prepare(
|
|
163
|
+
`SELECT * FROM session_rows
|
|
164
|
+
WHERE workspace_id = ? AND ${sqlStanding()} AND orch_token IS NOT NULL`
|
|
165
|
+
)
|
|
166
|
+
.all(workspaceId) as Session[];
|
|
167
|
+
let found: Session | null = null;
|
|
168
|
+
for (const s of rows) {
|
|
169
|
+
const a = Buffer.from(s.orch_token!);
|
|
170
|
+
if (a.length === b.length && timingSafeEqual(a, b)) found = s;
|
|
171
|
+
}
|
|
172
|
+
return found;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface LeadView {
|
|
176
|
+
kind: LeadKind;
|
|
177
|
+
title: string;
|
|
178
|
+
sessionId: string;
|
|
179
|
+
name: string;
|
|
180
|
+
since: string | null;
|
|
181
|
+
areas: { kind: string; value: string }[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function leadViews(workspaceId: string): LeadView[] {
|
|
185
|
+
return listLeads(workspaceId).map((s) => {
|
|
186
|
+
const kind = leadKindOf(s) ?? TECH;
|
|
187
|
+
return {
|
|
188
|
+
kind,
|
|
189
|
+
title: leadTitle(workspaceId, kind),
|
|
190
|
+
sessionId: s.id,
|
|
191
|
+
name: s.name,
|
|
192
|
+
since: s.lead_since ?? null,
|
|
193
|
+
areas: leadAreas(workspaceId, kind),
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import type { LeadKind, LeadMemo } from "./types";
|
|
3
|
+
|
|
4
|
+
// About 1.5k tokens: what a lead carries across a fresh start, with its
|
|
5
|
+
// pinned direction.
|
|
6
|
+
export const MEMO_CAP = 6000;
|
|
7
|
+
|
|
8
|
+
interface Row {
|
|
9
|
+
id: number;
|
|
10
|
+
workspace_id: string;
|
|
11
|
+
lead_kind: string;
|
|
12
|
+
text: string;
|
|
13
|
+
created_at: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const view = (r: Row): LeadMemo => ({
|
|
17
|
+
id: r.id,
|
|
18
|
+
workspaceId: r.workspace_id,
|
|
19
|
+
leadKind: r.lead_kind,
|
|
20
|
+
text: r.text,
|
|
21
|
+
createdAt: r.created_at,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Every version is kept; the newest is the memo.
|
|
25
|
+
export function writeMemo(
|
|
26
|
+
workspaceId: string,
|
|
27
|
+
leadKind: LeadKind,
|
|
28
|
+
text: string
|
|
29
|
+
): LeadMemo {
|
|
30
|
+
const body = text.trim().slice(0, MEMO_CAP);
|
|
31
|
+
if (!body) throw new Error("The memo is empty");
|
|
32
|
+
const { lastInsertRowid } = db
|
|
33
|
+
.prepare(
|
|
34
|
+
`INSERT INTO lead_memos (workspace_id, lead_kind, text) VALUES (?, ?, ?)`
|
|
35
|
+
)
|
|
36
|
+
.run(workspaceId, leadKind, body);
|
|
37
|
+
return view(
|
|
38
|
+
db
|
|
39
|
+
.prepare(`SELECT * FROM lead_memos WHERE id = ?`)
|
|
40
|
+
.get(Number(lastInsertRowid)) as Row
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function latestMemo(
|
|
45
|
+
workspaceId: string,
|
|
46
|
+
leadKind: LeadKind
|
|
47
|
+
): LeadMemo | null {
|
|
48
|
+
const r = db
|
|
49
|
+
.prepare(
|
|
50
|
+
`SELECT * FROM lead_memos WHERE workspace_id = ? AND lead_kind = ?
|
|
51
|
+
ORDER BY id DESC LIMIT 1`
|
|
52
|
+
)
|
|
53
|
+
.get(workspaceId, leadKind) as Row | undefined;
|
|
54
|
+
return r ? view(r) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function memoHistory(
|
|
58
|
+
workspaceId: string,
|
|
59
|
+
leadKind: LeadKind,
|
|
60
|
+
limit = 20
|
|
61
|
+
): LeadMemo[] {
|
|
62
|
+
return (
|
|
63
|
+
db
|
|
64
|
+
.prepare(
|
|
65
|
+
`SELECT * FROM lead_memos WHERE workspace_id = ? AND lead_kind = ?
|
|
66
|
+
ORDER BY id DESC LIMIT ?`
|
|
67
|
+
)
|
|
68
|
+
.all(workspaceId, leadKind, limit) as Row[]
|
|
69
|
+
).map(view);
|
|
70
|
+
}
|