negotium 0.1.7 → 0.1.8
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 -2
- package/dist/chunk-0ay6cjdy.js +1331 -0
- package/dist/chunk-0ay6cjdy.js.map +16 -0
- package/dist/chunk-1k7t7bnx.js +97 -0
- package/dist/chunk-1k7t7bnx.js.map +10 -0
- package/dist/chunk-5ahkew9c.js +806 -0
- package/dist/chunk-5ahkew9c.js.map +17 -0
- package/dist/chunk-6cpn7ht2.js +287 -0
- package/dist/chunk-6cpn7ht2.js.map +13 -0
- package/dist/chunk-6kctn20e.js +3529 -0
- package/dist/chunk-6kctn20e.js.map +20 -0
- package/dist/chunk-7xqj7aex.js +77 -0
- package/dist/chunk-7xqj7aex.js.map +11 -0
- package/dist/chunk-9p0ww3tk.js +41 -0
- package/dist/chunk-9p0ww3tk.js.map +10 -0
- package/dist/chunk-a92a7nrk.js +252 -0
- package/dist/chunk-a92a7nrk.js.map +10 -0
- package/dist/chunk-c500xe9z.js +33 -0
- package/dist/chunk-c500xe9z.js.map +10 -0
- package/dist/chunk-cds1qxsf.js +1644 -0
- package/dist/chunk-cds1qxsf.js.map +17 -0
- package/dist/chunk-e4407g8x.js +274 -0
- package/dist/chunk-e4407g8x.js.map +10 -0
- package/dist/chunk-e5pbggsh.js +363 -0
- package/dist/chunk-e5pbggsh.js.map +11 -0
- package/dist/chunk-edb07sjp.js +567 -0
- package/dist/chunk-edb07sjp.js.map +14 -0
- package/dist/chunk-exd0v74a.js +3012 -0
- package/dist/chunk-exd0v74a.js.map +32 -0
- package/dist/chunk-fgrme9tr.js +4398 -0
- package/dist/chunk-fgrme9tr.js.map +47 -0
- package/dist/chunk-fka9eqe1.js +581 -0
- package/dist/chunk-fka9eqe1.js.map +11 -0
- package/dist/chunk-g82jygez.js +108 -0
- package/dist/chunk-g82jygez.js.map +10 -0
- package/dist/chunk-ghs2j1fr.js +312 -0
- package/dist/chunk-ghs2j1fr.js.map +10 -0
- package/dist/chunk-gmy11efb.js +125 -0
- package/dist/chunk-gmy11efb.js.map +10 -0
- package/dist/chunk-gxma4c70.js +110 -0
- package/dist/chunk-gxma4c70.js.map +10 -0
- package/dist/chunk-hkdqhm34.js +61 -0
- package/dist/chunk-hkdqhm34.js.map +10 -0
- package/dist/chunk-hpa0cpzh.js +134 -0
- package/dist/chunk-hpa0cpzh.js.map +10 -0
- package/dist/chunk-hwy3n31w.js +21 -0
- package/dist/chunk-hwy3n31w.js.map +9 -0
- package/dist/chunk-j2behpy7.js +214 -0
- package/dist/chunk-j2behpy7.js.map +10 -0
- package/dist/chunk-jc0at0tc.js +102 -0
- package/dist/chunk-jc0at0tc.js.map +10 -0
- package/dist/chunk-jkqq5ef6.js +243 -0
- package/dist/chunk-jkqq5ef6.js.map +11 -0
- package/dist/chunk-jzkw2f01.js +51 -0
- package/dist/chunk-jzkw2f01.js.map +10 -0
- package/dist/chunk-jzz91jwh.js +70 -0
- package/dist/chunk-jzz91jwh.js.map +10 -0
- package/dist/chunk-k9z47mab.js +247 -0
- package/dist/chunk-k9z47mab.js.map +12 -0
- package/dist/chunk-n17yxpa8.js +23 -0
- package/dist/chunk-n17yxpa8.js.map +10 -0
- package/dist/chunk-naa09ags.js +2143 -0
- package/dist/chunk-naa09ags.js.map +15 -0
- package/dist/chunk-ns3ke88n.js +142 -0
- package/dist/chunk-ns3ke88n.js.map +10 -0
- package/dist/chunk-p3cr2ejg.js +1626 -0
- package/dist/chunk-p3cr2ejg.js.map +15 -0
- package/dist/chunk-q5bcpf23.js +2925 -0
- package/dist/chunk-q5bcpf23.js.map +20 -0
- package/dist/chunk-rafc55bs.js +424 -0
- package/dist/chunk-rafc55bs.js.map +11 -0
- package/dist/chunk-rbrxjvbj.js +2830 -0
- package/dist/chunk-rbrxjvbj.js.map +23 -0
- package/dist/chunk-sa874j5c.js +9 -0
- package/dist/chunk-sa874j5c.js.map +10 -0
- package/dist/chunk-sgsz2fpq.js +289 -0
- package/dist/chunk-sgsz2fpq.js.map +10 -0
- package/dist/chunk-sm6fh5c5.js +569 -0
- package/dist/chunk-sm6fh5c5.js.map +14 -0
- package/dist/chunk-t9zcg8f1.js +499 -0
- package/dist/chunk-t9zcg8f1.js.map +13 -0
- package/dist/chunk-vp2db42d.js +121 -0
- package/dist/chunk-vp2db42d.js.map +10 -0
- package/dist/chunk-w13jjf4a.js +46 -0
- package/dist/chunk-w13jjf4a.js.map +10 -0
- package/dist/chunk-x1arr7ze.js +63 -0
- package/dist/chunk-x1arr7ze.js.map +10 -0
- package/dist/chunk-xfq68rch.js +583 -0
- package/dist/chunk-xfq68rch.js.map +11 -0
- package/dist/main.js +132 -20
- package/dist/main.js.map +4 -4
- package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
- package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
- package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
- package/dist/runtime/src/agents/archiver.ts +396 -0
- package/dist/runtime/src/agents/auth-check.ts +64 -0
- package/dist/runtime/src/agents/claude-provider.ts +737 -0
- package/dist/runtime/src/agents/claude-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
- package/dist/runtime/src/agents/codex-provider.ts +785 -0
- package/dist/runtime/src/agents/codex-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
- package/dist/runtime/src/agents/contracts.ts +69 -0
- package/dist/runtime/src/agents/deep-map.ts +13 -0
- package/dist/runtime/src/agents/execution-host.ts +91 -0
- package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
- package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
- package/dist/runtime/src/agents/fork.ts +65 -0
- package/dist/runtime/src/agents/hosted-agent.ts +48 -0
- package/dist/runtime/src/agents/idle-archiver.ts +128 -0
- package/dist/runtime/src/agents/index.ts +205 -0
- package/dist/runtime/src/agents/maestro-provider.ts +206 -0
- package/dist/runtime/src/agents/maestro-registry.ts +31 -0
- package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
- package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
- package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
- package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
- package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
- package/dist/runtime/src/agents/model-catalog.ts +151 -0
- package/dist/runtime/src/agents/registry.ts +24 -0
- package/dist/runtime/src/agents/rollout/claude.ts +331 -0
- package/dist/runtime/src/agents/rollout/codex.ts +569 -0
- package/dist/runtime/src/agents/rollout/shared.ts +205 -0
- package/dist/runtime/src/agents/self-config-core.ts +415 -0
- package/dist/runtime/src/agents/task-events.ts +44 -0
- package/dist/runtime/src/agents/tool-format.ts +352 -0
- package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
- package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
- package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
- package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
- package/dist/runtime/src/application/submit-user-message.ts +47 -0
- package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
- package/dist/runtime/src/application/switch-topic-model.ts +79 -0
- package/dist/runtime/src/application/topic-service.ts +139 -0
- package/dist/runtime/src/application/vault-command.ts +170 -0
- package/dist/runtime/src/bus.ts +319 -0
- package/dist/runtime/src/index.ts +333 -0
- package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
- package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
- package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
- package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
- package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
- package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
- package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
- package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
- package/dist/runtime/src/mcp/runtime-spec.ts +134 -0
- package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
- package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
- package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
- package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
- package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
- package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
- package/dist/runtime/src/mcp/system-health-server.ts +130 -0
- package/dist/runtime/src/mcp/task-server.ts +173 -0
- package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
- package/dist/runtime/src/mcp/vault-http.ts +219 -0
- package/dist/runtime/src/mcp/vault-run.ts +153 -0
- package/dist/runtime/src/mcp/vault-server.ts +104 -0
- package/dist/runtime/src/mcp/wiki-server.ts +755 -0
- package/dist/runtime/src/media/file-events.ts +33 -0
- package/dist/runtime/src/media/text-extractor.ts +312 -0
- package/dist/runtime/src/media/video.ts +92 -0
- package/dist/runtime/src/outbox/file-ops.ts +210 -0
- package/dist/runtime/src/outbox/index.ts +8 -0
- package/dist/runtime/src/outbox/utils.ts +51 -0
- package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
- package/dist/runtime/src/platform/config.ts +371 -0
- package/dist/runtime/src/platform/constants.ts +18 -0
- package/dist/runtime/src/platform/debug-users.ts +14 -0
- package/dist/runtime/src/platform/delay.ts +1 -0
- package/dist/runtime/src/platform/error.ts +9 -0
- package/dist/runtime/src/platform/file-utils.ts +16 -0
- package/dist/runtime/src/platform/jsonl.ts +202 -0
- package/dist/runtime/src/platform/lifecycle.ts +174 -0
- package/dist/runtime/src/platform/logger.ts +31 -0
- package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
- package/dist/runtime/src/platform/mcp-config.ts +865 -0
- package/dist/runtime/src/platform/modules.ts +145 -0
- package/dist/runtime/src/platform/node-plugins.ts +46 -0
- package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
- package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
- package/dist/runtime/src/prompts/builders.ts +357 -0
- package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
- package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
- package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
- package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
- package/dist/runtime/src/query/active-rooms.ts +532 -0
- package/dist/runtime/src/query/control.ts +313 -0
- package/dist/runtime/src/query/index.ts +24 -0
- package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
- package/dist/runtime/src/query/session-inbox-path.ts +42 -0
- package/dist/runtime/src/query/state.ts +94 -0
- package/dist/runtime/src/query/types.ts +83 -0
- package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
- package/dist/runtime/src/runtime/attachments.ts +151 -0
- package/dist/runtime/src/runtime/background-sessions.ts +83 -0
- package/dist/runtime/src/runtime/channel-context.ts +107 -0
- package/dist/runtime/src/runtime/errors.ts +69 -0
- package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
- package/dist/runtime/src/runtime/file-hooks.ts +81 -0
- package/dist/runtime/src/runtime/footer.ts +23 -0
- package/dist/runtime/src/runtime/inbox.ts +946 -0
- package/dist/runtime/src/runtime/tasks.ts +56 -0
- package/dist/runtime/src/runtime/topic-config.ts +29 -0
- package/dist/runtime/src/runtime/turn-runner.ts +2286 -0
- package/dist/runtime/src/runtime/usage-alert.ts +84 -0
- package/dist/runtime/src/runtime/visual-html.ts +109 -0
- package/dist/runtime/src/runtime/visual-store.ts +430 -0
- package/dist/runtime/src/runtime/visuals.ts +171 -0
- package/dist/runtime/src/security/sanitize.ts +27 -0
- package/dist/runtime/src/security/sensitive-path.ts +34 -0
- package/dist/runtime/src/storage/activity-log.ts +152 -0
- package/dist/runtime/src/storage/api-messages.ts +617 -0
- package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
- package/dist/runtime/src/storage/api-topic-config.ts +192 -0
- package/dist/runtime/src/storage/api-topics.ts +844 -0
- package/dist/runtime/src/storage/app-settings.ts +44 -0
- package/dist/runtime/src/storage/browser-profiles.ts +133 -0
- package/dist/runtime/src/storage/conversations.ts +268 -0
- package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
- package/dist/runtime/src/storage/forum/index.ts +4 -0
- package/dist/runtime/src/storage/forum/repository.ts +283 -0
- package/dist/runtime/src/storage/forum/schema.ts +300 -0
- package/dist/runtime/src/storage/forum-db.ts +23 -0
- package/dist/runtime/src/storage/runtime-events.ts +130 -0
- package/dist/runtime/src/storage/runtime-leases.ts +189 -0
- package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
- package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +300 -0
- package/dist/runtime/src/storage/self-schedules.ts +298 -0
- package/dist/runtime/src/storage/session-asks.ts +347 -0
- package/dist/runtime/src/storage/sqlite.ts +129 -0
- package/dist/runtime/src/storage/tasks.ts +179 -0
- package/dist/runtime/src/storage/token-stats.ts +144 -0
- package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
- package/dist/runtime/src/storage/topic-archive.ts +74 -0
- package/dist/runtime/src/storage/topic-settings.ts +67 -0
- package/dist/runtime/src/storage/topic-transcript.ts +98 -0
- package/dist/runtime/src/storage/vault-crypto.ts +65 -0
- package/dist/runtime/src/storage/vault.ts +225 -0
- package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
- package/dist/runtime/src/storage/wiki.ts +85 -0
- package/dist/runtime/src/topics/create.ts +123 -0
- package/dist/runtime/src/topics/derive.ts +398 -0
- package/dist/runtime/src/topics/lifecycle.ts +311 -0
- package/dist/runtime/src/topics/links.ts +7 -0
- package/dist/runtime/src/topics/personal-general.ts +65 -0
- package/dist/runtime/src/topics/session.ts +381 -0
- package/dist/runtime/src/types/api.ts +497 -0
- package/dist/runtime/src/types.ts +255 -0
- package/dist/runtime/src/version.ts +1 -0
- package/dist/runtime/tsconfig.json +13 -0
- package/package.json +16 -3
- package/dist/main.d.ts +0 -3
- package/dist/main.d.ts.map +0 -1
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
// Persistent message store for the REST/WS API channel (Otium).
|
|
2
|
+
//
|
|
3
|
+
// Replaces the previous in-memory `Map<topicId, MessageDto[]>` so messages
|
|
4
|
+
// survive server restarts. Backed by the shared sessions SQLite DB — the same
|
|
5
|
+
// database the Telegram channel uses — which is the foundation for unifying
|
|
6
|
+
// conversation state across channels later.
|
|
7
|
+
//
|
|
8
|
+
// Ordering is by the implicit SQLite `rowid` (monotonic insert order). Cursor
|
|
9
|
+
// pagination resolves the cursor id → its rowid and returns rows after it.
|
|
10
|
+
|
|
11
|
+
import { db } from "#storage/forum-db";
|
|
12
|
+
import type { AgentKind } from "#types";
|
|
13
|
+
import type { MessageDto } from "#types/api";
|
|
14
|
+
|
|
15
|
+
db.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS api_messages (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
topic_id TEXT NOT NULL,
|
|
19
|
+
parent_id TEXT,
|
|
20
|
+
author_id TEXT NOT NULL,
|
|
21
|
+
source_adapter TEXT,
|
|
22
|
+
text TEXT NOT NULL,
|
|
23
|
+
query_id TEXT,
|
|
24
|
+
agent_type TEXT,
|
|
25
|
+
model TEXT,
|
|
26
|
+
attachments TEXT,
|
|
27
|
+
usage TEXT,
|
|
28
|
+
deleted INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
edited_at TEXT,
|
|
30
|
+
reactions TEXT,
|
|
31
|
+
kind TEXT,
|
|
32
|
+
ask_user_question TEXT,
|
|
33
|
+
mentions TEXT,
|
|
34
|
+
thread_root_id TEXT,
|
|
35
|
+
created_at TEXT NOT NULL
|
|
36
|
+
)
|
|
37
|
+
`);
|
|
38
|
+
try {
|
|
39
|
+
db.exec("ALTER TABLE api_messages ADD COLUMN source_adapter TEXT");
|
|
40
|
+
} catch {
|
|
41
|
+
// Column already exists.
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
db.exec("ALTER TABLE api_messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0");
|
|
45
|
+
} catch {
|
|
46
|
+
// Column already exists.
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
db.exec("ALTER TABLE api_messages ADD COLUMN edited_at TEXT");
|
|
50
|
+
} catch {
|
|
51
|
+
// Column already exists.
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
db.exec("ALTER TABLE api_messages ADD COLUMN reactions TEXT");
|
|
55
|
+
} catch {
|
|
56
|
+
// Column already exists.
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
db.exec("ALTER TABLE api_messages ADD COLUMN kind TEXT");
|
|
60
|
+
} catch {
|
|
61
|
+
// Column already exists.
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
db.exec("ALTER TABLE api_messages ADD COLUMN ask_user_question TEXT");
|
|
65
|
+
} catch {
|
|
66
|
+
// Column already exists.
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
db.exec("ALTER TABLE api_messages ADD COLUMN mentions TEXT");
|
|
70
|
+
} catch {
|
|
71
|
+
// Column already exists.
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
// Slack-style threads: a reply carries the ROOT message id (flat, no nesting).
|
|
75
|
+
// Distinct from parent_id (which is a Telegram-style inline quote-reply).
|
|
76
|
+
db.exec("ALTER TABLE api_messages ADD COLUMN thread_root_id TEXT");
|
|
77
|
+
} catch {
|
|
78
|
+
// Column already exists.
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
db.exec("ALTER TABLE api_messages ADD COLUMN subagent_card TEXT");
|
|
82
|
+
} catch {
|
|
83
|
+
// Column already exists.
|
|
84
|
+
}
|
|
85
|
+
// Index on topic_id; SQLite appends the implicit rowid to every index entry,
|
|
86
|
+
// so `WHERE topic_id=? AND rowid>? ORDER BY rowid` stays index-driven.
|
|
87
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_api_messages_topic ON api_messages(topic_id)");
|
|
88
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_api_messages_thread_root ON api_messages(thread_root_id)");
|
|
89
|
+
|
|
90
|
+
export interface ApiMessageRow {
|
|
91
|
+
id: string;
|
|
92
|
+
topic_id: string;
|
|
93
|
+
parent_id: string | null;
|
|
94
|
+
author_id: string;
|
|
95
|
+
source_adapter: string | null;
|
|
96
|
+
text: string;
|
|
97
|
+
query_id: string | null;
|
|
98
|
+
agent_type: string | null;
|
|
99
|
+
model: string | null;
|
|
100
|
+
attachments: string | null;
|
|
101
|
+
usage: string | null;
|
|
102
|
+
deleted: number;
|
|
103
|
+
edited_at: string | null;
|
|
104
|
+
reactions: string | null;
|
|
105
|
+
kind: string | null;
|
|
106
|
+
ask_user_question: string | null;
|
|
107
|
+
subagent_card: string | null;
|
|
108
|
+
mentions: string | null;
|
|
109
|
+
thread_root_id: string | null;
|
|
110
|
+
created_at: string;
|
|
111
|
+
rowid?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type ApiMessageAppendHook = (msg: MessageDto) => void | Promise<void>;
|
|
115
|
+
const appendHooks = new Set<ApiMessageAppendHook>();
|
|
116
|
+
|
|
117
|
+
export interface AppendApiMessageOptions {
|
|
118
|
+
notify?: boolean;
|
|
119
|
+
updateTopicLastMessageAt?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function registerApiMessageAppendHook(hook: ApiMessageAppendHook): () => void {
|
|
123
|
+
appendHooks.add(hook);
|
|
124
|
+
return () => {
|
|
125
|
+
appendHooks.delete(hook);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function emitApiMessageAppended(msg: MessageDto): void {
|
|
130
|
+
for (const hook of appendHooks) {
|
|
131
|
+
queueMicrotask(() => {
|
|
132
|
+
try {
|
|
133
|
+
void Promise.resolve(hook(msg)).catch(() => undefined);
|
|
134
|
+
} catch {
|
|
135
|
+
// Hooks are post-persist side effects; never make message writes fail.
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function rowToDto(r: ApiMessageRow): MessageDto {
|
|
142
|
+
return {
|
|
143
|
+
id: r.id,
|
|
144
|
+
topicId: r.topic_id,
|
|
145
|
+
parentId: r.parent_id ?? undefined,
|
|
146
|
+
authorId: r.author_id,
|
|
147
|
+
sourceAdapter: r.source_adapter ?? undefined,
|
|
148
|
+
text: r.text,
|
|
149
|
+
queryId: r.query_id ?? undefined,
|
|
150
|
+
agentType: (r.agent_type as AgentKind | null) ?? undefined,
|
|
151
|
+
model: r.model ?? undefined,
|
|
152
|
+
attachments: r.attachments ? JSON.parse(r.attachments) : undefined,
|
|
153
|
+
usage: r.usage ? JSON.parse(r.usage) : undefined,
|
|
154
|
+
deleted: r.deleted !== 0,
|
|
155
|
+
editedAt: r.edited_at ?? undefined,
|
|
156
|
+
reactions: r.reactions ? JSON.parse(r.reactions) : undefined,
|
|
157
|
+
kind: (r.kind as MessageDto["kind"] | null) ?? undefined,
|
|
158
|
+
askUserQuestion: r.ask_user_question ? JSON.parse(r.ask_user_question) : undefined,
|
|
159
|
+
subagentCard: r.subagent_card ? JSON.parse(r.subagent_card) : undefined,
|
|
160
|
+
mentions: r.mentions ? JSON.parse(r.mentions) : undefined,
|
|
161
|
+
threadRootId: r.thread_root_id ?? undefined,
|
|
162
|
+
createdAt: r.created_at,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function attachmentListHasFileId(raw: string | null, fileId: string): boolean {
|
|
167
|
+
if (!raw) return false;
|
|
168
|
+
try {
|
|
169
|
+
const attachments = JSON.parse(raw) as unknown;
|
|
170
|
+
return (
|
|
171
|
+
Array.isArray(attachments) &&
|
|
172
|
+
attachments.some(
|
|
173
|
+
(attachment) =>
|
|
174
|
+
typeof attachment === "object" &&
|
|
175
|
+
attachment !== null &&
|
|
176
|
+
"id" in attachment &&
|
|
177
|
+
attachment.id === fileId,
|
|
178
|
+
)
|
|
179
|
+
);
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Persist a single message. Idempotent on id: duplicate appends do not rewrite edited/reaction state. */
|
|
186
|
+
export function appendApiMessage(msg: MessageDto, options: AppendApiMessageOptions = {}): void {
|
|
187
|
+
const notify = options.notify ?? true;
|
|
188
|
+
const updateTopicLastMessageAt = options.updateTopicLastMessageAt ?? true;
|
|
189
|
+
let inserted = false;
|
|
190
|
+
db.transaction(() => {
|
|
191
|
+
const result = db
|
|
192
|
+
.query(
|
|
193
|
+
`INSERT INTO api_messages
|
|
194
|
+
(id, topic_id, parent_id, author_id, source_adapter, text, query_id, agent_type, model, attachments, usage, deleted, edited_at, reactions, kind, ask_user_question, subagent_card, mentions, thread_root_id, created_at)
|
|
195
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
196
|
+
ON CONFLICT(id) DO NOTHING`,
|
|
197
|
+
)
|
|
198
|
+
.run(
|
|
199
|
+
msg.id,
|
|
200
|
+
msg.topicId,
|
|
201
|
+
msg.parentId ?? null,
|
|
202
|
+
msg.authorId,
|
|
203
|
+
msg.sourceAdapter ?? null,
|
|
204
|
+
msg.text,
|
|
205
|
+
msg.queryId ?? null,
|
|
206
|
+
msg.agentType ?? null,
|
|
207
|
+
msg.model ?? null,
|
|
208
|
+
msg.attachments ? JSON.stringify(msg.attachments) : null,
|
|
209
|
+
msg.usage ? JSON.stringify(msg.usage) : null,
|
|
210
|
+
msg.deleted ? 1 : 0,
|
|
211
|
+
msg.editedAt ?? null,
|
|
212
|
+
msg.reactions?.length ? JSON.stringify(msg.reactions) : null,
|
|
213
|
+
msg.kind ?? null,
|
|
214
|
+
msg.askUserQuestion ? JSON.stringify(msg.askUserQuestion) : null,
|
|
215
|
+
msg.subagentCard ? JSON.stringify(msg.subagentCard) : null,
|
|
216
|
+
msg.mentions?.length ? JSON.stringify(msg.mentions) : null,
|
|
217
|
+
msg.threadRootId ?? null,
|
|
218
|
+
msg.createdAt,
|
|
219
|
+
);
|
|
220
|
+
inserted = Number(result.changes ?? 0) > 0;
|
|
221
|
+
if (inserted && updateTopicLastMessageAt && !msg.deleted) {
|
|
222
|
+
db.query(
|
|
223
|
+
`UPDATE api_topics
|
|
224
|
+
SET last_message_at = CASE
|
|
225
|
+
WHEN last_message_at IS NULL OR last_message_at < ? THEN ?
|
|
226
|
+
ELSE last_message_at
|
|
227
|
+
END
|
|
228
|
+
WHERE id = ?`,
|
|
229
|
+
).run(msg.createdAt, msg.createdAt, msg.topicId);
|
|
230
|
+
}
|
|
231
|
+
})();
|
|
232
|
+
if (inserted && notify && !msg.deleted) emitApiMessageAppended(msg);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function getApiMessage(topicId: string, messageId: string): MessageDto | null {
|
|
236
|
+
const row = db
|
|
237
|
+
.query("SELECT * FROM api_messages WHERE topic_id = ? AND id = ?")
|
|
238
|
+
.get(topicId, messageId) as ApiMessageRow | undefined;
|
|
239
|
+
return row ? rowToDto(row) : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function topicHasAttachmentFileId(topicId: string, fileId: string): boolean {
|
|
243
|
+
const rows = db
|
|
244
|
+
.query<{ attachments: string | null }, [string]>(
|
|
245
|
+
"SELECT attachments FROM api_messages WHERE topic_id = ? AND deleted = 0 AND attachments IS NOT NULL",
|
|
246
|
+
)
|
|
247
|
+
.all(topicId);
|
|
248
|
+
return rows.some((row) => attachmentListHasFileId(row.attachments, fileId));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function updateApiMessageText(
|
|
252
|
+
topicId: string,
|
|
253
|
+
messageId: string,
|
|
254
|
+
text: string,
|
|
255
|
+
editedAt = new Date().toISOString(),
|
|
256
|
+
): MessageDto | null {
|
|
257
|
+
const res = db
|
|
258
|
+
.query(
|
|
259
|
+
`UPDATE api_messages
|
|
260
|
+
SET text = ?, edited_at = ?
|
|
261
|
+
WHERE topic_id = ? AND id = ? AND deleted = 0`,
|
|
262
|
+
)
|
|
263
|
+
.run(text, editedAt, topicId, messageId);
|
|
264
|
+
if (Number(res.changes ?? 0) === 0) return null;
|
|
265
|
+
return getApiMessage(topicId, messageId);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Attach terminal turn usage to an already-persisted assistant segment. */
|
|
269
|
+
export function updateApiMessageUsage(
|
|
270
|
+
topicId: string,
|
|
271
|
+
messageId: string,
|
|
272
|
+
usage: NonNullable<MessageDto["usage"]>,
|
|
273
|
+
): MessageDto | null {
|
|
274
|
+
const res = db
|
|
275
|
+
.query(
|
|
276
|
+
`UPDATE api_messages
|
|
277
|
+
SET usage = ?
|
|
278
|
+
WHERE topic_id = ? AND id = ? AND deleted = 0`,
|
|
279
|
+
)
|
|
280
|
+
.run(JSON.stringify(usage), topicId, messageId);
|
|
281
|
+
if (Number(res.changes ?? 0) === 0) return null;
|
|
282
|
+
return getApiMessage(topicId, messageId);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function updateApiMessageAskUserQuestion(
|
|
286
|
+
topicId: string,
|
|
287
|
+
messageId: string,
|
|
288
|
+
askUserQuestion: NonNullable<MessageDto["askUserQuestion"]>,
|
|
289
|
+
editedAt = new Date().toISOString(),
|
|
290
|
+
): MessageDto | null {
|
|
291
|
+
const res = db
|
|
292
|
+
.query(
|
|
293
|
+
`UPDATE api_messages
|
|
294
|
+
SET ask_user_question = ?, edited_at = ?
|
|
295
|
+
WHERE topic_id = ? AND id = ? AND deleted = 0 AND kind = 'ask_user_question'`,
|
|
296
|
+
)
|
|
297
|
+
.run(JSON.stringify(askUserQuestion), editedAt, topicId, messageId);
|
|
298
|
+
if (Number(res.changes ?? 0) === 0) return null;
|
|
299
|
+
return getApiMessage(topicId, messageId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function updateApiMessageSubagentCard(
|
|
303
|
+
topicId: string,
|
|
304
|
+
messageId: string,
|
|
305
|
+
subagentCard: NonNullable<MessageDto["subagentCard"]>,
|
|
306
|
+
editedAt = new Date().toISOString(),
|
|
307
|
+
): MessageDto | null {
|
|
308
|
+
const res = db
|
|
309
|
+
.query(
|
|
310
|
+
`UPDATE api_messages
|
|
311
|
+
SET subagent_card = ?, edited_at = ?
|
|
312
|
+
WHERE topic_id = ? AND id = ? AND deleted = 0 AND kind = 'subagent'`,
|
|
313
|
+
)
|
|
314
|
+
.run(JSON.stringify(subagentCard), editedAt, topicId, messageId);
|
|
315
|
+
if (Number(res.changes ?? 0) === 0) return null;
|
|
316
|
+
return getApiMessage(topicId, messageId);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** All non-deleted messages of one kind, across topics (boot-time sweeps). */
|
|
320
|
+
export function listApiMessagesByKind(kind: NonNullable<MessageDto["kind"]>): MessageDto[] {
|
|
321
|
+
const rows = db
|
|
322
|
+
.query("SELECT * FROM api_messages WHERE kind = ? AND deleted = 0")
|
|
323
|
+
.all(kind) as ApiMessageRow[];
|
|
324
|
+
return rows.map(rowToDto);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Replace a message's full reactions array. Toggle logic lives in the route. */
|
|
328
|
+
export function setApiMessageReactions(
|
|
329
|
+
topicId: string,
|
|
330
|
+
messageId: string,
|
|
331
|
+
reactions: NonNullable<MessageDto["reactions"]>,
|
|
332
|
+
): MessageDto | null {
|
|
333
|
+
const res = db
|
|
334
|
+
.query(
|
|
335
|
+
`UPDATE api_messages
|
|
336
|
+
SET reactions = ?
|
|
337
|
+
WHERE topic_id = ? AND id = ? AND deleted = 0`,
|
|
338
|
+
)
|
|
339
|
+
.run(reactions.length ? JSON.stringify(reactions) : null, topicId, messageId);
|
|
340
|
+
if (Number(res.changes ?? 0) === 0) return null;
|
|
341
|
+
return getApiMessage(topicId, messageId);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function softDeleteApiMessage(topicId: string, messageId: string): MessageDto | null {
|
|
345
|
+
const res = db
|
|
346
|
+
.query(
|
|
347
|
+
`UPDATE api_messages
|
|
348
|
+
SET deleted = 1, text = ''
|
|
349
|
+
WHERE topic_id = ? AND id = ? AND deleted = 0`,
|
|
350
|
+
)
|
|
351
|
+
.run(topicId, messageId);
|
|
352
|
+
if (Number(res.changes ?? 0) === 0) return null;
|
|
353
|
+
return getApiMessage(topicId, messageId);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export interface MessagePage {
|
|
357
|
+
page: MessageDto[];
|
|
358
|
+
cursor?: string;
|
|
359
|
+
hasMore: boolean;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* List messages for a topic, NEWEST-FIRST by default (bug G).
|
|
364
|
+
*
|
|
365
|
+
* The chat client loads once on mount and renders whatever it gets, so the
|
|
366
|
+
* initial page must be the *latest* N messages, not the oldest. We therefore
|
|
367
|
+
* query newest→oldest (`ORDER BY rowid DESC`) and then reverse the page back to
|
|
368
|
+
* chronological (oldest→newest) order for display.
|
|
369
|
+
*
|
|
370
|
+
* Pagination is BACKWARD (toward older history): pass the returned `cursor`
|
|
371
|
+
* (the oldest id currently loaded) to fetch the page of messages *older* than
|
|
372
|
+
* it. `hasMore` therefore means "there are older messages above this page".
|
|
373
|
+
*/
|
|
374
|
+
export function listApiMessages(
|
|
375
|
+
topicId: string,
|
|
376
|
+
options?: { cursor?: string | null; limit?: number },
|
|
377
|
+
): MessagePage {
|
|
378
|
+
const limit = Math.max(1, Math.min(options?.limit ?? 50, 200));
|
|
379
|
+
|
|
380
|
+
let anchorRowid: number | null = null;
|
|
381
|
+
if (options?.cursor) {
|
|
382
|
+
const cur = db
|
|
383
|
+
.query("SELECT rowid AS rowid FROM api_messages WHERE topic_id = ? AND id = ?")
|
|
384
|
+
.get(topicId, options.cursor) as { rowid: number } | undefined;
|
|
385
|
+
if (cur) anchorRowid = cur.rowid;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const rows = (
|
|
389
|
+
anchorRowid !== null
|
|
390
|
+
? db
|
|
391
|
+
.query(
|
|
392
|
+
`SELECT *, rowid AS rowid FROM api_messages
|
|
393
|
+
WHERE topic_id = ? AND deleted = 0 AND thread_root_id IS NULL AND rowid < ?
|
|
394
|
+
ORDER BY rowid DESC
|
|
395
|
+
LIMIT ?`,
|
|
396
|
+
)
|
|
397
|
+
.all(topicId, anchorRowid, limit + 1)
|
|
398
|
+
: db
|
|
399
|
+
.query(
|
|
400
|
+
`SELECT *, rowid AS rowid FROM api_messages
|
|
401
|
+
WHERE topic_id = ? AND deleted = 0 AND thread_root_id IS NULL
|
|
402
|
+
ORDER BY rowid DESC
|
|
403
|
+
LIMIT ?`,
|
|
404
|
+
)
|
|
405
|
+
.all(topicId, limit + 1)
|
|
406
|
+
) as (ApiMessageRow & { rowid: number })[];
|
|
407
|
+
|
|
408
|
+
const hasMore = rows.length > limit;
|
|
409
|
+
// rows are newest→oldest; trim the look-ahead row, then flip to chronological.
|
|
410
|
+
const pageRows = (hasMore ? rows.slice(0, limit) : rows).reverse();
|
|
411
|
+
const page = pageRows.map(rowToDto);
|
|
412
|
+
|
|
413
|
+
// Stamp thread reply counts onto the messages that are thread roots.
|
|
414
|
+
const summaries = getThreadSummaries(page.map((m) => m.id));
|
|
415
|
+
if (summaries.size > 0) {
|
|
416
|
+
for (const m of page) {
|
|
417
|
+
const s = summaries.get(m.id);
|
|
418
|
+
if (s) {
|
|
419
|
+
m.threadReplyCount = s.replyCount;
|
|
420
|
+
m.threadLastReplyAt = s.lastReplyAt;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
page,
|
|
427
|
+
// Oldest loaded id — pass back as `cursor` to load the previous (older) page.
|
|
428
|
+
cursor: page.length > 0 ? page[0].id : undefined,
|
|
429
|
+
hasMore,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Reply count + last reply time per thread root, computed on read (no
|
|
435
|
+
* denormalized counters to drift). Only non-deleted replies are counted.
|
|
436
|
+
*/
|
|
437
|
+
export function getThreadSummaries(
|
|
438
|
+
rootIds: readonly string[],
|
|
439
|
+
): Map<string, { replyCount: number; lastReplyAt: string }> {
|
|
440
|
+
const out = new Map<string, { replyCount: number; lastReplyAt: string }>();
|
|
441
|
+
const ids = [...new Set(rootIds)].filter(Boolean);
|
|
442
|
+
if (ids.length === 0) return out;
|
|
443
|
+
const rows = db
|
|
444
|
+
.query(
|
|
445
|
+
`SELECT thread_root_id AS rootId, COUNT(*) AS replyCount, MAX(created_at) AS lastReplyAt
|
|
446
|
+
FROM api_messages
|
|
447
|
+
WHERE thread_root_id IN (${ids.map(() => "?").join(",")}) AND deleted = 0
|
|
448
|
+
GROUP BY thread_root_id`,
|
|
449
|
+
)
|
|
450
|
+
.all(...ids) as { rootId: string; replyCount: number; lastReplyAt: string }[];
|
|
451
|
+
for (const r of rows) {
|
|
452
|
+
out.set(r.rootId, { replyCount: Number(r.replyCount), lastReplyAt: r.lastReplyAt });
|
|
453
|
+
}
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The root message plus its thread replies (chronological). */
|
|
458
|
+
export function listThreadMessages(
|
|
459
|
+
topicId: string,
|
|
460
|
+
rootId: string,
|
|
461
|
+
): { root: MessageDto | null; replies: MessageDto[] } {
|
|
462
|
+
const root = getApiMessage(topicId, rootId);
|
|
463
|
+
const rows = db
|
|
464
|
+
.query(
|
|
465
|
+
`SELECT *, rowid AS rowid FROM api_messages
|
|
466
|
+
WHERE topic_id = ? AND thread_root_id = ? AND deleted = 0
|
|
467
|
+
ORDER BY rowid ASC
|
|
468
|
+
LIMIT 200`,
|
|
469
|
+
)
|
|
470
|
+
.all(topicId, rootId) as ApiMessageRow[];
|
|
471
|
+
return { root, replies: rows.map(rowToDto) };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Find the newest same-user/same-text message that is recent enough to be the
|
|
476
|
+
* message just posted before a follow-up `/ai` call. This is a compatibility
|
|
477
|
+
* fallback for clients that persist attachments through `/messages` but do not
|
|
478
|
+
* repeat their file ids in the `/ai` request body.
|
|
479
|
+
*/
|
|
480
|
+
export function findRecentUserMessage(
|
|
481
|
+
topicId: string,
|
|
482
|
+
authorId: string,
|
|
483
|
+
text: string,
|
|
484
|
+
maxAgeMs: number,
|
|
485
|
+
): MessageDto | undefined {
|
|
486
|
+
const row = db
|
|
487
|
+
.query(
|
|
488
|
+
`SELECT * FROM api_messages
|
|
489
|
+
WHERE topic_id = ? AND author_id = ? AND text = ?
|
|
490
|
+
AND deleted = 0
|
|
491
|
+
ORDER BY rowid DESC
|
|
492
|
+
LIMIT 1`,
|
|
493
|
+
)
|
|
494
|
+
.get(topicId, authorId, text) as ApiMessageRow | undefined;
|
|
495
|
+
if (!row) return undefined;
|
|
496
|
+
|
|
497
|
+
const createdMs = Date.parse(row.created_at);
|
|
498
|
+
if (!Number.isFinite(createdMs) || Date.now() - createdMs > maxAgeMs) return undefined;
|
|
499
|
+
return rowToDto(row);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Latest message text per topic, keyed by topic_id. Used to render a preview
|
|
504
|
+
* subtitle in the topic list. One index-driven query (MAX(rowid) per topic)
|
|
505
|
+
* instead of N round-trips. Empty topics are simply absent from the map.
|
|
506
|
+
*/
|
|
507
|
+
export function getLastMessagePreviews(topicIds?: readonly string[]): Map<string, string> {
|
|
508
|
+
const uniqueTopicIds = topicIds ? [...new Set(topicIds)] : undefined;
|
|
509
|
+
if (uniqueTopicIds && uniqueTopicIds.length === 0) return new Map();
|
|
510
|
+
const topicFilter = uniqueTopicIds
|
|
511
|
+
? `AND topic_id IN (${uniqueTopicIds.map(() => "?").join(",")})`
|
|
512
|
+
: "";
|
|
513
|
+
const rows = db
|
|
514
|
+
.query(
|
|
515
|
+
`SELECT m.topic_id AS topic_id, m.text AS text
|
|
516
|
+
FROM api_messages m
|
|
517
|
+
JOIN (
|
|
518
|
+
SELECT topic_id, MAX(rowid) AS mx FROM api_messages
|
|
519
|
+
WHERE deleted = 0 AND thread_root_id IS NULL
|
|
520
|
+
${topicFilter}
|
|
521
|
+
GROUP BY topic_id
|
|
522
|
+
) last ON m.topic_id = last.topic_id AND m.rowid = last.mx`,
|
|
523
|
+
)
|
|
524
|
+
.all(...(uniqueTopicIds ?? [])) as { topic_id: string; text: string }[];
|
|
525
|
+
|
|
526
|
+
const out = new Map<string, string>();
|
|
527
|
+
for (const r of rows) {
|
|
528
|
+
const preview = (r.text ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
529
|
+
if (preview) out.set(r.topic_id, preview);
|
|
530
|
+
}
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Hard-delete every message in a topic (used when a topic is deleted). */
|
|
535
|
+
export function deleteMessagesForTopic(topicId: string): number {
|
|
536
|
+
const res = db.query("DELETE FROM api_messages WHERE topic_id = ?").run(topicId);
|
|
537
|
+
return Number(res.changes ?? 0);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Anonymize all messages authored by a deleted user. */
|
|
541
|
+
export function anonymizeApiMessages(userId: string): void {
|
|
542
|
+
db.query("UPDATE api_messages SET author_id = 'deleted-user' WHERE author_id = ?").run(userId);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Topic ids whose persisted attachment JSON references this exact upload id.
|
|
546
|
+
* Used only as a compatibility bridge for uploads created before ACL metadata
|
|
547
|
+
* was introduced. JSON UUIDs are quoted, avoiding substring matches. */
|
|
548
|
+
export function listTopicIdsForAttachment(fileId: string): string[] {
|
|
549
|
+
const needle = `%"${fileId.replace(/[\\%_]/g, (char) => `\\${char}`)}"%`;
|
|
550
|
+
// The ESCAPE literal must reach SQLite as a SINGLE backslash. SQLite string
|
|
551
|
+
// literals do no backslash processing, so '\\' there is two characters and
|
|
552
|
+
// fails at row-evaluation time ("ESCAPE expression must be a single
|
|
553
|
+
// character") — which only surfaces once api_messages has any rows.
|
|
554
|
+
const rows = db
|
|
555
|
+
.query<{ topic_id: string }, [string]>(
|
|
556
|
+
"SELECT DISTINCT topic_id FROM api_messages WHERE attachments LIKE ? ESCAPE '\\'",
|
|
557
|
+
)
|
|
558
|
+
.all(needle);
|
|
559
|
+
return rows.map((row) => row.topic_id);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Copy all messages from one topic to another (used by fork).
|
|
564
|
+
* Each message gets a new UUID but retains original content, author, and timestamps.
|
|
565
|
+
* Returns the number of messages copied.
|
|
566
|
+
*/
|
|
567
|
+
export function copyMessagesForTopic(sourceTopicId: string, targetTopicId: string): number {
|
|
568
|
+
const rows = db
|
|
569
|
+
.query("SELECT * FROM api_messages WHERE topic_id = ? AND deleted = 0 ORDER BY rowid ASC")
|
|
570
|
+
.all(sourceTopicId) as ApiMessageRow[];
|
|
571
|
+
|
|
572
|
+
if (rows.length === 0) return 0;
|
|
573
|
+
|
|
574
|
+
const idMap = new Map(rows.map((r) => [r.id, crypto.randomUUID()]));
|
|
575
|
+
let copied = 0;
|
|
576
|
+
for (const r of rows) {
|
|
577
|
+
const newId = idMap.get(r.id);
|
|
578
|
+
if (!newId) continue;
|
|
579
|
+
appendApiMessage(
|
|
580
|
+
{
|
|
581
|
+
...rowToDto(r),
|
|
582
|
+
id: newId,
|
|
583
|
+
topicId: targetTopicId,
|
|
584
|
+
parentId: r.parent_id ? (idMap.get(r.parent_id) ?? r.parent_id) : undefined,
|
|
585
|
+
threadRootId: r.thread_root_id
|
|
586
|
+
? (idMap.get(r.thread_root_id) ?? r.thread_root_id)
|
|
587
|
+
: undefined,
|
|
588
|
+
},
|
|
589
|
+
{ notify: false, updateTopicLastMessageAt: false },
|
|
590
|
+
);
|
|
591
|
+
copied++;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return copied;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Get all messages for a topic in chronological order (used by export).
|
|
599
|
+
*/
|
|
600
|
+
export function getAllMessagesForTopic(topicId: string): ApiMessageRow[] {
|
|
601
|
+
return db
|
|
602
|
+
.query(
|
|
603
|
+
"SELECT *, rowid AS rowid FROM api_messages WHERE topic_id = ? AND deleted = 0 ORDER BY rowid ASC",
|
|
604
|
+
)
|
|
605
|
+
.all(topicId) as ApiMessageRow[];
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function getMessagesForTopicAfterRowid(
|
|
609
|
+
topicId: string,
|
|
610
|
+
afterRowid: number,
|
|
611
|
+
): ApiMessageRow[] {
|
|
612
|
+
return db
|
|
613
|
+
.query(
|
|
614
|
+
"SELECT *, rowid AS rowid FROM api_messages WHERE topic_id = ? AND rowid > ? AND deleted = 0 ORDER BY rowid ASC",
|
|
615
|
+
)
|
|
616
|
+
.all(topicId, afterRowid) as ApiMessageRow[];
|
|
617
|
+
}
|