botholomew 0.7.8 → 0.7.9
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 -0
- package/package.json +1 -1
- package/src/chat/agent.ts +68 -35
- package/src/chat/session.ts +27 -31
- package/src/commands/daemon.ts +15 -2
- package/src/commands/schedule.ts +10 -1
- package/src/commands/skill.ts +18 -3
- package/src/commands/task.ts +28 -4
- package/src/commands/thread.ts +3 -1
- package/src/commands/tools.ts +2 -0
- package/src/commands/with-db.ts +8 -9
- package/src/context/fetcher.ts +1 -0
- package/src/context/ingest.ts +3 -1
- package/src/daemon/index.ts +6 -5
- package/src/daemon/llm.ts +68 -42
- package/src/daemon/prompt.ts +6 -4
- package/src/daemon/schedules.ts +15 -10
- package/src/daemon/tick.ts +54 -38
- package/src/db/connection.ts +143 -14
- package/src/db/schedules.ts +7 -3
- package/src/db/tasks.ts +4 -4
- package/src/db/threads.ts +6 -4
- package/src/tools/tool.ts +8 -0
- package/src/tui/App.tsx +16 -11
- package/src/tui/components/ContextPanel.tsx +19 -15
- package/src/tui/components/SchedulePanel.tsx +15 -9
- package/src/tui/components/StatusBar.tsx +8 -6
- package/src/tui/components/TaskPanel.tsx +6 -6
- package/src/tui/components/ThreadPanel.tsx +29 -19
- package/src/utils/title.ts +5 -3
package/src/db/tasks.ts
CHANGED
|
@@ -109,6 +109,7 @@ export async function listTasks(
|
|
|
109
109
|
status?: Task["status"];
|
|
110
110
|
priority?: Task["priority"];
|
|
111
111
|
limit?: number;
|
|
112
|
+
offset?: number;
|
|
112
113
|
},
|
|
113
114
|
): Promise<Task[]> {
|
|
114
115
|
const { where, params } = buildWhereClause([
|
|
@@ -116,13 +117,12 @@ export async function listTasks(
|
|
|
116
117
|
["priority", filters?.priority],
|
|
117
118
|
]);
|
|
118
119
|
const limit = filters?.limit ? `LIMIT ${sanitizeInt(filters.limit)}` : "";
|
|
120
|
+
const offset = filters?.offset ? `OFFSET ${sanitizeInt(filters.offset)}` : "";
|
|
119
121
|
|
|
120
122
|
const rows = await db.queryAll<TaskRow>(
|
|
121
123
|
`SELECT * FROM tasks ${where}
|
|
122
|
-
ORDER BY
|
|
123
|
-
|
|
124
|
-
created_at ASC
|
|
125
|
-
${limit}`,
|
|
124
|
+
ORDER BY created_at DESC, id DESC
|
|
125
|
+
${limit} ${offset}`,
|
|
126
126
|
...params,
|
|
127
127
|
);
|
|
128
128
|
return rows.map(rowToTask);
|
package/src/db/threads.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DbConnection } from "./connection.ts";
|
|
2
|
-
import { buildWhereClause } from "./query.ts";
|
|
2
|
+
import { buildWhereClause, sanitizeInt } from "./query.ts";
|
|
3
3
|
import { uuidv7 } from "./uuid.ts";
|
|
4
4
|
|
|
5
5
|
export interface Thread {
|
|
@@ -256,18 +256,20 @@ export async function listThreads(
|
|
|
256
256
|
type?: Thread["type"];
|
|
257
257
|
taskId?: string;
|
|
258
258
|
limit?: number;
|
|
259
|
+
offset?: number;
|
|
259
260
|
},
|
|
260
261
|
): Promise<Thread[]> {
|
|
261
262
|
const { where, params } = buildWhereClause([
|
|
262
263
|
["type", filters?.type],
|
|
263
264
|
["task_id", filters?.taskId],
|
|
264
265
|
]);
|
|
265
|
-
const limit = filters?.limit ? `LIMIT ${filters.limit}` : "";
|
|
266
|
+
const limit = filters?.limit ? `LIMIT ${sanitizeInt(filters.limit)}` : "";
|
|
267
|
+
const offset = filters?.offset ? `OFFSET ${sanitizeInt(filters.offset)}` : "";
|
|
266
268
|
|
|
267
269
|
const rows = await db.queryAll<ThreadRow>(
|
|
268
270
|
`SELECT * FROM threads ${where}
|
|
269
|
-
ORDER BY started_at DESC
|
|
270
|
-
${limit}`,
|
|
271
|
+
ORDER BY started_at DESC, id DESC
|
|
272
|
+
${limit} ${offset}`,
|
|
271
273
|
...params,
|
|
272
274
|
);
|
|
273
275
|
return rows.map(rowToThread);
|
package/src/tools/tool.ts
CHANGED
|
@@ -5,7 +5,15 @@ import type { BotholomewConfig } from "../config/schemas.ts";
|
|
|
5
5
|
import type { DbConnection } from "../db/connection.ts";
|
|
6
6
|
|
|
7
7
|
export interface ToolContext {
|
|
8
|
+
/**
|
|
9
|
+
* Short-lived DB connection scoped to this tool call. Safe for single-query
|
|
10
|
+
* tools. Do NOT hold it across slow work (network, embedding, long loops) —
|
|
11
|
+
* the instance-level file lock stays held until every connection closes.
|
|
12
|
+
* For long-running tools, use `dbPath` with `withDb` per logical operation.
|
|
13
|
+
*/
|
|
8
14
|
conn: DbConnection;
|
|
15
|
+
/** Path to the DuckDB file. Use with `withDb` for long-running tools. */
|
|
16
|
+
dbPath: string;
|
|
9
17
|
projectDir: string;
|
|
10
18
|
config: Required<BotholomewConfig>;
|
|
11
19
|
mcpxClient: McpxClient | null;
|
package/src/tui/App.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
startChatSession,
|
|
8
8
|
} from "../chat/session.ts";
|
|
9
9
|
import { MAX_INLINE_CHARS, PAGE_SIZE_CHARS } from "../daemon/large-results.ts";
|
|
10
|
+
import { withDb } from "../db/connection.ts";
|
|
10
11
|
import type { Interaction } from "../db/threads.ts";
|
|
11
12
|
import { getThread } from "../db/threads.ts";
|
|
12
13
|
import {
|
|
@@ -163,7 +164,9 @@ export function App({
|
|
|
163
164
|
sessionRef.current = session;
|
|
164
165
|
|
|
165
166
|
if (session.messages.length > 0) {
|
|
166
|
-
const threadData = await
|
|
167
|
+
const threadData = await withDb(session.dbPath, (conn) =>
|
|
168
|
+
getThread(conn, session.threadId),
|
|
169
|
+
);
|
|
167
170
|
if (threadData) {
|
|
168
171
|
setMessages(
|
|
169
172
|
restoreMessagesFromInteractions(threadData.interactions),
|
|
@@ -409,7 +412,9 @@ export function App({
|
|
|
409
412
|
const refreshTitle = async () => {
|
|
410
413
|
const session = sessionRef.current;
|
|
411
414
|
if (!session) return;
|
|
412
|
-
const result = await
|
|
415
|
+
const result = await withDb(session.dbPath, (conn) =>
|
|
416
|
+
getThread(conn, session.threadId),
|
|
417
|
+
);
|
|
413
418
|
if (mounted && result?.thread.title) {
|
|
414
419
|
setChatTitle(result.thread.title);
|
|
415
420
|
}
|
|
@@ -547,18 +552,18 @@ export function App({
|
|
|
547
552
|
[exit, processQueue, syncQueue],
|
|
548
553
|
);
|
|
549
554
|
|
|
550
|
-
const
|
|
555
|
+
const sessionDbPath = sessionRef.current?.dbPath;
|
|
551
556
|
const inputBarHeader = useMemo(
|
|
552
557
|
() =>
|
|
553
|
-
|
|
558
|
+
sessionDbPath ? (
|
|
554
559
|
<StatusBar
|
|
555
560
|
projectDir={projectDir}
|
|
556
|
-
|
|
561
|
+
dbPath={sessionDbPath}
|
|
557
562
|
chatTitle={chatTitle}
|
|
558
563
|
onDaemonStatusChange={setDaemonRunning}
|
|
559
564
|
/>
|
|
560
565
|
) : null,
|
|
561
|
-
[projectDir,
|
|
566
|
+
[projectDir, sessionDbPath, chatTitle],
|
|
562
567
|
);
|
|
563
568
|
|
|
564
569
|
const sessionSkills = ready ? sessionRef.current?.skills : undefined;
|
|
@@ -602,7 +607,7 @@ export function App({
|
|
|
602
607
|
);
|
|
603
608
|
}
|
|
604
609
|
|
|
605
|
-
const
|
|
610
|
+
const dbPath = sessionRef.current.dbPath;
|
|
606
611
|
const threadId = sessionRef.current.threadId;
|
|
607
612
|
|
|
608
613
|
return (
|
|
@@ -642,14 +647,14 @@ export function App({
|
|
|
642
647
|
flexDirection="column"
|
|
643
648
|
flexGrow={1}
|
|
644
649
|
>
|
|
645
|
-
<ContextPanel
|
|
650
|
+
<ContextPanel dbPath={dbPath} isActive={activeTab === 3} />
|
|
646
651
|
</Box>
|
|
647
652
|
<Box
|
|
648
653
|
display={activeTab === 4 ? "flex" : "none"}
|
|
649
654
|
flexDirection="column"
|
|
650
655
|
flexGrow={1}
|
|
651
656
|
>
|
|
652
|
-
<TaskPanel
|
|
657
|
+
<TaskPanel dbPath={dbPath} isActive={activeTab === 4} />
|
|
653
658
|
</Box>
|
|
654
659
|
<Box
|
|
655
660
|
display={activeTab === 5 ? "flex" : "none"}
|
|
@@ -657,7 +662,7 @@ export function App({
|
|
|
657
662
|
flexGrow={1}
|
|
658
663
|
>
|
|
659
664
|
<ThreadPanel
|
|
660
|
-
|
|
665
|
+
dbPath={dbPath}
|
|
661
666
|
activeThreadId={threadId}
|
|
662
667
|
isActive={activeTab === 5}
|
|
663
668
|
/>
|
|
@@ -667,7 +672,7 @@ export function App({
|
|
|
667
672
|
flexDirection="column"
|
|
668
673
|
flexGrow={1}
|
|
669
674
|
>
|
|
670
|
-
<SchedulePanel
|
|
675
|
+
<SchedulePanel dbPath={dbPath} isActive={activeTab === 6} />
|
|
671
676
|
</Box>
|
|
672
677
|
<Box
|
|
673
678
|
display={activeTab === 7 ? "flex" : "none"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
2
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
-
import
|
|
3
|
+
import { withDb } from "../../db/connection.ts";
|
|
4
4
|
import {
|
|
5
5
|
type ContextItem,
|
|
6
6
|
deleteContextItem,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "../../db/context.ts";
|
|
12
12
|
|
|
13
13
|
interface ContextPanelProps {
|
|
14
|
-
|
|
14
|
+
dbPath: string;
|
|
15
15
|
isActive: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -32,7 +32,7 @@ type Entry = DirEntry | FileEntry;
|
|
|
32
32
|
const CHROME_LINES = 8;
|
|
33
33
|
|
|
34
34
|
export const ContextPanel = memo(function ContextPanel({
|
|
35
|
-
|
|
35
|
+
dbPath,
|
|
36
36
|
isActive,
|
|
37
37
|
}: ContextPanelProps) {
|
|
38
38
|
const { stdout } = useStdout();
|
|
@@ -64,10 +64,10 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
64
64
|
|
|
65
65
|
const loadEntries = useCallback(
|
|
66
66
|
async (path: string) => {
|
|
67
|
-
const dirs = await
|
|
68
|
-
|
|
69
|
-
recursive: false,
|
|
70
|
-
|
|
67
|
+
const [dirs, files] = await withDb(dbPath, async (conn) => [
|
|
68
|
+
await getDistinctDirectories(conn, path),
|
|
69
|
+
await listContextItemsByPrefix(conn, path, { recursive: false }),
|
|
70
|
+
]);
|
|
71
71
|
|
|
72
72
|
const dirEntries: DirEntry[] = dirs.map((d) => ({
|
|
73
73
|
type: "directory",
|
|
@@ -84,7 +84,7 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
84
84
|
setScrollOffset(0);
|
|
85
85
|
setPreview(null);
|
|
86
86
|
},
|
|
87
|
-
[
|
|
87
|
+
[dbPath],
|
|
88
88
|
);
|
|
89
89
|
|
|
90
90
|
useEffect(() => {
|
|
@@ -99,13 +99,15 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
99
99
|
setSearchResults(null);
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
102
|
-
const results = await
|
|
102
|
+
const results = await withDb(dbPath, (conn) =>
|
|
103
|
+
searchContextByKeyword(conn, query.trim(), 50),
|
|
104
|
+
);
|
|
103
105
|
setSearchResults(results);
|
|
104
106
|
setCursor(0);
|
|
105
107
|
setScrollOffset(0);
|
|
106
108
|
setPreview(null);
|
|
107
109
|
},
|
|
108
|
-
[
|
|
110
|
+
[dbPath],
|
|
109
111
|
);
|
|
110
112
|
|
|
111
113
|
// Compute the items list and visible window for the current view
|
|
@@ -171,11 +173,13 @@ export const ContextPanel = memo(function ContextPanel({
|
|
|
171
173
|
if (input === "y" || input === "d") {
|
|
172
174
|
const entry = entries[cursor];
|
|
173
175
|
if (entry) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
void withDb(dbPath, async (conn) => {
|
|
177
|
+
if (entry.type === "directory") {
|
|
178
|
+
await deleteContextItemsByPrefix(conn, entry.path);
|
|
179
|
+
} else {
|
|
180
|
+
await deleteContextItem(conn, entry.item.id);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
179
183
|
setConfirmDelete(false);
|
|
180
184
|
loadEntries(currentPath);
|
|
181
185
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
2
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
-
import
|
|
3
|
+
import { withDb } from "../../db/connection.ts";
|
|
4
4
|
import {
|
|
5
5
|
deleteSchedule,
|
|
6
6
|
listSchedules,
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { ansi, theme } from "../theme.ts";
|
|
11
11
|
|
|
12
12
|
interface SchedulePanelProps {
|
|
13
|
-
|
|
13
|
+
dbPath: string;
|
|
14
14
|
isActive: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -108,7 +108,7 @@ function cycleFilter<T>(current: T | null, values: readonly T[]): T | null {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
export const SchedulePanel = memo(function SchedulePanel({
|
|
111
|
-
|
|
111
|
+
dbPath,
|
|
112
112
|
isActive,
|
|
113
113
|
}: SchedulePanelProps) {
|
|
114
114
|
const { stdout } = useStdout();
|
|
@@ -127,7 +127,9 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
127
127
|
const refresh = async () => {
|
|
128
128
|
const filters: { enabled?: boolean } = {};
|
|
129
129
|
if (enabledFilter !== null) filters.enabled = enabledFilter;
|
|
130
|
-
const result = await
|
|
130
|
+
const result = await withDb(dbPath, (conn) =>
|
|
131
|
+
listSchedules(conn, filters),
|
|
132
|
+
);
|
|
131
133
|
if (mounted) {
|
|
132
134
|
setSchedules(result);
|
|
133
135
|
setSelectedIndex((prev) =>
|
|
@@ -142,7 +144,7 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
142
144
|
mounted = false;
|
|
143
145
|
clearInterval(interval);
|
|
144
146
|
};
|
|
145
|
-
}, [
|
|
147
|
+
}, [dbPath, enabledFilter, refreshTick]);
|
|
146
148
|
|
|
147
149
|
const selectedSchedule = schedules[selectedIndex];
|
|
148
150
|
|
|
@@ -182,7 +184,9 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
182
184
|
if (confirmDelete) {
|
|
183
185
|
if (input === "y" || input === "d") {
|
|
184
186
|
if (selectedSchedule) {
|
|
185
|
-
|
|
187
|
+
withDb(dbPath, (conn) =>
|
|
188
|
+
deleteSchedule(conn, selectedSchedule.id),
|
|
189
|
+
).then(() => {
|
|
186
190
|
forceRefresh();
|
|
187
191
|
});
|
|
188
192
|
}
|
|
@@ -242,9 +246,11 @@ export const SchedulePanel = memo(function SchedulePanel({
|
|
|
242
246
|
return;
|
|
243
247
|
}
|
|
244
248
|
if (input === "e" && selectedSchedule) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
249
|
+
withDb(dbPath, (conn) =>
|
|
250
|
+
updateSchedule(conn, selectedSchedule.id, {
|
|
251
|
+
enabled: !selectedSchedule.enabled,
|
|
252
|
+
}),
|
|
253
|
+
).then(() => {
|
|
248
254
|
forceRefresh();
|
|
249
255
|
});
|
|
250
256
|
return;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
|
-
import
|
|
3
|
+
import { withDb } from "../../db/connection.ts";
|
|
4
4
|
import { listTasks } from "../../db/tasks.ts";
|
|
5
5
|
import { getDaemonStatus } from "../../utils/pid.ts";
|
|
6
6
|
import { LogoChar } from "./Logo.tsx";
|
|
7
7
|
|
|
8
8
|
interface StatusBarProps {
|
|
9
9
|
projectDir: string;
|
|
10
|
-
|
|
10
|
+
dbPath: string;
|
|
11
11
|
chatTitle?: string;
|
|
12
12
|
onDaemonStatusChange?: (running: boolean) => void;
|
|
13
13
|
}
|
|
@@ -20,7 +20,7 @@ interface Status {
|
|
|
20
20
|
|
|
21
21
|
export function StatusBar({
|
|
22
22
|
projectDir,
|
|
23
|
-
|
|
23
|
+
dbPath,
|
|
24
24
|
chatTitle,
|
|
25
25
|
onDaemonStatusChange,
|
|
26
26
|
}: StatusBarProps) {
|
|
@@ -35,8 +35,10 @@ export function StatusBar({
|
|
|
35
35
|
|
|
36
36
|
const refresh = async () => {
|
|
37
37
|
const daemon = await getDaemonStatus(projectDir);
|
|
38
|
-
const pending = await
|
|
39
|
-
|
|
38
|
+
const [pending, inProgress] = await withDb(dbPath, async (conn) => [
|
|
39
|
+
await listTasks(conn, { status: "pending" }),
|
|
40
|
+
await listTasks(conn, { status: "in_progress" }),
|
|
41
|
+
]);
|
|
40
42
|
if (mounted) {
|
|
41
43
|
const daemonRunning = daemon !== null;
|
|
42
44
|
setStatus({
|
|
@@ -54,7 +56,7 @@ export function StatusBar({
|
|
|
54
56
|
mounted = false;
|
|
55
57
|
clearInterval(interval);
|
|
56
58
|
};
|
|
57
|
-
}, [projectDir,
|
|
59
|
+
}, [projectDir, dbPath, onDaemonStatusChange]);
|
|
58
60
|
|
|
59
61
|
return (
|
|
60
62
|
<Box paddingX={0}>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
2
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
-
import
|
|
3
|
+
import { withDb } from "../../db/connection.ts";
|
|
4
4
|
import {
|
|
5
5
|
deleteTask,
|
|
6
6
|
listTasks,
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import { ansi, theme } from "../theme.ts";
|
|
12
12
|
|
|
13
13
|
interface TaskPanelProps {
|
|
14
|
-
|
|
14
|
+
dbPath: string;
|
|
15
15
|
isActive: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -145,7 +145,7 @@ function cycleFilter<T>(current: T | null, values: readonly T[]): T | null {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
export const TaskPanel = memo(function TaskPanel({
|
|
148
|
-
|
|
148
|
+
dbPath,
|
|
149
149
|
isActive,
|
|
150
150
|
}: TaskPanelProps) {
|
|
151
151
|
const { stdout } = useStdout();
|
|
@@ -171,7 +171,7 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
171
171
|
} = {};
|
|
172
172
|
if (statusFilter) filters.status = statusFilter;
|
|
173
173
|
if (priorityFilter) filters.priority = priorityFilter;
|
|
174
|
-
const result = await listTasks(conn, filters);
|
|
174
|
+
const result = await withDb(dbPath, (conn) => listTasks(conn, filters));
|
|
175
175
|
if (mounted) {
|
|
176
176
|
setTasks(result);
|
|
177
177
|
setSelectedIndex((prev) =>
|
|
@@ -186,7 +186,7 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
186
186
|
mounted = false;
|
|
187
187
|
clearInterval(interval);
|
|
188
188
|
};
|
|
189
|
-
}, [
|
|
189
|
+
}, [dbPath, statusFilter, priorityFilter, refreshTick]);
|
|
190
190
|
|
|
191
191
|
const selectedTask = tasks[selectedIndex];
|
|
192
192
|
|
|
@@ -275,7 +275,7 @@ export const TaskPanel = memo(function TaskPanel({
|
|
|
275
275
|
return;
|
|
276
276
|
}
|
|
277
277
|
if (input === "d" && selectedTask) {
|
|
278
|
-
deleteTask(conn, selectedTask.id).then(() => {
|
|
278
|
+
withDb(dbPath, (conn) => deleteTask(conn, selectedTask.id)).then(() => {
|
|
279
279
|
forceRefresh();
|
|
280
280
|
});
|
|
281
281
|
return;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
2
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
-
import
|
|
3
|
+
import { withDb } from "../../db/connection.ts";
|
|
4
4
|
import {
|
|
5
5
|
deleteThread,
|
|
6
6
|
getInteractionsAfter,
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { ansi, theme } from "../theme.ts";
|
|
14
14
|
|
|
15
15
|
interface ThreadPanelProps {
|
|
16
|
-
|
|
16
|
+
dbPath: string;
|
|
17
17
|
activeThreadId: string;
|
|
18
18
|
isActive: boolean;
|
|
19
19
|
}
|
|
@@ -181,7 +181,7 @@ function cycleFilter<T>(current: T | null, values: readonly T[]): T | null {
|
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
export const ThreadPanel = memo(function ThreadPanel({
|
|
184
|
-
|
|
184
|
+
dbPath,
|
|
185
185
|
activeThreadId,
|
|
186
186
|
isActive,
|
|
187
187
|
}: ThreadPanelProps) {
|
|
@@ -210,7 +210,7 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
210
210
|
const refresh = async () => {
|
|
211
211
|
const filters: { type?: Thread["type"] } = {};
|
|
212
212
|
if (typeFilter) filters.type = typeFilter;
|
|
213
|
-
const result = await listThreads(conn, filters);
|
|
213
|
+
const result = await withDb(dbPath, (conn) => listThreads(conn, filters));
|
|
214
214
|
if (mounted) {
|
|
215
215
|
setThreads(result);
|
|
216
216
|
setSelectedIndex((prev) =>
|
|
@@ -225,7 +225,7 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
225
225
|
mounted = false;
|
|
226
226
|
clearInterval(interval);
|
|
227
227
|
};
|
|
228
|
-
}, [
|
|
228
|
+
}, [dbPath, typeFilter, refreshTick]);
|
|
229
229
|
|
|
230
230
|
// Filter threads by search query
|
|
231
231
|
const filteredThreads = useMemo(() => {
|
|
@@ -245,16 +245,18 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
245
245
|
return;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
getThread(conn, selectedThread.id).then(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
248
|
+
withDb(dbPath, (conn) => getThread(conn, selectedThread.id)).then(
|
|
249
|
+
(result) => {
|
|
250
|
+
if (mounted && result) {
|
|
251
|
+
setSelectedDetail(result);
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
);
|
|
253
255
|
|
|
254
256
|
return () => {
|
|
255
257
|
mounted = false;
|
|
256
258
|
};
|
|
257
|
-
}, [
|
|
259
|
+
}, [dbPath, selectedThread?.id, following]);
|
|
258
260
|
|
|
259
261
|
// Follow mode: poll for new interactions every 1s
|
|
260
262
|
// biome-ignore lint/correctness/useExhaustiveDependencies: following and selectedThread?.id are the intentional triggers
|
|
@@ -264,10 +266,12 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
264
266
|
|
|
265
267
|
const poll = async () => {
|
|
266
268
|
try {
|
|
267
|
-
const newInteractions = await
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
const newInteractions = await withDb(dbPath, (conn) =>
|
|
270
|
+
getInteractionsAfter(
|
|
271
|
+
conn,
|
|
272
|
+
selectedThread.id,
|
|
273
|
+
lastSeenSequenceRef.current,
|
|
274
|
+
),
|
|
271
275
|
);
|
|
272
276
|
if (!mounted || newInteractions.length === 0) return;
|
|
273
277
|
|
|
@@ -286,11 +290,15 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
286
290
|
// Auto-scroll will be handled by the detailLines/maxDetailScroll recalc
|
|
287
291
|
setDetailScroll(Number.MAX_SAFE_INTEGER);
|
|
288
292
|
|
|
289
|
-
const ended = await
|
|
293
|
+
const ended = await withDb(dbPath, (conn) =>
|
|
294
|
+
isThreadEnded(conn, selectedThread.id),
|
|
295
|
+
);
|
|
290
296
|
if (mounted && ended) {
|
|
291
297
|
setFollowing(false);
|
|
292
298
|
// Refresh the thread to get the ended_at timestamp
|
|
293
|
-
const result = await
|
|
299
|
+
const result = await withDb(dbPath, (conn) =>
|
|
300
|
+
getThread(conn, selectedThread.id),
|
|
301
|
+
);
|
|
294
302
|
if (mounted && result) {
|
|
295
303
|
setSelectedDetail(result);
|
|
296
304
|
}
|
|
@@ -306,7 +314,7 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
306
314
|
mounted = false;
|
|
307
315
|
clearInterval(interval);
|
|
308
316
|
};
|
|
309
|
-
}, [
|
|
317
|
+
}, [dbPath, following, selectedThread?.id]);
|
|
310
318
|
|
|
311
319
|
const isActiveSelected = selectedThread?.id === activeThreadId;
|
|
312
320
|
|
|
@@ -375,7 +383,9 @@ export const ThreadPanel = memo(function ThreadPanel({
|
|
|
375
383
|
if (confirmDelete) {
|
|
376
384
|
if (input === "y" || input === "d") {
|
|
377
385
|
if (selectedThread && !isActiveSelected) {
|
|
378
|
-
|
|
386
|
+
withDb(dbPath, (conn) =>
|
|
387
|
+
deleteThread(conn, selectedThread.id),
|
|
388
|
+
).then(() => {
|
|
379
389
|
forceRefresh();
|
|
380
390
|
});
|
|
381
391
|
}
|
package/src/utils/title.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2
2
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
3
|
-
import
|
|
3
|
+
import { withDb } from "../db/connection.ts";
|
|
4
4
|
import { updateThreadTitle } from "../db/threads.ts";
|
|
5
5
|
import { logger } from "./logger.ts";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Generate a short title for a thread using the chunker model (Haiku).
|
|
9
9
|
* Fire-and-forget — errors are logged at debug level and never propagated.
|
|
10
|
+
* Opens its own short-lived DB connection for the write so callers can
|
|
11
|
+
* safely `void`-chain without holding a connection during the LLM call.
|
|
10
12
|
*/
|
|
11
13
|
export async function generateThreadTitle(
|
|
12
14
|
config: Required<BotholomewConfig>,
|
|
13
|
-
|
|
15
|
+
dbPath: string,
|
|
14
16
|
threadId: string,
|
|
15
17
|
context: string,
|
|
16
18
|
): Promise<void> {
|
|
@@ -39,7 +41,7 @@ export async function generateThreadTitle(
|
|
|
39
41
|
.trim();
|
|
40
42
|
|
|
41
43
|
if (title) {
|
|
42
|
-
await updateThreadTitle(conn, threadId, title);
|
|
44
|
+
await withDb(dbPath, (conn) => updateThreadTitle(conn, threadId, title));
|
|
43
45
|
}
|
|
44
46
|
} catch (err) {
|
|
45
47
|
logger.warn(`Failed to generate thread title: ${err}`);
|