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.
@@ -1,8 +1,11 @@
1
1
  import type { RouteHost } from "./host.js";
2
2
  import type { BridgeRoutes } from "./table.js";
3
- import { asPositiveInt, asString, deviceIdParam } from "./params.js";
4
-
5
- const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
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
- // Text may be empty when an image is attached; require one or the
93
- // other.
94
- if (!id || (!text.trim() && !attachmentPath))
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
- const bytes = await host.readRaw(req, MAX_UPLOAD_BYTES);
107
- if (!bytes.length)
108
- return json(res, 400, { ok: false, error: "Empty upload" });
109
- const result = await h.upload(filename, contentType, bytes);
110
- json(res, 200, { ok: true, ...result });
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
- /** Persist an uploaded image and return its render path + on-disk path. */
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
- bytes: Buffer,
49
- ): Promise<{ imagePath: string; path: string }>;
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 { BridgeEvent, ClientToolCall, ContextInfo } from "./protocol.js";
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 attachment paths intact. */
29
+ /** A chat's single queued follow-up, with its attachments intact. */
25
30
  export type QueuedEntry = {
26
31
  text: string;
27
- imagePath?: string;
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
- capabilities: ["mesh", "mesh-commands", "plugins-skills"],
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 = { imagePath?: string; attachmentPath?: string };
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
- attachmentPath?: string,
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
- // Point the model at an attached image so it can read the file itself.
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
- imagePath: queued.imagePath,
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 messageId = emitUser(
307
- runtime,
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, options?.attachmentPath);
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) are ensured by attempting the ALTER and
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);
@@ -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 { HistoryMessage } from "./repositories/history-repo.js";
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
 
@@ -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
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
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);