@saadnvd1/agent-os 0.3.585 → 0.3.588
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 +1 -0
- package/app/api/bookmarks/[id]/route.ts +35 -0
- package/app/api/bookmarks/route.test.ts +103 -0
- package/app/api/bookmarks/route.ts +18 -0
- package/app/api/sessions/[id]/bookmarks/route.ts +38 -0
- package/app/api/tasks/[id]/merge/route.ts +5 -1
- package/app/globals.css +1 -0
- package/app/page.tsx +16 -0
- package/components/Bookmarks/BookmarkButton.tsx +149 -0
- package/components/Bookmarks/BookmarkRow.tsx +161 -0
- package/components/Bookmarks/BookmarksDialog.tsx +186 -0
- package/components/Bookmarks/ChatBookmarks.tsx +58 -0
- package/components/Bookmarks/ChatBookmarksButton.tsx +38 -0
- package/components/Bookmarks/EmptyBookmarks.tsx +20 -0
- package/components/Bookmarks/context.ts +41 -0
- package/components/Bookmarks/helpers.test.ts +69 -0
- package/components/Bookmarks/helpers.ts +65 -0
- package/components/Bookmarks/index.ts +4 -0
- package/components/Bookmarks/useBookmarkToggle.ts +51 -0
- package/components/Chat/ChatPanel.tsx +73 -32
- package/components/Chat/Messages.test.ts +14 -9
- package/components/Chat/Messages.tsx +59 -43
- package/components/Chat/Tools.tsx +3 -1
- package/components/Chat/composer/ComposerBody.tsx +13 -5
- package/components/Chat/useLongPress.ts +43 -0
- package/components/CommandPalette/useAppCommands.ts +10 -0
- package/components/Pane/DesktopTabBar.tsx +10 -0
- package/components/Pane/MobileTabBar.tsx +10 -0
- package/components/Pane/index.tsx +8 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/data/bookmarks/index.ts +2 -0
- package/data/bookmarks/keys.ts +7 -0
- package/data/bookmarks/queries.ts +146 -0
- package/data/chat/useChat.ts +4 -0
- package/data/push/topics.ts +3 -0
- package/lib/bookmarks/body.ts +23 -0
- package/lib/bookmarks/snapshot.ts +33 -0
- package/lib/bookmarks/store.ts +333 -0
- package/lib/bookmarks/types.ts +38 -0
- package/lib/chat/commands.ts +9 -5
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +28 -0
- package/lib/tasks/finish.ts +26 -4
- package/lib/tasks/move-bundle.ts +28 -0
- package/lib/tasks/move-chat.ts +10 -1
- package/lib/tasks/remote.ts +21 -3
- package/package.json +1 -1
- package/stores/bookmarksUi.ts +50 -0
- package/styles/themes.css +3 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text a bookmark keeps of what it marks: readable, clipped, and with
|
|
3
|
+
* secrets masked so a key in a tool's output doesn't sit in a second table.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ChatItem } from "../chat/events";
|
|
7
|
+
import { toRaw } from "../share/snapshot";
|
|
8
|
+
import { NO_CONTEXT, redactForShare } from "../share/redact";
|
|
9
|
+
import { envSecrets } from "../share/context";
|
|
10
|
+
import { MAX_SNAPSHOT_CHARS } from "./types";
|
|
11
|
+
|
|
12
|
+
function plain(item: ChatItem): string {
|
|
13
|
+
const raw = toRaw(item);
|
|
14
|
+
if (!raw) return "";
|
|
15
|
+
return raw.label ? `${raw.label}\n${raw.text}` : raw.text;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A turn reads as what was asked and the start of the answer.
|
|
19
|
+
export function snapshotText(item: ChatItem, reply?: ChatItem): string {
|
|
20
|
+
const text = [plain(item), reply ? plain(reply) : ""]
|
|
21
|
+
.filter((t) => t.trim())
|
|
22
|
+
.join("\n\n")
|
|
23
|
+
.trim();
|
|
24
|
+
// Redacted before the cut, so a secret the cut splits is still caught; the
|
|
25
|
+
// wide first cut only bounds the work on a huge tool output.
|
|
26
|
+
const safe = redactForShare(text.slice(0, MAX_SNAPSHOT_CHARS * 4), {
|
|
27
|
+
...NO_CONTEXT,
|
|
28
|
+
secrets: envSecrets(),
|
|
29
|
+
}).text;
|
|
30
|
+
return safe.length > MAX_SNAPSHOT_CHARS
|
|
31
|
+
? safe.slice(0, MAX_SNAPSHOT_CHARS) + "…"
|
|
32
|
+
: safe;
|
|
33
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bookmarks: a person's "come back to this" marks on single chat items,
|
|
3
|
+
* keyed by the item's stable id. Each keeps a redacted snapshot of what it
|
|
4
|
+
* marks, so it can be searched and still reads after the chat is gone.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from "crypto";
|
|
8
|
+
import { db, type Session } from "../db";
|
|
9
|
+
import type { ChatItem } from "../chat/events";
|
|
10
|
+
import { getItem } from "../chat/store";
|
|
11
|
+
import { getProject } from "../projects";
|
|
12
|
+
import { snapshotText } from "./snapshot";
|
|
13
|
+
import {
|
|
14
|
+
BOOKMARKABLE_KINDS,
|
|
15
|
+
MAX_NOTE_CHARS,
|
|
16
|
+
type Bookmark,
|
|
17
|
+
type BookmarkKind,
|
|
18
|
+
type BookmarkScope,
|
|
19
|
+
type BookmarksPage,
|
|
20
|
+
} from "./types";
|
|
21
|
+
|
|
22
|
+
// An orchestrator's own workspace, or its project's (as sessionWorkspace in
|
|
23
|
+
// lib/schedules/store, without pulling the scheduler into a move).
|
|
24
|
+
const sessionWorkspace = (s: Session): string | null =>
|
|
25
|
+
s.workspace_id ??
|
|
26
|
+
(s.project_id ? (getProject(s.project_id)?.workspace_id ?? null) : null);
|
|
27
|
+
|
|
28
|
+
/** A refusal the routes turn into its status. */
|
|
29
|
+
export class BookmarkError extends Error {
|
|
30
|
+
constructor(
|
|
31
|
+
message: string,
|
|
32
|
+
readonly status: 400 | 404
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Row {
|
|
39
|
+
id: string;
|
|
40
|
+
session_id: string;
|
|
41
|
+
item_id: string;
|
|
42
|
+
scope: BookmarkScope;
|
|
43
|
+
workspace_id: string | null;
|
|
44
|
+
kind: BookmarkKind;
|
|
45
|
+
text: string;
|
|
46
|
+
session_name: string;
|
|
47
|
+
note: string | null;
|
|
48
|
+
created_at: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Joined = Row & {
|
|
52
|
+
s_id: string | null;
|
|
53
|
+
task_status: string | null;
|
|
54
|
+
archived_at: string | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// The bookmark with its chat's state as of now.
|
|
58
|
+
const SELECT = `
|
|
59
|
+
SELECT b.*, s.id AS s_id, s.task_status, s.archived_at
|
|
60
|
+
FROM chat_bookmarks b LEFT JOIN sessions s ON s.id = b.session_id`;
|
|
61
|
+
|
|
62
|
+
function toBookmark(r: Joined): Bookmark {
|
|
63
|
+
return {
|
|
64
|
+
id: r.id,
|
|
65
|
+
sessionId: r.session_id,
|
|
66
|
+
itemId: r.item_id,
|
|
67
|
+
scope: r.scope,
|
|
68
|
+
kind: r.kind,
|
|
69
|
+
text: r.text,
|
|
70
|
+
note: r.note,
|
|
71
|
+
sessionName: r.session_name,
|
|
72
|
+
workspaceId: r.workspace_id,
|
|
73
|
+
createdAt: r.created_at,
|
|
74
|
+
state: !r.s_id
|
|
75
|
+
? "deleted"
|
|
76
|
+
: r.task_status === "moved"
|
|
77
|
+
? "moved"
|
|
78
|
+
: r.archived_at
|
|
79
|
+
? "archived"
|
|
80
|
+
: "live",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getBookmark(id: string): Bookmark | null {
|
|
85
|
+
const r = db.prepare(`${SELECT} WHERE b.id = ?`).get(id) as
|
|
86
|
+
| Joined
|
|
87
|
+
| undefined;
|
|
88
|
+
return r ? toBookmark(r) : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const cleanNote = (note: unknown): string | null => {
|
|
92
|
+
if (note === null || note === undefined) return null;
|
|
93
|
+
if (typeof note !== "string") throw new BookmarkError("Bad note", 400);
|
|
94
|
+
const t = note.replace(/\s+/g, " ").trim().slice(0, MAX_NOTE_CHARS);
|
|
95
|
+
return t || null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// The first reply in the turn a user item starts, for its excerpt.
|
|
99
|
+
function firstReply(
|
|
100
|
+
sessionId: string,
|
|
101
|
+
userItemId: string
|
|
102
|
+
): ChatItem | undefined {
|
|
103
|
+
const rows = db
|
|
104
|
+
.prepare(
|
|
105
|
+
`SELECT data FROM chat_items
|
|
106
|
+
WHERE session_id = ? AND seq > (
|
|
107
|
+
SELECT seq FROM chat_items WHERE session_id = ? AND item_id = ?)
|
|
108
|
+
ORDER BY seq LIMIT 200`
|
|
109
|
+
)
|
|
110
|
+
.all(sessionId, sessionId, userItemId) as { data: string }[];
|
|
111
|
+
for (const r of rows) {
|
|
112
|
+
let item: ChatItem | null;
|
|
113
|
+
try {
|
|
114
|
+
item = JSON.parse(r.data) as ChatItem | null;
|
|
115
|
+
} catch {
|
|
116
|
+
item = null;
|
|
117
|
+
}
|
|
118
|
+
// A row that isn't an item just isn't the reply.
|
|
119
|
+
if (!item || typeof item !== "object") continue;
|
|
120
|
+
if (item.kind === "user" || item.kind === "turn_end") return undefined;
|
|
121
|
+
if (item.kind === "assistant" && item.text.trim()) return item;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Marks an item. Idempotent: the same item and scope again returns the
|
|
128
|
+
* bookmark it already has, with `created: false`.
|
|
129
|
+
*/
|
|
130
|
+
export function createBookmark(input: {
|
|
131
|
+
sessionId: string;
|
|
132
|
+
itemId?: unknown;
|
|
133
|
+
scope?: unknown;
|
|
134
|
+
note?: unknown;
|
|
135
|
+
}): { bookmark: Bookmark; created: boolean } {
|
|
136
|
+
const { sessionId } = input;
|
|
137
|
+
if (typeof input.itemId !== "string" || !input.itemId)
|
|
138
|
+
throw new BookmarkError("itemId is required", 400);
|
|
139
|
+
const scope = input.scope ?? "item";
|
|
140
|
+
if (scope !== "item" && scope !== "turn")
|
|
141
|
+
throw new BookmarkError("scope must be 'item' or 'turn'", 400);
|
|
142
|
+
const note = cleanNote(input.note);
|
|
143
|
+
const session = db
|
|
144
|
+
.prepare(`SELECT * FROM sessions WHERE id = ?`)
|
|
145
|
+
.get(sessionId) as Session | undefined;
|
|
146
|
+
if (!session) throw new BookmarkError("Session not found", 404);
|
|
147
|
+
const existing = db
|
|
148
|
+
.prepare(
|
|
149
|
+
`SELECT id FROM chat_bookmarks WHERE session_id = ? AND item_id = ? AND scope = ?`
|
|
150
|
+
)
|
|
151
|
+
.get(sessionId, input.itemId, scope) as { id: string } | undefined;
|
|
152
|
+
if (existing) return { bookmark: getBookmark(existing.id)!, created: false };
|
|
153
|
+
const item = getItem(sessionId, input.itemId);
|
|
154
|
+
if (!item) throw new BookmarkError("Message not found", 404);
|
|
155
|
+
if (!(BOOKMARKABLE_KINDS as readonly string[]).includes(item.kind))
|
|
156
|
+
throw new BookmarkError("That can't be bookmarked", 400);
|
|
157
|
+
if (scope === "turn" && item.kind !== "user")
|
|
158
|
+
throw new BookmarkError("A turn is bookmarked by its first message", 400);
|
|
159
|
+
const kind = (scope === "turn" ? "turn" : item.kind) as BookmarkKind;
|
|
160
|
+
const text = snapshotText(
|
|
161
|
+
item,
|
|
162
|
+
scope === "turn" ? firstReply(sessionId, item.id) : undefined
|
|
163
|
+
);
|
|
164
|
+
const id = randomUUID();
|
|
165
|
+
db.prepare(
|
|
166
|
+
`INSERT INTO chat_bookmarks
|
|
167
|
+
(id, session_id, item_id, scope, workspace_id, kind, text, session_name, note)
|
|
168
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
169
|
+
ON CONFLICT (session_id, item_id, scope) DO NOTHING`
|
|
170
|
+
).run(
|
|
171
|
+
id,
|
|
172
|
+
sessionId,
|
|
173
|
+
item.id,
|
|
174
|
+
scope,
|
|
175
|
+
sessionWorkspace(session),
|
|
176
|
+
kind,
|
|
177
|
+
text,
|
|
178
|
+
session.name,
|
|
179
|
+
note
|
|
180
|
+
);
|
|
181
|
+
// A request that raced this one may have made it first.
|
|
182
|
+
const made = db
|
|
183
|
+
.prepare(
|
|
184
|
+
`SELECT id FROM chat_bookmarks WHERE session_id = ? AND item_id = ? AND scope = ?`
|
|
185
|
+
)
|
|
186
|
+
.get(sessionId, item.id, scope) as { id: string };
|
|
187
|
+
return { bookmark: getBookmark(made.id)!, created: made.id === id };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One chat's bookmarks, in the order they appear in it. */
|
|
191
|
+
export function listSessionBookmarks(sessionId: string): Bookmark[] {
|
|
192
|
+
return (
|
|
193
|
+
db
|
|
194
|
+
.prepare(
|
|
195
|
+
`${SELECT}
|
|
196
|
+
LEFT JOIN chat_items c ON c.session_id = b.session_id AND c.item_id = b.item_id
|
|
197
|
+
WHERE b.session_id = ?
|
|
198
|
+
ORDER BY c.seq IS NULL, c.seq, b.created_at, b.id`
|
|
199
|
+
)
|
|
200
|
+
.all(sessionId) as Joined[]
|
|
201
|
+
).map(toBookmark);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
205
|
+
|
|
206
|
+
const cursorOf = (b: Bookmark) => `${b.createdAt}|${b.id}`;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Bookmarks across chats, newest first: one workspace's (null: those with
|
|
210
|
+
* none) or every one's, matching `q` in the text, the note or the chat's
|
|
211
|
+
* name. The copy a move left behind isn't listed: its live twin is.
|
|
212
|
+
*/
|
|
213
|
+
export function listBookmarks(opts: {
|
|
214
|
+
workspace: string | null | "all";
|
|
215
|
+
q?: string;
|
|
216
|
+
before?: string | null;
|
|
217
|
+
limit?: number;
|
|
218
|
+
}): BookmarksPage {
|
|
219
|
+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
|
220
|
+
const where = [`(s.task_status IS NULL OR s.task_status != 'moved')`];
|
|
221
|
+
const args: (string | number)[] = [];
|
|
222
|
+
if (opts.workspace === null) where.push(`b.workspace_id IS NULL`);
|
|
223
|
+
else if (opts.workspace !== "all") {
|
|
224
|
+
where.push(`b.workspace_id = ?`);
|
|
225
|
+
args.push(opts.workspace);
|
|
226
|
+
}
|
|
227
|
+
const q = opts.q?.trim();
|
|
228
|
+
if (q) {
|
|
229
|
+
const like = `%${escapeLike(q)}%`;
|
|
230
|
+
where.push(
|
|
231
|
+
`(b.text LIKE ? ESCAPE '\\' OR b.note LIKE ? ESCAPE '\\' OR b.session_name LIKE ? ESCAPE '\\')`
|
|
232
|
+
);
|
|
233
|
+
args.push(like, like, like);
|
|
234
|
+
}
|
|
235
|
+
if (opts.before) {
|
|
236
|
+
const at = opts.before.lastIndexOf("|");
|
|
237
|
+
if (at > 0) {
|
|
238
|
+
where.push(`(b.created_at < ? OR (b.created_at = ? AND b.id < ?))`);
|
|
239
|
+
const created = opts.before.slice(0, at);
|
|
240
|
+
args.push(created, created, opts.before.slice(at + 1));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const rows = (
|
|
244
|
+
db
|
|
245
|
+
.prepare(
|
|
246
|
+
`${SELECT} WHERE ${where.join(" AND ")}
|
|
247
|
+
ORDER BY b.created_at DESC, b.id DESC LIMIT ?`
|
|
248
|
+
)
|
|
249
|
+
.all(...args, limit + 1) as Joined[]
|
|
250
|
+
).map(toBookmark);
|
|
251
|
+
const more = rows.length > limit;
|
|
252
|
+
const bookmarks = more ? rows.slice(0, limit) : rows;
|
|
253
|
+
return {
|
|
254
|
+
bookmarks,
|
|
255
|
+
next: more ? cursorOf(bookmarks[bookmarks.length - 1]) : null,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function setBookmarkNote(id: string, note: unknown): Bookmark | null {
|
|
260
|
+
const clean = cleanNote(note);
|
|
261
|
+
const changed = db
|
|
262
|
+
.prepare(`UPDATE chat_bookmarks SET note = ? WHERE id = ?`)
|
|
263
|
+
.run(clean, id).changes;
|
|
264
|
+
return changed ? getBookmark(id) : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function removeBookmark(id: string): boolean {
|
|
268
|
+
return (
|
|
269
|
+
db.prepare(`DELETE FROM chat_bookmarks WHERE id = ?`).run(id).changes > 0
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** What a move carries: the chat's bookmarks, as stored. */
|
|
274
|
+
export interface MovedBookmark {
|
|
275
|
+
itemId: string;
|
|
276
|
+
scope: BookmarkScope;
|
|
277
|
+
kind: BookmarkKind;
|
|
278
|
+
text: string;
|
|
279
|
+
note: string | null;
|
|
280
|
+
createdAt: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function bookmarksForMove(sessionId: string): MovedBookmark[] {
|
|
284
|
+
return (
|
|
285
|
+
db
|
|
286
|
+
.prepare(
|
|
287
|
+
`SELECT item_id, scope, kind, text, note, created_at
|
|
288
|
+
FROM chat_bookmarks WHERE session_id = ? ORDER BY created_at, id`
|
|
289
|
+
)
|
|
290
|
+
.all(sessionId) as Row[]
|
|
291
|
+
).map((r) => ({
|
|
292
|
+
itemId: r.item_id,
|
|
293
|
+
scope: r.scope,
|
|
294
|
+
kind: r.kind,
|
|
295
|
+
text: r.text,
|
|
296
|
+
note: r.note,
|
|
297
|
+
createdAt: r.created_at,
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Writes a moved chat's bookmarks under the row it arrived as, scoped to
|
|
303
|
+
* that row's workspace here. Run inside the transaction that makes the row.
|
|
304
|
+
*/
|
|
305
|
+
export function insertMovedBookmarks(
|
|
306
|
+
sessionId: string,
|
|
307
|
+
rows: MovedBookmark[]
|
|
308
|
+
): void {
|
|
309
|
+
if (!rows.length) return;
|
|
310
|
+
const session = db
|
|
311
|
+
.prepare(`SELECT * FROM sessions WHERE id = ?`)
|
|
312
|
+
.get(sessionId) as Session | undefined;
|
|
313
|
+
if (!session) return;
|
|
314
|
+
const workspace = sessionWorkspace(session);
|
|
315
|
+
const insert = db.prepare(
|
|
316
|
+
`INSERT OR IGNORE INTO chat_bookmarks
|
|
317
|
+
(id, session_id, item_id, scope, workspace_id, kind, text, session_name, note, created_at)
|
|
318
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
319
|
+
);
|
|
320
|
+
for (const r of rows)
|
|
321
|
+
insert.run(
|
|
322
|
+
randomUUID(),
|
|
323
|
+
sessionId,
|
|
324
|
+
r.itemId,
|
|
325
|
+
r.scope,
|
|
326
|
+
workspace,
|
|
327
|
+
r.kind,
|
|
328
|
+
r.text,
|
|
329
|
+
session.name,
|
|
330
|
+
r.note,
|
|
331
|
+
r.createdAt
|
|
332
|
+
);
|
|
333
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// A person's mark on one chat item, shared by the routes, the web app and
|
|
2
|
+
// the native app.
|
|
3
|
+
|
|
4
|
+
export type BookmarkScope = "item" | "turn";
|
|
5
|
+
export type BookmarkKind = "user" | "assistant" | "tool" | "turn";
|
|
6
|
+
// live: the chat is there; archived: it is, archived; moved: this row is the
|
|
7
|
+
// copy left behind by a move; deleted: the chat is gone, the snapshot stays.
|
|
8
|
+
export type BookmarkState = "live" | "archived" | "moved" | "deleted";
|
|
9
|
+
|
|
10
|
+
export interface Bookmark {
|
|
11
|
+
id: string;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
itemId: string;
|
|
14
|
+
scope: BookmarkScope;
|
|
15
|
+
kind: BookmarkKind;
|
|
16
|
+
text: string;
|
|
17
|
+
note: string | null;
|
|
18
|
+
sessionName: string;
|
|
19
|
+
workspaceId: string | null;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
state: BookmarkState;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BookmarksPage {
|
|
25
|
+
bookmarks: Bookmark[];
|
|
26
|
+
// Pass as `before` for the next page; null on the last one.
|
|
27
|
+
next: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const BOOKMARKABLE_KINDS = ["user", "assistant", "tool"] as const;
|
|
31
|
+
|
|
32
|
+
export const MAX_NOTE_CHARS = 500;
|
|
33
|
+
// A snapshot is clipped to this, plus the ellipsis that marks the cut.
|
|
34
|
+
export const MAX_SNAPSHOT_CHARS = 4_000;
|
|
35
|
+
|
|
36
|
+
// The key the stars look a bookmark up by.
|
|
37
|
+
export const bookmarkKey = (itemId: string, scope: BookmarkScope) =>
|
|
38
|
+
`${itemId}:${scope}`;
|
package/lib/chat/commands.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { ChatCommand } from "./events";
|
|
2
2
|
|
|
3
|
-
// The command being typed
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
// The /command being typed: a "/" at the start of a line or after whitespace,
|
|
4
|
+
// then the partial name, right up to the caret. Not "a/b" or "http://x".
|
|
5
|
+
// `start` is where the "/" sits in the text before the caret.
|
|
6
|
+
export function slashQuery(
|
|
7
|
+
beforeCaret: string
|
|
8
|
+
): { query: string; start: number } | null {
|
|
9
|
+
const m = /(^|\s)\/([^\s/]*)$/.exec(beforeCaret);
|
|
10
|
+
if (!m) return null;
|
|
11
|
+
return { query: m[2].toLowerCase(), start: m.index + m[1].length };
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
function isSubsequence(q: string, s: string): boolean {
|
package/lib/db/changes.ts
CHANGED
|
@@ -41,6 +41,8 @@ export const TABLES_WATCHED_80 = [
|
|
|
41
41
|
"feature_pieces",
|
|
42
42
|
] as const;
|
|
43
43
|
|
|
44
|
+
export const TABLES_WATCHED_CHAT_BOOKMARKS = ["chat_bookmarks"] as const;
|
|
45
|
+
|
|
44
46
|
// Every table any migration watches, for the browser's topic map to cover.
|
|
45
47
|
export const WATCHED_TABLES: readonly string[] = [
|
|
46
48
|
...TABLES_WATCHED_45,
|
|
@@ -49,6 +51,7 @@ export const WATCHED_TABLES: readonly string[] = [
|
|
|
49
51
|
...TABLES_WATCHED_58,
|
|
50
52
|
...TABLES_WATCHED_78,
|
|
51
53
|
...TABLES_WATCHED_80,
|
|
54
|
+
...TABLES_WATCHED_CHAT_BOOKMARKS,
|
|
52
55
|
];
|
|
53
56
|
|
|
54
57
|
export function installChangeTriggers(
|
package/lib/db/migrations.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
TABLES_WATCHED_58,
|
|
7
7
|
TABLES_WATCHED_78,
|
|
8
8
|
TABLES_WATCHED_80,
|
|
9
|
+
TABLES_WATCHED_CHAT_BOOKMARKS,
|
|
9
10
|
} from "./changes";
|
|
10
11
|
import type Database from "better-sqlite3";
|
|
11
12
|
import { moveSchedulesToRecipes } from "./schedules-move";
|
|
@@ -2297,6 +2298,33 @@ export const migrations: Migration[] = [
|
|
|
2297
2298
|
`);
|
|
2298
2299
|
},
|
|
2299
2300
|
},
|
|
2301
|
+
{
|
|
2302
|
+
key: "20261014T2000_chat_bookmarks",
|
|
2303
|
+
up: (db) => {
|
|
2304
|
+
// A person's marks on single chat items. No FK to sessions: a
|
|
2305
|
+
// bookmark outlives a deleted chat with its own snapshot. Additive.
|
|
2306
|
+
db.exec(`
|
|
2307
|
+
CREATE TABLE IF NOT EXISTS chat_bookmarks (
|
|
2308
|
+
id TEXT PRIMARY KEY,
|
|
2309
|
+
session_id TEXT NOT NULL,
|
|
2310
|
+
item_id TEXT NOT NULL,
|
|
2311
|
+
scope TEXT NOT NULL DEFAULT 'item',
|
|
2312
|
+
workspace_id TEXT,
|
|
2313
|
+
kind TEXT NOT NULL,
|
|
2314
|
+
text TEXT NOT NULL,
|
|
2315
|
+
session_name TEXT NOT NULL,
|
|
2316
|
+
note TEXT,
|
|
2317
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
2318
|
+
UNIQUE (session_id, item_id, scope)
|
|
2319
|
+
)
|
|
2320
|
+
`);
|
|
2321
|
+
db.exec(
|
|
2322
|
+
`CREATE INDEX IF NOT EXISTS idx_chat_bookmarks_ws
|
|
2323
|
+
ON chat_bookmarks(workspace_id, created_at DESC)`
|
|
2324
|
+
);
|
|
2325
|
+
installChangeTriggers(db, TABLES_WATCHED_CHAT_BOOKMARKS);
|
|
2326
|
+
},
|
|
2327
|
+
},
|
|
2300
2328
|
];
|
|
2301
2329
|
|
|
2302
2330
|
// `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -24,7 +24,13 @@ import {
|
|
|
24
24
|
import { mergePR } from "./merge-pr";
|
|
25
25
|
import { getProject } from "../projects";
|
|
26
26
|
import { canSignOff } from "./state";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
forgetPR,
|
|
29
|
+
getTaskSession,
|
|
30
|
+
isAllowedBase,
|
|
31
|
+
prFor,
|
|
32
|
+
projectPathFor,
|
|
33
|
+
} from "./session";
|
|
28
34
|
import { dropSessionDatabase } from "../project-config/database";
|
|
29
35
|
import { captureMergedTask } from "../session-logs";
|
|
30
36
|
import { sessionEnded } from "../experiments/hooks";
|
|
@@ -114,7 +120,13 @@ async function finishMerge(
|
|
|
114
120
|
// machine merged it and didn't say).
|
|
115
121
|
export async function signOffTask(
|
|
116
122
|
id: string,
|
|
117
|
-
opts: {
|
|
123
|
+
opts: {
|
|
124
|
+
wait?: boolean;
|
|
125
|
+
head?: string;
|
|
126
|
+
device?: string | null;
|
|
127
|
+
// The base another machine judged the gates against (a forwarded merge).
|
|
128
|
+
base?: string;
|
|
129
|
+
} = {}
|
|
118
130
|
): Promise<MergeMethod | null> {
|
|
119
131
|
const session = getTaskSession(id);
|
|
120
132
|
if (session.task_status !== "running")
|
|
@@ -124,14 +136,24 @@ export async function signOffTask(
|
|
|
124
136
|
if (held) throw new Error(held);
|
|
125
137
|
// Its own machine checks the gates and merges.
|
|
126
138
|
// That machine's settings chose the method; null if it didn't say.
|
|
127
|
-
if (isMirror(session))
|
|
139
|
+
if (isMirror(session))
|
|
140
|
+
return remoteTaskAction(session, "merge", opts.head, session.base_branch);
|
|
128
141
|
const repo = projectPathFor(session);
|
|
129
142
|
if (!repo) throw new Error("Task has no project");
|
|
130
143
|
const refusal = signOffRefusal(id);
|
|
131
144
|
if (refusal) throw new Error(refusal);
|
|
132
145
|
// prFor follows a retarget onto the record, so compare with what the
|
|
133
146
|
// checks above were judged against.
|
|
134
|
-
|
|
147
|
+
// A forwarded merge was judged on the other machine against the base it
|
|
148
|
+
// recorded, which may be newer than this machine's row; only a base this
|
|
149
|
+
// task may have is taken.
|
|
150
|
+
if (
|
|
151
|
+
opts.base &&
|
|
152
|
+
opts.base !== session.base_branch &&
|
|
153
|
+
!(await isAllowedBase(session, repo, opts.base))
|
|
154
|
+
)
|
|
155
|
+
throw new Error(`${opts.base} isn't a base ${session.name} may merge into`);
|
|
156
|
+
const judgedBase = opts.base ?? session.base_branch;
|
|
135
157
|
const pr = await prFor(session, true);
|
|
136
158
|
const verdict = canSignOff(pr);
|
|
137
159
|
if (!verdict.ok) throw new Error(verdict.reason);
|
package/lib/tasks/move-bundle.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { isBranchName } from "../git";
|
|
7
7
|
import { CHAT_ACCESS, type ChatAccess } from "../chat/events";
|
|
8
8
|
import type { ProjectRef } from "./project-ref";
|
|
9
|
+
import type { MovedBookmark } from "../bookmarks/store";
|
|
10
|
+
import { MAX_NOTE_CHARS, MAX_SNAPSHOT_CHARS } from "../bookmarks/types";
|
|
9
11
|
import { isClaudeSessionId } from "./transcript";
|
|
10
12
|
|
|
11
13
|
// A long task's conversation runs to a few MB; this is far past that.
|
|
@@ -92,11 +94,17 @@ export interface ChatBundle {
|
|
|
92
94
|
// An undo's resume point not yet used up, and the context meter.
|
|
93
95
|
resumeAt: string | null;
|
|
94
96
|
context: string | null;
|
|
97
|
+
// Its bookmarks; absent from a machine that predates them.
|
|
98
|
+
bookmarks?: MovedBookmark[];
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
const isStr = (v: unknown): v is string => typeof v === "string";
|
|
98
102
|
const isStrOrNull = (v: unknown) => v === null || isStr(v);
|
|
99
103
|
|
|
104
|
+
const BOOKMARK_KINDS = ["user", "assistant", "tool", "turn"];
|
|
105
|
+
// Far past what a person stars in one chat.
|
|
106
|
+
export const MAX_MOVED_BOOKMARKS = 2000;
|
|
107
|
+
|
|
100
108
|
function validateChat(c: ChatBundle): ChatBundle {
|
|
101
109
|
if (!c || typeof c !== "object" || !Array.isArray(c.items))
|
|
102
110
|
throw new Error("Bad chat");
|
|
@@ -119,6 +127,26 @@ function validateChat(c: ChatBundle): ChatBundle {
|
|
|
119
127
|
throw new Error("Bad queued message");
|
|
120
128
|
bytes += m.id.length + m.text.length + (m.images?.length ?? 0);
|
|
121
129
|
}
|
|
130
|
+
if (c.bookmarks !== undefined && !Array.isArray(c.bookmarks))
|
|
131
|
+
throw new Error("Bad chat bookmarks");
|
|
132
|
+
if ((c.bookmarks?.length ?? 0) > MAX_MOVED_BOOKMARKS)
|
|
133
|
+
throw new Error("Too many bookmarks to move");
|
|
134
|
+
for (const b of c.bookmarks ?? []) {
|
|
135
|
+
if (
|
|
136
|
+
!b ||
|
|
137
|
+
!isStr(b.itemId) ||
|
|
138
|
+
b.itemId.length > 200 ||
|
|
139
|
+
!(b.scope === "item" || b.scope === "turn") ||
|
|
140
|
+
!BOOKMARK_KINDS.includes(b.kind) ||
|
|
141
|
+
!isStr(b.text) ||
|
|
142
|
+
b.text.length > MAX_SNAPSHOT_CHARS + 1 ||
|
|
143
|
+
!isStrOrNull(b.note) ||
|
|
144
|
+
(b.note?.length ?? 0) > MAX_NOTE_CHARS ||
|
|
145
|
+
!isStr(b.createdAt)
|
|
146
|
+
)
|
|
147
|
+
throw new Error("Bad bookmark");
|
|
148
|
+
bytes += b.itemId.length + b.text.length + (b.note?.length ?? 0);
|
|
149
|
+
}
|
|
122
150
|
if (bytes > MAX_CHAT_BYTES) throw new Error("The chat is too large to move");
|
|
123
151
|
if (!isStrOrNull(c.resumeAt) || !isStrOrNull(c.context))
|
|
124
152
|
throw new Error("Bad chat");
|
package/lib/tasks/move-chat.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { settle, listItems } from "../chat/store";
|
|
|
11
11
|
import { stopChatAtTurnEnd } from "../chat/stop";
|
|
12
12
|
import { rewritePaths } from "./transcript";
|
|
13
13
|
import { MAX_CHAT_BYTES, type ChatBundle } from "./move-bundle";
|
|
14
|
+
import { bookmarksForMove, insertMovedBookmarks } from "../bookmarks/store";
|
|
14
15
|
|
|
15
16
|
// A turn this long is left to finish: the move is refused, not cut short.
|
|
16
17
|
export const MOVE_TURN_WAIT_MS = 3 * 60_000;
|
|
@@ -50,6 +51,7 @@ export function packChat(session: Session): ChatBundle {
|
|
|
50
51
|
sentBy: r.sent_by,
|
|
51
52
|
createdAt: r.created_at,
|
|
52
53
|
}));
|
|
54
|
+
const bookmarks = bookmarksForMove(session.id);
|
|
53
55
|
const chat: ChatBundle = {
|
|
54
56
|
items,
|
|
55
57
|
queue,
|
|
@@ -57,10 +59,15 @@ export function packChat(session: Session): ChatBundle {
|
|
|
57
59
|
plan: !!session.chat_plan,
|
|
58
60
|
resumeAt: session.chat_resume_at,
|
|
59
61
|
context: session.chat_context,
|
|
62
|
+
bookmarks,
|
|
60
63
|
};
|
|
61
64
|
const bytes =
|
|
62
65
|
items.reduce((n, i) => n + i.id.length + i.data.length, 0) +
|
|
63
|
-
queue.reduce((n, m) => n + m.text.length + (m.images?.length ?? 0), 0)
|
|
66
|
+
queue.reduce((n, m) => n + m.text.length + (m.images?.length ?? 0), 0) +
|
|
67
|
+
bookmarks.reduce(
|
|
68
|
+
(n, b) => n + b.itemId.length + b.text.length + (b.note?.length ?? 0),
|
|
69
|
+
0
|
|
70
|
+
);
|
|
64
71
|
if (bytes > MAX_CHAT_BYTES) throw new Error("The chat is too large to move");
|
|
65
72
|
return chat;
|
|
66
73
|
}
|
|
@@ -103,6 +110,7 @@ export function unpackChat(
|
|
|
103
110
|
m.createdAt
|
|
104
111
|
)
|
|
105
112
|
);
|
|
113
|
+
insertMovedBookmarks(id, chat.bookmarks ?? []);
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
const imageCount = (images: string) => {
|
|
@@ -118,4 +126,5 @@ const imageCount = (images: string) => {
|
|
|
118
126
|
export function dropChat(id: string): void {
|
|
119
127
|
db.prepare(`DELETE FROM chat_items WHERE session_id = ?`).run(id);
|
|
120
128
|
db.prepare(`DELETE FROM chat_queue WHERE session_id = ?`).run(id);
|
|
129
|
+
db.prepare(`DELETE FROM chat_bookmarks WHERE session_id = ?`).run(id);
|
|
121
130
|
}
|
package/lib/tasks/remote.ts
CHANGED
|
@@ -191,7 +191,8 @@ export function setMirrorStatus(id: string, status: TaskStatus): void {
|
|
|
191
191
|
export async function remoteTaskAction(
|
|
192
192
|
session: Session,
|
|
193
193
|
action: "merge" | "drop",
|
|
194
|
-
head?: string
|
|
194
|
+
head?: string,
|
|
195
|
+
base?: string | null
|
|
195
196
|
): Promise<MergeMethod | null> {
|
|
196
197
|
const link = requireHostLink(session.host_id);
|
|
197
198
|
// An AgentOS that predates pinned merges would ignore the head and merge
|
|
@@ -203,11 +204,28 @@ export async function remoteTaskAction(
|
|
|
203
204
|
`${link.hostName}'s AgentOS can't pin a merge to a commit; update it first`
|
|
204
205
|
);
|
|
205
206
|
}
|
|
207
|
+
// base: what the gates here were judged against; the machine that merges
|
|
208
|
+
// compares the PR with it, not with a base only it recorded.
|
|
206
209
|
const res = await hostApi<{ head?: string | null; method?: unknown }>(
|
|
207
210
|
link,
|
|
208
211
|
`/api/tasks/${encodeURIComponent(session.id)}/${action}`,
|
|
209
|
-
{
|
|
210
|
-
|
|
212
|
+
{
|
|
213
|
+
body: { ...(head && { head }), ...(head && base && { base }) },
|
|
214
|
+
timeout: 300000,
|
|
215
|
+
}
|
|
216
|
+
).catch(async (err) => {
|
|
217
|
+
if (action !== "merge" || !unknownOutcome(err)) throw err;
|
|
218
|
+
// A proxy or a dropped connection after the merge ran answers 502 with
|
|
219
|
+
// nothing from AgentOS: ask what state the task is in before failing.
|
|
220
|
+
const merged = await hostTasks(link, true)
|
|
221
|
+
.then((l) => l.tasks.find((t) => t.id === session.id)?.state === "merged")
|
|
222
|
+
.catch(() => false);
|
|
223
|
+
if (!merged)
|
|
224
|
+
throw new Error(
|
|
225
|
+
`${(err as Error).message}: couldn't confirm whether ${link.hostName} merged ${session.name}; check its PR before signing off again`
|
|
226
|
+
);
|
|
227
|
+
return { head: head ?? null, method: undefined };
|
|
228
|
+
});
|
|
211
229
|
forgetHostTasks(link.hostId);
|
|
212
230
|
setMirrorStatus(session.id, action === "merge" ? "merged" : "dropped");
|
|
213
231
|
if (head && res.head !== head)
|