@saadnvd1/agent-os 0.3.672 → 0.3.678
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/app/api/bus/read/route.ts +34 -0
- package/app/api/files/upload-temp/route.test.ts +144 -0
- package/app/api/files/upload-temp/route.ts +52 -21
- package/app/api/search/conversations/context/route.ts +26 -0
- package/app/api/search/conversations/route.test.ts +139 -0
- package/app/api/search/conversations/route.ts +25 -0
- package/app/api/sessions/[id]/route.ts +2 -0
- package/app/api/sessions/route.ts +6 -2
- package/app/globals.css +25 -0
- package/components/Bookmarks/BookmarksDialog.tsx +2 -8
- package/components/Chat/ChatPanel.tsx +9 -2
- package/components/Chat/FileChip.tsx +67 -0
- package/components/Chat/Messages.tsx +8 -0
- package/components/Chat/Queue.tsx +6 -0
- package/components/Chat/composer/Attachments.tsx +16 -2
- package/components/Chat/composer/ComposerBody.tsx +35 -14
- package/components/Chat/composer/Toolbar.tsx +11 -8
- package/components/Chat/composer/editor.test.ts +1 -1
- package/components/Chat/composer/paste.ts +5 -4
- package/components/Chat/composer/useAttachFiles.ts +142 -0
- package/components/Chat/composer/useComposerEditor.ts +3 -3
- package/components/Chat/useDraft.ts +9 -2
- package/components/Chat/useGoToItem.ts +11 -0
- package/components/CommandPalette/CommandPalette.tsx +23 -156
- package/components/CommandPalette/CommandsBody.tsx +216 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +246 -0
- package/components/CommandPalette/conversations/EmptyState.tsx +95 -0
- package/components/CommandPalette/conversations/Preview.tsx +129 -0
- package/components/CommandPalette/conversations/ResultList.tsx +91 -0
- package/components/CommandPalette/conversations/Snippet.tsx +24 -0
- package/components/CommandPalette/conversations/format.ts +30 -0
- package/components/CommandPalette/conversations/recent.test.ts +20 -0
- package/components/CommandPalette/conversations/recent.ts +35 -0
- package/components/CommandPalette/conversations/useOpenHit.ts +30 -0
- package/components/Draft/DraftPanel.tsx +7 -4
- package/components/Terminal/index.tsx +6 -5
- package/data/chat/useChat.ts +10 -3
- package/data/client-config/queries.ts +18 -0
- package/data/search/index.ts +65 -0
- package/data/search/keys.ts +7 -0
- package/data/sessions/queries.ts +2 -1
- package/hooks/useDebouncedValue.ts +11 -0
- package/lib/agents/brief.ts +1 -0
- package/lib/attachments/check.ts +50 -0
- package/lib/attachments/message.ts +49 -0
- package/lib/attachments/policy.ts +116 -0
- package/lib/attachments/store.ts +92 -0
- package/lib/attachments/types.ts +8 -0
- package/lib/bus/condense.ts +242 -0
- package/lib/bus/read-scope.ts +109 -0
- package/lib/bus/read.ts +181 -0
- package/lib/chat/client-send.ts +6 -2
- package/lib/chat/driver.ts +4 -1
- package/lib/chat/drivers/claude.ts +2 -0
- package/lib/chat/events.ts +13 -1
- package/lib/chat/pending.ts +2 -1
- package/lib/chat/queued.ts +24 -7
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/worker/host.ts +9 -3
- package/lib/chat/worker/protocol.ts +2 -0
- package/lib/config/client-config-types.ts +14 -3
- package/lib/config/client-config.ts +1 -1
- package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
- package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
- package/lib/file-upload.ts +61 -27
- package/lib/intake/intake.ts +2 -17
- package/lib/intake/sniff.ts +1 -1
- package/lib/orchestrator/read.ts +18 -4
- package/lib/palette/registry.ts +4 -0
- package/lib/resurrect/index.ts +2 -3
- package/lib/search/conversations/backfill.ts +117 -0
- package/lib/search/conversations/buildMatchQuery.ts +31 -0
- package/lib/search/conversations/index.ts +9 -0
- package/lib/search/conversations/messageContext.ts +45 -0
- package/lib/search/conversations/schema.ts +71 -0
- package/lib/search/conversations/searchConversations.ts +151 -0
- package/lib/search/conversations/types.ts +45 -0
- package/lib/sessions/launch.ts +12 -6
- package/lib/sidebar/row-actions.ts +19 -8
- package/lib/sidebar/row-menu.ts +1 -1
- package/lib/tasks/restorable.ts +16 -0
- package/package.json +1 -1
- package/scripts/bench-conversation-search.ts +73 -0
- package/server.ts +3 -0
- package/stores/chatJump.ts +10 -0
- package/stores/palette.ts +22 -4
- package/components/Chat/composer/useImageDrop.ts +0 -52
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261019T1200_chat_queue_files",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// Files attached to a queued message, as a JSON list of references to
|
|
7
|
+
// the session's attachments folder (never the bytes).
|
|
8
|
+
const cols = db.prepare(`PRAGMA table_info(chat_queue)`).all() as {
|
|
9
|
+
name: string;
|
|
10
|
+
}[];
|
|
11
|
+
if (!cols.some((c) => c.name === "files"))
|
|
12
|
+
db.exec(`ALTER TABLE chat_queue ADD COLUMN files TEXT`);
|
|
13
|
+
},
|
|
14
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
import { createConversationSearch } from "../../search/conversations/schema";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
key: "20261019T1500_conversation_search",
|
|
6
|
+
up: (db) => {
|
|
7
|
+
// The index and the triggers that keep it current. Existing chats are
|
|
8
|
+
// indexed in the background after startup (lib/search/conversations/backfill.ts),
|
|
9
|
+
// never in this migration, so a big database doesn't hold up the boot.
|
|
10
|
+
createConversationSearch(db);
|
|
11
|
+
},
|
|
12
|
+
} satisfies Migration;
|
package/lib/file-upload.ts
CHANGED
|
@@ -1,34 +1,68 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* Converts the file to base64 and POSTs to /api/files/upload-temp.
|
|
4
|
-
*
|
|
5
|
-
* @param file - The file to upload
|
|
6
|
-
* @returns The path to the uploaded file, or null if upload failed
|
|
7
|
-
*/
|
|
8
|
-
export async function uploadFileToTemp(file: File): Promise<string | null> {
|
|
9
|
-
const buffer = await file.arrayBuffer();
|
|
10
|
-
const base64 = btoa(
|
|
11
|
-
new Uint8Array(buffer).reduce(
|
|
12
|
-
(data, byte) => data + String.fromCharCode(byte),
|
|
13
|
-
""
|
|
14
|
-
)
|
|
15
|
-
);
|
|
1
|
+
import type { AttachmentKind } from "./attachments/policy";
|
|
2
|
+
import type { ChatFile } from "./attachments/types";
|
|
16
3
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
4
|
+
export type UploadResult =
|
|
5
|
+
| { ok: true; file: ChatFile; kind: AttachmentKind }
|
|
6
|
+
| { ok: false; error: string };
|
|
7
|
+
|
|
8
|
+
function toBase64(file: File): Promise<string> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const reader = new FileReader();
|
|
11
|
+
reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
|
|
12
|
+
reader.onerror = () => reject(reader.error);
|
|
13
|
+
reader.readAsDataURL(file);
|
|
25
14
|
});
|
|
15
|
+
}
|
|
26
16
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Upload a file through /api/files/upload-temp. With a `sessionId` (or
|
|
19
|
+
* "draft") the server checks it against its attachment limits and keeps it in
|
|
20
|
+
* that session's attachments folder; without one it goes to temp storage.
|
|
21
|
+
* The error is the server's own message, ready to show.
|
|
22
|
+
*/
|
|
23
|
+
export async function uploadFile(
|
|
24
|
+
file: File,
|
|
25
|
+
sessionId?: string
|
|
26
|
+
): Promise<UploadResult> {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetch("/api/files/upload-temp", {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
body: JSON.stringify({
|
|
32
|
+
filename: file.name || `file-${Date.now()}`,
|
|
33
|
+
base64: await toBase64(file),
|
|
34
|
+
mimeType: file.type || "application/octet-stream",
|
|
35
|
+
sessionId,
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
const data = await res.json().catch(() => ({}));
|
|
39
|
+
if (res.ok && data.path)
|
|
40
|
+
return {
|
|
41
|
+
ok: true,
|
|
42
|
+
kind: data.kind,
|
|
43
|
+
file: {
|
|
44
|
+
path: data.path,
|
|
45
|
+
name: data.name,
|
|
46
|
+
size: data.size,
|
|
47
|
+
mediaType: data.mediaType,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: data.error ?? `Couldn't upload ${file.name} (${res.status})`,
|
|
53
|
+
};
|
|
54
|
+
} catch {
|
|
55
|
+
return { ok: false, error: `Couldn't upload ${file.name}` };
|
|
30
56
|
}
|
|
57
|
+
}
|
|
31
58
|
|
|
32
|
-
|
|
59
|
+
/** The saved path, or null when the upload failed (terminal and picker). */
|
|
60
|
+
export async function uploadFileToTemp(
|
|
61
|
+
file: File,
|
|
62
|
+
sessionId?: string
|
|
63
|
+
): Promise<string | null> {
|
|
64
|
+
const r = await uploadFile(file, sessionId);
|
|
65
|
+
if (r.ok) return r.file.path;
|
|
66
|
+
console.error("Upload failed:", r.error);
|
|
33
67
|
return null;
|
|
34
68
|
}
|
package/lib/intake/intake.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { getWorkspace } from "../workspaces";
|
|
|
8
8
|
import { INTAKE_LIMITS, MAX_INLINE_IMAGE_BYTES } from "./limits";
|
|
9
9
|
import { buildIntakeMessage, type IntakeMeta } from "./message";
|
|
10
10
|
import { sniff } from "./sniff";
|
|
11
|
+
import { safeName } from "../attachments/store";
|
|
11
12
|
|
|
12
13
|
export class IntakeError extends Error {
|
|
13
14
|
constructor(
|
|
@@ -39,23 +40,7 @@ const day = (t: number) => new Date(t).toISOString().slice(0, 10);
|
|
|
39
40
|
const workspaceRoot = (workspaceId: string) =>
|
|
40
41
|
path.join(dataRoot(), "intake", segment(workspaceId));
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
export function safeName(name: string, taken: Set<string>, n: number): string {
|
|
44
|
-
let base = path.basename(name.replace(/\\/g, "/"));
|
|
45
|
-
base = base
|
|
46
|
-
.replace(/[^a-zA-Z0-9.-]/g, "_")
|
|
47
|
-
.replace(/^\.+/, "")
|
|
48
|
-
.slice(0, 80);
|
|
49
|
-
if (!base) base = `file-${n}`;
|
|
50
|
-
let out = base;
|
|
51
|
-
for (let i = 2; taken.has(out); i++) {
|
|
52
|
-
const dot = base.lastIndexOf(".");
|
|
53
|
-
out =
|
|
54
|
-
dot > 0 ? `${base.slice(0, dot)}-${i}${base.slice(dot)}` : `${base}-${i}`;
|
|
55
|
-
}
|
|
56
|
-
taken.add(out);
|
|
57
|
-
return out;
|
|
58
|
-
}
|
|
43
|
+
export { safeName };
|
|
59
44
|
|
|
60
45
|
function checkMeta(meta: IntakeMeta) {
|
|
61
46
|
if (!meta || typeof meta !== "object") throw new IntakeError(400, "Bad meta");
|
package/lib/intake/sniff.ts
CHANGED
|
@@ -9,7 +9,7 @@ const BLOCKED_NAME =
|
|
|
9
9
|
const starts = (b: Buffer, sig: number[], at = 0) =>
|
|
10
10
|
sig.every((v, i) => b[at + i] === v);
|
|
11
11
|
|
|
12
|
-
function isText(b: Buffer): boolean {
|
|
12
|
+
export function isText(b: Buffer): boolean {
|
|
13
13
|
const head = b.subarray(0, 8192);
|
|
14
14
|
if (head.includes(0)) return false;
|
|
15
15
|
try {
|
package/lib/orchestrator/read.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { hostLink } from "../hosts/remote-api";
|
|
|
10
10
|
import { peerPane } from "../hosts/peer-sessions";
|
|
11
11
|
import { workspaceSessions } from "./facts";
|
|
12
12
|
import { untrusted } from "./untrusted";
|
|
13
|
+
import { grepText } from "../bus/condense";
|
|
13
14
|
import { heldChatTail, isHeldRemote } from "../tasks/contract/held-calls";
|
|
14
15
|
import {
|
|
15
16
|
findExternalSession,
|
|
@@ -55,7 +56,10 @@ export function findWorkspaceSession(
|
|
|
55
56
|
// A session AgentOS didn't start: its screen, read the way the sidebar
|
|
56
57
|
// reads one (a linked machine's only through its AgentOS, which doesn't
|
|
57
58
|
// serve a screen it didn't start).
|
|
58
|
-
async function readExternal(
|
|
59
|
+
export async function readExternal(
|
|
60
|
+
s: ExternalSession,
|
|
61
|
+
n: number
|
|
62
|
+
): Promise<string> {
|
|
59
63
|
const head = `${s.ref} (external terminal on ${s.host}, not started by AgentOS)`;
|
|
60
64
|
if (hostLink(s.hostId))
|
|
61
65
|
return `${head}: its screen is on that machine and isn't served to this one.`;
|
|
@@ -127,6 +131,16 @@ export async function readSession(
|
|
|
127
131
|
if (!external) throw error;
|
|
128
132
|
return readExternal(external, n);
|
|
129
133
|
}
|
|
134
|
+
return readResolved(s, n);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// One session's recent conversation or screen, whichever it has.
|
|
138
|
+
export async function readResolved(
|
|
139
|
+
s: Session,
|
|
140
|
+
n: number,
|
|
141
|
+
grep?: string
|
|
142
|
+
): Promise<string> {
|
|
143
|
+
const cut = (text: string) => tail(grep ? grepText(text, grep) : text, n);
|
|
130
144
|
const link = hostLink(s.host_id);
|
|
131
145
|
if (s.view === "chat" && link) {
|
|
132
146
|
// Its orchestrator's task is read through the machine that holds it.
|
|
@@ -137,12 +151,12 @@ export async function readSession(
|
|
|
137
151
|
}
|
|
138
152
|
if (s.view === "chat") {
|
|
139
153
|
const text = chatText(lastItems(s.id, n * 2));
|
|
140
|
-
const body = text ? untrusted(s.name,
|
|
154
|
+
const body = text ? untrusted(s.name, cut(text)) : "(no messages yet)";
|
|
141
155
|
return `${s.name} (chat), last ${n} lines:\n${body}`;
|
|
142
156
|
}
|
|
143
157
|
if (link) {
|
|
144
158
|
const lines = await peerPane(link, s.id);
|
|
145
|
-
const body = lines ?
|
|
159
|
+
const body = lines ? cut(lines.join("\n")) : "";
|
|
146
160
|
return `${s.name} (terminal on ${link.hostName}), last ${n} lines:\n${body ? untrusted(s.name, body) : "(couldn't be read)"}`;
|
|
147
161
|
}
|
|
148
162
|
await statusDetector.refreshCache();
|
|
@@ -152,6 +166,6 @@ export async function readSession(
|
|
|
152
166
|
s.host_id || "local",
|
|
153
167
|
`tmux capture-pane -t ${shellQuote(`=${s.tmux_name}:`)} -p -J -S -${n}`
|
|
154
168
|
);
|
|
155
|
-
const body =
|
|
169
|
+
const body = cut(pane);
|
|
156
170
|
return `${s.name} (terminal), last ${n} lines:\n${body ? untrusted(s.name, body) : "(empty)"}`;
|
|
157
171
|
}
|
package/lib/palette/registry.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface PaletteCommand {
|
|
|
12
12
|
keywords?: string[];
|
|
13
13
|
// Shown beside it, e.g. ⌘K.
|
|
14
14
|
hint?: string;
|
|
15
|
+
// A line under the title (what a conversation hit said).
|
|
16
|
+
detail?: string;
|
|
17
|
+
// Running it leaves the palette open (it changes the palette itself).
|
|
18
|
+
keepOpen?: boolean;
|
|
15
19
|
icon?: unknown;
|
|
16
20
|
run: () => void;
|
|
17
21
|
}
|
package/lib/resurrect/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { planFromTranscript } from "./plan";
|
|
|
46
46
|
import { dropSnapshot, restoreWorktree, type RestoredAs } from "./snapshot";
|
|
47
47
|
import { restoreSwept, sweptNote } from "./swept";
|
|
48
48
|
import { forgetSwept } from "../worktree-sweep/store";
|
|
49
|
+
import { isRestorableTask } from "../tasks/restorable";
|
|
49
50
|
import { setPr, setTask } from "../db/session-parts";
|
|
50
51
|
|
|
51
52
|
export interface ResurrectOptions {
|
|
@@ -66,8 +67,6 @@ export interface ResurrectOutcome {
|
|
|
66
67
|
const row = (id: string) =>
|
|
67
68
|
queries.getSession(db).get(id) as Session | undefined;
|
|
68
69
|
|
|
69
|
-
const ENDED_TASK = new Set(["dropped", "done"]);
|
|
70
|
-
|
|
71
70
|
// Restores in flight here, so two clicks can't both build a worktree.
|
|
72
71
|
const g = globalThis as unknown as { __agentosResurrecting?: Set<string> };
|
|
73
72
|
const inFlight = (g.__agentosResurrecting ??= new Set());
|
|
@@ -113,7 +112,7 @@ export async function resurrectSession(
|
|
|
113
112
|
throw new Error(`${s.name} runs on ${peer.hostName}: restore it there.`);
|
|
114
113
|
if (isMirror(s)) return onItsMachine(s);
|
|
115
114
|
|
|
116
|
-
const endedTask =
|
|
115
|
+
const endedTask = isRestorableTask(s.task_status);
|
|
117
116
|
if (s.task_status === "merged")
|
|
118
117
|
throw new Error(
|
|
119
118
|
`${s.name} was merged: its work is in the base branch. Start a new task to continue it.`
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { getDb } from "@/lib/db";
|
|
3
|
+
import { SCHEMA_VERSION, SEARCHED_KINDS } from "./schema";
|
|
4
|
+
|
|
5
|
+
const BATCH = 500;
|
|
6
|
+
|
|
7
|
+
interface State {
|
|
8
|
+
version: number;
|
|
9
|
+
cursor: number;
|
|
10
|
+
backfill_to: number;
|
|
11
|
+
done: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const stateOf = (db: Database.Database): State | undefined =>
|
|
15
|
+
db.prepare(`SELECT * FROM chat_search_state WHERE id = 1`).get() as
|
|
16
|
+
| State
|
|
17
|
+
| undefined;
|
|
18
|
+
|
|
19
|
+
// Starts the index over: the stored version is not this build's.
|
|
20
|
+
function reset(db: Database.Database): void {
|
|
21
|
+
db.transaction(() => {
|
|
22
|
+
db.exec(`DELETE FROM chat_search; DELETE FROM chat_search_keys`);
|
|
23
|
+
db.prepare(
|
|
24
|
+
`UPDATE chat_search_state SET version = ?, cursor = 0, done = 0,
|
|
25
|
+
backfill_to = (SELECT COALESCE(MAX(rowid), 0) FROM chat_items)
|
|
26
|
+
WHERE id = 1`
|
|
27
|
+
).run(SCHEMA_VERSION);
|
|
28
|
+
})();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Indexes one batch of the chats that were already there when the index was
|
|
32
|
+
// made, in rowid order, and records where it got to in the same transaction:
|
|
33
|
+
// stopped at any point, the next call carries on. Returns true when finished.
|
|
34
|
+
export function backfillStep(db: Database.Database, batch = BATCH): boolean {
|
|
35
|
+
const state = stateOf(db);
|
|
36
|
+
if (!state) return true;
|
|
37
|
+
if (state.version !== SCHEMA_VERSION) reset(db);
|
|
38
|
+
else if (state.done) return true;
|
|
39
|
+
const { cursor, backfill_to } = stateOf(db)!;
|
|
40
|
+
const kinds = SEARCHED_KINDS.map(() => "?").join(", ");
|
|
41
|
+
return db.transaction(() => {
|
|
42
|
+
// Rowids are not dense (tool calls are most rows), so the batch is
|
|
43
|
+
// counted in rows of the kinds searched and the cursor moves to the last.
|
|
44
|
+
const rows = db
|
|
45
|
+
.prepare(
|
|
46
|
+
`SELECT rowid AS rid, session_id, item_id,
|
|
47
|
+
json_extract(data, '$.text') AS text
|
|
48
|
+
FROM chat_items
|
|
49
|
+
WHERE rowid > ? AND rowid <= ? AND kind IN (${kinds})
|
|
50
|
+
ORDER BY rowid LIMIT ?`
|
|
51
|
+
)
|
|
52
|
+
.all(cursor, backfill_to, ...SEARCHED_KINDS, batch) as {
|
|
53
|
+
rid: number;
|
|
54
|
+
session_id: string;
|
|
55
|
+
item_id: string;
|
|
56
|
+
text: string | null;
|
|
57
|
+
}[];
|
|
58
|
+
const key = db.prepare(
|
|
59
|
+
`SELECT id FROM chat_search_keys WHERE session_id = ? AND item_id = ?`
|
|
60
|
+
);
|
|
61
|
+
const del = db.prepare(`DELETE FROM chat_search WHERE rowid = ?`);
|
|
62
|
+
const mk = db.prepare(
|
|
63
|
+
`INSERT OR IGNORE INTO chat_search_keys (session_id, item_id) VALUES (?, ?)`
|
|
64
|
+
);
|
|
65
|
+
const put = db.prepare(
|
|
66
|
+
`INSERT INTO chat_search (rowid, body) VALUES (?, ?)`
|
|
67
|
+
);
|
|
68
|
+
for (const r of rows) {
|
|
69
|
+
if (!r.text) continue;
|
|
70
|
+
const have = key.get(r.session_id, r.item_id) as
|
|
71
|
+
| { id: number }
|
|
72
|
+
| undefined;
|
|
73
|
+
if (have) del.run(have.id);
|
|
74
|
+
else mk.run(r.session_id, r.item_id);
|
|
75
|
+
const id = (key.get(r.session_id, r.item_id) as { id: number }).id;
|
|
76
|
+
put.run(id, r.text);
|
|
77
|
+
}
|
|
78
|
+
const finished = rows.length < batch;
|
|
79
|
+
const next = finished ? backfill_to : rows[rows.length - 1].rid;
|
|
80
|
+
db.prepare(
|
|
81
|
+
`UPDATE chat_search_state SET cursor = ?, done = ? WHERE id = 1`
|
|
82
|
+
).run(next, finished ? 1 : 0);
|
|
83
|
+
return finished;
|
|
84
|
+
})();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 0..1 while the first index is being built; null once it is complete.
|
|
88
|
+
export function indexProgress(): number | null {
|
|
89
|
+
const s = stateOf(getDb());
|
|
90
|
+
if (!s || (s.done && s.version === SCHEMA_VERSION)) return null;
|
|
91
|
+
return s.backfill_to ? Math.min(1, s.cursor / s.backfill_to) : 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let running = false;
|
|
95
|
+
|
|
96
|
+
// Runs batches until done, giving the event loop back between them, so a
|
|
97
|
+
// large history never holds up a request or the boot.
|
|
98
|
+
export function startConversationBackfill(): void {
|
|
99
|
+
if (running) return;
|
|
100
|
+
running = true;
|
|
101
|
+
const t0 = Date.now();
|
|
102
|
+
const tick = () => {
|
|
103
|
+
try {
|
|
104
|
+
if (backfillStep(getDb())) {
|
|
105
|
+
running = false;
|
|
106
|
+
console.log(`Conversation search index ready (${Date.now() - t0} ms)`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
running = false;
|
|
111
|
+
console.error("Indexing conversations for search failed:", error);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
setImmediate(tick);
|
|
115
|
+
};
|
|
116
|
+
setImmediate(tick);
|
|
117
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface MatchQuery {
|
|
2
|
+
// An FTS5 MATCH expression.
|
|
3
|
+
match: string;
|
|
4
|
+
// The words typed, lowercased, for marking a preview.
|
|
5
|
+
terms: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const HAS_WORD = /[\p{L}\p{N}]/u;
|
|
9
|
+
const WORDS = /[\p{L}\p{N}_]+/gu;
|
|
10
|
+
// A quoted phrase (possibly still being typed) or a bare word.
|
|
11
|
+
const TOKEN = /"([^"]*)(")?|[^\s"]+/g;
|
|
12
|
+
|
|
13
|
+
// What was typed as an FTS5 expression. Every piece goes in as a quoted
|
|
14
|
+
// string, so FTS syntax typed by hand (AND, NEAR, *, :, -, parentheses) is
|
|
15
|
+
// plain text. Bare words match as prefixes, so a word being typed finds its
|
|
16
|
+
// completions; a closed "phrase" is exact, an open one is a prefix on its last
|
|
17
|
+
// word. Null when nothing searchable was typed.
|
|
18
|
+
export function buildMatchQuery(input: string): MatchQuery | null {
|
|
19
|
+
const parts: string[] = [];
|
|
20
|
+
const terms: string[] = [];
|
|
21
|
+
for (const m of input.matchAll(TOKEN)) {
|
|
22
|
+
const quoted = m[1] !== undefined;
|
|
23
|
+
const text = (quoted ? m[1] : m[0]).trim();
|
|
24
|
+
if (!HAS_WORD.test(text)) continue;
|
|
25
|
+
const prefix = !quoted || !m[2];
|
|
26
|
+
parts.push(`"${text}"${prefix ? "*" : ""}`);
|
|
27
|
+
for (const w of text.toLowerCase().match(WORDS) ?? []) terms.push(w);
|
|
28
|
+
}
|
|
29
|
+
if (!parts.length || terms.join("").length < 2) return null;
|
|
30
|
+
return { match: parts.join(" "), terms };
|
|
31
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { buildMatchQuery } from "./buildMatchQuery";
|
|
2
|
+
export { searchConversations } from "./searchConversations";
|
|
3
|
+
export { getMessageContext } from "./messageContext";
|
|
4
|
+
export {
|
|
5
|
+
startConversationBackfill,
|
|
6
|
+
backfillStep,
|
|
7
|
+
indexProgress,
|
|
8
|
+
} from "./backfill";
|
|
9
|
+
export type * from "./types";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
import type { ContextItem } from "./types";
|
|
3
|
+
|
|
4
|
+
interface Row {
|
|
5
|
+
item_id: string;
|
|
6
|
+
kind: "user" | "assistant";
|
|
7
|
+
text: string | null;
|
|
8
|
+
created_at: number | null;
|
|
9
|
+
seq: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const COLS = `item_id, kind, seq, json_extract(data, '$.text') AS text,
|
|
13
|
+
json_extract(data, '$.createdAt') AS created_at`;
|
|
14
|
+
|
|
15
|
+
// A message and the ones said just before and after it, in order. Null when
|
|
16
|
+
// the message is gone. Only text: images and files stay out of a preview.
|
|
17
|
+
export function getMessageContext(
|
|
18
|
+
sessionId: string,
|
|
19
|
+
itemId: string,
|
|
20
|
+
around = 3
|
|
21
|
+
): ContextItem[] | null {
|
|
22
|
+
const db = getDb();
|
|
23
|
+
const hit = db
|
|
24
|
+
.prepare(
|
|
25
|
+
`SELECT ${COLS} FROM chat_items WHERE session_id = ? AND item_id = ?`
|
|
26
|
+
)
|
|
27
|
+
.get(sessionId, itemId) as Row | undefined;
|
|
28
|
+
if (!hit) return null;
|
|
29
|
+
const side = (op: "<" | ">", order: "ASC" | "DESC") =>
|
|
30
|
+
db
|
|
31
|
+
.prepare(
|
|
32
|
+
`SELECT ${COLS} FROM chat_items
|
|
33
|
+
WHERE session_id = ? AND kind IN ('user', 'assistant') AND seq ${op} ?
|
|
34
|
+
ORDER BY seq ${order} LIMIT ?`
|
|
35
|
+
)
|
|
36
|
+
.all(sessionId, hit.seq, around) as Row[];
|
|
37
|
+
const rows = [...side("<", "DESC").reverse(), hit, ...side(">", "ASC")];
|
|
38
|
+
return rows.map((r) => ({
|
|
39
|
+
id: r.item_id,
|
|
40
|
+
role: r.kind,
|
|
41
|
+
text: r.text ?? "",
|
|
42
|
+
createdAt: r.created_at,
|
|
43
|
+
hit: r.item_id === itemId,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
|
|
3
|
+
// Bump to rebuild the index from scratch (a different set of kinds, a new
|
|
4
|
+
// tokenizer); the next start's backfill does it.
|
|
5
|
+
export const SCHEMA_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
// The item kinds whose text is searched. Tool calls and their output are
|
|
8
|
+
// most of the rows and mostly noise.
|
|
9
|
+
export const SEARCHED_KINDS = ["user", "assistant"] as const;
|
|
10
|
+
|
|
11
|
+
const KINDS_SQL = SEARCHED_KINDS.map((k) => `'${k}'`).join(", ");
|
|
12
|
+
|
|
13
|
+
// An item worth indexing: a searched kind with some text.
|
|
14
|
+
const eligible = `new.kind IN (${KINDS_SQL}) AND COALESCE(json_extract(new.data, '$.text'), '') <> ''`;
|
|
15
|
+
|
|
16
|
+
// The item's own key: the FTS rowid. chat_items has no integer primary key,
|
|
17
|
+
// so its rowids can be renumbered by a VACUUM.
|
|
18
|
+
const keyOf = (row: "new" | "old") =>
|
|
19
|
+
`(SELECT id FROM chat_search_keys WHERE session_id = ${row}.session_id AND item_id = ${row}.item_id)`;
|
|
20
|
+
|
|
21
|
+
const reindexItem = `
|
|
22
|
+
DELETE FROM chat_search WHERE rowid = ${keyOf("old")};
|
|
23
|
+
DELETE FROM chat_search_keys WHERE session_id = old.session_id AND item_id = old.item_id;
|
|
24
|
+
INSERT INTO chat_search_keys (session_id, item_id)
|
|
25
|
+
SELECT new.session_id, new.item_id WHERE ${eligible};
|
|
26
|
+
INSERT INTO chat_search (rowid, body)
|
|
27
|
+
SELECT ${keyOf("new")}, json_extract(new.data, '$.text') WHERE ${eligible};`;
|
|
28
|
+
|
|
29
|
+
// Every writer of chat_items (the server, the chat worker, an import) is
|
|
30
|
+
// indexed by these, so no code path can forget.
|
|
31
|
+
export function createConversationSearch(db: Database.Database): void {
|
|
32
|
+
db.exec(`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS chat_search_keys (
|
|
34
|
+
id INTEGER PRIMARY KEY,
|
|
35
|
+
session_id TEXT NOT NULL,
|
|
36
|
+
item_id TEXT NOT NULL,
|
|
37
|
+
UNIQUE (session_id, item_id)
|
|
38
|
+
);
|
|
39
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chat_search USING fts5(
|
|
40
|
+
body,
|
|
41
|
+
tokenize = "unicode61 remove_diacritics 2 tokenchars '_'",
|
|
42
|
+
prefix = '2 3'
|
|
43
|
+
);
|
|
44
|
+
CREATE TABLE IF NOT EXISTS chat_search_state (
|
|
45
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
46
|
+
version INTEGER NOT NULL,
|
|
47
|
+
cursor INTEGER NOT NULL DEFAULT 0,
|
|
48
|
+
backfill_to INTEGER NOT NULL DEFAULT 0,
|
|
49
|
+
done INTEGER NOT NULL DEFAULT 0
|
|
50
|
+
);
|
|
51
|
+
INSERT OR IGNORE INTO chat_search_state (id, version, backfill_to, done)
|
|
52
|
+
SELECT 1, ${SCHEMA_VERSION}, COALESCE(MAX(rowid), 0), MAX(rowid) IS NULL
|
|
53
|
+
FROM chat_items;
|
|
54
|
+
CREATE TRIGGER IF NOT EXISTS chat_search_ai AFTER INSERT ON chat_items
|
|
55
|
+
BEGIN
|
|
56
|
+
DELETE FROM chat_search WHERE rowid = ${keyOf("new")};
|
|
57
|
+
INSERT OR IGNORE INTO chat_search_keys (session_id, item_id)
|
|
58
|
+
SELECT new.session_id, new.item_id WHERE ${eligible};
|
|
59
|
+
INSERT INTO chat_search (rowid, body)
|
|
60
|
+
SELECT ${keyOf("new")}, json_extract(new.data, '$.text') WHERE ${eligible};
|
|
61
|
+
END;
|
|
62
|
+
CREATE TRIGGER IF NOT EXISTS chat_search_au AFTER UPDATE OF data ON chat_items
|
|
63
|
+
BEGIN${reindexItem}
|
|
64
|
+
END;
|
|
65
|
+
CREATE TRIGGER IF NOT EXISTS chat_search_ad AFTER DELETE ON chat_items
|
|
66
|
+
BEGIN
|
|
67
|
+
DELETE FROM chat_search WHERE rowid = ${keyOf("old")};
|
|
68
|
+
DELETE FROM chat_search_keys WHERE session_id = old.session_id AND item_id = old.item_id;
|
|
69
|
+
END;
|
|
70
|
+
`);
|
|
71
|
+
}
|