walkietalkiebot 0.3.0
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/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +8 -0
- package/README.md +169 -0
- package/bin/wtb-server.js +336 -0
- package/bin/wtb.js +43 -0
- package/dist/Talkie_logo.png +0 -0
- package/dist/assets/index-UPnYoRh1.js +81 -0
- package/dist/assets/index-VbGv60d-.css +1 -0
- package/dist/index.html +14 -0
- package/mcp-server/dist/index.js +401 -0
- package/package.json +86 -0
- package/server/api.js +629 -0
- package/server/db/index.js +67 -0
- package/server/db/repositories/activities.js +85 -0
- package/server/db/repositories/activities.test.js +106 -0
- package/server/db/repositories/conversations.js +93 -0
- package/server/db/repositories/conversations.test.js +137 -0
- package/server/db/repositories/jobs.js +128 -0
- package/server/db/repositories/messages.js +98 -0
- package/server/db/repositories/messages.test.js +152 -0
- package/server/db/repositories/plans.js +57 -0
- package/server/db/repositories/plans.test.js +98 -0
- package/server/db/repositories/search.js +34 -0
- package/server/db/repositories/search.test.js +61 -0
- package/server/db/repositories/telegram.js +30 -0
- package/server/db/schema.js +165 -0
- package/server/index.js +137 -0
- package/server/jobs/api.js +108 -0
- package/server/jobs/manager.js +231 -0
- package/server/jobs/runner.js +246 -0
- package/server/notifications/dispatcher.js +40 -0
- package/server/notifications/macos.js +24 -0
- package/server/notifications/types.js +0 -0
- package/server/ssl.js +61 -0
- package/server/state.js +30 -0
- package/server/telegram/commands.js +160 -0
- package/server/telegram/handlers.js +299 -0
- package/server/telegram/index.js +46 -0
- package/server/test/helpers.js +14 -0
- package/skills/export-tape/SKILL.md +26 -0
- package/skills/launch-voice/SKILL.md +25 -0
- package/skills/manage-plans/SKILL.md +32 -0
- package/skills/save-conversation/SKILL.md +24 -0
- package/skills/search-tapes/SKILL.md +21 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { resetTestDb } from "../../test/helpers.js";
|
|
3
|
+
import { createConversation } from "./conversations.js";
|
|
4
|
+
import {
|
|
5
|
+
getMessagesForConversation,
|
|
6
|
+
getImagesForMessage,
|
|
7
|
+
getImagesForMessages,
|
|
8
|
+
createMessage,
|
|
9
|
+
updateImageDescription,
|
|
10
|
+
deleteMessage,
|
|
11
|
+
countMessagesInConversation
|
|
12
|
+
} from "./messages.js";
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
resetTestDb();
|
|
15
|
+
createConversation({ id: "conv1", title: "Test Conversation" });
|
|
16
|
+
});
|
|
17
|
+
describe("createMessage", () => {
|
|
18
|
+
it("creates a basic message", () => {
|
|
19
|
+
const msg = createMessage({
|
|
20
|
+
id: "m1",
|
|
21
|
+
conversationId: "conv1",
|
|
22
|
+
role: "user",
|
|
23
|
+
content: "Hello"
|
|
24
|
+
});
|
|
25
|
+
expect(msg.id).toBe("m1");
|
|
26
|
+
expect(msg.role).toBe("user");
|
|
27
|
+
expect(msg.content).toBe("Hello");
|
|
28
|
+
expect(msg.position).toBe(0);
|
|
29
|
+
expect(msg.source).toBe("web");
|
|
30
|
+
});
|
|
31
|
+
it("auto-increments position", () => {
|
|
32
|
+
createMessage({ id: "m1", conversationId: "conv1", role: "user", content: "First" });
|
|
33
|
+
const m2 = createMessage({ id: "m2", conversationId: "conv1", role: "assistant", content: "Second" });
|
|
34
|
+
expect(m2.position).toBe(1);
|
|
35
|
+
});
|
|
36
|
+
it("creates message with images", () => {
|
|
37
|
+
createMessage({
|
|
38
|
+
id: "m1",
|
|
39
|
+
conversationId: "conv1",
|
|
40
|
+
role: "user",
|
|
41
|
+
content: "See this image",
|
|
42
|
+
images: [
|
|
43
|
+
{ id: "img1", dataUrl: "data:image/png;base64,abc", fileName: "test.png", description: "A test image" }
|
|
44
|
+
]
|
|
45
|
+
});
|
|
46
|
+
const images = getImagesForMessage("m1");
|
|
47
|
+
expect(images).toHaveLength(1);
|
|
48
|
+
expect(images[0].file_name).toBe("test.png");
|
|
49
|
+
expect(images[0].description).toBe("A test image");
|
|
50
|
+
});
|
|
51
|
+
it("creates message with custom source", () => {
|
|
52
|
+
const msg = createMessage({
|
|
53
|
+
id: "m1",
|
|
54
|
+
conversationId: "conv1",
|
|
55
|
+
role: "user",
|
|
56
|
+
content: "From telegram",
|
|
57
|
+
source: "telegram"
|
|
58
|
+
});
|
|
59
|
+
expect(msg.source).toBe("telegram");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe("getMessagesForConversation", () => {
|
|
63
|
+
it("returns empty array for no messages", () => {
|
|
64
|
+
expect(getMessagesForConversation("conv1")).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
it("returns messages ordered by position", () => {
|
|
67
|
+
createMessage({ id: "m1", conversationId: "conv1", role: "user", content: "First" });
|
|
68
|
+
createMessage({ id: "m2", conversationId: "conv1", role: "assistant", content: "Second" });
|
|
69
|
+
createMessage({ id: "m3", conversationId: "conv1", role: "user", content: "Third" });
|
|
70
|
+
const msgs = getMessagesForConversation("conv1");
|
|
71
|
+
expect(msgs).toHaveLength(3);
|
|
72
|
+
expect(msgs[0].content).toBe("First");
|
|
73
|
+
expect(msgs[1].content).toBe("Second");
|
|
74
|
+
expect(msgs[2].content).toBe("Third");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe("getImagesForMessages", () => {
|
|
78
|
+
it("returns empty map for empty input", () => {
|
|
79
|
+
const result = getImagesForMessages([]);
|
|
80
|
+
expect(result.size).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
it("returns images grouped by message", () => {
|
|
83
|
+
createMessage({
|
|
84
|
+
id: "m1",
|
|
85
|
+
conversationId: "conv1",
|
|
86
|
+
role: "user",
|
|
87
|
+
content: "Image 1",
|
|
88
|
+
images: [{ id: "img1", dataUrl: "data:a", fileName: "a.png" }]
|
|
89
|
+
});
|
|
90
|
+
createMessage({
|
|
91
|
+
id: "m2",
|
|
92
|
+
conversationId: "conv1",
|
|
93
|
+
role: "user",
|
|
94
|
+
content: "Image 2",
|
|
95
|
+
images: [
|
|
96
|
+
{ id: "img2", dataUrl: "data:b", fileName: "b.png" },
|
|
97
|
+
{ id: "img3", dataUrl: "data:c", fileName: "c.png" }
|
|
98
|
+
]
|
|
99
|
+
});
|
|
100
|
+
const result = getImagesForMessages(["m1", "m2"]);
|
|
101
|
+
expect(result.get("m1")).toHaveLength(1);
|
|
102
|
+
expect(result.get("m2")).toHaveLength(2);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe("updateImageDescription", () => {
|
|
106
|
+
it("updates existing image description", () => {
|
|
107
|
+
createMessage({
|
|
108
|
+
id: "m1",
|
|
109
|
+
conversationId: "conv1",
|
|
110
|
+
role: "user",
|
|
111
|
+
content: "img",
|
|
112
|
+
images: [{ id: "img1", dataUrl: "data:a", fileName: "a.png" }]
|
|
113
|
+
});
|
|
114
|
+
expect(updateImageDescription("img1", "A nice photo")).toBe(true);
|
|
115
|
+
const images = getImagesForMessage("m1");
|
|
116
|
+
expect(images[0].description).toBe("A nice photo");
|
|
117
|
+
});
|
|
118
|
+
it("returns false for non-existing image", () => {
|
|
119
|
+
expect(updateImageDescription("nope", "desc")).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe("deleteMessage", () => {
|
|
123
|
+
it("deletes existing message", () => {
|
|
124
|
+
createMessage({ id: "m1", conversationId: "conv1", role: "user", content: "Hello" });
|
|
125
|
+
expect(deleteMessage("m1")).toBe(true);
|
|
126
|
+
expect(getMessagesForConversation("conv1")).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
it("cascade deletes images", () => {
|
|
129
|
+
createMessage({
|
|
130
|
+
id: "m1",
|
|
131
|
+
conversationId: "conv1",
|
|
132
|
+
role: "user",
|
|
133
|
+
content: "img",
|
|
134
|
+
images: [{ id: "img1", dataUrl: "data:a", fileName: "a.png" }]
|
|
135
|
+
});
|
|
136
|
+
deleteMessage("m1");
|
|
137
|
+
expect(getImagesForMessage("m1")).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
it("returns false for non-existing", () => {
|
|
140
|
+
expect(deleteMessage("nope")).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
describe("countMessagesInConversation", () => {
|
|
144
|
+
it("returns 0 for empty conversation", () => {
|
|
145
|
+
expect(countMessagesInConversation("conv1")).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
it("returns correct count", () => {
|
|
148
|
+
createMessage({ id: "m1", conversationId: "conv1", role: "user", content: "a" });
|
|
149
|
+
createMessage({ id: "m2", conversationId: "conv1", role: "assistant", content: "b" });
|
|
150
|
+
expect(countMessagesInConversation("conv1")).toBe(2);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { getDb } from "../index.js";
|
|
2
|
+
function listPlans(limit = 50, offset = 0) {
|
|
3
|
+
const db = getDb();
|
|
4
|
+
return db.prepare(
|
|
5
|
+
"SELECT * FROM plans ORDER BY updated_at DESC LIMIT ? OFFSET ?"
|
|
6
|
+
).all(limit, offset);
|
|
7
|
+
}
|
|
8
|
+
function getPlan(id) {
|
|
9
|
+
const db = getDb();
|
|
10
|
+
return db.prepare("SELECT * FROM plans WHERE id = ?").get(id);
|
|
11
|
+
}
|
|
12
|
+
function createPlan(plan) {
|
|
13
|
+
const db = getDb();
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
db.prepare(
|
|
16
|
+
"INSERT INTO plans (id, title, content, status, conversation_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
17
|
+
).run(
|
|
18
|
+
plan.id,
|
|
19
|
+
plan.title,
|
|
20
|
+
plan.content,
|
|
21
|
+
plan.status || "draft",
|
|
22
|
+
plan.conversationId || null,
|
|
23
|
+
now,
|
|
24
|
+
now
|
|
25
|
+
);
|
|
26
|
+
return getPlan(plan.id);
|
|
27
|
+
}
|
|
28
|
+
function updatePlan(id, updates) {
|
|
29
|
+
const db = getDb();
|
|
30
|
+
const sets = ["updated_at = ?"];
|
|
31
|
+
const values = [Date.now()];
|
|
32
|
+
if (updates.title !== void 0) {
|
|
33
|
+
sets.push("title = ?");
|
|
34
|
+
values.push(updates.title);
|
|
35
|
+
}
|
|
36
|
+
if (updates.content !== void 0) {
|
|
37
|
+
sets.push("content = ?");
|
|
38
|
+
values.push(updates.content);
|
|
39
|
+
}
|
|
40
|
+
if (updates.status !== void 0) {
|
|
41
|
+
sets.push("status = ?");
|
|
42
|
+
values.push(updates.status);
|
|
43
|
+
}
|
|
44
|
+
values.push(id);
|
|
45
|
+
db.prepare(`UPDATE plans SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
46
|
+
}
|
|
47
|
+
function deletePlan(id) {
|
|
48
|
+
const db = getDb();
|
|
49
|
+
db.prepare("DELETE FROM plans WHERE id = ?").run(id);
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
createPlan,
|
|
53
|
+
deletePlan,
|
|
54
|
+
getPlan,
|
|
55
|
+
listPlans,
|
|
56
|
+
updatePlan
|
|
57
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { resetTestDb } from "../../test/helpers.js";
|
|
3
|
+
import { createConversation } from "./conversations.js";
|
|
4
|
+
import {
|
|
5
|
+
listPlans,
|
|
6
|
+
getPlan,
|
|
7
|
+
createPlan,
|
|
8
|
+
updatePlan,
|
|
9
|
+
deletePlan
|
|
10
|
+
} from "./plans.js";
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
resetTestDb();
|
|
13
|
+
});
|
|
14
|
+
describe("createPlan", () => {
|
|
15
|
+
it("creates a plan with default status", () => {
|
|
16
|
+
const plan = createPlan({ id: "p1", title: "My Plan", content: "# Steps\n1. Do this" });
|
|
17
|
+
expect(plan.id).toBe("p1");
|
|
18
|
+
expect(plan.title).toBe("My Plan");
|
|
19
|
+
expect(plan.content).toBe("# Steps\n1. Do this");
|
|
20
|
+
expect(plan.status).toBe("draft");
|
|
21
|
+
expect(plan.conversation_id).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
it("creates a plan with custom status", () => {
|
|
24
|
+
const plan = createPlan({ id: "p1", title: "Plan", content: "x", status: "approved" });
|
|
25
|
+
expect(plan.status).toBe("approved");
|
|
26
|
+
});
|
|
27
|
+
it("creates a plan linked to a conversation", () => {
|
|
28
|
+
createConversation({ id: "c1" });
|
|
29
|
+
const plan = createPlan({ id: "p1", title: "Plan", content: "x", conversationId: "c1" });
|
|
30
|
+
expect(plan.conversation_id).toBe("c1");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("getPlan", () => {
|
|
34
|
+
it("returns existing plan", () => {
|
|
35
|
+
createPlan({ id: "p1", title: "Test", content: "content" });
|
|
36
|
+
const plan = getPlan("p1");
|
|
37
|
+
expect(plan).toBeDefined();
|
|
38
|
+
expect(plan.title).toBe("Test");
|
|
39
|
+
});
|
|
40
|
+
it("returns undefined for non-existing", () => {
|
|
41
|
+
expect(getPlan("nope")).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("listPlans", () => {
|
|
45
|
+
it("returns empty list initially", () => {
|
|
46
|
+
expect(listPlans()).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
it("returns plans ordered by updated_at DESC", () => {
|
|
49
|
+
createPlan({ id: "p1", title: "First", content: "a" });
|
|
50
|
+
createPlan({ id: "p2", title: "Second", content: "b" });
|
|
51
|
+
updatePlan("p1", { title: "First Updated" });
|
|
52
|
+
const plans = listPlans();
|
|
53
|
+
expect(plans).toHaveLength(2);
|
|
54
|
+
expect(plans[0].id).toBe("p1");
|
|
55
|
+
});
|
|
56
|
+
it("respects limit and offset", () => {
|
|
57
|
+
createPlan({ id: "p1", title: "A", content: "a" });
|
|
58
|
+
createPlan({ id: "p2", title: "B", content: "b" });
|
|
59
|
+
createPlan({ id: "p3", title: "C", content: "c" });
|
|
60
|
+
expect(listPlans(2, 0)).toHaveLength(2);
|
|
61
|
+
expect(listPlans(2, 2)).toHaveLength(1);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe("updatePlan", () => {
|
|
65
|
+
it("updates title", () => {
|
|
66
|
+
createPlan({ id: "p1", title: "Old", content: "x" });
|
|
67
|
+
updatePlan("p1", { title: "New" });
|
|
68
|
+
expect(getPlan("p1").title).toBe("New");
|
|
69
|
+
});
|
|
70
|
+
it("updates content", () => {
|
|
71
|
+
createPlan({ id: "p1", title: "Plan", content: "old content" });
|
|
72
|
+
updatePlan("p1", { content: "new content" });
|
|
73
|
+
expect(getPlan("p1").content).toBe("new content");
|
|
74
|
+
});
|
|
75
|
+
it("updates status", () => {
|
|
76
|
+
createPlan({ id: "p1", title: "Plan", content: "x" });
|
|
77
|
+
updatePlan("p1", { status: "in_progress" });
|
|
78
|
+
expect(getPlan("p1").status).toBe("in_progress");
|
|
79
|
+
});
|
|
80
|
+
it("updates multiple fields at once", () => {
|
|
81
|
+
createPlan({ id: "p1", title: "Old", content: "old" });
|
|
82
|
+
updatePlan("p1", { title: "New", content: "new", status: "completed" });
|
|
83
|
+
const plan = getPlan("p1");
|
|
84
|
+
expect(plan.title).toBe("New");
|
|
85
|
+
expect(plan.content).toBe("new");
|
|
86
|
+
expect(plan.status).toBe("completed");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe("deletePlan", () => {
|
|
90
|
+
it("deletes existing plan", () => {
|
|
91
|
+
createPlan({ id: "p1", title: "Plan", content: "x" });
|
|
92
|
+
deletePlan("p1");
|
|
93
|
+
expect(getPlan("p1")).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
it("does not throw for non-existing", () => {
|
|
96
|
+
expect(() => deletePlan("nope")).not.toThrow();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { getDb } from "../index.js";
|
|
2
|
+
function searchMessages(query, limit = 50) {
|
|
3
|
+
if (!query.trim()) return [];
|
|
4
|
+
const db = getDb();
|
|
5
|
+
const escapedQuery = query.replace(/['"]/g, "").trim();
|
|
6
|
+
const searchTerms = escapedQuery.split(/\s+/).map((term) => `"${term}"*`).join(" ");
|
|
7
|
+
const results = db.prepare(`
|
|
8
|
+
SELECT
|
|
9
|
+
m.id as message_id,
|
|
10
|
+
m.conversation_id,
|
|
11
|
+
c.title as conversation_title,
|
|
12
|
+
m.role,
|
|
13
|
+
m.content,
|
|
14
|
+
m.timestamp,
|
|
15
|
+
snippet(messages_fts, 0, '<mark>', '</mark>', '...', 32) as snippet
|
|
16
|
+
FROM messages_fts
|
|
17
|
+
JOIN messages m ON messages_fts.rowid = m.rowid
|
|
18
|
+
JOIN conversations c ON m.conversation_id = c.id
|
|
19
|
+
WHERE messages_fts MATCH ?
|
|
20
|
+
ORDER BY rank
|
|
21
|
+
LIMIT ?
|
|
22
|
+
`).all(searchTerms, limit);
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
function rebuildSearchIndex() {
|
|
26
|
+
const db = getDb();
|
|
27
|
+
db.exec(`
|
|
28
|
+
INSERT INTO messages_fts(messages_fts) VALUES('rebuild');
|
|
29
|
+
`);
|
|
30
|
+
}
|
|
31
|
+
export {
|
|
32
|
+
rebuildSearchIndex,
|
|
33
|
+
searchMessages
|
|
34
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { resetTestDb } from "../../test/helpers.js";
|
|
3
|
+
import { createConversation } from "./conversations.js";
|
|
4
|
+
import { createMessage } from "./messages.js";
|
|
5
|
+
import { searchMessages, rebuildSearchIndex } from "./search.js";
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
resetTestDb();
|
|
8
|
+
createConversation({ id: "c1", title: "Test Conversation" });
|
|
9
|
+
});
|
|
10
|
+
describe("searchMessages", () => {
|
|
11
|
+
it("returns empty for empty query", () => {
|
|
12
|
+
expect(searchMessages("")).toEqual([]);
|
|
13
|
+
expect(searchMessages(" ")).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
it("finds matching messages", () => {
|
|
16
|
+
createMessage({ id: "m1", conversationId: "c1", role: "user", content: "How do I fix the authentication bug?" });
|
|
17
|
+
createMessage({ id: "m2", conversationId: "c1", role: "assistant", content: "Check your JWT token expiration." });
|
|
18
|
+
const results = searchMessages("authentication");
|
|
19
|
+
expect(results).toHaveLength(1);
|
|
20
|
+
expect(results[0].message_id).toBe("m1");
|
|
21
|
+
expect(results[0].conversation_title).toBe("Test Conversation");
|
|
22
|
+
});
|
|
23
|
+
it("finds messages with prefix matching", () => {
|
|
24
|
+
createMessage({ id: "m1", conversationId: "c1", role: "user", content: "debugging the server" });
|
|
25
|
+
const results = searchMessages("debug");
|
|
26
|
+
expect(results).toHaveLength(1);
|
|
27
|
+
});
|
|
28
|
+
it("returns multiple matches ranked", () => {
|
|
29
|
+
createMessage({ id: "m1", conversationId: "c1", role: "user", content: "react component rendering issue" });
|
|
30
|
+
createMessage({ id: "m2", conversationId: "c1", role: "assistant", content: "The react lifecycle hooks need updating" });
|
|
31
|
+
createMessage({ id: "m3", conversationId: "c1", role: "user", content: "unrelated message about python" });
|
|
32
|
+
const results = searchMessages("react");
|
|
33
|
+
expect(results).toHaveLength(2);
|
|
34
|
+
});
|
|
35
|
+
it("respects limit", () => {
|
|
36
|
+
for (let i = 0; i < 5; i++) {
|
|
37
|
+
createMessage({ id: `m${i}`, conversationId: "c1", role: "user", content: `test message ${i}` });
|
|
38
|
+
}
|
|
39
|
+
const results = searchMessages("test", 3);
|
|
40
|
+
expect(results).toHaveLength(3);
|
|
41
|
+
});
|
|
42
|
+
it("includes snippet with match markers", () => {
|
|
43
|
+
createMessage({ id: "m1", conversationId: "c1", role: "user", content: "How to configure webpack for production?" });
|
|
44
|
+
const results = searchMessages("webpack");
|
|
45
|
+
expect(results).toHaveLength(1);
|
|
46
|
+
expect(results[0].snippet).toContain("<mark>");
|
|
47
|
+
});
|
|
48
|
+
it("searches across conversations", () => {
|
|
49
|
+
createConversation({ id: "c2", title: "Second Conversation" });
|
|
50
|
+
createMessage({ id: "m1", conversationId: "c1", role: "user", content: "database migration" });
|
|
51
|
+
createMessage({ id: "m2", conversationId: "c2", role: "user", content: "database schema" });
|
|
52
|
+
const results = searchMessages("database");
|
|
53
|
+
expect(results).toHaveLength(2);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe("rebuildSearchIndex", () => {
|
|
57
|
+
it("runs without error", () => {
|
|
58
|
+
createMessage({ id: "m1", conversationId: "c1", role: "user", content: "test" });
|
|
59
|
+
expect(() => rebuildSearchIndex()).not.toThrow();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getDb } from "../index.js";
|
|
2
|
+
function getTelegramState(userId) {
|
|
3
|
+
const db = getDb();
|
|
4
|
+
const row = db.prepare(`
|
|
5
|
+
SELECT user_id, current_conversation_id, updated_at
|
|
6
|
+
FROM telegram_state
|
|
7
|
+
WHERE user_id = ?
|
|
8
|
+
`).get(userId);
|
|
9
|
+
return row || null;
|
|
10
|
+
}
|
|
11
|
+
function setTelegramConversation(userId, conversationId) {
|
|
12
|
+
const db = getDb();
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
db.prepare(`
|
|
15
|
+
INSERT INTO telegram_state (user_id, current_conversation_id, updated_at)
|
|
16
|
+
VALUES (?, ?, ?)
|
|
17
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
18
|
+
current_conversation_id = excluded.current_conversation_id,
|
|
19
|
+
updated_at = excluded.updated_at
|
|
20
|
+
`).run(userId, conversationId, now);
|
|
21
|
+
}
|
|
22
|
+
function clearTelegramState(userId) {
|
|
23
|
+
const db = getDb();
|
|
24
|
+
db.prepare("DELETE FROM telegram_state WHERE user_id = ?").run(userId);
|
|
25
|
+
}
|
|
26
|
+
export {
|
|
27
|
+
clearTelegramState,
|
|
28
|
+
getTelegramState,
|
|
29
|
+
setTelegramConversation
|
|
30
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const SCHEMA_VERSION = 4;
|
|
2
|
+
function initSchema(db) {
|
|
3
|
+
db.exec(`
|
|
4
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
5
|
+
version INTEGER PRIMARY KEY
|
|
6
|
+
)
|
|
7
|
+
`);
|
|
8
|
+
const row = db.prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get();
|
|
9
|
+
const currentVersion = row?.version || 0;
|
|
10
|
+
if (currentVersion < SCHEMA_VERSION) {
|
|
11
|
+
runMigrations(db, currentVersion);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function runMigrations(db, fromVersion) {
|
|
15
|
+
const migrations = [
|
|
16
|
+
migrateV1,
|
|
17
|
+
migrateV2,
|
|
18
|
+
migrateV3,
|
|
19
|
+
migrateV4
|
|
20
|
+
];
|
|
21
|
+
for (let i = fromVersion; i < migrations.length; i++) {
|
|
22
|
+
console.log(`Running migration to version ${i + 1}...`);
|
|
23
|
+
migrations[i](db);
|
|
24
|
+
db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(i + 1);
|
|
25
|
+
}
|
|
26
|
+
console.log(`Schema migrated to version ${SCHEMA_VERSION}`);
|
|
27
|
+
}
|
|
28
|
+
function migrateV1(db) {
|
|
29
|
+
db.exec(`
|
|
30
|
+
-- Conversations
|
|
31
|
+
CREATE TABLE conversations (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
title TEXT NOT NULL DEFAULT 'New conversation',
|
|
34
|
+
created_at INTEGER NOT NULL,
|
|
35
|
+
updated_at INTEGER NOT NULL,
|
|
36
|
+
project_id TEXT,
|
|
37
|
+
parent_id TEXT
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
-- Messages
|
|
41
|
+
CREATE TABLE messages (
|
|
42
|
+
id TEXT PRIMARY KEY,
|
|
43
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
44
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
|
45
|
+
content TEXT NOT NULL,
|
|
46
|
+
timestamp INTEGER NOT NULL,
|
|
47
|
+
position INTEGER NOT NULL,
|
|
48
|
+
source TEXT DEFAULT 'web'
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
-- Message images
|
|
52
|
+
CREATE TABLE message_images (
|
|
53
|
+
id TEXT PRIMARY KEY,
|
|
54
|
+
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
55
|
+
data_url TEXT NOT NULL,
|
|
56
|
+
file_name TEXT NOT NULL,
|
|
57
|
+
description TEXT,
|
|
58
|
+
position INTEGER DEFAULT 0
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
-- Activities (tool usage)
|
|
62
|
+
CREATE TABLE activities (
|
|
63
|
+
id TEXT PRIMARY KEY,
|
|
64
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
65
|
+
message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
|
66
|
+
tool TEXT NOT NULL,
|
|
67
|
+
input TEXT,
|
|
68
|
+
status TEXT NOT NULL CHECK (status IN ('complete', 'error')),
|
|
69
|
+
timestamp INTEGER NOT NULL,
|
|
70
|
+
duration INTEGER,
|
|
71
|
+
error TEXT
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
-- Telegram user state
|
|
75
|
+
CREATE TABLE telegram_state (
|
|
76
|
+
user_id INTEGER PRIMARY KEY,
|
|
77
|
+
current_conversation_id TEXT REFERENCES conversations(id) ON DELETE SET NULL,
|
|
78
|
+
updated_at INTEGER NOT NULL
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
-- Full-text search
|
|
82
|
+
CREATE VIRTUAL TABLE messages_fts USING fts5(
|
|
83
|
+
content,
|
|
84
|
+
content='messages',
|
|
85
|
+
content_rowid='rowid'
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
-- Triggers to keep FTS in sync
|
|
89
|
+
CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
|
|
90
|
+
INSERT INTO messages_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
|
|
91
|
+
END;
|
|
92
|
+
|
|
93
|
+
CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
|
|
94
|
+
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', OLD.rowid, OLD.content);
|
|
95
|
+
END;
|
|
96
|
+
|
|
97
|
+
CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN
|
|
98
|
+
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', OLD.rowid, OLD.content);
|
|
99
|
+
INSERT INTO messages_fts(rowid, content) VALUES (NEW.rowid, NEW.content);
|
|
100
|
+
END;
|
|
101
|
+
|
|
102
|
+
-- Indexes
|
|
103
|
+
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
|
|
104
|
+
CREATE INDEX idx_messages_conversation ON messages(conversation_id, position);
|
|
105
|
+
CREATE INDEX idx_activities_conversation ON activities(conversation_id, timestamp DESC);
|
|
106
|
+
`);
|
|
107
|
+
}
|
|
108
|
+
function migrateV3(db) {
|
|
109
|
+
db.exec(`
|
|
110
|
+
-- Liner notes (artifact viewer) for conversations
|
|
111
|
+
ALTER TABLE conversations ADD COLUMN liner_notes TEXT;
|
|
112
|
+
`);
|
|
113
|
+
}
|
|
114
|
+
function migrateV4(db) {
|
|
115
|
+
db.exec(`
|
|
116
|
+
-- Plans (implementation plans from Claude Code plan mode)
|
|
117
|
+
CREATE TABLE plans (
|
|
118
|
+
id TEXT PRIMARY KEY,
|
|
119
|
+
title TEXT NOT NULL,
|
|
120
|
+
content TEXT NOT NULL,
|
|
121
|
+
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'approved', 'in_progress', 'completed', 'archived')),
|
|
122
|
+
conversation_id TEXT REFERENCES conversations(id) ON DELETE SET NULL,
|
|
123
|
+
created_at INTEGER NOT NULL,
|
|
124
|
+
updated_at INTEGER NOT NULL
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
CREATE INDEX idx_plans_status ON plans(status);
|
|
128
|
+
CREATE INDEX idx_plans_updated ON plans(updated_at DESC);
|
|
129
|
+
`);
|
|
130
|
+
}
|
|
131
|
+
function migrateV2(db) {
|
|
132
|
+
db.exec(`
|
|
133
|
+
-- Background jobs
|
|
134
|
+
CREATE TABLE jobs (
|
|
135
|
+
id TEXT PRIMARY KEY,
|
|
136
|
+
conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
137
|
+
prompt TEXT NOT NULL,
|
|
138
|
+
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'completed', 'failed', 'cancelled')),
|
|
139
|
+
source TEXT NOT NULL DEFAULT 'web',
|
|
140
|
+
result TEXT,
|
|
141
|
+
error TEXT,
|
|
142
|
+
pid INTEGER,
|
|
143
|
+
created_at INTEGER NOT NULL,
|
|
144
|
+
updated_at INTEGER NOT NULL,
|
|
145
|
+
started_at INTEGER,
|
|
146
|
+
completed_at INTEGER
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
-- Job event stream
|
|
150
|
+
CREATE TABLE job_events (
|
|
151
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
152
|
+
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
|
153
|
+
event_type TEXT NOT NULL,
|
|
154
|
+
data TEXT,
|
|
155
|
+
timestamp INTEGER NOT NULL
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
CREATE INDEX idx_jobs_status ON jobs(status);
|
|
159
|
+
CREATE INDEX idx_jobs_conversation ON jobs(conversation_id);
|
|
160
|
+
CREATE INDEX idx_job_events_job ON job_events(job_id, timestamp);
|
|
161
|
+
`);
|
|
162
|
+
}
|
|
163
|
+
export {
|
|
164
|
+
initSchema
|
|
165
|
+
};
|