talon-agent 3.35.0 → 3.36.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/package.json +1 -1
- package/src/frontend/native/chat-wire.ts +6 -2
- package/src/frontend/native/emit.ts +30 -11
- package/src/frontend/native/handlers.ts +62 -13
- package/src/frontend/native/history.ts +30 -6
- package/src/frontend/native/media.ts +271 -15
- package/src/frontend/native/protocol.ts +34 -1
- package/src/frontend/native/queue.ts +4 -7
- package/src/frontend/native/routes/chats.ts +30 -12
- package/src/frontend/native/routes/host.ts +17 -5
- package/src/frontend/native/routes/params.ts +21 -0
- package/src/frontend/native/runtime.ts +16 -4
- package/src/frontend/native/server.ts +2 -35
- package/src/frontend/native/status.ts +3 -1
- package/src/frontend/native/turn.ts +39 -17
- package/src/storage/db.ts +7 -1
- package/src/storage/history.ts +4 -1
- package/src/storage/repositories/history-repo.ts +47 -1
- package/src/storage/sql/db.sql +6 -0
- package/src/storage/sql/history.sql +11 -10
- package/src/storage/sql/schema.sql +6 -1
- package/src/storage/sql/statements.generated.ts +21 -11
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { RouteHost } from "./host.js";
|
|
2
2
|
import type { BridgeRoutes } from "./table.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
asAttachmentRefs,
|
|
5
|
+
asPositiveInt,
|
|
6
|
+
asString,
|
|
7
|
+
deviceIdParam,
|
|
8
|
+
} from "./params.js";
|
|
6
9
|
|
|
7
10
|
export function chatRoutes(
|
|
8
11
|
host: RouteHost,
|
|
@@ -87,27 +90,42 @@ export function chatRoutes(
|
|
|
87
90
|
const body = await readJson(req);
|
|
88
91
|
const id = asString(body.chatId) ?? "";
|
|
89
92
|
const text = asString(body.text) ?? "";
|
|
93
|
+
// Multi-file clients send `attachments`; the single-image shape older
|
|
94
|
+
// clients send is folded into the same list by the handler.
|
|
95
|
+
const attachments = asAttachmentRefs(body.attachments);
|
|
90
96
|
const imagePath = asString(body.imagePath);
|
|
91
97
|
const attachmentPath = asString(body.attachmentPath);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
const hasAttachment =
|
|
99
|
+
attachments.length > 0 || Boolean(attachmentPath || imagePath);
|
|
100
|
+
// Text may be empty when a file is attached; require one or the other.
|
|
101
|
+
if (!id || (!text.trim() && !hasAttachment))
|
|
95
102
|
return json(res, 400, {
|
|
96
103
|
ok: false,
|
|
97
104
|
error: "chatId and text (or an attachment) required",
|
|
98
105
|
});
|
|
99
|
-
h.send(id, text, { imagePath, attachmentPath });
|
|
106
|
+
h.send(id, text, { attachments, imagePath, attachmentPath });
|
|
100
107
|
json(res, 202, { ok: true });
|
|
101
108
|
},
|
|
102
109
|
"POST /upload": async ({ req, res, url }) => {
|
|
103
110
|
const filename = url.searchParams.get("filename") ?? "upload";
|
|
104
111
|
const contentType =
|
|
105
112
|
req.headers["content-type"] ?? "application/octet-stream";
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
try {
|
|
114
|
+
const attachment = await h.upload(filename, contentType, req);
|
|
115
|
+
// `imagePath` mirrors the URL for clients written against the
|
|
116
|
+
// single-image upload response.
|
|
117
|
+
json(res, 200, { ok: true, ...attachment, imagePath: attachment.url });
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// The client can only act on this if it knows why: too large (pick a
|
|
120
|
+
// smaller file), empty (nothing was read), or a server-side failure.
|
|
121
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
122
|
+
const status = /limit/i.test(error)
|
|
123
|
+
? 413
|
|
124
|
+
: /empty/i.test(error)
|
|
125
|
+
? 400
|
|
126
|
+
: 500;
|
|
127
|
+
json(res, status, { ok: false, error });
|
|
128
|
+
}
|
|
111
129
|
},
|
|
112
130
|
"GET /media": ({ res, url }) =>
|
|
113
131
|
host.serveMedia(res, url.searchParams.get("id") ?? ""),
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
import type { AttachmentRef } from "./params.js";
|
|
2
4
|
import type {
|
|
3
5
|
BackendOption,
|
|
6
|
+
ClientAttachment,
|
|
4
7
|
BridgeEvent,
|
|
5
8
|
BridgeStatus,
|
|
6
9
|
ClientChat,
|
|
@@ -18,7 +21,13 @@ import type {
|
|
|
18
21
|
import type { ConfigSnapshot } from "../settings.js";
|
|
19
22
|
|
|
20
23
|
/** Optional attachment references carried alongside a sent message. */
|
|
21
|
-
type SendOptions = {
|
|
24
|
+
export type SendOptions = {
|
|
25
|
+
/**
|
|
26
|
+
* Every file attached to the message, as the client's references to its
|
|
27
|
+
* own uploads. `imagePath` and `attachmentPath` below are the single-file
|
|
28
|
+
* shape older clients send; the handler folds them into the same list.
|
|
29
|
+
*/
|
|
30
|
+
attachments?: AttachmentRef[];
|
|
22
31
|
/** Relative bridge path to render inline (e.g. `/media?id=…`). */
|
|
23
32
|
imagePath?: string;
|
|
24
33
|
/** Absolute on-disk path handed to the model so it can read the file. */
|
|
@@ -41,12 +50,16 @@ export type BridgeServerHandlers = {
|
|
|
41
50
|
search(query: string, chatId?: string): SearchResult[];
|
|
42
51
|
/** Fire-and-forget: streams its results back through `broadcast`. */
|
|
43
52
|
send(id: string, text: string, opts?: SendOptions): void;
|
|
44
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Stream an uploaded file to disk and return its wire description. The body
|
|
55
|
+
* is consumed as it arrives (never buffered whole), so the size ceiling is
|
|
56
|
+
* disk, not memory.
|
|
57
|
+
*/
|
|
45
58
|
upload(
|
|
46
59
|
filename: string,
|
|
47
60
|
contentType: string,
|
|
48
|
-
|
|
49
|
-
): Promise<
|
|
61
|
+
body: Readable,
|
|
62
|
+
): Promise<ClientAttachment>;
|
|
50
63
|
listModels(
|
|
51
64
|
id?: string,
|
|
52
65
|
):
|
|
@@ -139,7 +152,6 @@ export type RouteHost = {
|
|
|
139
152
|
fingerprint: () => string | null;
|
|
140
153
|
json: (res: ServerResponse, code: number, body: unknown) => void;
|
|
141
154
|
readJson: (req: IncomingMessage) => Promise<Record<string, unknown>>;
|
|
142
|
-
readRaw: (req: IncomingMessage, max: number) => Promise<Buffer>;
|
|
143
155
|
corsHeaders: () => Record<string, string>;
|
|
144
156
|
streamFile: (
|
|
145
157
|
res: ServerResponse,
|
|
@@ -27,3 +27,24 @@ export function asPositiveInt(v: string | null): number | undefined {
|
|
|
27
27
|
const n = Number(v);
|
|
28
28
|
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/** A client's reference to a file it already uploaded. */
|
|
32
|
+
export type AttachmentRef = { url?: string; path?: string };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Coerce the `attachments` array on a `/send` body to reference shape. Only
|
|
36
|
+
* the identifying fields are read: name, size and type are whatever the
|
|
37
|
+
* daemon recorded when it wrote the file, never what the client claims now.
|
|
38
|
+
*/
|
|
39
|
+
export function asAttachmentRefs(v: unknown): AttachmentRef[] {
|
|
40
|
+
if (!Array.isArray(v)) return [];
|
|
41
|
+
const refs: AttachmentRef[] = [];
|
|
42
|
+
for (const item of v) {
|
|
43
|
+
if (typeof item !== "object" || item === null) continue;
|
|
44
|
+
const row = item as Record<string, unknown>;
|
|
45
|
+
const url = asString(row.url);
|
|
46
|
+
const path = asString(row.path);
|
|
47
|
+
if (url || path) refs.push({ url, path });
|
|
48
|
+
}
|
|
49
|
+
return refs;
|
|
50
|
+
}
|
|
@@ -12,7 +12,12 @@ import type { TalonConfig } from "../../util/config.js";
|
|
|
12
12
|
import type { Gateway } from "../../core/engine/gateway.js";
|
|
13
13
|
import { getMeshService, type MeshService } from "../../core/mesh/index.js";
|
|
14
14
|
import { NativeChats } from "./chats.js";
|
|
15
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
BridgeEvent,
|
|
17
|
+
ClientAttachment,
|
|
18
|
+
ClientToolCall,
|
|
19
|
+
ContextInfo,
|
|
20
|
+
} from "./protocol.js";
|
|
16
21
|
|
|
17
22
|
/** One tool call observed during a running turn, kept for mid-turn replay. */
|
|
18
23
|
export type LiveToolEntry = {
|
|
@@ -21,11 +26,10 @@ export type LiveToolEntry = {
|
|
|
21
26
|
done?: boolean;
|
|
22
27
|
};
|
|
23
28
|
|
|
24
|
-
/** A chat's single queued follow-up, with its
|
|
29
|
+
/** A chat's single queued follow-up, with its attachments intact. */
|
|
25
30
|
export type QueuedEntry = {
|
|
26
31
|
text: string;
|
|
27
|
-
|
|
28
|
-
attachmentPath?: string;
|
|
32
|
+
attachments: ClientAttachment[];
|
|
29
33
|
};
|
|
30
34
|
|
|
31
35
|
export type NativeRuntime = {
|
|
@@ -51,6 +55,13 @@ export type NativeRuntime = {
|
|
|
51
55
|
* frontends behave).
|
|
52
56
|
*/
|
|
53
57
|
readonly media: Map<string, string>;
|
|
58
|
+
/**
|
|
59
|
+
* Uploads this daemon run has accepted, keyed by their media id. `/send`
|
|
60
|
+
* resolves a client's attachment references through here rather than
|
|
61
|
+
* trusting the paths in the request body, so a message can only ever point
|
|
62
|
+
* the model at a file this daemon itself wrote to the uploads dir.
|
|
63
|
+
*/
|
|
64
|
+
readonly uploads: Map<string, ClientAttachment>;
|
|
54
65
|
/**
|
|
55
66
|
* Live context-window fill per chat, refreshed at the end of each turn.
|
|
56
67
|
* Cached (not computed inline) so the sync `toClientChat` projection stays
|
|
@@ -99,6 +110,7 @@ export function createNativeRuntime(
|
|
|
99
110
|
chats: new NativeChats(),
|
|
100
111
|
mesh: getMeshService(),
|
|
101
112
|
media: new Map(),
|
|
113
|
+
uploads: new Map(),
|
|
102
114
|
contextByChat: new Map(),
|
|
103
115
|
liveTurns: new Map(),
|
|
104
116
|
queuedByChat: new Map(),
|
|
@@ -24,9 +24,9 @@ import { createServer as createTlsServer } from "node:https";
|
|
|
24
24
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
25
25
|
import { createReadStream } from "node:fs";
|
|
26
26
|
import { stat } from "node:fs/promises";
|
|
27
|
-
import { extname } from "node:path";
|
|
28
27
|
import { log, logError, logDebug, logWarn } from "../../util/log.js";
|
|
29
28
|
import { formatFingerprint, type BridgeTlsIdentity } from "./tls.js";
|
|
29
|
+
import { contentTypeFor } from "./media.js";
|
|
30
30
|
import { type BridgeEvent } from "./protocol.js";
|
|
31
31
|
import { buildRoutes } from "./routes/index.js";
|
|
32
32
|
import type { BridgeServerHandlers, RouteHost } from "./routes/host.js";
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
type RouteHandler,
|
|
39
39
|
} from "./routes/table.js";
|
|
40
40
|
|
|
41
|
-
export type { BridgeServerHandlers } from "./routes/host.js";
|
|
41
|
+
export type { BridgeServerHandlers, SendOptions } from "./routes/host.js";
|
|
42
42
|
export { BRIDGE_ROUTE_AUTH, type BridgeRouteKey } from "./routes/table.js";
|
|
43
43
|
|
|
44
44
|
const SSE_PING_MS = 25_000;
|
|
@@ -352,7 +352,6 @@ export class BridgeServer {
|
|
|
352
352
|
fingerprint: () => this.getFingerprint(),
|
|
353
353
|
json: (res, code, body) => this.json(res, code, body),
|
|
354
354
|
readJson: (req) => this.readJson(req),
|
|
355
|
-
readRaw: (req, max) => this.readRaw(req, max),
|
|
356
355
|
corsHeaders: () => this.corsHeaders(),
|
|
357
356
|
streamFile: (res, file) => this.streamFile(res, file),
|
|
358
357
|
serveMedia: (res, id) => this.serveMedia(res, id),
|
|
@@ -591,17 +590,6 @@ export class BridgeServer {
|
|
|
591
590
|
}
|
|
592
591
|
|
|
593
592
|
/** Read a raw request body (binary-safe) up to `max` bytes. */
|
|
594
|
-
private async readRaw(req: IncomingMessage, max: number): Promise<Buffer> {
|
|
595
|
-
const chunks: Buffer[] = [];
|
|
596
|
-
let total = 0;
|
|
597
|
-
for await (const chunk of req) {
|
|
598
|
-
total += (chunk as Buffer).length;
|
|
599
|
-
if (total > max) throw new Error("Upload too large");
|
|
600
|
-
chunks.push(chunk as Buffer);
|
|
601
|
-
}
|
|
602
|
-
return Buffer.concat(chunks);
|
|
603
|
-
}
|
|
604
|
-
|
|
605
593
|
private async readJson(
|
|
606
594
|
req: IncomingMessage,
|
|
607
595
|
): Promise<Record<string, unknown>> {
|
|
@@ -621,24 +609,3 @@ export class BridgeServer {
|
|
|
621
609
|
return parsed as Record<string, unknown>;
|
|
622
610
|
}
|
|
623
611
|
}
|
|
624
|
-
|
|
625
|
-
/** Minimal image content-type map for the media endpoint. */
|
|
626
|
-
function contentTypeFor(filePath: string): string {
|
|
627
|
-
switch (extname(filePath).toLowerCase()) {
|
|
628
|
-
case ".png":
|
|
629
|
-
return "image/png";
|
|
630
|
-
case ".jpg":
|
|
631
|
-
case ".jpeg":
|
|
632
|
-
return "image/jpeg";
|
|
633
|
-
case ".gif":
|
|
634
|
-
return "image/gif";
|
|
635
|
-
case ".webp":
|
|
636
|
-
return "image/webp";
|
|
637
|
-
case ".bmp":
|
|
638
|
-
return "image/bmp";
|
|
639
|
-
case ".svg":
|
|
640
|
-
return "image/svg+xml";
|
|
641
|
-
default:
|
|
642
|
-
return "application/octet-stream";
|
|
643
|
-
}
|
|
644
|
-
}
|
|
@@ -12,7 +12,9 @@ export function bridgeStatus(runtime: NativeRuntime): BridgeStatus {
|
|
|
12
12
|
return {
|
|
13
13
|
app: "talon-bridge",
|
|
14
14
|
protocol: BRIDGE_PROTOCOL_VERSION,
|
|
15
|
-
|
|
15
|
+
// "attachments": /upload streams any file type and /send takes an
|
|
16
|
+
// `attachments` list, so a client can stage several files per message.
|
|
17
|
+
capabilities: ["mesh", "mesh-commands", "plugins-skills", "attachments"],
|
|
16
18
|
botName: runtime.botName,
|
|
17
19
|
backend: config.backend,
|
|
18
20
|
model: resolveModel(config.model)?.displayName ?? config.model,
|
|
@@ -16,7 +16,7 @@ import { recordTurnMeta } from "./turn-meta.js";
|
|
|
16
16
|
import type { ChatEntry } from "./chats.js";
|
|
17
17
|
import { refreshContext } from "./context.js";
|
|
18
18
|
import { emitAssistant, emitUser } from "./emit.js";
|
|
19
|
-
import type { BridgeEvent } from "./protocol.js";
|
|
19
|
+
import type { BridgeEvent, ClientAttachment } from "./protocol.js";
|
|
20
20
|
import { takeQueued } from "./queue.js";
|
|
21
21
|
import type { LiveToolEntry, NativeRuntime } from "./runtime.js";
|
|
22
22
|
import { summarizeToolResult } from "./tool-result.js";
|
|
@@ -24,7 +24,38 @@ import { summarizeToolResult } from "./tool-result.js";
|
|
|
24
24
|
/** The tool calls of one running turn, in call order. */
|
|
25
25
|
type TurnTools = Map<string, LiveToolEntry>;
|
|
26
26
|
|
|
27
|
-
export type StartTurnOptions = {
|
|
27
|
+
export type StartTurnOptions = { attachments?: ClientAttachment[] };
|
|
28
|
+
|
|
29
|
+
/** Human-readable size for the attachment lines handed to the model. */
|
|
30
|
+
function formatSize(bytes: number): string {
|
|
31
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
32
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
33
|
+
let value = bytes / 1024;
|
|
34
|
+
let unit = 0;
|
|
35
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
36
|
+
value /= 1024;
|
|
37
|
+
unit += 1;
|
|
38
|
+
}
|
|
39
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Point the model at the files the user attached. Each gets its own line with
|
|
44
|
+
* an absolute path so the model can read it with its own tools; images keep
|
|
45
|
+
* the `[Attached image: …]` wording backends already recognise.
|
|
46
|
+
*/
|
|
47
|
+
export function attachmentPrompt(
|
|
48
|
+
text: string,
|
|
49
|
+
attachments: ClientAttachment[],
|
|
50
|
+
): string {
|
|
51
|
+
if (!attachments.length) return text;
|
|
52
|
+
const lines = attachments.map((a) =>
|
|
53
|
+
a.image
|
|
54
|
+
? `[Attached image: ${a.path}]`
|
|
55
|
+
: `[Attached file: ${a.name} (${a.mimeType}, ${formatSize(a.size)}) at ${a.path}]`,
|
|
56
|
+
);
|
|
57
|
+
return `${text ? `${text}\n\n` : ""}${lines.join("\n")}`;
|
|
58
|
+
}
|
|
28
59
|
|
|
29
60
|
/** True when a turn is currently running for a chat (used to decide queue). */
|
|
30
61
|
export function isBusy(runtime: NativeRuntime, chatId: string): boolean {
|
|
@@ -247,7 +278,7 @@ async function runTurn(
|
|
|
247
278
|
entry: ChatEntry,
|
|
248
279
|
text: string,
|
|
249
280
|
messageId: number,
|
|
250
|
-
|
|
281
|
+
attachments: ClientAttachment[],
|
|
251
282
|
): Promise<void> {
|
|
252
283
|
const start = Date.now();
|
|
253
284
|
// Tool calls observed during this turn, in call order — recorded into the
|
|
@@ -256,10 +287,7 @@ async function runTurn(
|
|
|
256
287
|
// the activity so far (cleared in the `finally`).
|
|
257
288
|
const turnTools: TurnTools = new Map();
|
|
258
289
|
runtime.liveTurns.set(entry.id, turnTools);
|
|
259
|
-
|
|
260
|
-
const prompt = attachmentPath
|
|
261
|
-
? `${text ? `${text}\n\n` : ""}[Attached image: ${attachmentPath}]`
|
|
262
|
-
: text;
|
|
290
|
+
const prompt = attachmentPrompt(text, attachments);
|
|
263
291
|
try {
|
|
264
292
|
const result = await execute({
|
|
265
293
|
chatId: entry.id,
|
|
@@ -287,8 +315,7 @@ async function runTurn(
|
|
|
287
315
|
if (queued) {
|
|
288
316
|
setImmediate(() =>
|
|
289
317
|
startTurn(runtime, entry, queued.text, {
|
|
290
|
-
|
|
291
|
-
attachmentPath: queued.attachmentPath,
|
|
318
|
+
attachments: queued.attachments,
|
|
292
319
|
}),
|
|
293
320
|
);
|
|
294
321
|
}
|
|
@@ -303,16 +330,11 @@ export function startTurn(
|
|
|
303
330
|
text: string,
|
|
304
331
|
options?: StartTurnOptions,
|
|
305
332
|
): void {
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
entry,
|
|
309
|
-
text,
|
|
310
|
-
options?.imagePath,
|
|
311
|
-
options?.attachmentPath,
|
|
312
|
-
);
|
|
333
|
+
const attachments = options?.attachments ?? [];
|
|
334
|
+
const messageId = emitUser(runtime, entry, text, attachments);
|
|
313
335
|
runtime.broadcast({ kind: "turn_start", chatId: entry.id });
|
|
314
336
|
runtime.broadcast({ kind: "typing", chatId: entry.id, on: true });
|
|
315
|
-
void runTurn(runtime, entry, text, messageId,
|
|
337
|
+
void runTurn(runtime, entry, text, messageId, attachments);
|
|
316
338
|
}
|
|
317
339
|
|
|
318
340
|
/** Best-effort interrupt of a chat's in-flight turn. `true` if one was
|
package/src/storage/db.ts
CHANGED
|
@@ -68,7 +68,8 @@ let db: SqlDatabase | null = null;
|
|
|
68
68
|
*
|
|
69
69
|
* Column reconciliation runs first: `ALTER TABLE … ADD COLUMN` has no
|
|
70
70
|
* IF NOT EXISTS form, so columns added to already-shipped tables
|
|
71
|
-
* (media_index.content_hash, sessions.metrics
|
|
71
|
+
* (media_index.content_hash, sessions.metrics,
|
|
72
|
+
* history_messages.attachments) are ensured by attempting the ALTER and
|
|
72
73
|
* swallowing the two expected failures — "duplicate column name"
|
|
73
74
|
* (column already there) and "no such table" (fresh database; the
|
|
74
75
|
* CREATE TABLE in schema.sql includes the column).
|
|
@@ -94,6 +95,11 @@ function ensureSchema(database: SqlDatabase): void {
|
|
|
94
95
|
} catch {
|
|
95
96
|
/* duplicate column or no such table — both mean nothing to do */
|
|
96
97
|
}
|
|
98
|
+
try {
|
|
99
|
+
database.exec(dbSql.addHistoryAttachmentsColumn);
|
|
100
|
+
} catch {
|
|
101
|
+
/* duplicate column or no such table — both mean nothing to do */
|
|
102
|
+
}
|
|
97
103
|
database.exec("BEGIN");
|
|
98
104
|
try {
|
|
99
105
|
database.exec(SCHEMA);
|
package/src/storage/history.ts
CHANGED
|
@@ -26,7 +26,10 @@ import { formatSmartTimestamp, formatRelativeAge } from "../util/time.js";
|
|
|
26
26
|
import { importLegacyJson } from "./legacy-import.js";
|
|
27
27
|
import * as repo from "./repositories/history-repo.js";
|
|
28
28
|
|
|
29
|
-
export type {
|
|
29
|
+
export type {
|
|
30
|
+
HistoryMessage,
|
|
31
|
+
MessageAttachment,
|
|
32
|
+
} from "./repositories/history-repo.js";
|
|
30
33
|
import type { HistoryMessage } from "./repositories/history-repo.js";
|
|
31
34
|
|
|
32
35
|
// ── Persistence lifecycle ───────────────────────────────────────────────────
|
|
@@ -9,6 +9,24 @@ import { escapeLike } from "../../native/sqlguard.js";
|
|
|
9
9
|
import { getDatabase, inTransaction } from "../db.js";
|
|
10
10
|
import { historySql } from "../sql/statements.generated.js";
|
|
11
11
|
/** One chat message as the domain sees it — the shape the rows map to. */
|
|
12
|
+
/**
|
|
13
|
+
* A file attached to a message, persisted with it. The bridge's own
|
|
14
|
+
* `ClientAttachment` is this plus a per-run `/media` URL — storage keeps only
|
|
15
|
+
* what survives a restart, and the URL is minted fresh on read.
|
|
16
|
+
*/
|
|
17
|
+
export type MessageAttachment = {
|
|
18
|
+
/** Absolute path on this host. */
|
|
19
|
+
path: string;
|
|
20
|
+
/** Original file name, for display. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Size in bytes. */
|
|
23
|
+
size: number;
|
|
24
|
+
/** Best-effort MIME type. */
|
|
25
|
+
mimeType: string;
|
|
26
|
+
/** True when clients render it inline as an image. */
|
|
27
|
+
image: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
12
30
|
export type HistoryMessage = {
|
|
13
31
|
msgId: number;
|
|
14
32
|
senderId: number;
|
|
@@ -25,8 +43,11 @@ export type HistoryMessage = {
|
|
|
25
43
|
mediaType?:
|
|
26
44
|
"photo" | "document" | "voice" | "sticker" | "video" | "animation";
|
|
27
45
|
stickerFileId?: string;
|
|
28
|
-
/** Saved file path for downloaded media
|
|
46
|
+
/** Saved file path for downloaded media (the first attachment's, when
|
|
47
|
+
* a message carries several). */
|
|
29
48
|
filePath?: string;
|
|
49
|
+
/** Every file attached to this message, oldest row shape holds none. */
|
|
50
|
+
attachments?: MessageAttachment[];
|
|
30
51
|
};
|
|
31
52
|
|
|
32
53
|
type Row = {
|
|
@@ -40,8 +61,31 @@ type Row = {
|
|
|
40
61
|
media_type: string | null;
|
|
41
62
|
sticker_file_id: string | null;
|
|
42
63
|
file_path: string | null;
|
|
64
|
+
attachments: string | null;
|
|
43
65
|
};
|
|
44
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Parse the persisted attachments column. Anything unreadable (hand-edited
|
|
69
|
+
* row, a shape from a future version) degrades to "no attachments" rather
|
|
70
|
+
* than failing the whole history read.
|
|
71
|
+
*/
|
|
72
|
+
function parseAttachments(raw: string | null): MessageAttachment[] | undefined {
|
|
73
|
+
if (!raw) return undefined;
|
|
74
|
+
try {
|
|
75
|
+
const parsed: unknown = JSON.parse(raw);
|
|
76
|
+
if (!Array.isArray(parsed)) return undefined;
|
|
77
|
+
const rows = parsed.filter(
|
|
78
|
+
(a): a is MessageAttachment =>
|
|
79
|
+
typeof a === "object" &&
|
|
80
|
+
a !== null &&
|
|
81
|
+
typeof (a as MessageAttachment).path === "string",
|
|
82
|
+
);
|
|
83
|
+
return rows.length ? rows : undefined;
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
45
89
|
function rowToMessage(row: Row): HistoryMessage {
|
|
46
90
|
return {
|
|
47
91
|
msgId: row.msg_id,
|
|
@@ -54,6 +98,7 @@ function rowToMessage(row: Row): HistoryMessage {
|
|
|
54
98
|
mediaType: (row.media_type ?? undefined) as HistoryMessage["mediaType"],
|
|
55
99
|
stickerFileId: row.sticker_file_id ?? undefined,
|
|
56
100
|
filePath: row.file_path ?? undefined,
|
|
101
|
+
attachments: parseAttachments(row.attachments),
|
|
57
102
|
};
|
|
58
103
|
}
|
|
59
104
|
|
|
@@ -72,6 +117,7 @@ export function insert(chatId: string, msg: HistoryMessage): void {
|
|
|
72
117
|
msg.mediaType ?? null,
|
|
73
118
|
msg.stickerFileId ?? null,
|
|
74
119
|
msg.filePath ?? null,
|
|
120
|
+
msg.attachments?.length ? JSON.stringify(msg.attachments) : null,
|
|
75
121
|
);
|
|
76
122
|
}
|
|
77
123
|
|
package/src/storage/sql/db.sql
CHANGED
|
@@ -19,3 +19,9 @@ ALTER TABLE history_messages ADD COLUMN sender_handle TEXT
|
|
|
19
19
|
-- Column reconciliation for databases that shipped before per-session
|
|
20
20
|
-- metrics existed. Fresh databases get the column via schema.sql.
|
|
21
21
|
ALTER TABLE sessions ADD COLUMN metrics TEXT NOT NULL DEFAULT '{"lifetime":{"counters":{"queries":0,"toolCalls":0,"turnsWithTools":0,"apiCalls":0,"inputTokens":0,"outputTokens":0,"cacheReadTokens":0,"cacheWriteTokens":0,"failedTurns":0,"flowViolationRetries":0,"flowViolationCapExhausted":0,"trailingTextDropped":0},"latency":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsByName":{},"backend":{},"cacheHitPercent":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"toolCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0},"apiCallsPerTurn":{"count":0,"sumMs":0,"minMs":null,"maxMs":0}},"buckets":{}}'
|
|
22
|
+
|
|
23
|
+
-- name: addHistoryAttachmentsColumn
|
|
24
|
+
-- Column reconciliation for databases that shipped before a message could
|
|
25
|
+
-- carry more than one attachment. Fresh databases get the column via
|
|
26
|
+
-- schema.sql.
|
|
27
|
+
ALTER TABLE history_messages ADD COLUMN attachments TEXT
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
-- name: insert
|
|
5
5
|
INSERT OR IGNORE INTO history_messages
|
|
6
6
|
(chat_id, msg_id, sender_id, sender_name, sender_handle, text,
|
|
7
|
-
reply_to_msg_id, timestamp, media_type, sticker_file_id, file_path
|
|
8
|
-
|
|
7
|
+
reply_to_msg_id, timestamp, media_type, sticker_file_id, file_path,
|
|
8
|
+
attachments)
|
|
9
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
9
10
|
|
|
10
11
|
-- name: recent
|
|
11
12
|
SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
|
|
12
|
-
timestamp, media_type, sticker_file_id, file_path
|
|
13
|
+
timestamp, media_type, sticker_file_id, file_path, attachments
|
|
13
14
|
FROM history_messages
|
|
14
15
|
WHERE chat_id = ? ORDER BY id DESC LIMIT ?
|
|
15
16
|
|
|
@@ -17,7 +18,7 @@ WHERE chat_id = ? ORDER BY id DESC LIMIT ?
|
|
|
17
18
|
-- Scroll-back pagination: the window of messages strictly older than a
|
|
18
19
|
-- given msg_id, newest-first (the repository reverses to chronological).
|
|
19
20
|
SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
|
|
20
|
-
timestamp, media_type, sticker_file_id, file_path
|
|
21
|
+
timestamp, media_type, sticker_file_id, file_path, attachments
|
|
21
22
|
FROM history_messages
|
|
22
23
|
WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?
|
|
23
24
|
|
|
@@ -25,7 +26,7 @@ WHERE chat_id = ? AND msg_id < ? ORDER BY id DESC LIMIT ?
|
|
|
25
26
|
-- Time-cursor variant of recentBefore for the read_history `before` date
|
|
26
27
|
-- parameter: the newest `limit` messages strictly older than a timestamp.
|
|
27
28
|
SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
|
|
28
|
-
timestamp, media_type, sticker_file_id, file_path
|
|
29
|
+
timestamp, media_type, sticker_file_id, file_path, attachments
|
|
29
30
|
FROM history_messages
|
|
30
31
|
WHERE chat_id = ? AND timestamp < ? ORDER BY id DESC LIMIT ?
|
|
31
32
|
|
|
@@ -39,7 +40,7 @@ DELETE FROM history_messages WHERE chat_id = ?
|
|
|
39
40
|
-- The match param must already be a valid FTS5 expression
|
|
40
41
|
-- (see history.ts ftsQuery).
|
|
41
42
|
SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
|
|
42
|
-
timestamp, media_type, sticker_file_id, file_path
|
|
43
|
+
timestamp, media_type, sticker_file_id, file_path, attachments
|
|
43
44
|
FROM history_messages
|
|
44
45
|
WHERE chat_id = ?
|
|
45
46
|
AND id IN (SELECT rowid FROM history_fts WHERE history_fts MATCH ?)
|
|
@@ -50,7 +51,7 @@ ORDER BY id DESC LIMIT ?
|
|
|
50
51
|
-- search_history `after` / `before` date parameters. Either bound may be
|
|
51
52
|
-- the open end of the range (0 / a far-future value).
|
|
52
53
|
SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
|
|
53
|
-
timestamp, media_type, sticker_file_id, file_path
|
|
54
|
+
timestamp, media_type, sticker_file_id, file_path, attachments
|
|
54
55
|
FROM history_messages
|
|
55
56
|
WHERE chat_id = ?
|
|
56
57
|
AND id IN (SELECT rowid FROM history_fts WHERE history_fts MATCH ?)
|
|
@@ -60,20 +61,20 @@ ORDER BY id DESC LIMIT ?
|
|
|
60
61
|
-- name: bySenderName
|
|
61
62
|
-- The fragment param is LIKE-escaped by the repository (backslash escape).
|
|
62
63
|
SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
|
|
63
|
-
timestamp, media_type, sticker_file_id, file_path
|
|
64
|
+
timestamp, media_type, sticker_file_id, file_path, attachments
|
|
64
65
|
FROM history_messages
|
|
65
66
|
WHERE chat_id = ? AND lower(sender_name) LIKE ? ESCAPE '\'
|
|
66
67
|
ORDER BY id DESC LIMIT ?
|
|
67
68
|
|
|
68
69
|
-- name: byMsgId
|
|
69
70
|
SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
|
|
70
|
-
timestamp, media_type, sticker_file_id, file_path
|
|
71
|
+
timestamp, media_type, sticker_file_id, file_path, attachments
|
|
71
72
|
FROM history_messages
|
|
72
73
|
WHERE chat_id = ? AND msg_id = ? ORDER BY id DESC LIMIT 1
|
|
73
74
|
|
|
74
75
|
-- name: bySenderId
|
|
75
76
|
SELECT msg_id, sender_id, sender_name, sender_handle, text, reply_to_msg_id,
|
|
76
|
-
timestamp, media_type, sticker_file_id, file_path
|
|
77
|
+
timestamp, media_type, sticker_file_id, file_path, attachments
|
|
77
78
|
FROM history_messages
|
|
78
79
|
WHERE chat_id = ? AND sender_id = ? ORDER BY id DESC LIMIT ?
|
|
79
80
|
|
|
@@ -20,7 +20,12 @@ CREATE TABLE IF NOT EXISTS history_messages (
|
|
|
20
20
|
timestamp INTEGER NOT NULL,
|
|
21
21
|
media_type TEXT,
|
|
22
22
|
sticker_file_id TEXT,
|
|
23
|
-
file_path TEXT
|
|
23
|
+
file_path TEXT,
|
|
24
|
+
-- Files attached to this message, as a JSON array of
|
|
25
|
+
-- {path,name,size,mimeType,image}. A message can carry several (the
|
|
26
|
+
-- companion's composer stages any number), so file_path above holds
|
|
27
|
+
-- only the first for the pre-multi-file row shape. Null when none.
|
|
28
|
+
attachments TEXT
|
|
24
29
|
);
|
|
25
30
|
CREATE INDEX IF NOT EXISTS idx_history_chat ON history_messages(chat_id, id);
|
|
26
31
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_history_chat_msg ON history_messages(chat_id, msg_id);
|