agents 0.21.0 → 0.23.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/README.md +21 -1
- package/dist/{agent-tool-types-CzGGB-20.d.ts → agent-routing-DE5zmCQ8.d.ts} +1844 -1336
- package/dist/agent-routing.d.ts +14 -0
- package/dist/agent-routing.js +187 -0
- package/dist/agent-routing.js.map +1 -0
- package/dist/agent-tool-types.d.ts +26 -26
- package/dist/{agent-tools-zR2d5uij.d.ts → agent-tools-DtXMTDGM.d.ts} +7 -7
- package/dist/agent-tools.d.ts +21 -21
- package/dist/agent-tools.js +2 -1
- package/dist/agent-tools.js.map +1 -1
- package/dist/browser/ai.js +6 -2
- package/dist/browser/ai.js.map +1 -1
- package/dist/browser/tanstack-ai.js.map +1 -1
- package/dist/callable-decorator-DP__HhBA.d.ts +72 -0
- package/dist/callable-decorator.d.ts +18 -0
- package/dist/callable-decorator.js +71 -0
- package/dist/callable-decorator.js.map +1 -0
- package/dist/capability-BjSKYpzg.js +42 -0
- package/dist/capability-BjSKYpzg.js.map +1 -0
- package/dist/capability-runner-Be_-PLR1.d.ts +459 -0
- package/dist/channel-Bnm4S7T2.d.ts +491 -0
- package/dist/channels/ai-sdk.d.ts +46 -0
- package/dist/channels/ai-sdk.js +120 -0
- package/dist/channels/ai-sdk.js.map +1 -0
- package/dist/channels/email.d.ts +95 -0
- package/dist/channels/email.js +323 -0
- package/dist/channels/email.js.map +1 -0
- package/dist/channels/index.d.ts +233 -0
- package/dist/channels/index.js +608 -0
- package/dist/channels/index.js.map +1 -0
- package/dist/channels/slack.d.ts +140 -0
- package/dist/channels/slack.js +614 -0
- package/dist/channels/slack.js.map +1 -0
- package/dist/channels/tanstack-ai.d.ts +39 -0
- package/dist/channels/tanstack-ai.js +17 -0
- package/dist/channels/tanstack-ai.js.map +1 -0
- package/dist/channels/telegram.d.ts +106 -0
- package/dist/channels/telegram.js +427 -0
- package/dist/channels/telegram.js.map +1 -0
- package/dist/channels/voice.d.ts +45 -0
- package/dist/channels/voice.js +122 -0
- package/dist/channels/voice.js.map +1 -0
- package/dist/chat/index.d.ts +2328 -2015
- package/dist/chat/index.js +891 -521
- package/dist/chat/index.js.map +1 -1
- package/dist/chat/react.d.ts +14 -1
- package/dist/chat/react.js +82 -52
- package/dist/chat/react.js.map +1 -1
- package/dist/chat/transport.js +1 -1
- package/dist/chat-sdk/index.d.ts +7 -7
- package/dist/chat-sdk/index.js +1 -1
- package/dist/{client-zqKcsyFa.js → client-jagG8a9_.js} +129 -37
- package/dist/client-jagG8a9_.js.map +1 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/{cloudflare-BduZwmYK.js → cloudflare-Dzvc7V2N.js} +10 -3
- package/dist/{cloudflare-BduZwmYK.js.map → cloudflare-Dzvc7V2N.js.map} +1 -1
- package/dist/context/index.d.ts +216 -0
- package/dist/context/index.js +454 -0
- package/dist/context/index.js.map +1 -0
- package/dist/current-agent-Da_C9a3b.d.ts +266 -0
- package/dist/current-agent-DhoDkSnH.js +51 -0
- package/dist/current-agent-DhoDkSnH.js.map +1 -0
- package/dist/diagnostics-BzvaX2UT.js +45 -0
- package/dist/diagnostics-BzvaX2UT.js.map +1 -0
- package/dist/diagnostics-C4jcz3VK.js +360 -0
- package/dist/diagnostics-C4jcz3VK.js.map +1 -0
- package/dist/{do-oauth-client-provider-VTZj2VtM.d.ts → do-oauth-client-provider-Tmf1vgKz.d.ts} +2 -2
- package/dist/{email-CL27preh.d.ts → email-7TatiTnl.d.ts} +38 -9
- package/dist/email-send.d.ts +15 -0
- package/dist/email-send.js +32 -0
- package/dist/email-send.js.map +1 -0
- package/dist/email.d.ts +14 -10
- package/dist/email.js.map +1 -1
- package/dist/{handler-stateless-C_bo-Ytq.d.ts → handler-stateless-DxYpJ_XF.d.ts} +3 -3
- package/dist/{handler-stateless-CIkKPETH.js → handler-stateless-VvrWSAVA.js} +5 -5
- package/dist/handler-stateless-VvrWSAVA.js.map +1 -0
- package/dist/index-BB0kqhIz.d.ts +101 -0
- package/dist/index-XDkuQ7zm.d.ts +89 -0
- package/dist/{index-BRnybD6X.d.ts → index-YSKgfgg9.d.ts} +21 -31
- package/dist/index.d.ts +113 -101
- package/dist/index.js +11 -7234
- package/dist/ingress-BfetZbMO.js +83 -0
- package/dist/ingress-BfetZbMO.js.map +1 -0
- package/dist/internal-CYlgHl1l.js +59 -0
- package/dist/internal-CYlgHl1l.js.map +1 -0
- package/dist/internal_context-BlxFEWfn.d.ts +19 -0
- package/dist/internal_context.d.ts +10 -4
- package/dist/internal_context.js +1 -10
- package/dist/{client-invoker-BNSZxAkv.d.ts → invoker-CG0_p_Wq.d.ts} +2 -2
- package/dist/{client-invoker-VNZ7X0nn.js → invoker-CHMnoxIA.js} +2 -2
- package/dist/invoker-CHMnoxIA.js.map +1 -0
- package/dist/lifecycle/index.d.ts +66 -0
- package/dist/lifecycle/index.js +4 -0
- package/dist/lifecycle-CMRGjZdw.js +1299 -0
- package/dist/lifecycle-CMRGjZdw.js.map +1 -0
- package/dist/mcp/{do-oauth-client-provider.d.ts → client/do-oauth-client-provider.d.ts} +1 -1
- package/dist/mcp/{do-oauth-client-provider.js → client/do-oauth-client-provider.js} +1 -1
- package/dist/mcp/client/do-oauth-client-provider.js.map +1 -0
- package/dist/mcp/client/index.d.ts +42 -0
- package/dist/mcp/{client.js → client/index.js} +1 -1
- package/dist/mcp/{x402.d.ts → client/x402.d.ts} +2 -2
- package/dist/mcp/{x402.js → client/x402.js} +2 -2
- package/dist/mcp/client/x402.js.map +1 -0
- package/dist/mcp/index.d.ts +36 -36
- package/dist/mcp/index.js +14 -16
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/{server.d.ts → server/index.d.ts} +1 -1
- package/dist/mcp/{server.js → server/index.js} +1 -1
- package/dist/observability/ai/index.js +50 -35
- package/dist/observability/ai/index.js.map +1 -1
- package/dist/observability/index.d.ts +4 -4
- package/dist/observability/index.js +3 -50
- package/dist/observability/index.js.map +1 -1
- package/dist/{protocol-Dqc2MQxo.js → protocol-B0nh6KNf.js} +19 -21
- package/dist/protocol-B0nh6KNf.js.map +1 -0
- package/dist/react.d.ts +4 -4
- package/dist/react.js +1 -1
- package/dist/{retries-CAvxtG9d.d.ts → retries-D9Ds-1lz.d.ts} +17 -6
- package/dist/retries.d.ts +8 -6
- package/dist/retries.js +13 -1
- package/dist/retries.js.map +1 -1
- package/dist/routing/index.d.ts +137 -0
- package/dist/routing/index.js +244 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/sanitize-D9TujEK8.js +79 -0
- package/dist/sanitize-D9TujEK8.js.map +1 -0
- package/dist/schedule.d.ts +25 -94
- package/dist/schedule.js +1 -98
- package/dist/schedule.js.map +1 -1
- package/dist/scheduler-DD9NdYbF.js +665 -0
- package/dist/scheduler-DD9NdYbF.js.map +1 -0
- package/dist/scheduler-Dwh85ZGl.d.ts +223 -0
- package/dist/schedules/index.d.ts +22 -0
- package/dist/schedules/index.js +2 -0
- package/dist/schedules/parser.d.ts +79 -0
- package/dist/schedules/parser.js +103 -0
- package/dist/schedules/parser.js.map +1 -0
- package/dist/sentence-chunker-BAidJ4DA.d.ts +68 -0
- package/dist/serializable.d.ts +1 -1
- package/dist/sessions/index.d.ts +441 -0
- package/dist/sessions/index.js +2063 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/skills/index.d.ts +99 -0
- package/dist/skills/index.js +254 -5
- package/dist/skills/index.js.map +1 -1
- package/dist/sql-error-CPY-GXyI.d.ts +12 -0
- package/dist/sql-error.d.ts +2 -0
- package/dist/sql-error.js +16 -0
- package/dist/sql-error.js.map +1 -0
- package/dist/src-DlSHshb2.js +6963 -0
- package/dist/src-DlSHshb2.js.map +1 -0
- package/dist/streams/index.d.ts +120 -0
- package/dist/streams/index.js +107 -0
- package/dist/streams/index.js.map +1 -0
- package/dist/streams-D6tJ0NN9.d.ts +370 -0
- package/dist/streams-DZKgAj9b.js +709 -0
- package/dist/streams-DZKgAj9b.js.map +1 -0
- package/dist/sub-routing.d.ts +12 -12
- package/dist/surface-bZZJqBka.js +17 -0
- package/dist/surface-bZZJqBka.js.map +1 -0
- package/dist/tasks/index.d.ts +64 -0
- package/dist/tasks/index.js +2 -0
- package/dist/tasks-BRJ5zgya.d.ts +517 -0
- package/dist/tasks-ylZgBjhj.js +1656 -0
- package/dist/tasks-ylZgBjhj.js.map +1 -0
- package/dist/text-segment-joiner-BtAFQSA_.js +57 -0
- package/dist/text-segment-joiner-BtAFQSA_.js.map +1 -0
- package/dist/text-stream-CpdiKrJB.js +272 -0
- package/dist/text-stream-CpdiKrJB.js.map +1 -0
- package/dist/tokens-nHAKcN6M.js +52 -0
- package/dist/tokens-nHAKcN6M.js.map +1 -0
- package/dist/tool-schema-CBjGPrsQ.js +31 -0
- package/dist/tool-schema-CBjGPrsQ.js.map +1 -0
- package/dist/types-B7LojTe4.d.ts +202 -0
- package/dist/types-_Faxb570.d.ts +439 -0
- package/dist/voice/client.d.ts +226 -0
- package/dist/voice/client.js +932 -0
- package/dist/voice/client.js.map +1 -0
- package/dist/voice/errors.d.ts +43 -0
- package/dist/voice/errors.js +41 -0
- package/dist/voice/errors.js.map +1 -0
- package/dist/voice/index.d.ts +271 -0
- package/dist/voice/index.js +1812 -0
- package/dist/voice/index.js.map +1 -0
- package/dist/voice/react.d.ts +167 -0
- package/dist/voice/react.js +234 -0
- package/dist/voice/react.js.map +1 -0
- package/dist/voice/sfu.d.ts +71 -0
- package/dist/voice/sfu.js +157 -0
- package/dist/voice/sfu.js.map +1 -0
- package/dist/voice/text.d.ts +6 -0
- package/dist/voice/text.js +2 -0
- package/dist/voice/types.d.ts +58 -0
- package/dist/voice/types.js +18 -0
- package/dist/voice/types.js.map +1 -0
- package/dist/voice/workers-ai.d.ts +136 -0
- package/dist/voice/workers-ai.js +568 -0
- package/dist/voice/workers-ai.js.map +1 -0
- package/dist/websockets/index.d.ts +192 -0
- package/dist/websockets/index.js +2 -0
- package/dist/websockets-DUfRHPRq.js +502 -0
- package/dist/websockets-DUfRHPRq.js.map +1 -0
- package/dist/workflow-types.d.ts +25 -25
- package/dist/workflows.d.ts +22 -22
- package/dist/workflows.js +2 -1
- package/dist/workflows.js.map +1 -1
- package/dist/{ws-chat-transport-CIoOBbO7.js → ws-chat-transport-rWwta645.js} +152 -15
- package/dist/ws-chat-transport-rWwta645.js.map +1 -0
- package/docs/agent-class.md +29 -87
- package/docs/agent-tools.md +2 -1
- package/docs/channels.md +323 -0
- package/docs/chat-agents.md +19 -25
- package/docs/context.md +131 -0
- package/docs/durable-execution.md +1 -1
- package/docs/http-websockets.md +1 -11
- package/docs/human-in-the-loop.md +1 -1
- package/docs/index.md +16 -12
- package/docs/lifecycle.md +370 -0
- package/docs/long-running-agents.md +4 -6
- package/docs/mcp-client.md +55 -0
- package/docs/mcp-servers.md +5 -1
- package/docs/observability.md +11 -11
- package/docs/resumable-streaming.md +2 -2
- package/docs/routing.md +105 -0
- package/docs/scheduling.md +175 -15
- package/docs/server-driven-messages.md +1 -1
- package/docs/sessions.md +237 -871
- package/docs/streams.md +213 -0
- package/docs/sub-agents.md +185 -125
- package/docs/tasks.md +246 -0
- package/docs/voice.md +745 -0
- package/package.json +144 -33
- package/dist/cli/index.js +0 -26
- package/dist/cli/index.js.map +0 -1
- package/dist/client-invoker-VNZ7X0nn.js.map +0 -1
- package/dist/client-zqKcsyFa.js.map +0 -1
- package/dist/compaction-helpers-iiKMr2TQ.js +0 -340
- package/dist/compaction-helpers-iiKMr2TQ.js.map +0 -1
- package/dist/compaction-helpers-wUz6M3us.d.ts +0 -621
- package/dist/experimental/memory/session/index.d.ts +0 -670
- package/dist/experimental/memory/session/index.js +0 -2374
- package/dist/experimental/memory/session/index.js.map +0 -1
- package/dist/experimental/memory/utils/index.d.ts +0 -96
- package/dist/experimental/memory/utils/index.js +0 -79
- package/dist/experimental/memory/utils/index.js.map +0 -1
- package/dist/handler-stateless-CIkKPETH.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/internal_context-Dg4Cgjcu.d.ts +0 -37
- package/dist/internal_context.js.map +0 -1
- package/dist/mcp/client.d.ts +0 -42
- package/dist/mcp/do-oauth-client-provider.js.map +0 -1
- package/dist/mcp/x402.js.map +0 -1
- package/dist/protocol-Dqc2MQxo.js.map +0 -1
- package/dist/tool-output-truncation-CNnnGZQ3.js +0 -98
- package/dist/tool-output-truncation-CNnnGZQ3.js.map +0 -1
- package/dist/ws-chat-transport-CIoOBbO7.js.map +0 -1
- /package/dist/{cli/index.d.ts → index-BVVgDSdq.d.ts} +0 -0
|
@@ -0,0 +1,2063 @@
|
|
|
1
|
+
import { i as _classPrivateFieldInitSpec, n as _classPrivateFieldSet2, r as _assertClassBrand, t as _classPrivateFieldGet2 } from "../classPrivateFieldGet2-DZBYAB34.js";
|
|
2
|
+
import { t as LifecycleCapability } from "../capability-BjSKYpzg.js";
|
|
3
|
+
import { t as _classPrivateMethodInitSpec } from "../classPrivateMethodInitSpec-qMjJ6sHQ.js";
|
|
4
|
+
import { SqlError } from "../sql-error.js";
|
|
5
|
+
import { n as sanitizeMessage, t as byteLength } from "../sanitize-D9TujEK8.js";
|
|
6
|
+
import { i as estimatedDataUrlBytes, n as estimateMessageTokens, r as estimateStringTokens, t as estimateAttachmentTokens } from "../tokens-nHAKcN6M.js";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
//#region src/sessions/chunking.ts
|
|
9
|
+
/**
|
|
10
|
+
* Row chunking for the Sessions capability.
|
|
11
|
+
*
|
|
12
|
+
* SQLite on a Durable Object caps how many bytes one row may hold, so a
|
|
13
|
+
* message whose serialized JSON exceeds the budget is split across the
|
|
14
|
+
* message row and numbered continuation rows. Reads concatenate the slices
|
|
15
|
+
* back into the original string, so a round-trip is exact and nothing is
|
|
16
|
+
* ever truncated or too large to store.
|
|
17
|
+
*
|
|
18
|
+
* The split is on BYTES, not characters: SQLite's limit is a byte limit and
|
|
19
|
+
* one character can be up to four of them. A boundary is never placed
|
|
20
|
+
* between a high surrogate and its low surrogate either, because a lone
|
|
21
|
+
* surrogate is not valid UTF-8 and would not survive the round-trip.
|
|
22
|
+
* `splitContent(s).join("")` always equals `s`.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Serialized byte ceiling for one stored row. A message larger than this is
|
|
26
|
+
* split across continuation rows; the overwhelmingly common message fits in
|
|
27
|
+
* one row and costs exactly one billed row write, as it always has.
|
|
28
|
+
*/
|
|
29
|
+
const MAX_INLINE_ROW_BYTES = 1536 * 1024;
|
|
30
|
+
/**
|
|
31
|
+
* Split a serialized message into row-sized slices, root slice first.
|
|
32
|
+
*
|
|
33
|
+
* Always returns at least one slice, and never an empty slice unless the
|
|
34
|
+
* input itself is empty. Slice 0 lives in the message row; the rest become
|
|
35
|
+
* continuation rows numbered from 1.
|
|
36
|
+
*/
|
|
37
|
+
function splitContent(json, budget = MAX_INLINE_ROW_BYTES) {
|
|
38
|
+
if (json.length === 0) return [""];
|
|
39
|
+
const slices = [];
|
|
40
|
+
let start = 0;
|
|
41
|
+
let bytes = 0;
|
|
42
|
+
let index = 0;
|
|
43
|
+
while (index < json.length) {
|
|
44
|
+
const code = json.charCodeAt(index);
|
|
45
|
+
let width;
|
|
46
|
+
let step = 1;
|
|
47
|
+
if (code < 128) width = 1;
|
|
48
|
+
else if (code < 2048) width = 2;
|
|
49
|
+
else if (code >= 55296 && code <= 56319 && index + 1 < json.length) {
|
|
50
|
+
const low = json.charCodeAt(index + 1);
|
|
51
|
+
if (low >= 56320 && low <= 57343) {
|
|
52
|
+
width = 4;
|
|
53
|
+
step = 2;
|
|
54
|
+
} else width = 3;
|
|
55
|
+
} else width = 3;
|
|
56
|
+
if (bytes > 0 && bytes + width > budget) {
|
|
57
|
+
slices.push(json.slice(start, index));
|
|
58
|
+
start = index;
|
|
59
|
+
bytes = 0;
|
|
60
|
+
}
|
|
61
|
+
bytes += width;
|
|
62
|
+
index += step;
|
|
63
|
+
}
|
|
64
|
+
slices.push(json.slice(start));
|
|
65
|
+
return slices;
|
|
66
|
+
}
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/sessions/attachment-store.ts
|
|
69
|
+
/**
|
|
70
|
+
* Content-addressed byte storage for session attachments.
|
|
71
|
+
*
|
|
72
|
+
* An attachment is a typed media payload that never belongs inside a message
|
|
73
|
+
* row: an image, an audio clip, a PDF. The store holds the raw bytes under
|
|
74
|
+
* their SHA-256 and hands back a content address; the message keeps only a
|
|
75
|
+
* pointer part. Payloads larger than one SQLite row are split across numbered
|
|
76
|
+
* chunk rows, the same way an oversized message is.
|
|
77
|
+
*
|
|
78
|
+
* Everything here is synchronous, so bytes and the message row that points at
|
|
79
|
+
* them commit in ONE transaction. There is no window in which a stored pointer
|
|
80
|
+
* has no bytes behind it, and no cleanup pass for half-written payloads.
|
|
81
|
+
*
|
|
82
|
+
* Content addressing buys idempotency — a replayed append re-derives the same
|
|
83
|
+
* address and stores nothing new. It is not a space-saving claim: two messages
|
|
84
|
+
* that happen to carry the same image share a record, but nothing in the design
|
|
85
|
+
* depends on that being common.
|
|
86
|
+
*/
|
|
87
|
+
/**
|
|
88
|
+
* SQLite window for one attachment chunk. Attachments are immutable, so a
|
|
89
|
+
* larger row means fewer billed writes; this leaves headroom below SQLite's
|
|
90
|
+
* 2 MiB ceiling for the row key and record overhead.
|
|
91
|
+
*/
|
|
92
|
+
const ATTACHMENT_CHUNK_BYTES = MAX_INLINE_ROW_BYTES;
|
|
93
|
+
/** The content address of a payload. Pure: no storage access. */
|
|
94
|
+
function hashPayload(bytes) {
|
|
95
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
96
|
+
}
|
|
97
|
+
/** A standalone copy of a slice, since `SqlParam` accepts only `ArrayBuffer`. */
|
|
98
|
+
function chunkBuffer(bytes, start, end) {
|
|
99
|
+
return bytes.slice(start, end).buffer;
|
|
100
|
+
}
|
|
101
|
+
var _io = /* @__PURE__ */ new WeakMap();
|
|
102
|
+
var _AttachmentStore_brand = /* @__PURE__ */ new WeakSet();
|
|
103
|
+
var AttachmentStore = class {
|
|
104
|
+
constructor(io) {
|
|
105
|
+
_classPrivateMethodInitSpec(this, _AttachmentStore_brand);
|
|
106
|
+
_classPrivateFieldInitSpec(this, _io, void 0);
|
|
107
|
+
_classPrivateFieldSet2(_io, this, io);
|
|
108
|
+
}
|
|
109
|
+
ensureTables() {
|
|
110
|
+
_classPrivateFieldGet2(_io, this).sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_attachment_meta (
|
|
111
|
+
hash TEXT PRIMARY KEY,
|
|
112
|
+
bytes INTEGER NOT NULL,
|
|
113
|
+
media_type TEXT NOT NULL,
|
|
114
|
+
chunks INTEGER NOT NULL
|
|
115
|
+
) WITHOUT ROWID`, []);
|
|
116
|
+
_classPrivateFieldGet2(_io, this).sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_attachment_chunks (
|
|
117
|
+
hash TEXT NOT NULL,
|
|
118
|
+
idx INTEGER NOT NULL,
|
|
119
|
+
data BLOB NOT NULL,
|
|
120
|
+
PRIMARY KEY (hash, idx)
|
|
121
|
+
) WITHOUT ROWID`, []);
|
|
122
|
+
_classPrivateFieldGet2(_io, this).sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_attachment_refs (
|
|
123
|
+
session_id TEXT NOT NULL,
|
|
124
|
+
message_id TEXT NOT NULL,
|
|
125
|
+
hash TEXT NOT NULL,
|
|
126
|
+
PRIMARY KEY (session_id, message_id, hash)
|
|
127
|
+
) WITHOUT ROWID`, []);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Store one payload under its precomputed address. Idempotent: bytes that
|
|
131
|
+
* are already present cost a single read and no writes.
|
|
132
|
+
*
|
|
133
|
+
* Call inside the caller's transaction so the payload and the message that
|
|
134
|
+
* references it commit together. The address is passed in rather than
|
|
135
|
+
* derived so hashing stays outside the transaction.
|
|
136
|
+
*/
|
|
137
|
+
put(payload, hash) {
|
|
138
|
+
const { bytes, mediaType } = payload;
|
|
139
|
+
if (_classPrivateFieldGet2(_io, this).sql("SELECT hash FROM cf_agents_session_attachment_meta WHERE hash = ?", [hash]).length > 0) return;
|
|
140
|
+
let chunks = 0;
|
|
141
|
+
for (let start = 0; start < bytes.length; start += ATTACHMENT_CHUNK_BYTES) {
|
|
142
|
+
const end = Math.min(start + ATTACHMENT_CHUNK_BYTES, bytes.length);
|
|
143
|
+
_classPrivateFieldGet2(_io, this).sqlWrite("INSERT INTO cf_agents_session_attachment_chunks (hash, idx, data) VALUES (?, ?, ?)", [
|
|
144
|
+
hash,
|
|
145
|
+
chunks,
|
|
146
|
+
chunkBuffer(bytes, start, end)
|
|
147
|
+
]);
|
|
148
|
+
chunks++;
|
|
149
|
+
}
|
|
150
|
+
_classPrivateFieldGet2(_io, this).sqlWrite(`INSERT INTO cf_agents_session_attachment_meta (hash, bytes, media_type, chunks)
|
|
151
|
+
VALUES (?, ?, ?, ?)`, [
|
|
152
|
+
hash,
|
|
153
|
+
bytes.length,
|
|
154
|
+
mediaType,
|
|
155
|
+
chunks
|
|
156
|
+
]);
|
|
157
|
+
}
|
|
158
|
+
/** Read one payload back, or `undefined` when the address is unknown. */
|
|
159
|
+
get(hash) {
|
|
160
|
+
const [meta] = _classPrivateFieldGet2(_io, this).sql("SELECT bytes, media_type, chunks FROM cf_agents_session_attachment_meta WHERE hash = ?", [hash]);
|
|
161
|
+
if (!meta) return void 0;
|
|
162
|
+
const out = new Uint8Array(meta.bytes);
|
|
163
|
+
if (meta.chunks > 0) {
|
|
164
|
+
const rows = _classPrivateFieldGet2(_io, this).sql("SELECT idx, data FROM cf_agents_session_attachment_chunks WHERE hash = ? ORDER BY idx", [hash]);
|
|
165
|
+
let offset = 0;
|
|
166
|
+
for (const row of rows) {
|
|
167
|
+
const slice = new Uint8Array(row.data);
|
|
168
|
+
out.set(slice, offset);
|
|
169
|
+
offset += slice.length;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
mediaType: meta.media_type,
|
|
174
|
+
bytes: out
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** Record that one message references these payloads. */
|
|
178
|
+
addRefs(sessionId, messageId, hashes) {
|
|
179
|
+
for (const hash of hashes) _classPrivateFieldGet2(_io, this).sqlWrite(`INSERT OR IGNORE INTO cf_agents_session_attachment_refs
|
|
180
|
+
(session_id, message_id, hash) VALUES (?, ?, ?)`, [
|
|
181
|
+
sessionId,
|
|
182
|
+
messageId,
|
|
183
|
+
hash
|
|
184
|
+
]);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Point one message at exactly `hashes`, adding and dropping references to
|
|
188
|
+
* match, and collect whatever that orphaned. Call after the new payloads are
|
|
189
|
+
* stored, so a hash the message still uses is never briefly unreferenced.
|
|
190
|
+
*/
|
|
191
|
+
replaceRefs(sessionId, messageId, hashes) {
|
|
192
|
+
const current = _classPrivateFieldGet2(_io, this).sql(`SELECT hash FROM cf_agents_session_attachment_refs
|
|
193
|
+
WHERE session_id = ? AND message_id = ?`, [sessionId, messageId]);
|
|
194
|
+
const wanted = new Set(hashes);
|
|
195
|
+
const held = new Set(current.map((row) => row.hash));
|
|
196
|
+
const dropped = [];
|
|
197
|
+
for (const hash of held) {
|
|
198
|
+
if (wanted.has(hash)) continue;
|
|
199
|
+
_classPrivateFieldGet2(_io, this).sqlWrite(`DELETE FROM cf_agents_session_attachment_refs
|
|
200
|
+
WHERE session_id = ? AND message_id = ? AND hash = ?`, [
|
|
201
|
+
sessionId,
|
|
202
|
+
messageId,
|
|
203
|
+
hash
|
|
204
|
+
]);
|
|
205
|
+
dropped.push(hash);
|
|
206
|
+
}
|
|
207
|
+
this.addRefs(sessionId, messageId, hashes.filter((hash) => !held.has(hash)));
|
|
208
|
+
if (dropped.length > 0) _assertClassBrand(_AttachmentStore_brand, this, _collect).call(this, dropped);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Drop the references held by the given messages and collect any payload
|
|
212
|
+
* that no longer has a reader.
|
|
213
|
+
*/
|
|
214
|
+
releaseMessages(sessionId, messageIds) {
|
|
215
|
+
if (messageIds.length === 0) return;
|
|
216
|
+
const ids = JSON.stringify(messageIds);
|
|
217
|
+
const orphanCandidates = _classPrivateFieldGet2(_io, this).sql(`SELECT DISTINCT hash FROM cf_agents_session_attachment_refs
|
|
218
|
+
WHERE session_id = ? AND message_id IN (SELECT value FROM json_each(?))`, [sessionId, ids]);
|
|
219
|
+
if (orphanCandidates.length === 0) return;
|
|
220
|
+
_classPrivateFieldGet2(_io, this).sqlWrite(`DELETE FROM cf_agents_session_attachment_refs
|
|
221
|
+
WHERE session_id = ? AND message_id IN (SELECT value FROM json_each(?))`, [sessionId, ids]);
|
|
222
|
+
_assertClassBrand(_AttachmentStore_brand, this, _collect).call(this, orphanCandidates.map((row) => row.hash));
|
|
223
|
+
}
|
|
224
|
+
/** Drop every reference held by one session and collect what it orphaned. */
|
|
225
|
+
releaseSession(sessionId) {
|
|
226
|
+
const candidates = _classPrivateFieldGet2(_io, this).sql("SELECT DISTINCT hash FROM cf_agents_session_attachment_refs WHERE session_id = ?", [sessionId]);
|
|
227
|
+
if (candidates.length === 0) return;
|
|
228
|
+
_classPrivateFieldGet2(_io, this).sqlWrite("DELETE FROM cf_agents_session_attachment_refs WHERE session_id = ?", [sessionId]);
|
|
229
|
+
_assertClassBrand(_AttachmentStore_brand, this, _collect).call(this, candidates.map((row) => row.hash));
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
/** Delete payloads that no reference points at any more. */
|
|
233
|
+
function _collect(hashes) {
|
|
234
|
+
for (const hash of hashes) {
|
|
235
|
+
if (_classPrivateFieldGet2(_io, this).sql("SELECT hash FROM cf_agents_session_attachment_refs WHERE hash = ? LIMIT 1", [hash]).length > 0) continue;
|
|
236
|
+
_classPrivateFieldGet2(_io, this).sqlWrite("DELETE FROM cf_agents_session_attachment_chunks WHERE hash = ?", [hash]);
|
|
237
|
+
_classPrivateFieldGet2(_io, this).sqlWrite("DELETE FROM cf_agents_session_attachment_meta WHERE hash = ?", [hash]);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/sessions/attachment-ingest.ts
|
|
242
|
+
/**
|
|
243
|
+
* Which parts of a message become attachments, and how they come back.
|
|
244
|
+
*
|
|
245
|
+
* The rule is typed, not size-based: a part that DECLARES a non-text media
|
|
246
|
+
* type and carries its payload inline is an attachment, whatever its size.
|
|
247
|
+
* Text, reasoning and plain tool output are never extracted — they stay in the
|
|
248
|
+
* row and, if they are too large for one, chunk across continuation rows.
|
|
249
|
+
*
|
|
250
|
+
* That distinction is the whole point. Sizing the rule off bytes would make a
|
|
251
|
+
* message's stored shape depend on how big an image happened to be, which is
|
|
252
|
+
* how the previous design ended up as a rescue mechanism that competed with
|
|
253
|
+
* row chunking. Extraction here is uniform, so a reader never has to ask why
|
|
254
|
+
* one image is a pointer and another is inline.
|
|
255
|
+
*
|
|
256
|
+
* Extraction is lossless: bytes move to the attachment store and a read puts
|
|
257
|
+
* them back verbatim. Nothing that shapes what a model SEES belongs here — a
|
|
258
|
+
* cap on tool output discards content and so lives on the read path, in
|
|
259
|
+
* `agents/context`, where it can change without having destroyed anything.
|
|
260
|
+
*/
|
|
261
|
+
/** Pointer scheme written into a stored part in place of its payload. */
|
|
262
|
+
const ATTACHMENT_URL_PREFIX = "attachment:sha256:";
|
|
263
|
+
/** Hostile or deeply nested tool output stops here rather than recursing forever. */
|
|
264
|
+
const MAX_WALK_DEPTH = 8;
|
|
265
|
+
/** Build the pointer for a content address. */
|
|
266
|
+
function attachmentUrl(hash) {
|
|
267
|
+
return `${ATTACHMENT_URL_PREFIX}${hash}`;
|
|
268
|
+
}
|
|
269
|
+
/** The content address in a pointer, or `null` when the value is not one. */
|
|
270
|
+
function parseAttachmentUrl(url) {
|
|
271
|
+
if (typeof url !== "string" || !url.startsWith(ATTACHMENT_URL_PREFIX)) return null;
|
|
272
|
+
const hash = url.slice(18);
|
|
273
|
+
return /^[0-9a-f]{64}$/.test(hash) ? hash : null;
|
|
274
|
+
}
|
|
275
|
+
/** Parse a base64 `data:` URL. Non-base64 data URLs are left alone. */
|
|
276
|
+
function parseDataUrl(url) {
|
|
277
|
+
if (!url.startsWith("data:")) return null;
|
|
278
|
+
const comma = url.indexOf(",");
|
|
279
|
+
if (comma < 0) return null;
|
|
280
|
+
const header = url.slice(5, comma);
|
|
281
|
+
if (!header.endsWith(";base64")) return null;
|
|
282
|
+
return {
|
|
283
|
+
mediaType: header.slice(0, -7) || "application/octet-stream",
|
|
284
|
+
payload: url.slice(comma + 1)
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Text payloads stay in the message. They chunk perfectly well, they are what
|
|
289
|
+
* FTS indexes, and moving them out would put prose behind a pointer for no gain.
|
|
290
|
+
*/
|
|
291
|
+
function isTextMediaType(mediaType) {
|
|
292
|
+
return mediaType.startsWith("text/");
|
|
293
|
+
}
|
|
294
|
+
function decodeBase64(payload) {
|
|
295
|
+
const compact = /[\t\n\f\r ]/.test(payload) ? payload.replace(/[\t\n\f\r ]/g, "") : payload;
|
|
296
|
+
if (compact.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) return null;
|
|
297
|
+
let binary;
|
|
298
|
+
try {
|
|
299
|
+
binary = atob(compact);
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
const bytes = new Uint8Array(binary.length);
|
|
304
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
305
|
+
return bytes;
|
|
306
|
+
}
|
|
307
|
+
function encodeBase64(bytes) {
|
|
308
|
+
const step = 32768;
|
|
309
|
+
let binary = "";
|
|
310
|
+
for (let i = 0; i < bytes.length; i += step) binary += String.fromCharCode(...bytes.subarray(i, i + step));
|
|
311
|
+
return btoa(binary);
|
|
312
|
+
}
|
|
313
|
+
/** Rebuild the exact `data:` URL a payload was extracted from. */
|
|
314
|
+
function dataUrl(mediaType, bytes) {
|
|
315
|
+
return `data:${mediaType};base64,${encodeBase64(bytes)}`;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* A value carrying inline media: anything that declares a `mediaType` and
|
|
319
|
+
* holds its bytes in a `url` data URL or a base64 `data` string. Covers AI SDK
|
|
320
|
+
* file parts and the media entries tool results return, without either shape
|
|
321
|
+
* being named here.
|
|
322
|
+
*/
|
|
323
|
+
function inlineMediaOf(value) {
|
|
324
|
+
const declared = typeof value.mediaType === "string" ? value.mediaType : null;
|
|
325
|
+
const url = value.url;
|
|
326
|
+
if (typeof url === "string") {
|
|
327
|
+
const parsed = parseDataUrl(url);
|
|
328
|
+
if (!parsed) return null;
|
|
329
|
+
const mediaType = declared ?? parsed.mediaType;
|
|
330
|
+
if (isTextMediaType(mediaType)) return null;
|
|
331
|
+
const bytes = decodeBase64(parsed.payload);
|
|
332
|
+
return bytes ? {
|
|
333
|
+
field: "url",
|
|
334
|
+
mediaType,
|
|
335
|
+
bytes
|
|
336
|
+
} : null;
|
|
337
|
+
}
|
|
338
|
+
if (declared && typeof value.data === "string" && !isTextMediaType(declared)) {
|
|
339
|
+
const bytes = decodeBase64(value.data);
|
|
340
|
+
return bytes ? {
|
|
341
|
+
field: "data",
|
|
342
|
+
mediaType: declared,
|
|
343
|
+
bytes
|
|
344
|
+
} : null;
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Replace inline media with pointers, collecting the payloads to store.
|
|
350
|
+
*
|
|
351
|
+
* Returns the original message by reference when nothing was extracted, so the
|
|
352
|
+
* overwhelmingly common text-only write allocates nothing and pays only a walk.
|
|
353
|
+
*/
|
|
354
|
+
function extractAttachments(message) {
|
|
355
|
+
const attachments = [];
|
|
356
|
+
const seen = /* @__PURE__ */ new Set();
|
|
357
|
+
const walk = (value, depth) => {
|
|
358
|
+
if (depth > MAX_WALK_DEPTH || value === null || typeof value !== "object") return value;
|
|
359
|
+
if (Array.isArray(value)) {
|
|
360
|
+
let changed = false;
|
|
361
|
+
const next = value.map((entry) => {
|
|
362
|
+
const walked = walk(entry, depth + 1);
|
|
363
|
+
if (walked !== entry) changed = true;
|
|
364
|
+
return walked;
|
|
365
|
+
});
|
|
366
|
+
return changed ? next : value;
|
|
367
|
+
}
|
|
368
|
+
const record = value;
|
|
369
|
+
const media = inlineMediaOf(record);
|
|
370
|
+
if (media) {
|
|
371
|
+
const hash = hashPayload(media.bytes);
|
|
372
|
+
if (!seen.has(hash)) {
|
|
373
|
+
seen.add(hash);
|
|
374
|
+
attachments.push({
|
|
375
|
+
hash,
|
|
376
|
+
payload: {
|
|
377
|
+
bytes: media.bytes,
|
|
378
|
+
mediaType: media.mediaType
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
...record,
|
|
384
|
+
mediaType: media.mediaType,
|
|
385
|
+
[media.field]: attachmentUrl(hash)
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
let changed = false;
|
|
389
|
+
const next = {};
|
|
390
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
391
|
+
const walked = walk(entry, depth + 1);
|
|
392
|
+
if (walked !== entry) changed = true;
|
|
393
|
+
next[key] = walked;
|
|
394
|
+
}
|
|
395
|
+
return changed ? next : value;
|
|
396
|
+
};
|
|
397
|
+
if (message.parts.length === 0) return {
|
|
398
|
+
message,
|
|
399
|
+
attachments
|
|
400
|
+
};
|
|
401
|
+
const parts = walk(message.parts, 0);
|
|
402
|
+
if (parts === message.parts) return {
|
|
403
|
+
message,
|
|
404
|
+
attachments
|
|
405
|
+
};
|
|
406
|
+
return {
|
|
407
|
+
message: {
|
|
408
|
+
...message,
|
|
409
|
+
parts
|
|
410
|
+
},
|
|
411
|
+
attachments
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Put payloads back inline, undoing extraction exactly.
|
|
416
|
+
*
|
|
417
|
+
* A pointer whose bytes cannot be loaded is left as it is: an unresolvable
|
|
418
|
+
* pointer is a truthful record that the reference survived its payload, which
|
|
419
|
+
* only a bug could produce, and inventing a placeholder would hide it.
|
|
420
|
+
*/
|
|
421
|
+
function resolveAttachments(message, load) {
|
|
422
|
+
const walk = (value, depth) => {
|
|
423
|
+
if (depth > MAX_WALK_DEPTH || value === null || typeof value !== "object") return value;
|
|
424
|
+
if (Array.isArray(value)) {
|
|
425
|
+
let changed = false;
|
|
426
|
+
const next = value.map((entry) => {
|
|
427
|
+
const walked = walk(entry, depth + 1);
|
|
428
|
+
if (walked !== entry) changed = true;
|
|
429
|
+
return walked;
|
|
430
|
+
});
|
|
431
|
+
return changed ? next : value;
|
|
432
|
+
}
|
|
433
|
+
const record = value;
|
|
434
|
+
const urlHash = parseAttachmentUrl(record.url);
|
|
435
|
+
if (urlHash) {
|
|
436
|
+
const loaded = load(urlHash);
|
|
437
|
+
if (!loaded) return value;
|
|
438
|
+
return {
|
|
439
|
+
...record,
|
|
440
|
+
url: dataUrl(loaded.mediaType, loaded.bytes)
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const dataHash = parseAttachmentUrl(record.data);
|
|
444
|
+
if (dataHash) {
|
|
445
|
+
const loaded = load(dataHash);
|
|
446
|
+
if (!loaded) return value;
|
|
447
|
+
return {
|
|
448
|
+
...record,
|
|
449
|
+
data: encodeBase64(loaded.bytes)
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
let changed = false;
|
|
453
|
+
const next = {};
|
|
454
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
455
|
+
const walked = walk(entry, depth + 1);
|
|
456
|
+
if (walked !== entry) changed = true;
|
|
457
|
+
next[key] = walked;
|
|
458
|
+
}
|
|
459
|
+
return changed ? next : value;
|
|
460
|
+
};
|
|
461
|
+
const parts = walk(message.parts, 0);
|
|
462
|
+
return parts === message.parts ? message : {
|
|
463
|
+
...message,
|
|
464
|
+
parts
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
//#endregion
|
|
468
|
+
//#region src/sessions/compaction-helpers.ts
|
|
469
|
+
/** Prefix for all compaction messages (overlays and summaries) */
|
|
470
|
+
const COMPACTION_PREFIX = "compaction_";
|
|
471
|
+
/** Head messages kept verbatim so the conversation's opening survives. */
|
|
472
|
+
const PROTECT_HEAD = 3;
|
|
473
|
+
/** Tail messages kept verbatim regardless of the token budget. */
|
|
474
|
+
const MIN_TAIL_MESSAGES = 2;
|
|
475
|
+
/** Check if a message is a compaction message */
|
|
476
|
+
function isCompactionMessage(msg) {
|
|
477
|
+
return msg.id.startsWith(COMPACTION_PREFIX);
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Check if a message contains tool invocations.
|
|
481
|
+
*/
|
|
482
|
+
function hasToolCalls(msg) {
|
|
483
|
+
return msg.parts.some((p) => p.type.startsWith("tool-") || p.type === "dynamic-tool");
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Get tool call IDs from a message's parts.
|
|
487
|
+
*/
|
|
488
|
+
function getToolCallIds(msg) {
|
|
489
|
+
const ids = /* @__PURE__ */ new Set();
|
|
490
|
+
for (const part of msg.parts) if ((part.type.startsWith("tool-") || part.type === "dynamic-tool") && "toolCallId" in part) ids.add(part.toolCallId);
|
|
491
|
+
return ids;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Check if a message is a tool result referencing a specific call ID.
|
|
495
|
+
*/
|
|
496
|
+
function isToolResultFor(msg, callIds) {
|
|
497
|
+
return msg.parts.some((p) => (p.type.startsWith("tool-") || p.type === "dynamic-tool") && "toolCallId" in p && callIds.has(p.toolCallId));
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Align a boundary index forward to avoid splitting tool call/result groups.
|
|
501
|
+
* If the boundary falls between an assistant message with tool calls and its
|
|
502
|
+
* tool results, move it forward past the results.
|
|
503
|
+
*/
|
|
504
|
+
function alignBoundaryForward(messages, idx) {
|
|
505
|
+
if (idx <= 0 || idx >= messages.length) return idx;
|
|
506
|
+
const prev = messages[idx - 1];
|
|
507
|
+
if (prev.role === "assistant" && hasToolCalls(prev)) {
|
|
508
|
+
const callIds = getToolCallIds(prev);
|
|
509
|
+
while (idx < messages.length && isToolResultFor(messages[idx], callIds)) idx++;
|
|
510
|
+
}
|
|
511
|
+
return idx;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Align a boundary index backward to avoid splitting tool call/result groups.
|
|
515
|
+
* If the boundary falls in the middle of tool results, move it backward to
|
|
516
|
+
* include the assistant message that made the calls.
|
|
517
|
+
*/
|
|
518
|
+
function alignBoundaryBackward(messages, idx) {
|
|
519
|
+
if (idx <= 0 || idx >= messages.length) return idx;
|
|
520
|
+
while (idx > 0) {
|
|
521
|
+
const msg = messages[idx];
|
|
522
|
+
if (msg.role === "assistant" && hasToolCalls(msg)) break;
|
|
523
|
+
const prev = messages[idx - 1];
|
|
524
|
+
if (prev.role === "assistant" && hasToolCalls(prev)) {
|
|
525
|
+
if (isToolResultFor(msg, getToolCallIds(prev))) {
|
|
526
|
+
idx--;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
return idx;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Find the compression end boundary using a token budget for the tail.
|
|
536
|
+
* Walks backward from the end, accumulating tokens until budget is reached.
|
|
537
|
+
* Returns the index where compression should stop (everything from this
|
|
538
|
+
* index onward is protected).
|
|
539
|
+
*
|
|
540
|
+
* @param messages All messages
|
|
541
|
+
* @param headEnd Index where the protected head ends (compression starts here)
|
|
542
|
+
* @param tailTokenBudget Maximum tokens to keep in the tail
|
|
543
|
+
* @param minTailMessages Minimum messages to protect in the tail (fallback)
|
|
544
|
+
*/
|
|
545
|
+
function findTailCutByTokens(messages, headEnd, tailTokenBudget = 2e4, minTailMessages = 2) {
|
|
546
|
+
const n = messages.length;
|
|
547
|
+
let accumulated = 0;
|
|
548
|
+
let tokenCut = n;
|
|
549
|
+
for (let i = n - 1; i >= headEnd; i--) {
|
|
550
|
+
const msgTokens = estimateMessageTokens([messages[i]]);
|
|
551
|
+
if (accumulated + msgTokens > tailTokenBudget && tokenCut < n) break;
|
|
552
|
+
accumulated += msgTokens;
|
|
553
|
+
tokenCut = i;
|
|
554
|
+
}
|
|
555
|
+
const minCut = n - minTailMessages;
|
|
556
|
+
return alignBoundaryBackward(messages, minCut >= headEnd ? Math.min(tokenCut, minCut) : tokenCut);
|
|
557
|
+
}
|
|
558
|
+
function computeSummaryBudget(messages) {
|
|
559
|
+
const contentTokens = estimateMessageTokens(messages);
|
|
560
|
+
const budget = Math.floor(contentTokens * .2);
|
|
561
|
+
return Math.max(100, budget);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Build a prompt for LLM summarization of compressed messages.
|
|
565
|
+
*
|
|
566
|
+
* @param messages Messages to summarize
|
|
567
|
+
* @param previousSummary Previous summary for iterative updates (or null for first compaction)
|
|
568
|
+
* @param budget Target token count for the summary
|
|
569
|
+
*/
|
|
570
|
+
function buildSummaryPrompt(messages, previousSummary, budget) {
|
|
571
|
+
const content = messages.map((msg) => {
|
|
572
|
+
const textParts = msg.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n");
|
|
573
|
+
const toolParts = msg.parts.filter((p) => p.type.startsWith("tool-") || p.type === "dynamic-tool").map((p) => {
|
|
574
|
+
const tp = p;
|
|
575
|
+
const parts = [`[Tool: ${tp.toolName ?? "unknown"}]`];
|
|
576
|
+
if (tp.input) parts.push(`Input: ${JSON.stringify(tp.input).slice(0, 500)}`);
|
|
577
|
+
if (tp.output) parts.push(`Output: ${JSON.stringify(tp.output).slice(0, 500)}`);
|
|
578
|
+
return parts.join("\n");
|
|
579
|
+
}).join("\n");
|
|
580
|
+
return `[${msg.role}]\n${textParts}${toolParts ? "\n" + toolParts : ""}`;
|
|
581
|
+
}).join("\n\n---\n\n");
|
|
582
|
+
if (previousSummary) return `You are updating a conversation summary. A previous summary exists below. New conversation turns have occurred since then and need to be incorporated.
|
|
583
|
+
|
|
584
|
+
PREVIOUS SUMMARY:
|
|
585
|
+
${previousSummary}
|
|
586
|
+
|
|
587
|
+
NEW TURNS TO INCORPORATE:
|
|
588
|
+
${content}
|
|
589
|
+
|
|
590
|
+
Update the summary. PRESERVE existing information that is still relevant. ADD new information. Remove information only if it is clearly obsolete.
|
|
591
|
+
|
|
592
|
+
## Topic
|
|
593
|
+
[What the conversation is about]
|
|
594
|
+
|
|
595
|
+
## Key Points
|
|
596
|
+
[Important information, decisions, and conclusions from the conversation]
|
|
597
|
+
|
|
598
|
+
## Current State
|
|
599
|
+
[Where things stand now — what has been done, what is in progress]
|
|
600
|
+
|
|
601
|
+
## Open Items
|
|
602
|
+
[Unresolved questions, pending tasks, or next steps discussed]
|
|
603
|
+
|
|
604
|
+
Target ~${budget} tokens. Be factual — only include information that was explicitly discussed in the conversation. Do NOT invent file paths, commands, or details that were not mentioned. Write only the summary body.`;
|
|
605
|
+
return `Create a concise summary of this conversation that preserves the important information for future context.
|
|
606
|
+
|
|
607
|
+
CONVERSATION TO SUMMARIZE:
|
|
608
|
+
${content}
|
|
609
|
+
|
|
610
|
+
Use this structure:
|
|
611
|
+
|
|
612
|
+
## Topic
|
|
613
|
+
[What the conversation is about]
|
|
614
|
+
|
|
615
|
+
## Key Points
|
|
616
|
+
[Important information, decisions, and conclusions from the conversation]
|
|
617
|
+
|
|
618
|
+
## Current State
|
|
619
|
+
[Where things stand now — what has been done, what is in progress]
|
|
620
|
+
|
|
621
|
+
## Open Items
|
|
622
|
+
[Unresolved questions, pending tasks, or next steps discussed]
|
|
623
|
+
|
|
624
|
+
Target ~${budget} tokens. Be factual — only include information that was explicitly discussed in the conversation. Do NOT invent file paths, commands, or details that were not mentioned. Write only the summary body.`;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Reference compaction implementation.
|
|
628
|
+
*
|
|
629
|
+
* Implements the full hermes-style compaction algorithm:
|
|
630
|
+
* 1. Protect head messages (first N)
|
|
631
|
+
* 2. Protect tail by token budget (walk backward)
|
|
632
|
+
* 3. Align boundaries to tool call groups
|
|
633
|
+
* 4. Summarize middle section with LLM (structured format)
|
|
634
|
+
* 5. Iterative summary updates on subsequent compactions
|
|
635
|
+
*
|
|
636
|
+
* @example
|
|
637
|
+
* ```typescript
|
|
638
|
+
* import { createCompactFunction } from "agents/sessions";
|
|
639
|
+
*
|
|
640
|
+
* sessions
|
|
641
|
+
* .session()
|
|
642
|
+
* .onCompaction(
|
|
643
|
+
* createCompactFunction({
|
|
644
|
+
* summarize: (prompt) => generateText({ model, prompt }).then((r) => r.text)
|
|
645
|
+
* })
|
|
646
|
+
* )
|
|
647
|
+
* .compactAfter(100_000);
|
|
648
|
+
* ```
|
|
649
|
+
*/
|
|
650
|
+
function createCompactFunction(opts) {
|
|
651
|
+
const keepRecentTokens = opts.keepRecentTokens ?? 2e4;
|
|
652
|
+
return async (messages) => {
|
|
653
|
+
if (messages.length <= 5) return null;
|
|
654
|
+
const compressStart = alignBoundaryForward(messages, PROTECT_HEAD);
|
|
655
|
+
const compressEnd = findTailCutByTokens(messages, compressStart, keepRecentTokens, MIN_TAIL_MESSAGES);
|
|
656
|
+
if (compressEnd <= compressStart) return null;
|
|
657
|
+
const middleMessages = messages.slice(compressStart, compressEnd).filter((m) => !isCompactionMessage(m));
|
|
658
|
+
if (middleMessages.length === 0) return null;
|
|
659
|
+
const existingCompaction = messages.find(isCompactionMessage);
|
|
660
|
+
const prompt = buildSummaryPrompt(middleMessages, existingCompaction ? existingCompaction.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n") : null, computeSummaryBudget(middleMessages));
|
|
661
|
+
const summary = await opts.summarize(prompt);
|
|
662
|
+
if (!summary.trim()) return null;
|
|
663
|
+
return {
|
|
664
|
+
fromMessageId: middleMessages[0].id,
|
|
665
|
+
toMessageId: middleMessages[middleMessages.length - 1].id,
|
|
666
|
+
summary
|
|
667
|
+
};
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
//#endregion
|
|
671
|
+
//#region src/sessions/overlays.ts
|
|
672
|
+
/**
|
|
673
|
+
* Compaction overlay planning shared by the read path and the stats
|
|
674
|
+
* derivation. Reproduces the selection semantics of the legacy
|
|
675
|
+
* `applyCompactions` exactly: walk the path root → leaf; at each position the
|
|
676
|
+
* newest overlay that starts here and ends at-or-after here on this branch
|
|
677
|
+
* wins; its span is skipped and later overlaps inside the span never apply.
|
|
678
|
+
*/
|
|
679
|
+
/** Plan overlay spans over an ordered list of path message ids. */
|
|
680
|
+
function planOverlays(pathIds, compactions) {
|
|
681
|
+
if (compactions.length === 0) return [];
|
|
682
|
+
const indexById = new Map(pathIds.map((id, index) => [id, index]));
|
|
683
|
+
const spans = [];
|
|
684
|
+
let i = 0;
|
|
685
|
+
while (i < pathIds.length) {
|
|
686
|
+
const compaction = compactions.filter((compaction) => compaction.fromMessageId === pathIds[i] && (indexById.get(compaction.toMessageId) ?? -1) >= i).at(-1);
|
|
687
|
+
if (compaction) {
|
|
688
|
+
const endIndex = indexById.get(compaction.toMessageId) ?? -1;
|
|
689
|
+
if (endIndex >= i) {
|
|
690
|
+
spans.push({
|
|
691
|
+
startIndex: i,
|
|
692
|
+
endIndex,
|
|
693
|
+
compaction
|
|
694
|
+
});
|
|
695
|
+
i = endIndex + 1;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
i++;
|
|
700
|
+
}
|
|
701
|
+
return spans;
|
|
702
|
+
}
|
|
703
|
+
/** The synthetic message an overlay span renders as. */
|
|
704
|
+
function overlayMessage(compaction) {
|
|
705
|
+
return {
|
|
706
|
+
id: `${COMPACTION_PREFIX}${compaction.id}`,
|
|
707
|
+
role: "assistant",
|
|
708
|
+
parts: [{
|
|
709
|
+
type: "text",
|
|
710
|
+
text: compaction.summary
|
|
711
|
+
}],
|
|
712
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
//#endregion
|
|
716
|
+
//#region src/sessions/core.ts
|
|
717
|
+
/**
|
|
718
|
+
* @internal Storage engine behind the Sessions capability. One instance per
|
|
719
|
+
* capability, owning the `cf_agents_session_*` tables. All methods assume the
|
|
720
|
+
* caller has settled startup ordering (`lifecycle.ready()` for public API).
|
|
721
|
+
*
|
|
722
|
+
* Write economics: rows written cost ~1000× rows read on DO SQLite. Every
|
|
723
|
+
* table is WITHOUT ROWID with no secondary index, so one row write bills one
|
|
724
|
+
* row. State is derived from existing rows, never kept in counter rows, and
|
|
725
|
+
* an unchanged update writes nothing. The only in-memory state is the tail
|
|
726
|
+
* of each session (its leaf id and next `seq`), read once per object
|
|
727
|
+
* lifetime because finding it means scanning the session's rows.
|
|
728
|
+
*/
|
|
729
|
+
/**
|
|
730
|
+
* Bounds for each content-hydration query on a history path. In workerd the
|
|
731
|
+
* SQLite allocator shares the isolate's memory budget with the JS heap, so
|
|
732
|
+
* oversized transient result sets surface as SQLITE_NOMEM (#1710). Chunks
|
|
733
|
+
* are bounded by BOTH row count and cumulative stored bytes.
|
|
734
|
+
*/
|
|
735
|
+
const HISTORY_CONTENT_CHUNK_SIZE = 50;
|
|
736
|
+
const HISTORY_CONTENT_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
737
|
+
/**
|
|
738
|
+
* Rows per content window on a newest-first read. Such a read walks the path
|
|
739
|
+
* by id alone — no per-row byte subqueries, so no byte-bounded chunking — and
|
|
740
|
+
* is typically stopped by its consumer within the first few messages, so a
|
|
741
|
+
* small fixed window keeps both the rows read and the memory held low.
|
|
742
|
+
*/
|
|
743
|
+
const NEWEST_FIRST_WINDOW_ROWS = 8;
|
|
744
|
+
/**
|
|
745
|
+
* Deepest path a history read follows: the root row is depth 0, so a read
|
|
746
|
+
* returns at most this many rows plus one. A longer branch shows its most
|
|
747
|
+
* recent rows only, and `getRecentHistory` reports that as truncated.
|
|
748
|
+
*/
|
|
749
|
+
const MAX_PATH_DEPTH = 1e4;
|
|
750
|
+
var _reservedMetadataKeys = /* @__PURE__ */ new WeakMap();
|
|
751
|
+
var _listeners = /* @__PURE__ */ new WeakMap();
|
|
752
|
+
var _tails = /* @__PURE__ */ new WeakMap();
|
|
753
|
+
var _pathTokens = /* @__PURE__ */ new WeakMap();
|
|
754
|
+
var _attachments = /* @__PURE__ */ new WeakMap();
|
|
755
|
+
var _tablesEnsured = /* @__PURE__ */ new WeakMap();
|
|
756
|
+
var _fts = /* @__PURE__ */ new WeakMap();
|
|
757
|
+
var _SessionsCore_brand = /* @__PURE__ */ new WeakSet();
|
|
758
|
+
var SessionsCore = class {
|
|
759
|
+
constructor(options, io) {
|
|
760
|
+
_classPrivateMethodInitSpec(this, _SessionsCore_brand);
|
|
761
|
+
_classPrivateFieldInitSpec(this, _reservedMetadataKeys, void 0);
|
|
762
|
+
_classPrivateFieldInitSpec(this, _listeners, /* @__PURE__ */ new Set());
|
|
763
|
+
_classPrivateFieldInitSpec(this, _tails, /* @__PURE__ */ new Map());
|
|
764
|
+
_classPrivateFieldInitSpec(this, _pathTokens, /* @__PURE__ */ new Map());
|
|
765
|
+
_classPrivateFieldInitSpec(this, _attachments, void 0);
|
|
766
|
+
_classPrivateFieldInitSpec(this, _tablesEnsured, false);
|
|
767
|
+
_classPrivateFieldInitSpec(this, _fts, false);
|
|
768
|
+
this.io = io;
|
|
769
|
+
_classPrivateFieldSet2(_attachments, this, new AttachmentStore(io));
|
|
770
|
+
_classPrivateFieldSet2(_reservedMetadataKeys, this, options.reservedMetadataKeys ?? []);
|
|
771
|
+
}
|
|
772
|
+
ensureTables() {
|
|
773
|
+
if (_classPrivateFieldGet2(_tablesEnsured, this)) return;
|
|
774
|
+
this.io.sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_messages (
|
|
775
|
+
session_id TEXT NOT NULL,
|
|
776
|
+
id TEXT NOT NULL,
|
|
777
|
+
seq INTEGER NOT NULL,
|
|
778
|
+
parent_id TEXT,
|
|
779
|
+
type TEXT NOT NULL DEFAULT 'message',
|
|
780
|
+
role TEXT NOT NULL,
|
|
781
|
+
content TEXT NOT NULL,
|
|
782
|
+
content_chunks INTEGER NOT NULL DEFAULT 0,
|
|
783
|
+
token_estimate INTEGER NOT NULL DEFAULT 0,
|
|
784
|
+
created_at INTEGER NOT NULL,
|
|
785
|
+
PRIMARY KEY (session_id, id)
|
|
786
|
+
) WITHOUT ROWID`, []);
|
|
787
|
+
this.io.sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_message_chunks (
|
|
788
|
+
session_id TEXT NOT NULL,
|
|
789
|
+
id TEXT NOT NULL,
|
|
790
|
+
idx INTEGER NOT NULL,
|
|
791
|
+
content TEXT NOT NULL,
|
|
792
|
+
PRIMARY KEY (session_id, id, idx)
|
|
793
|
+
) WITHOUT ROWID`, []);
|
|
794
|
+
this.io.sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_compactions (
|
|
795
|
+
session_id TEXT NOT NULL,
|
|
796
|
+
id TEXT NOT NULL,
|
|
797
|
+
seq INTEGER NOT NULL,
|
|
798
|
+
summary TEXT NOT NULL,
|
|
799
|
+
from_message_id TEXT NOT NULL,
|
|
800
|
+
to_message_id TEXT NOT NULL,
|
|
801
|
+
created_at INTEGER NOT NULL,
|
|
802
|
+
PRIMARY KEY (session_id, id)
|
|
803
|
+
) WITHOUT ROWID`, []);
|
|
804
|
+
this.io.sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_config (
|
|
805
|
+
session_id TEXT NOT NULL,
|
|
806
|
+
key TEXT NOT NULL,
|
|
807
|
+
value TEXT NOT NULL,
|
|
808
|
+
PRIMARY KEY (session_id, key)
|
|
809
|
+
) WITHOUT ROWID`, []);
|
|
810
|
+
_classPrivateFieldGet2(_attachments, this).ensureTables();
|
|
811
|
+
_classPrivateFieldSet2(_fts, this, _assertClassBrand(_SessionsCore_brand, this, _tableExists).call(this, "cf_agents_session_fts"));
|
|
812
|
+
_classPrivateFieldSet2(_tablesEnsured, this, true);
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Lift the legacy `assistant_*` message and compaction tables.
|
|
816
|
+
*
|
|
817
|
+
* The copy is pure SQL, so SQLite streams it rather than materializing rows
|
|
818
|
+
* in the isolate. Each source is then verified row by row against its
|
|
819
|
+
* destination and DROPPED: keeping tombstones would leave every upgraded
|
|
820
|
+
* object holding its history twice inside the same 10 GB. A table whose
|
|
821
|
+
* verification fails is left in place with a `session:migration:incomplete`
|
|
822
|
+
* event, and the method returns false so the caller leaves the schema
|
|
823
|
+
* version unstamped and retries on a later start. `assistant_config`
|
|
824
|
+
* belongs to Think, which lifts and drops it itself.
|
|
825
|
+
*/
|
|
826
|
+
migrateLegacy() {
|
|
827
|
+
_classPrivateFieldGet2(_pathTokens, this).clear();
|
|
828
|
+
let complete = true;
|
|
829
|
+
const drop = (name) => {
|
|
830
|
+
if (_assertClassBrand(_SessionsCore_brand, this, _tableExists).call(this, name)) this.io.sqlWrite(`DROP TABLE ${name}`, []);
|
|
831
|
+
};
|
|
832
|
+
/**
|
|
833
|
+
* Drop a lifted source only once every one of its rows has a copy holding
|
|
834
|
+
* the same payload. Matching on the key alone would accept a destination
|
|
835
|
+
* row that merely occupies the key.
|
|
836
|
+
*/
|
|
837
|
+
const dropWhenCopied = (source, destination, payload) => {
|
|
838
|
+
const [counts] = this.io.sql(`SELECT
|
|
839
|
+
(SELECT COUNT(*) FROM ${source}) AS source,
|
|
840
|
+
(SELECT COUNT(*) FROM ${source} AS legacy
|
|
841
|
+
JOIN ${destination} AS lifted
|
|
842
|
+
ON lifted.session_id = legacy.session_id
|
|
843
|
+
AND lifted.id = legacy.id
|
|
844
|
+
AND lifted.${payload} = legacy.${payload}) AS copied`, []);
|
|
845
|
+
if (counts && counts.source === counts.copied) {
|
|
846
|
+
drop(source);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
complete = false;
|
|
850
|
+
this.io.emit("session:migration:incomplete", {
|
|
851
|
+
table: source,
|
|
852
|
+
source: counts?.source ?? 0,
|
|
853
|
+
copied: counts?.copied ?? 0
|
|
854
|
+
});
|
|
855
|
+
};
|
|
856
|
+
if (_assertClassBrand(_SessionsCore_brand, this, _tableExists).call(this, "assistant_messages")) {
|
|
857
|
+
this.io.sqlWrite(`INSERT OR IGNORE INTO cf_agents_session_messages
|
|
858
|
+
(session_id, id, seq, parent_id, role, content, token_estimate, created_at)
|
|
859
|
+
SELECT session_id, id,
|
|
860
|
+
ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, rowid ASC),
|
|
861
|
+
parent_id, role, content,
|
|
862
|
+
CAST(LENGTH(CAST(content AS BLOB)) / 4 AS INTEGER),
|
|
863
|
+
COALESCE(CAST(strftime('%s', created_at) AS INTEGER), 0) * 1000
|
|
864
|
+
FROM assistant_messages`, []);
|
|
865
|
+
if (_classPrivateFieldGet2(_fts, this)) _assertClassBrand(_SessionsCore_brand, this, _backfillMissingFtsRows).call(this);
|
|
866
|
+
dropWhenCopied("assistant_messages", "cf_agents_session_messages", "content");
|
|
867
|
+
}
|
|
868
|
+
if (_assertClassBrand(_SessionsCore_brand, this, _tableExists).call(this, "assistant_compactions")) {
|
|
869
|
+
this.io.sqlWrite(`INSERT OR IGNORE INTO cf_agents_session_compactions
|
|
870
|
+
(session_id, id, seq, summary, from_message_id, to_message_id, created_at)
|
|
871
|
+
SELECT session_id, id,
|
|
872
|
+
ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, rowid ASC),
|
|
873
|
+
summary, from_message_id, to_message_id,
|
|
874
|
+
COALESCE(CAST(strftime('%s', created_at) AS INTEGER), 0) * 1000
|
|
875
|
+
FROM assistant_compactions`, []);
|
|
876
|
+
dropWhenCopied("assistant_compactions", "cf_agents_session_compactions", "summary");
|
|
877
|
+
}
|
|
878
|
+
drop("assistant_sessions");
|
|
879
|
+
drop("assistant_fts");
|
|
880
|
+
return complete;
|
|
881
|
+
}
|
|
882
|
+
subscribe(listener) {
|
|
883
|
+
_classPrivateFieldGet2(_listeners, this).add(listener);
|
|
884
|
+
return () => _classPrivateFieldGet2(_listeners, this).delete(listener);
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Dispatch after a durable write. A listener that throws must not turn a
|
|
888
|
+
* committed write into a rejected call, so failures are reported through
|
|
889
|
+
* telemetry and dispatch continues.
|
|
890
|
+
*/
|
|
891
|
+
async notify(event) {
|
|
892
|
+
for (const listener of _classPrivateFieldGet2(_listeners, this)) try {
|
|
893
|
+
await listener(event);
|
|
894
|
+
} catch (error) {
|
|
895
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
896
|
+
console.warn(`[Sessions] change listener failed: ${detail}`);
|
|
897
|
+
this.io.emit("session:error", {
|
|
898
|
+
sessionId: event.sessionId,
|
|
899
|
+
event: event.type,
|
|
900
|
+
error: detail
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
exists(sessionId, id) {
|
|
905
|
+
return this.io.sql("SELECT id FROM cf_agents_session_messages WHERE session_id = ? AND id = ?", [sessionId, id]).length > 0;
|
|
906
|
+
}
|
|
907
|
+
getMessage(sessionId, id) {
|
|
908
|
+
const content = _assertClassBrand(_SessionsCore_brand, this, _content).call(this, sessionId, id);
|
|
909
|
+
const parsed = content === null ? null : _assertClassBrand(_SessionsCore_brand, this, _parse).call(this, content);
|
|
910
|
+
return parsed && _assertClassBrand(_SessionsCore_brand, this, _inline).call(this, parsed);
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Drop the in-memory tail and token-total caches for a session. For a
|
|
914
|
+
* caller whose enclosing transaction rolled back after an append or update
|
|
915
|
+
* ran inside it: the rows are gone but the caches already moved. The next
|
|
916
|
+
* read re-derives both from storage.
|
|
917
|
+
*/
|
|
918
|
+
forgetCaches(sessionId) {
|
|
919
|
+
_classPrivateFieldGet2(_tails, this).delete(sessionId);
|
|
920
|
+
_classPrivateFieldGet2(_pathTokens, this).delete(sessionId);
|
|
921
|
+
}
|
|
922
|
+
latestLeafId(sessionId) {
|
|
923
|
+
return _assertClassBrand(_SessionsCore_brand, this, _tail).call(this, sessionId).leafId;
|
|
924
|
+
}
|
|
925
|
+
getLatestLeaf(sessionId) {
|
|
926
|
+
const leafId = this.latestLeafId(sessionId);
|
|
927
|
+
return leafId ? this.getMessage(sessionId, leafId) : null;
|
|
928
|
+
}
|
|
929
|
+
getBranches(sessionId, messageId) {
|
|
930
|
+
const rows = this.io.sql(`SELECT id, content, content_chunks FROM cf_agents_session_messages
|
|
931
|
+
WHERE session_id = ? AND parent_id = ? ORDER BY seq ASC`, [sessionId, messageId]);
|
|
932
|
+
const continued = _assertClassBrand(_SessionsCore_brand, this, _continuations).call(this, sessionId, rows.filter((row) => row.content_chunks > 0).map((row) => row.id));
|
|
933
|
+
const result = [];
|
|
934
|
+
for (const row of rows) {
|
|
935
|
+
const parsed = _assertClassBrand(_SessionsCore_brand, this, _parse).call(this, row.content_chunks === 0 ? row.content : row.content + (continued.get(row.id) ?? ""));
|
|
936
|
+
if (parsed) result.push(_assertClassBrand(_SessionsCore_brand, this, _inline).call(this, parsed));
|
|
937
|
+
}
|
|
938
|
+
return result;
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* The active branch path as content-free rows, root → leaf. Recurses over
|
|
942
|
+
* (id, parent_id) only — carrying content through the recursive queue
|
|
943
|
+
* materializes the transcript several times inside SQLite (#1710).
|
|
944
|
+
* `bytes` counts the message row, its continuation rows, AND the payloads
|
|
945
|
+
* it points at, charged at the size they take once inlined, so a byte
|
|
946
|
+
* budget over these rows bounds real hydrated memory.
|
|
947
|
+
*/
|
|
948
|
+
pathRowStats(sessionId, leafId) {
|
|
949
|
+
const leaf = _assertClassBrand(_SessionsCore_brand, this, _resolveLeafId).call(this, sessionId, leafId);
|
|
950
|
+
if (!leaf) return [];
|
|
951
|
+
return this.io.sql(`WITH RECURSIVE path(id, parent_id, depth) AS (
|
|
952
|
+
SELECT id, parent_id, 0 FROM cf_agents_session_messages
|
|
953
|
+
WHERE session_id = ? AND id = ?
|
|
954
|
+
UNION ALL
|
|
955
|
+
SELECT m.id, m.parent_id, p.depth + 1 FROM cf_agents_session_messages m
|
|
956
|
+
JOIN path p ON m.id = p.parent_id
|
|
957
|
+
WHERE m.session_id = ? AND p.depth < ${MAX_PATH_DEPTH}
|
|
958
|
+
)
|
|
959
|
+
SELECT path.id AS id, am.role AS role,
|
|
960
|
+
LENGTH(CAST(am.content AS BLOB)) + CASE WHEN am.content_chunks = 0 THEN 0
|
|
961
|
+
ELSE COALESCE((
|
|
962
|
+
SELECT SUM(LENGTH(CAST(c.content AS BLOB)))
|
|
963
|
+
FROM cf_agents_session_message_chunks c
|
|
964
|
+
WHERE c.session_id = am.session_id AND c.id = am.id
|
|
965
|
+
), 0) END
|
|
966
|
+
+ COALESCE((
|
|
967
|
+
SELECT SUM((meta.bytes + 2) / 3 * 4)
|
|
968
|
+
FROM cf_agents_session_attachment_refs r
|
|
969
|
+
JOIN cf_agents_session_attachment_meta meta ON meta.hash = r.hash
|
|
970
|
+
WHERE r.session_id = am.session_id AND r.message_id = am.id
|
|
971
|
+
), 0) AS bytes,
|
|
972
|
+
am.token_estimate AS tokenEstimate
|
|
973
|
+
FROM path JOIN cf_agents_session_messages am
|
|
974
|
+
ON am.session_id = ? AND am.id = path.id
|
|
975
|
+
ORDER BY path.depth DESC`, [
|
|
976
|
+
sessionId,
|
|
977
|
+
leaf,
|
|
978
|
+
sessionId,
|
|
979
|
+
sessionId
|
|
980
|
+
]);
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Stream the path ending at `leafId` (default: active leaf), root → leaf
|
|
984
|
+
* (or leaf → root with `newestFirst`), compaction overlays collapsed. Peak
|
|
985
|
+
* memory is one bounded content window — never the whole transcript.
|
|
986
|
+
*/
|
|
987
|
+
async *streamHistory(sessionId, options) {
|
|
988
|
+
if (options.newestFirst === true) {
|
|
989
|
+
yield* _assertClassBrand(_SessionsCore_brand, this, _walkFromLeaf).call(this, sessionId, options.leafId, options.signal);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const stats = this.pathRowStats(sessionId, options.leafId);
|
|
993
|
+
if (stats.length === 0) return;
|
|
994
|
+
yield* _assertClassBrand(_SessionsCore_brand, this, _streamStats).call(this, sessionId, stats, options.signal);
|
|
995
|
+
}
|
|
996
|
+
async getHistory(sessionId, options) {
|
|
997
|
+
const messages = [];
|
|
998
|
+
for await (const message of this.streamHistory(sessionId, options)) messages.push(message);
|
|
999
|
+
return messages;
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Byte-budgeted read of the most recent messages on the active branch
|
|
1003
|
+
* path — the longest suffix whose stored size fits `maxContentBytes`.
|
|
1004
|
+
*
|
|
1005
|
+
* There is no message-count floor: one used to exist, and it admitted
|
|
1006
|
+
* rows regardless of size, so a window of media-heavy messages could
|
|
1007
|
+
* hydrate far past the limit meant to bound it. The newest message is
|
|
1008
|
+
* always returned even if it alone exceeds the budget, since returning
|
|
1009
|
+
* nothing is worse. Overlays whose anchors fall outside the window are
|
|
1010
|
+
* skipped, showing the raw recent messages.
|
|
1011
|
+
*/
|
|
1012
|
+
async getRecentHistory(sessionId, maxContentBytes, leafId) {
|
|
1013
|
+
const stats = this.pathRowStats(sessionId, leafId);
|
|
1014
|
+
if (stats.length === 0) return {
|
|
1015
|
+
messages: [],
|
|
1016
|
+
truncated: false,
|
|
1017
|
+
totalContentBytes: 0
|
|
1018
|
+
};
|
|
1019
|
+
const totalContentBytes = stats.reduce((sum, row) => sum + row.bytes, 0);
|
|
1020
|
+
let start = stats.length - 1;
|
|
1021
|
+
let used = stats[start].bytes;
|
|
1022
|
+
while (start > 0) {
|
|
1023
|
+
const next = stats[start - 1].bytes;
|
|
1024
|
+
if (used + next > maxContentBytes) break;
|
|
1025
|
+
start--;
|
|
1026
|
+
used += next;
|
|
1027
|
+
}
|
|
1028
|
+
const messages = [];
|
|
1029
|
+
for await (const message of _assertClassBrand(_SessionsCore_brand, this, _streamStats).call(this, sessionId, stats.slice(start))) messages.push(message);
|
|
1030
|
+
const capped = stats.length > MAX_PATH_DEPTH && _assertClassBrand(_SessionsCore_brand, this, _hasParent).call(this, sessionId, stats[0].id);
|
|
1031
|
+
return {
|
|
1032
|
+
messages,
|
|
1033
|
+
truncated: start > 0 || capped,
|
|
1034
|
+
totalContentBytes
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Heuristic token estimate for the active path with compaction overlays
|
|
1039
|
+
* applied: stamped per-row estimates, minus compacted spans, plus their
|
|
1040
|
+
* summaries. Derived from content-free rows on each call; it gates cheap
|
|
1041
|
+
* triggers only, and model-reported usage stays authoritative.
|
|
1042
|
+
*/
|
|
1043
|
+
tokenEstimate(sessionId) {
|
|
1044
|
+
const leafId = this.latestLeafId(sessionId);
|
|
1045
|
+
const memo = _classPrivateFieldGet2(_pathTokens, this).get(sessionId);
|
|
1046
|
+
if (memo && memo.leafId === leafId) return Math.max(0, Math.ceil(memo.total));
|
|
1047
|
+
const stats = this.pathRowStats(sessionId);
|
|
1048
|
+
const counted = new Set(stats.map((row) => row.id));
|
|
1049
|
+
let tokens = stats.reduce((sum, row) => sum + row.tokenEstimate, 0);
|
|
1050
|
+
for (const span of planOverlays(stats.map((row) => row.id), this.getCompactions(sessionId))) {
|
|
1051
|
+
for (let i = span.startIndex; i <= span.endIndex; i++) {
|
|
1052
|
+
tokens -= stats[i].tokenEstimate;
|
|
1053
|
+
counted.delete(stats[i].id);
|
|
1054
|
+
}
|
|
1055
|
+
tokens += estimateStringTokens(span.compaction.summary);
|
|
1056
|
+
}
|
|
1057
|
+
_classPrivateFieldGet2(_pathTokens, this).set(sessionId, {
|
|
1058
|
+
leafId,
|
|
1059
|
+
counted,
|
|
1060
|
+
total: tokens,
|
|
1061
|
+
depth: stats.length
|
|
1062
|
+
});
|
|
1063
|
+
return Math.max(0, Math.ceil(tokens));
|
|
1064
|
+
}
|
|
1065
|
+
stripReservedMetadata(message) {
|
|
1066
|
+
if (_classPrivateFieldGet2(_reservedMetadataKeys, this).length === 0 || typeof message.metadata !== "object" || message.metadata === null || Array.isArray(message.metadata)) return message;
|
|
1067
|
+
const metadata = { ...message.metadata };
|
|
1068
|
+
let changed = false;
|
|
1069
|
+
for (const key of _classPrivateFieldGet2(_reservedMetadataKeys, this)) if (key in metadata) {
|
|
1070
|
+
delete metadata[key];
|
|
1071
|
+
changed = true;
|
|
1072
|
+
}
|
|
1073
|
+
if (!changed) return message;
|
|
1074
|
+
if (Object.keys(metadata).length > 0) return {
|
|
1075
|
+
...message,
|
|
1076
|
+
metadata
|
|
1077
|
+
};
|
|
1078
|
+
const { metadata: _dropped, ...withoutMetadata } = message;
|
|
1079
|
+
return withoutMetadata;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Stamped row estimate: the part heuristic over the message as written,
|
|
1083
|
+
* plus a weight per inline file payload so media never counts as zero.
|
|
1084
|
+
*/
|
|
1085
|
+
estimateRowTokens(message) {
|
|
1086
|
+
let tokens = estimateMessageTokens([message]);
|
|
1087
|
+
for (const part of message.parts) {
|
|
1088
|
+
if (part.type !== "file") continue;
|
|
1089
|
+
if (typeof part.url === "string" && part.url.startsWith("data:")) tokens += estimateAttachmentTokens(part.mediaType ?? "application/octet-stream", estimatedDataUrlBytes(part.url));
|
|
1090
|
+
}
|
|
1091
|
+
return tokens;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Durable append. The caller has already sanitized the message. Message,
|
|
1095
|
+
* continuation, attachment, and FTS rows commit in one synchronous SQLite
|
|
1096
|
+
* transaction. Returns the stored message: the input itself when it was
|
|
1097
|
+
* inserted (extraction is lossless, so the two are identical), or the row
|
|
1098
|
+
* already holding the id when it was not.
|
|
1099
|
+
*/
|
|
1100
|
+
append(sessionId, message, parentId, tokenEstimate) {
|
|
1101
|
+
if (this.exists(sessionId, message.id)) {
|
|
1102
|
+
const existing = this.getMessage(sessionId, message.id);
|
|
1103
|
+
if (existing) return {
|
|
1104
|
+
inserted: false,
|
|
1105
|
+
message: existing
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
const tail = _assertClassBrand(_SessionsCore_brand, this, _tail).call(this, sessionId);
|
|
1109
|
+
let parent;
|
|
1110
|
+
if (parentId === void 0) parent = tail.leafId;
|
|
1111
|
+
else parent = parentId && this.exists(sessionId, parentId) ? parentId : null;
|
|
1112
|
+
const { message: staged, attachments } = extractAttachments(message);
|
|
1113
|
+
const slices = splitContent(JSON.stringify(staged));
|
|
1114
|
+
const seq = tail.nextSeq;
|
|
1115
|
+
this.io.transaction(() => {
|
|
1116
|
+
for (const attachment of attachments) _classPrivateFieldGet2(_attachments, this).put(attachment.payload, attachment.hash);
|
|
1117
|
+
this.io.sqlWrite(`INSERT INTO cf_agents_session_messages
|
|
1118
|
+
(session_id, id, seq, parent_id, role, content, content_chunks, token_estimate, created_at)
|
|
1119
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1120
|
+
sessionId,
|
|
1121
|
+
message.id,
|
|
1122
|
+
seq,
|
|
1123
|
+
parent,
|
|
1124
|
+
message.role,
|
|
1125
|
+
slices[0],
|
|
1126
|
+
slices.length - 1,
|
|
1127
|
+
tokenEstimate,
|
|
1128
|
+
Date.now()
|
|
1129
|
+
]);
|
|
1130
|
+
_assertClassBrand(_SessionsCore_brand, this, _writeContinuations).call(this, sessionId, message.id, slices);
|
|
1131
|
+
_classPrivateFieldGet2(_attachments, this).addRefs(sessionId, message.id, attachments.map((attachment) => attachment.hash));
|
|
1132
|
+
_assertClassBrand(_SessionsCore_brand, this, _indexFts).call(this, sessionId, staged, false);
|
|
1133
|
+
});
|
|
1134
|
+
_classPrivateFieldGet2(_tails, this).set(sessionId, {
|
|
1135
|
+
leafId: message.id,
|
|
1136
|
+
nextSeq: seq + 1
|
|
1137
|
+
});
|
|
1138
|
+
const memo = _classPrivateFieldGet2(_pathTokens, this).get(sessionId);
|
|
1139
|
+
if (memo) if (memo.leafId === parent && memo.depth <= MAX_PATH_DEPTH) {
|
|
1140
|
+
memo.leafId = message.id;
|
|
1141
|
+
memo.counted.add(message.id);
|
|
1142
|
+
memo.total += tokenEstimate;
|
|
1143
|
+
memo.depth += 1;
|
|
1144
|
+
} else _classPrivateFieldGet2(_pathTokens, this).delete(sessionId);
|
|
1145
|
+
this.io.emit("session:message:appended", {
|
|
1146
|
+
sessionId,
|
|
1147
|
+
messageId: message.id,
|
|
1148
|
+
tokenEstimate
|
|
1149
|
+
});
|
|
1150
|
+
return {
|
|
1151
|
+
inserted: true,
|
|
1152
|
+
message
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Durable update of an existing row. An identical row writes nothing: no
|
|
1157
|
+
* row, no continuation, no FTS, no event. The no-op guard compares the
|
|
1158
|
+
* FULL reassembled content, not just the slice the message row holds.
|
|
1159
|
+
*/
|
|
1160
|
+
update(sessionId, message, tokenEstimate) {
|
|
1161
|
+
const oldRows = this.io.sql("SELECT content, content_chunks, token_estimate FROM cf_agents_session_messages WHERE session_id = ? AND id = ?", [sessionId, message.id]);
|
|
1162
|
+
if (oldRows.length === 0) return "missing";
|
|
1163
|
+
const old = oldRows[0];
|
|
1164
|
+
const oldContent = old.content_chunks === 0 ? old.content : old.content + (_assertClassBrand(_SessionsCore_brand, this, _continuations).call(this, sessionId, [message.id]).get(message.id) ?? "");
|
|
1165
|
+
const { message: staged, attachments } = extractAttachments(message);
|
|
1166
|
+
const json = JSON.stringify(staged);
|
|
1167
|
+
if (oldContent === json) return "unchanged";
|
|
1168
|
+
const slices = splitContent(json);
|
|
1169
|
+
this.io.transaction(() => {
|
|
1170
|
+
for (const attachment of attachments) _classPrivateFieldGet2(_attachments, this).put(attachment.payload, attachment.hash);
|
|
1171
|
+
this.io.sqlWrite(`UPDATE cf_agents_session_messages
|
|
1172
|
+
SET role = ?, content = ?, content_chunks = ?, token_estimate = ?
|
|
1173
|
+
WHERE session_id = ? AND id = ?`, [
|
|
1174
|
+
message.role,
|
|
1175
|
+
slices[0],
|
|
1176
|
+
slices.length - 1,
|
|
1177
|
+
tokenEstimate,
|
|
1178
|
+
sessionId,
|
|
1179
|
+
message.id
|
|
1180
|
+
]);
|
|
1181
|
+
if (old.content_chunks > slices.length - 1) this.io.sqlWrite(`DELETE FROM cf_agents_session_message_chunks
|
|
1182
|
+
WHERE session_id = ? AND id = ? AND idx > ?`, [
|
|
1183
|
+
sessionId,
|
|
1184
|
+
message.id,
|
|
1185
|
+
slices.length - 1
|
|
1186
|
+
]);
|
|
1187
|
+
_assertClassBrand(_SessionsCore_brand, this, _writeContinuations).call(this, sessionId, message.id, slices);
|
|
1188
|
+
_classPrivateFieldGet2(_attachments, this).replaceRefs(sessionId, message.id, attachments.map((attachment) => attachment.hash));
|
|
1189
|
+
_assertClassBrand(_SessionsCore_brand, this, _indexFts).call(this, sessionId, staged, true);
|
|
1190
|
+
});
|
|
1191
|
+
const memo = _classPrivateFieldGet2(_pathTokens, this).get(sessionId);
|
|
1192
|
+
if (memo?.counted.has(message.id)) memo.total += tokenEstimate - (old.token_estimate ?? 0);
|
|
1193
|
+
this.io.emit("session:message:updated", {
|
|
1194
|
+
sessionId,
|
|
1195
|
+
messageId: message.id
|
|
1196
|
+
});
|
|
1197
|
+
return "updated";
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Delete rows, SPLICING children to their grandparent so a mid-chain
|
|
1201
|
+
* delete never decapitates older history. Only surviving boundary children
|
|
1202
|
+
* are rewired: a prefix delete writes one boundary child, not one child
|
|
1203
|
+
* per deleted message.
|
|
1204
|
+
*/
|
|
1205
|
+
deleteMessages(sessionId, messageIds) {
|
|
1206
|
+
const uniqueIds = [...new Set(messageIds)];
|
|
1207
|
+
if (uniqueIds.length === 0) return;
|
|
1208
|
+
const ids = JSON.stringify(uniqueIds);
|
|
1209
|
+
this.io.transaction(() => {
|
|
1210
|
+
this.io.sqlWrite(`WITH RECURSIVE
|
|
1211
|
+
deleted(id) AS (SELECT value FROM json_each(?)),
|
|
1212
|
+
rewire(child_id, ancestor_id, depth) AS (
|
|
1213
|
+
SELECT child.id, child.parent_id, 0
|
|
1214
|
+
FROM cf_agents_session_messages AS child
|
|
1215
|
+
JOIN deleted ON deleted.id = child.parent_id
|
|
1216
|
+
WHERE child.session_id = ?
|
|
1217
|
+
AND child.id NOT IN (SELECT id FROM deleted)
|
|
1218
|
+
UNION ALL
|
|
1219
|
+
SELECT rewire.child_id, parent.parent_id, rewire.depth + 1
|
|
1220
|
+
FROM rewire
|
|
1221
|
+
JOIN cf_agents_session_messages AS parent
|
|
1222
|
+
ON parent.id = rewire.ancestor_id
|
|
1223
|
+
JOIN deleted ON deleted.id = parent.id
|
|
1224
|
+
WHERE parent.session_id = ? AND rewire.depth < 10000
|
|
1225
|
+
),
|
|
1226
|
+
nearest(child_id, ancestor_id) AS (
|
|
1227
|
+
SELECT child_id, ancestor_id FROM rewire
|
|
1228
|
+
WHERE ancestor_id IS NULL
|
|
1229
|
+
OR ancestor_id NOT IN (SELECT id FROM deleted)
|
|
1230
|
+
)
|
|
1231
|
+
UPDATE cf_agents_session_messages
|
|
1232
|
+
SET parent_id = (
|
|
1233
|
+
SELECT nearest.ancestor_id FROM nearest
|
|
1234
|
+
WHERE nearest.child_id = cf_agents_session_messages.id
|
|
1235
|
+
)
|
|
1236
|
+
WHERE session_id = ?
|
|
1237
|
+
AND id IN (SELECT child_id FROM nearest)`, [
|
|
1238
|
+
ids,
|
|
1239
|
+
sessionId,
|
|
1240
|
+
sessionId,
|
|
1241
|
+
sessionId
|
|
1242
|
+
]);
|
|
1243
|
+
this.io.sqlWrite(`DELETE FROM cf_agents_session_messages
|
|
1244
|
+
WHERE session_id = ? AND id IN (SELECT value FROM json_each(?))`, [sessionId, ids]);
|
|
1245
|
+
this.io.sqlWrite(`DELETE FROM cf_agents_session_message_chunks
|
|
1246
|
+
WHERE session_id = ? AND id IN (SELECT value FROM json_each(?))`, [sessionId, ids]);
|
|
1247
|
+
_classPrivateFieldGet2(_attachments, this).releaseMessages(sessionId, uniqueIds);
|
|
1248
|
+
if (_classPrivateFieldGet2(_fts, this)) this.io.sqlWrite(`DELETE FROM cf_agents_session_fts
|
|
1249
|
+
WHERE session_id = ? AND id IN (SELECT value FROM json_each(?))`, [sessionId, ids]);
|
|
1250
|
+
});
|
|
1251
|
+
_classPrivateFieldGet2(_tails, this).delete(sessionId);
|
|
1252
|
+
_classPrivateFieldGet2(_pathTokens, this).delete(sessionId);
|
|
1253
|
+
this.io.emit("session:messages:deleted", {
|
|
1254
|
+
sessionId,
|
|
1255
|
+
count: uniqueIds.length
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
clearMessages(sessionId) {
|
|
1259
|
+
this.io.transaction(() => {
|
|
1260
|
+
this.io.sqlWrite("DELETE FROM cf_agents_session_messages WHERE session_id = ?", [sessionId]);
|
|
1261
|
+
this.io.sqlWrite("DELETE FROM cf_agents_session_message_chunks WHERE session_id = ?", [sessionId]);
|
|
1262
|
+
this.io.sqlWrite("DELETE FROM cf_agents_session_compactions WHERE session_id = ?", [sessionId]);
|
|
1263
|
+
_classPrivateFieldGet2(_attachments, this).releaseSession(sessionId);
|
|
1264
|
+
if (_classPrivateFieldGet2(_fts, this)) this.io.sqlWrite("DELETE FROM cf_agents_session_fts WHERE session_id = ?", [sessionId]);
|
|
1265
|
+
});
|
|
1266
|
+
_classPrivateFieldGet2(_tails, this).set(sessionId, {
|
|
1267
|
+
leafId: null,
|
|
1268
|
+
nextSeq: 1
|
|
1269
|
+
});
|
|
1270
|
+
_classPrivateFieldGet2(_pathTokens, this).delete(sessionId);
|
|
1271
|
+
this.io.emit("session:cleared", { sessionId });
|
|
1272
|
+
}
|
|
1273
|
+
addCompaction(sessionId, summary, fromMessageId, toMessageId) {
|
|
1274
|
+
const id = crypto.randomUUID();
|
|
1275
|
+
const now = Date.now();
|
|
1276
|
+
const seq = this.io.sql("SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM cf_agents_session_compactions WHERE session_id = ?", [sessionId])[0]?.seq ?? 1;
|
|
1277
|
+
this.io.sqlWrite(`INSERT INTO cf_agents_session_compactions
|
|
1278
|
+
(session_id, id, seq, summary, from_message_id, to_message_id, created_at)
|
|
1279
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`, [
|
|
1280
|
+
sessionId,
|
|
1281
|
+
id,
|
|
1282
|
+
seq,
|
|
1283
|
+
summary,
|
|
1284
|
+
fromMessageId,
|
|
1285
|
+
toMessageId,
|
|
1286
|
+
now
|
|
1287
|
+
]);
|
|
1288
|
+
_classPrivateFieldGet2(_pathTokens, this).delete(sessionId);
|
|
1289
|
+
this.io.emit("session:compacted", {
|
|
1290
|
+
sessionId,
|
|
1291
|
+
compactionId: id
|
|
1292
|
+
});
|
|
1293
|
+
return {
|
|
1294
|
+
id,
|
|
1295
|
+
summary,
|
|
1296
|
+
fromMessageId,
|
|
1297
|
+
toMessageId,
|
|
1298
|
+
createdAt: new Date(now).toISOString()
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
getCompactions(sessionId) {
|
|
1302
|
+
return this.io.sql(`SELECT id, summary, from_message_id, to_message_id, created_at
|
|
1303
|
+
FROM cf_agents_session_compactions
|
|
1304
|
+
WHERE session_id = ? ORDER BY seq ASC`, [sessionId]).map((row) => ({
|
|
1305
|
+
id: row.id,
|
|
1306
|
+
summary: row.summary,
|
|
1307
|
+
fromMessageId: row.from_message_id,
|
|
1308
|
+
toMessageId: row.to_message_id,
|
|
1309
|
+
createdAt: new Date(row.created_at).toISOString()
|
|
1310
|
+
}));
|
|
1311
|
+
}
|
|
1312
|
+
search(sessionId, query, limit) {
|
|
1313
|
+
_assertClassBrand(_SessionsCore_brand, this, _ensureFts).call(this);
|
|
1314
|
+
const sanitized = `"${query.replace(/"/g, "\"\"")}"`;
|
|
1315
|
+
return this.io.sql(`SELECT f.id, f.role, f.content FROM cf_agents_session_fts f
|
|
1316
|
+
INNER JOIN cf_agents_session_messages m
|
|
1317
|
+
ON m.session_id = f.session_id AND m.id = f.id
|
|
1318
|
+
WHERE cf_agents_session_fts MATCH ? AND f.session_id = ?
|
|
1319
|
+
ORDER BY rank LIMIT ?`, [
|
|
1320
|
+
sanitized,
|
|
1321
|
+
sessionId,
|
|
1322
|
+
limit
|
|
1323
|
+
]).map((row) => ({
|
|
1324
|
+
id: row.id,
|
|
1325
|
+
role: row.role,
|
|
1326
|
+
content: row.content
|
|
1327
|
+
}));
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Import one historical message verbatim (migrations, cross-DO moves):
|
|
1331
|
+
* explicit parent and timestamp, stamped estimate, no change-feed events.
|
|
1332
|
+
*/
|
|
1333
|
+
/** Returns `false` when the id already exists and nothing was written. */
|
|
1334
|
+
importMessage(sessionId, message, options) {
|
|
1335
|
+
const { message: staged, attachments } = extractAttachments(message);
|
|
1336
|
+
const slices = splitContent(JSON.stringify(staged));
|
|
1337
|
+
const tail = _assertClassBrand(_SessionsCore_brand, this, _tail).call(this, sessionId);
|
|
1338
|
+
let inserted = 0;
|
|
1339
|
+
this.io.transaction(() => {
|
|
1340
|
+
for (const attachment of attachments) _classPrivateFieldGet2(_attachments, this).put(attachment.payload, attachment.hash);
|
|
1341
|
+
inserted = this.io.sqlWrite(`INSERT OR IGNORE INTO cf_agents_session_messages
|
|
1342
|
+
(session_id, id, seq, parent_id, role, content, content_chunks, token_estimate, created_at)
|
|
1343
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
1344
|
+
sessionId,
|
|
1345
|
+
message.id,
|
|
1346
|
+
tail.nextSeq,
|
|
1347
|
+
options.parentId,
|
|
1348
|
+
message.role,
|
|
1349
|
+
slices[0],
|
|
1350
|
+
slices.length - 1,
|
|
1351
|
+
this.estimateRowTokens(message),
|
|
1352
|
+
options.createdAt
|
|
1353
|
+
]);
|
|
1354
|
+
if (inserted === 0) return;
|
|
1355
|
+
_assertClassBrand(_SessionsCore_brand, this, _writeContinuations).call(this, sessionId, message.id, slices);
|
|
1356
|
+
_classPrivateFieldGet2(_attachments, this).addRefs(sessionId, message.id, attachments.map((attachment) => attachment.hash));
|
|
1357
|
+
_assertClassBrand(_SessionsCore_brand, this, _indexFts).call(this, sessionId, staged, false);
|
|
1358
|
+
});
|
|
1359
|
+
if (inserted === 0) return false;
|
|
1360
|
+
_classPrivateFieldGet2(_tails, this).set(sessionId, {
|
|
1361
|
+
leafId: message.id,
|
|
1362
|
+
nextSeq: tail.nextSeq + 1
|
|
1363
|
+
});
|
|
1364
|
+
_classPrivateFieldGet2(_pathTokens, this).delete(sessionId);
|
|
1365
|
+
return true;
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
function _tableExists(name) {
|
|
1369
|
+
return this.io.sql("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name = ?", [name]).length > 0;
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Build the FTS index on demand. Maintaining it costs an extra billed row
|
|
1373
|
+
* on every append, so it exists only on objects that have actually
|
|
1374
|
+
* searched; the first search pays a one-time SQL backfill instead.
|
|
1375
|
+
*/
|
|
1376
|
+
function _ensureFts() {
|
|
1377
|
+
if (_classPrivateFieldGet2(_fts, this)) return;
|
|
1378
|
+
this.io.sqlWrite(`CREATE VIRTUAL TABLE cf_agents_session_fts
|
|
1379
|
+
USING fts5(id UNINDEXED, session_id UNINDEXED, role UNINDEXED, content, tokenize='porter unicode61')`, []);
|
|
1380
|
+
_classPrivateFieldSet2(_fts, this, true);
|
|
1381
|
+
_assertClassBrand(_SessionsCore_brand, this, _backfillMissingFtsRows).call(this);
|
|
1382
|
+
}
|
|
1383
|
+
/** Index rows that predate indexing, in SQL, without loading JSON into JS. */
|
|
1384
|
+
function _backfillMissingFtsRows() {
|
|
1385
|
+
this.io.sqlWrite(`INSERT INTO cf_agents_session_fts (id, session_id, role, content)
|
|
1386
|
+
SELECT m.id, m.session_id, m.role,
|
|
1387
|
+
group_concat(json_extract(part.value, '$.text'), ' ')
|
|
1388
|
+
FROM cf_agents_session_messages AS m
|
|
1389
|
+
JOIN json_each(
|
|
1390
|
+
CASE WHEN json_valid(m.content) THEN m.content ELSE '{"parts":[]}' END,
|
|
1391
|
+
'$.parts'
|
|
1392
|
+
) AS part
|
|
1393
|
+
WHERE json_extract(part.value, '$.type') = 'text'
|
|
1394
|
+
AND COALESCE(json_extract(part.value, '$.text'), '') <> ''
|
|
1395
|
+
AND NOT EXISTS (
|
|
1396
|
+
SELECT 1 FROM cf_agents_session_fts AS existing
|
|
1397
|
+
WHERE existing.id = m.id AND existing.session_id = m.session_id
|
|
1398
|
+
)
|
|
1399
|
+
GROUP BY m.id, m.session_id, m.role`, []);
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Continuation slices for the given ids, keyed by id and already joined in
|
|
1403
|
+
* `idx` order. One query, and only for ids the caller knows have them.
|
|
1404
|
+
*/
|
|
1405
|
+
function _continuations(sessionId, ids) {
|
|
1406
|
+
const joined = /* @__PURE__ */ new Map();
|
|
1407
|
+
if (ids.length === 0) return joined;
|
|
1408
|
+
const rows = this.io.sql(`SELECT id, content FROM cf_agents_session_message_chunks
|
|
1409
|
+
WHERE session_id = ? AND id IN (SELECT value FROM json_each(?))
|
|
1410
|
+
ORDER BY id ASC, idx ASC`, [sessionId, JSON.stringify([...ids])]);
|
|
1411
|
+
for (const row of rows) joined.set(row.id, (joined.get(row.id) ?? "") + row.content);
|
|
1412
|
+
return joined;
|
|
1413
|
+
}
|
|
1414
|
+
/** Reassemble one stored row, reading continuations only when it has any. */
|
|
1415
|
+
function _content(sessionId, id) {
|
|
1416
|
+
const rows = this.io.sql("SELECT content, content_chunks FROM cf_agents_session_messages WHERE session_id = ? AND id = ?", [sessionId, id]);
|
|
1417
|
+
if (rows.length === 0) return null;
|
|
1418
|
+
const row = rows[0];
|
|
1419
|
+
if (row.content_chunks === 0) return row.content;
|
|
1420
|
+
return row.content + (_assertClassBrand(_SessionsCore_brand, this, _continuations).call(this, sessionId, [id]).get(id) ?? "");
|
|
1421
|
+
}
|
|
1422
|
+
function _hasParent(sessionId, id) {
|
|
1423
|
+
const [row] = this.io.sql("SELECT parent_id FROM cf_agents_session_messages WHERE session_id = ? AND id = ?", [sessionId, id]);
|
|
1424
|
+
return row?.parent_id != null;
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* The session's newest row. Children insert after their parents, so the
|
|
1428
|
+
* max-seq row is provably childless: it is the active leaf, and the next
|
|
1429
|
+
* append numbers from it. Read once per object lifetime, since the table
|
|
1430
|
+
* is keyed by id and finding the max means scanning the session's rows.
|
|
1431
|
+
*/
|
|
1432
|
+
function _tail(sessionId) {
|
|
1433
|
+
const cached = _classPrivateFieldGet2(_tails, this).get(sessionId);
|
|
1434
|
+
if (cached) return cached;
|
|
1435
|
+
const [row] = this.io.sql("SELECT id, seq FROM cf_agents_session_messages WHERE session_id = ? ORDER BY seq DESC LIMIT 1", [sessionId]);
|
|
1436
|
+
const tail = row ? {
|
|
1437
|
+
leafId: row.id,
|
|
1438
|
+
nextSeq: row.seq + 1
|
|
1439
|
+
} : {
|
|
1440
|
+
leafId: null,
|
|
1441
|
+
nextSeq: 1
|
|
1442
|
+
};
|
|
1443
|
+
_classPrivateFieldGet2(_tails, this).set(sessionId, tail);
|
|
1444
|
+
return tail;
|
|
1445
|
+
}
|
|
1446
|
+
function _resolveLeafId(sessionId, leafId) {
|
|
1447
|
+
if (leafId) return this.exists(sessionId, leafId) ? leafId : null;
|
|
1448
|
+
return this.latestLeafId(sessionId);
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* The active branch path as ids alone, root → leaf. The cheapest walk the
|
|
1452
|
+
* tree allows — one row per step, following `parent_id` by primary key —
|
|
1453
|
+
* for readers that will hydrate only a few rows and do not need the sizes
|
|
1454
|
+
* `pathRowStats` charges per row.
|
|
1455
|
+
*/
|
|
1456
|
+
function _pathIds(sessionId, leafId) {
|
|
1457
|
+
const leaf = _assertClassBrand(_SessionsCore_brand, this, _resolveLeafId).call(this, sessionId, leafId);
|
|
1458
|
+
if (!leaf) return [];
|
|
1459
|
+
return this.io.sql(`WITH RECURSIVE path(id, parent_id, depth) AS (
|
|
1460
|
+
SELECT id, parent_id, 0 FROM cf_agents_session_messages
|
|
1461
|
+
WHERE session_id = ? AND id = ?
|
|
1462
|
+
UNION ALL
|
|
1463
|
+
SELECT m.id, m.parent_id, p.depth + 1 FROM cf_agents_session_messages m
|
|
1464
|
+
JOIN path p ON m.id = p.parent_id
|
|
1465
|
+
WHERE m.session_id = ? AND p.depth < ${MAX_PATH_DEPTH}
|
|
1466
|
+
)
|
|
1467
|
+
SELECT id FROM path ORDER BY depth DESC`, [
|
|
1468
|
+
sessionId,
|
|
1469
|
+
leaf,
|
|
1470
|
+
sessionId
|
|
1471
|
+
]).map((row) => row.id);
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Split path rows into bounded hydration queries: at most `maxRows` rows,
|
|
1475
|
+
* and — when the rows carry sizes — at most `HISTORY_CONTENT_CHUNK_BYTES`
|
|
1476
|
+
* of stored content, with a single oversized row always standing alone.
|
|
1477
|
+
*/
|
|
1478
|
+
function* _boundedStatsChunks(rows, maxRows = HISTORY_CONTENT_CHUNK_SIZE) {
|
|
1479
|
+
let start = 0;
|
|
1480
|
+
while (start < rows.length) {
|
|
1481
|
+
let end = start;
|
|
1482
|
+
let bytes = 0;
|
|
1483
|
+
while (end < rows.length && end - start < maxRows) {
|
|
1484
|
+
const nextBytes = rows[end].bytes;
|
|
1485
|
+
if (end > start && bytes + nextBytes > HISTORY_CONTENT_CHUNK_BYTES) break;
|
|
1486
|
+
bytes += nextBytes;
|
|
1487
|
+
end++;
|
|
1488
|
+
}
|
|
1489
|
+
yield rows.slice(start, end);
|
|
1490
|
+
start = end;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Fetch and parse one already-bounded content window. The common window
|
|
1495
|
+
* has no continuation rows at all, so the second query is issued only for
|
|
1496
|
+
* the ids that actually carry them and never runs otherwise.
|
|
1497
|
+
*/
|
|
1498
|
+
function _contentByStats(sessionId, rows) {
|
|
1499
|
+
const result = /* @__PURE__ */ new Map();
|
|
1500
|
+
if (rows.length === 0) return result;
|
|
1501
|
+
const fetched = this.io.sql(`SELECT id, content, content_chunks FROM cf_agents_session_messages
|
|
1502
|
+
WHERE session_id = ? AND id IN (SELECT value FROM json_each(?))`, [sessionId, JSON.stringify(rows.map((row) => row.id))]);
|
|
1503
|
+
const continued = _assertClassBrand(_SessionsCore_brand, this, _continuations).call(this, sessionId, fetched.filter((row) => row.content_chunks > 0).map((row) => row.id));
|
|
1504
|
+
for (const row of fetched) {
|
|
1505
|
+
const parsed = _assertClassBrand(_SessionsCore_brand, this, _parse).call(this, row.content_chunks === 0 ? row.content : row.content + (continued.get(row.id) ?? ""));
|
|
1506
|
+
if (parsed) result.set(row.id, _assertClassBrand(_SessionsCore_brand, this, _inline).call(this, parsed));
|
|
1507
|
+
}
|
|
1508
|
+
return result;
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Stream a known path window without retaining earlier content chunks.
|
|
1512
|
+
*
|
|
1513
|
+
* The path is first cut into segments — a compaction overlay, or a run of
|
|
1514
|
+
* raw rows between overlays — in root → leaf order. `newestFirst` walks
|
|
1515
|
+
* those segments, and the rows inside each, from the leaf instead, in
|
|
1516
|
+
* small fixed windows, so the first content fetched is the newest and a
|
|
1517
|
+
* consumer that stops early never touches older rows.
|
|
1518
|
+
*/
|
|
1519
|
+
async function* _streamStats(sessionId, stats, signal, newestFirst = false, plannedSpans) {
|
|
1520
|
+
const spans = plannedSpans ?? planOverlays(stats.map((row) => row.id), this.getCompactions(sessionId));
|
|
1521
|
+
const spanByStart = new Map(spans.map((span) => [span.startIndex, span]));
|
|
1522
|
+
const segments = [];
|
|
1523
|
+
let index = 0;
|
|
1524
|
+
while (index < stats.length) {
|
|
1525
|
+
const span = spanByStart.get(index);
|
|
1526
|
+
if (span) {
|
|
1527
|
+
segments.push({ overlay: span.compaction });
|
|
1528
|
+
index = span.endIndex + 1;
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
let runEnd = index + 1;
|
|
1532
|
+
while (runEnd < stats.length && !spanByStart.has(runEnd)) runEnd++;
|
|
1533
|
+
segments.push({ rows: stats.slice(index, runEnd) });
|
|
1534
|
+
index = runEnd;
|
|
1535
|
+
}
|
|
1536
|
+
if (newestFirst) segments.reverse();
|
|
1537
|
+
for (const segment of segments) {
|
|
1538
|
+
if (signal?.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("History read aborted");
|
|
1539
|
+
if ("overlay" in segment) {
|
|
1540
|
+
yield overlayMessage(segment.overlay);
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
const rows = newestFirst ? [...segment.rows].reverse() : segment.rows;
|
|
1544
|
+
const windowRows = newestFirst ? NEWEST_FIRST_WINDOW_ROWS : HISTORY_CONTENT_CHUNK_SIZE;
|
|
1545
|
+
for (const chunk of _assertClassBrand(_SessionsCore_brand, this, _boundedStatsChunks).call(this, rows, windowRows)) {
|
|
1546
|
+
const content = _assertClassBrand(_SessionsCore_brand, this, _contentByStats).call(this, sessionId, chunk);
|
|
1547
|
+
for (const row of chunk) {
|
|
1548
|
+
const parsed = content.get(row.id);
|
|
1549
|
+
if (parsed) yield parsed;
|
|
1550
|
+
}
|
|
1551
|
+
if (signal?.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("History read aborted");
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* The path leaf → root as a chain of point reads: each row names its
|
|
1557
|
+
* parent, so the next read is known before the current message is
|
|
1558
|
+
* yielded, and a consumer that stops early has paid for exactly the rows it
|
|
1559
|
+
* saw.
|
|
1560
|
+
*
|
|
1561
|
+
* Compaction overlays are honored without planning them up front. An
|
|
1562
|
+
* overlay that applies to this branch ends at a row the walk reaches
|
|
1563
|
+
* before any row it covers, so the raw walk is exact until it lands on
|
|
1564
|
+
* some compaction's `toMessageId`. Only then is the remaining prefix read
|
|
1565
|
+
* as ids and planned root → leaf — the order overlay selection is defined
|
|
1566
|
+
* in — and streamed leaf-first with the overlays collapsed. A lookup that
|
|
1567
|
+
* stops in the messages after the last compaction never pays for that.
|
|
1568
|
+
*/
|
|
1569
|
+
async function* _walkFromLeaf(sessionId, leafId, signal) {
|
|
1570
|
+
const compactions = this.getCompactions(sessionId);
|
|
1571
|
+
const spanEnds = new Set(compactions.map((c) => c.toMessageId));
|
|
1572
|
+
let next = _assertClassBrand(_SessionsCore_brand, this, _resolveLeafId).call(this, sessionId, leafId);
|
|
1573
|
+
let depth = 0;
|
|
1574
|
+
while (next !== null && depth <= MAX_PATH_DEPTH) {
|
|
1575
|
+
if (signal?.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("History read aborted");
|
|
1576
|
+
if (spanEnds.has(next)) {
|
|
1577
|
+
yield* _assertClassBrand(_SessionsCore_brand, this, _streamOverlaidPrefix).call(this, sessionId, leafId, next, compactions, signal);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
const [row] = this.io.sql(`SELECT parent_id, content, content_chunks FROM cf_agents_session_messages
|
|
1581
|
+
WHERE session_id = ? AND id = ?`, [sessionId, next]);
|
|
1582
|
+
if (!row) return;
|
|
1583
|
+
const json = row.content_chunks === 0 ? row.content : row.content + (_assertClassBrand(_SessionsCore_brand, this, _continuations).call(this, sessionId, [next]).get(next) ?? "");
|
|
1584
|
+
const parsed = _assertClassBrand(_SessionsCore_brand, this, _parse).call(this, json);
|
|
1585
|
+
if (parsed) yield _assertClassBrand(_SessionsCore_brand, this, _inline).call(this, parsed);
|
|
1586
|
+
next = row.parent_id;
|
|
1587
|
+
depth++;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* The path from `fromId` down to the root, leaf-first with overlays
|
|
1592
|
+
* collapsed. Spans are planned over the WHOLE path (selection is
|
|
1593
|
+
* root → leaf and a chosen span suppresses overlaps inside it), then only
|
|
1594
|
+
* those ending at or before `fromId` apply: `fromId` is some compaction's
|
|
1595
|
+
* end, and a chosen span reaching past it would have ended at a row the
|
|
1596
|
+
* raw walk visited first.
|
|
1597
|
+
*/
|
|
1598
|
+
async function* _streamOverlaidPrefix(sessionId, leafId, fromId, compactions, signal) {
|
|
1599
|
+
const ids = _assertClassBrand(_SessionsCore_brand, this, _pathIds).call(this, sessionId, leafId);
|
|
1600
|
+
const end = ids.indexOf(fromId);
|
|
1601
|
+
if (end === -1) return;
|
|
1602
|
+
const spans = planOverlays(ids, compactions).filter((span) => span.endIndex <= end);
|
|
1603
|
+
yield* _assertClassBrand(_SessionsCore_brand, this, _streamStats).call(this, sessionId, ids.slice(0, end + 1).map((id) => ({
|
|
1604
|
+
id,
|
|
1605
|
+
bytes: 0
|
|
1606
|
+
})), signal, true, spans);
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Write one message's slices: the row itself plus its continuation rows.
|
|
1610
|
+
* Callers run this inside their own transaction, so a message and its
|
|
1611
|
+
* continuations always commit together.
|
|
1612
|
+
*/
|
|
1613
|
+
function _writeContinuations(sessionId, id, slices) {
|
|
1614
|
+
for (let idx = 1; idx < slices.length; idx++) this.io.sqlWrite(`INSERT OR REPLACE INTO cf_agents_session_message_chunks
|
|
1615
|
+
(session_id, id, idx, content) VALUES (?, ?, ?, ?)`, [
|
|
1616
|
+
sessionId,
|
|
1617
|
+
id,
|
|
1618
|
+
idx,
|
|
1619
|
+
slices[idx]
|
|
1620
|
+
]);
|
|
1621
|
+
}
|
|
1622
|
+
/** Maintain the FTS row when the index exists; an unchanged text writes nothing. */
|
|
1623
|
+
function _indexFts(sessionId, message, replace) {
|
|
1624
|
+
if (!_classPrivateFieldGet2(_fts, this)) return;
|
|
1625
|
+
const text = message.parts.filter((part) => part.type === "text").map((part) => part.text ?? "").join(" ");
|
|
1626
|
+
if (replace) {
|
|
1627
|
+
const existing = this.io.sql("SELECT content FROM cf_agents_session_fts WHERE id = ? AND session_id = ?", [message.id, sessionId]);
|
|
1628
|
+
if (existing.length > 0) {
|
|
1629
|
+
if (existing[0].content === text) return;
|
|
1630
|
+
this.io.sqlWrite("DELETE FROM cf_agents_session_fts WHERE id = ? AND session_id = ?", [message.id, sessionId]);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
if (text) this.io.sqlWrite("INSERT INTO cf_agents_session_fts (id, session_id, role, content) VALUES (?, ?, ?, ?)", [
|
|
1634
|
+
message.id,
|
|
1635
|
+
sessionId,
|
|
1636
|
+
message.role,
|
|
1637
|
+
text
|
|
1638
|
+
]);
|
|
1639
|
+
}
|
|
1640
|
+
/** Put attachment payloads back inline, so a read returns what was written. */
|
|
1641
|
+
function _inline(message) {
|
|
1642
|
+
return resolveAttachments(message, (hash) => _classPrivateFieldGet2(_attachments, this).get(hash));
|
|
1643
|
+
}
|
|
1644
|
+
function _parse(json) {
|
|
1645
|
+
try {
|
|
1646
|
+
const message = JSON.parse(json);
|
|
1647
|
+
if (typeof message?.id === "string" && typeof message?.role === "string" && Array.isArray(message?.parts)) return message;
|
|
1648
|
+
} catch {}
|
|
1649
|
+
return null;
|
|
1650
|
+
}
|
|
1651
|
+
//#endregion
|
|
1652
|
+
//#region src/sessions/handle.ts
|
|
1653
|
+
var _core$1 = /* @__PURE__ */ new WeakMap();
|
|
1654
|
+
var _ready = /* @__PURE__ */ new WeakMap();
|
|
1655
|
+
var _compactionFn = /* @__PURE__ */ new WeakMap();
|
|
1656
|
+
var _tokenThreshold = /* @__PURE__ */ new WeakMap();
|
|
1657
|
+
var _Session_brand = /* @__PURE__ */ new WeakSet();
|
|
1658
|
+
var Session = class {
|
|
1659
|
+
/** @internal Constructed by the Sessions capability only. */
|
|
1660
|
+
constructor(sessionId, core, ready) {
|
|
1661
|
+
_classPrivateMethodInitSpec(this, _Session_brand);
|
|
1662
|
+
_classPrivateFieldInitSpec(this, _core$1, void 0);
|
|
1663
|
+
_classPrivateFieldInitSpec(this, _ready, void 0);
|
|
1664
|
+
_classPrivateFieldInitSpec(this, _compactionFn, null);
|
|
1665
|
+
_classPrivateFieldInitSpec(this, _tokenThreshold, void 0);
|
|
1666
|
+
this.sessionId = sessionId;
|
|
1667
|
+
_classPrivateFieldSet2(_core$1, this, core);
|
|
1668
|
+
_classPrivateFieldSet2(_ready, this, ready);
|
|
1669
|
+
}
|
|
1670
|
+
/** Register the function `compact()` calls to summarize a branch. */
|
|
1671
|
+
onCompaction(fn) {
|
|
1672
|
+
_classPrivateFieldSet2(_compactionFn, this, fn);
|
|
1673
|
+
return this;
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1676
|
+
* Auto-compact after an append once the estimated token count crosses the
|
|
1677
|
+
* threshold. Requires `onCompaction()`. The estimate is derived from the
|
|
1678
|
+
* stamped per-row estimates, never from the transcript.
|
|
1679
|
+
*/
|
|
1680
|
+
compactAfter(tokenThreshold) {
|
|
1681
|
+
_classPrivateFieldSet2(_tokenThreshold, this, tokenThreshold);
|
|
1682
|
+
return this;
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Stream the active branch path root → leaf (leaf → root with
|
|
1686
|
+
* `newestFirst`) with compaction overlays applied. Peak memory is one
|
|
1687
|
+
* bounded content window, never the whole transcript, and a consumer that
|
|
1688
|
+
* breaks out early leaves the rows it never reached unread.
|
|
1689
|
+
*/
|
|
1690
|
+
async *history(options = {}) {
|
|
1691
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1692
|
+
yield* _classPrivateFieldGet2(_core$1, this).streamHistory(this.sessionId, options);
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Stream history in bounded non-empty batches. Both message count and
|
|
1696
|
+
* serialized bytes bound each batch; a single large message is yielded by
|
|
1697
|
+
* itself.
|
|
1698
|
+
*/
|
|
1699
|
+
async *historyBatches(options = {}) {
|
|
1700
|
+
const batchSize = Math.max(1, Math.floor(options.batchSize ?? 50));
|
|
1701
|
+
const maxBatchBytes = Math.max(1, Math.floor(options.maxBatchBytes ?? 4 * 1024 * 1024));
|
|
1702
|
+
let batch = [];
|
|
1703
|
+
let batchBytes = 0;
|
|
1704
|
+
for await (const message of this.history(options)) {
|
|
1705
|
+
const bytes = byteLength(JSON.stringify(message));
|
|
1706
|
+
if (batch.length > 0 && (batch.length >= batchSize || batchBytes + bytes > maxBatchBytes)) {
|
|
1707
|
+
yield batch;
|
|
1708
|
+
batch = [];
|
|
1709
|
+
batchBytes = 0;
|
|
1710
|
+
}
|
|
1711
|
+
batch.push(message);
|
|
1712
|
+
batchBytes += bytes;
|
|
1713
|
+
if (batch.length >= batchSize || batchBytes >= maxBatchBytes) {
|
|
1714
|
+
yield batch;
|
|
1715
|
+
batch = [];
|
|
1716
|
+
batchBytes = 0;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
if (batch.length > 0) yield batch;
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* Materialize the whole selected path. Prefer `history()` or
|
|
1723
|
+
* `getRecentHistory()` inside a Durable Object: this holds every message
|
|
1724
|
+
* of the branch in memory at once.
|
|
1725
|
+
*/
|
|
1726
|
+
async getHistory(options = {}) {
|
|
1727
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1728
|
+
return _classPrivateFieldGet2(_core$1, this).getHistory(this.sessionId, options);
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Byte-budgeted read of the most recent messages on the active branch path
|
|
1732
|
+
* (always at least the leaf). The budget counts each row, its continuation
|
|
1733
|
+
* rows, and the payloads it points at, so it bounds hydrated memory (#1710).
|
|
1734
|
+
*/
|
|
1735
|
+
async getRecentHistory(maxContentBytes, options = {}) {
|
|
1736
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1737
|
+
return _classPrivateFieldGet2(_core$1, this).getRecentHistory(this.sessionId, maxContentBytes, options.leafId);
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Per-row stored sizes (row plus continuation rows and attachments) and
|
|
1741
|
+
* stamped token estimates for the active branch path (root → leaf)
|
|
1742
|
+
* WITHOUT loading message content.
|
|
1743
|
+
*/
|
|
1744
|
+
async getHistoryRowStats(leafId) {
|
|
1745
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1746
|
+
return _classPrivateFieldGet2(_core$1, this).pathRowStats(this.sessionId, leafId);
|
|
1747
|
+
}
|
|
1748
|
+
async getMessage(id) {
|
|
1749
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1750
|
+
return _classPrivateFieldGet2(_core$1, this).getMessage(this.sessionId, id);
|
|
1751
|
+
}
|
|
1752
|
+
async getLatestLeaf() {
|
|
1753
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1754
|
+
return _classPrivateFieldGet2(_core$1, this).getLatestLeaf(this.sessionId);
|
|
1755
|
+
}
|
|
1756
|
+
async getBranches(messageId) {
|
|
1757
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1758
|
+
return _classPrivateFieldGet2(_core$1, this).getBranches(this.sessionId, messageId);
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Full-text search over this session's text parts. The index is built on
|
|
1762
|
+
* the first call and maintained from then on.
|
|
1763
|
+
*/
|
|
1764
|
+
async search(query, options) {
|
|
1765
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1766
|
+
return _classPrivateFieldGet2(_core$1, this).search(this.sessionId, query, options?.limit ?? 20);
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* Append one message. Idempotent on id: a repeated append returns the row
|
|
1770
|
+
* already stored and dispatches an `append` event with `inserted: false`.
|
|
1771
|
+
*/
|
|
1772
|
+
async appendMessage(message, options = {}) {
|
|
1773
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1774
|
+
const prepared = _assertClassBrand(_Session_brand, this, _prepare).call(this, message, options.source);
|
|
1775
|
+
const result = _classPrivateFieldGet2(_core$1, this).append(this.sessionId, prepared.message, options.parentId, prepared.tokenEstimate);
|
|
1776
|
+
await _classPrivateFieldGet2(_core$1, this).notify({
|
|
1777
|
+
type: "append",
|
|
1778
|
+
sessionId: this.sessionId,
|
|
1779
|
+
message: result.message,
|
|
1780
|
+
parentId: options.parentId,
|
|
1781
|
+
inserted: result.inserted
|
|
1782
|
+
});
|
|
1783
|
+
if (result.inserted) await _assertClassBrand(_Session_brand, this, _maybeAutoCompact).call(this);
|
|
1784
|
+
return result;
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Update one stored row. Returns the stored form, or `null` when the id is
|
|
1788
|
+
* not in this session. An unchanged message writes nothing and dispatches
|
|
1789
|
+
* no event.
|
|
1790
|
+
*/
|
|
1791
|
+
async updateMessage(message, options = {}) {
|
|
1792
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1793
|
+
const prepared = _assertClassBrand(_Session_brand, this, _prepare).call(this, message, options.source);
|
|
1794
|
+
const outcome = _classPrivateFieldGet2(_core$1, this).update(this.sessionId, prepared.message, prepared.tokenEstimate);
|
|
1795
|
+
if (outcome === "missing") return null;
|
|
1796
|
+
if (outcome === "updated") await _classPrivateFieldGet2(_core$1, this).notify({
|
|
1797
|
+
type: "update",
|
|
1798
|
+
sessionId: this.sessionId,
|
|
1799
|
+
message: prepared.message
|
|
1800
|
+
});
|
|
1801
|
+
return prepared.message;
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* @internal Synchronous write aperture for same-isolate first-party
|
|
1805
|
+
* machinery that must commit a message inside a caller-owned SQLite
|
|
1806
|
+
* transaction — the stream cutover, where the finished message and the
|
|
1807
|
+
* discard of its stream rows land together. Bypasses readiness (the
|
|
1808
|
+
* caller owns startup ordering) and defers everything that follows a
|
|
1809
|
+
* write — the change-feed dispatch and auto-compaction: call the returned
|
|
1810
|
+
* `after()` once the transaction commits, or subscribers (the host's
|
|
1811
|
+
* message mirror) never hear about the write. If the transaction rolls
|
|
1812
|
+
* back after an `upsert` ran inside it, call `abandon()`: the write is
|
|
1813
|
+
* gone but the in-memory tail and token-total caches already moved.
|
|
1814
|
+
* Will break without notice; never use from application code.
|
|
1815
|
+
*/
|
|
1816
|
+
__DO_NOT_USE_WILL_BREAK__sync() {
|
|
1817
|
+
return {
|
|
1818
|
+
abandon: () => _classPrivateFieldGet2(_core$1, this).forgetCaches(this.sessionId),
|
|
1819
|
+
upsert: (message, options = {}) => {
|
|
1820
|
+
const prepared = _assertClassBrand(_Session_brand, this, _prepare).call(this, message, options.source);
|
|
1821
|
+
if (!_classPrivateFieldGet2(_core$1, this).exists(this.sessionId, message.id)) {
|
|
1822
|
+
const result = _classPrivateFieldGet2(_core$1, this).append(this.sessionId, prepared.message, options.parentId, prepared.tokenEstimate);
|
|
1823
|
+
return {
|
|
1824
|
+
result,
|
|
1825
|
+
after: async () => {
|
|
1826
|
+
await _classPrivateFieldGet2(_core$1, this).notify({
|
|
1827
|
+
type: "append",
|
|
1828
|
+
sessionId: this.sessionId,
|
|
1829
|
+
message: result.message,
|
|
1830
|
+
parentId: options.parentId,
|
|
1831
|
+
inserted: result.inserted
|
|
1832
|
+
});
|
|
1833
|
+
if (result.inserted) await _assertClassBrand(_Session_brand, this, _maybeAutoCompact).call(this);
|
|
1834
|
+
}
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
const outcome = _classPrivateFieldGet2(_core$1, this).update(this.sessionId, prepared.message, prepared.tokenEstimate);
|
|
1838
|
+
return {
|
|
1839
|
+
result: {
|
|
1840
|
+
inserted: false,
|
|
1841
|
+
message: prepared.message
|
|
1842
|
+
},
|
|
1843
|
+
after: () => outcome === "updated" ? _classPrivateFieldGet2(_core$1, this).notify({
|
|
1844
|
+
type: "update",
|
|
1845
|
+
sessionId: this.sessionId,
|
|
1846
|
+
message: prepared.message
|
|
1847
|
+
}) : Promise.resolve()
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
async upsertMessage(message, options = {}) {
|
|
1853
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1854
|
+
if (!_classPrivateFieldGet2(_core$1, this).exists(this.sessionId, message.id)) return this.appendMessage(message, options);
|
|
1855
|
+
return {
|
|
1856
|
+
inserted: false,
|
|
1857
|
+
message: await this.updateMessage(message, { source: options.source }) ?? message
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
/**
|
|
1861
|
+
* Import one historical message verbatim (migrations, cross-object moves):
|
|
1862
|
+
* explicit parent and timestamp. A row actually written dispatches an
|
|
1863
|
+
* `import` change event so a host cache can mark itself stale; it is not
|
|
1864
|
+
* an `append`, so a cache does not patch itself per imported row, and an
|
|
1865
|
+
* id that already exists writes nothing and dispatches nothing.
|
|
1866
|
+
*/
|
|
1867
|
+
async importMessage(message, options) {
|
|
1868
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1869
|
+
if (!_classPrivateFieldGet2(_core$1, this).importMessage(this.sessionId, message, options)) return;
|
|
1870
|
+
await _classPrivateFieldGet2(_core$1, this).notify({
|
|
1871
|
+
type: "import",
|
|
1872
|
+
sessionId: this.sessionId,
|
|
1873
|
+
message,
|
|
1874
|
+
parentId: options.parentId
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
async deleteMessages(messageIds) {
|
|
1878
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1879
|
+
_classPrivateFieldGet2(_core$1, this).deleteMessages(this.sessionId, messageIds);
|
|
1880
|
+
await _classPrivateFieldGet2(_core$1, this).notify({
|
|
1881
|
+
type: "delete",
|
|
1882
|
+
sessionId: this.sessionId,
|
|
1883
|
+
messageIds
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
async clearMessages() {
|
|
1887
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1888
|
+
_classPrivateFieldGet2(_core$1, this).clearMessages(this.sessionId);
|
|
1889
|
+
await _classPrivateFieldGet2(_core$1, this).notify({
|
|
1890
|
+
type: "clear",
|
|
1891
|
+
sessionId: this.sessionId
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
1895
|
+
* Store an overlay directly. Dispatches a `compaction` change event: the
|
|
1896
|
+
* rows are untouched, but what a path read returns has changed.
|
|
1897
|
+
*/
|
|
1898
|
+
async addCompaction(summary, fromMessageId, toMessageId) {
|
|
1899
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1900
|
+
const compaction = _classPrivateFieldGet2(_core$1, this).addCompaction(this.sessionId, summary, fromMessageId, toMessageId);
|
|
1901
|
+
await _classPrivateFieldGet2(_core$1, this).notify({
|
|
1902
|
+
type: "compaction",
|
|
1903
|
+
sessionId: this.sessionId,
|
|
1904
|
+
compaction
|
|
1905
|
+
});
|
|
1906
|
+
return compaction;
|
|
1907
|
+
}
|
|
1908
|
+
async getCompactions() {
|
|
1909
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1910
|
+
return _classPrivateFieldGet2(_core$1, this).getCompactions(this.sessionId);
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* Run the registered compaction function and store the result as an
|
|
1914
|
+
* overlay. When `leafId` is given, compact that root-to-leaf branch instead
|
|
1915
|
+
* of the active branch. Requires `onCompaction()`. A compaction function
|
|
1916
|
+
* that throws is reported through the `session:error` capability event and
|
|
1917
|
+
* the call returns `null`.
|
|
1918
|
+
*/
|
|
1919
|
+
async compact(leafId) {
|
|
1920
|
+
await _classPrivateFieldGet2(_ready, this).call(this);
|
|
1921
|
+
const fn = _classPrivateFieldGet2(_compactionFn, this);
|
|
1922
|
+
if (!fn) throw new Error("No compaction function registered. Call onCompaction() first.");
|
|
1923
|
+
const history = await _classPrivateFieldGet2(_core$1, this).getHistory(this.sessionId, { leafId });
|
|
1924
|
+
let result;
|
|
1925
|
+
try {
|
|
1926
|
+
result = await fn(history);
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
_classPrivateFieldGet2(_core$1, this).io.emit("session:error", {
|
|
1929
|
+
sessionId: this.sessionId,
|
|
1930
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1931
|
+
});
|
|
1932
|
+
return null;
|
|
1933
|
+
}
|
|
1934
|
+
if (!result) return null;
|
|
1935
|
+
const historyIds = new Set(history.map((message) => message.id));
|
|
1936
|
+
if (!historyIds.has(result.toMessageId)) return null;
|
|
1937
|
+
const existing = _classPrivateFieldGet2(_core$1, this).getCompactions(this.sessionId).filter((compaction) => historyIds.has(`compaction_${compaction.id}`) || historyIds.has(compaction.fromMessageId) && historyIds.has(compaction.toMessageId));
|
|
1938
|
+
const fromId = existing.length > 0 ? existing[0].fromMessageId : result.fromMessageId;
|
|
1939
|
+
_classPrivateFieldGet2(_core$1, this).addCompaction(this.sessionId, result.summary, fromId, result.toMessageId);
|
|
1940
|
+
await _classPrivateFieldGet2(_core$1, this).notify({
|
|
1941
|
+
type: "compact",
|
|
1942
|
+
sessionId: this.sessionId
|
|
1943
|
+
});
|
|
1944
|
+
return {
|
|
1945
|
+
...result,
|
|
1946
|
+
fromMessageId: fromId
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
};
|
|
1950
|
+
/**
|
|
1951
|
+
* The shared write pipeline: sanitize provider metadata and strip reserved
|
|
1952
|
+
* metadata on client-source input. Content is never truncated and never
|
|
1953
|
+
* too large: a message that exceeds the row budget is split across
|
|
1954
|
+
* continuation rows by the durable write.
|
|
1955
|
+
*/
|
|
1956
|
+
function _prepare(message, source) {
|
|
1957
|
+
let prepared = sanitizeMessage(message);
|
|
1958
|
+
if (source === "client") prepared = _classPrivateFieldGet2(_core$1, this).stripReservedMetadata(prepared);
|
|
1959
|
+
return {
|
|
1960
|
+
message: prepared,
|
|
1961
|
+
tokenEstimate: _classPrivateFieldGet2(_core$1, this).estimateRowTokens(prepared)
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
/** Gate on the derived estimate, then compact. Failures are non-fatal. */
|
|
1965
|
+
async function _maybeAutoCompact() {
|
|
1966
|
+
const threshold = _classPrivateFieldGet2(_tokenThreshold, this);
|
|
1967
|
+
if (threshold == null || !_classPrivateFieldGet2(_compactionFn, this)) return;
|
|
1968
|
+
if (_classPrivateFieldGet2(_core$1, this).tokenEstimate(this.sessionId) <= threshold) return;
|
|
1969
|
+
try {
|
|
1970
|
+
await this.compact();
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1973
|
+
console.warn(`[Sessions] auto-compaction failed: ${detail}`);
|
|
1974
|
+
_classPrivateFieldGet2(_core$1, this).io.emit("session:error", {
|
|
1975
|
+
sessionId: this.sessionId,
|
|
1976
|
+
error: detail
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
//#endregion
|
|
1981
|
+
//#region src/sessions/sessions.ts
|
|
1982
|
+
/**
|
|
1983
|
+
* Durable conversation history for Lifecycle Objects. `Sessions` owns the
|
|
1984
|
+
* `cf_agents_session_*` tables: tree-structured messages (branch
|
|
1985
|
+
* regeneration, latest-leaf paths), compaction overlays, and full-text
|
|
1986
|
+
* search whose index is built on first use. A message larger than one
|
|
1987
|
+
* SQLite row is split across continuation rows and reassembled on read, so
|
|
1988
|
+
* nothing is ever too large to store.
|
|
1989
|
+
*
|
|
1990
|
+
* Sessions consumes only the standard capability services — storage and
|
|
1991
|
+
* events. It needs no alarm, so it also works on facets (facets have
|
|
1992
|
+
* isolated SQLite but no independent alarm slot).
|
|
1993
|
+
*
|
|
1994
|
+
* @experimental The API surface may change before stabilizing.
|
|
1995
|
+
*/
|
|
1996
|
+
const SESSIONS_SCHEMA_VERSION_KEY = "cf_agents:sessions_schema_version";
|
|
1997
|
+
const CURRENT_SESSIONS_SCHEMA_VERSION = 1;
|
|
1998
|
+
var _core = /* @__PURE__ */ new WeakMap();
|
|
1999
|
+
var _handles = /* @__PURE__ */ new WeakMap();
|
|
2000
|
+
/**
|
|
2001
|
+
* Durable conversation history for a Lifecycle Object.
|
|
2002
|
+
*
|
|
2003
|
+
* `session()` returns a per-session handle for reads (streamed, byte
|
|
2004
|
+
* budgeted), writes (sanitized, media offloaded), branch navigation, and
|
|
2005
|
+
* compaction overlays. `subscribe()` is the change feed a cache-owning host
|
|
2006
|
+
* mirrors.
|
|
2007
|
+
*
|
|
2008
|
+
* @experimental The API surface may change before stabilizing.
|
|
2009
|
+
*/
|
|
2010
|
+
var Sessions = class extends LifecycleCapability {
|
|
2011
|
+
constructor(options = {}) {
|
|
2012
|
+
super("sessions");
|
|
2013
|
+
_classPrivateFieldInitSpec(this, _core, void 0);
|
|
2014
|
+
_classPrivateFieldInitSpec(this, _handles, /* @__PURE__ */ new Map());
|
|
2015
|
+
const exec = (query, params) => {
|
|
2016
|
+
try {
|
|
2017
|
+
return this.lifecycle.storage.sql.exec(query, ...params);
|
|
2018
|
+
} catch (cause) {
|
|
2019
|
+
throw new SqlError(query, cause);
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
2022
|
+
_classPrivateFieldSet2(_core, this, new SessionsCore(options, {
|
|
2023
|
+
sql: (query, params) => [...exec(query, params)],
|
|
2024
|
+
sqlWrite: (query, params) => exec(query, params).rowsWritten,
|
|
2025
|
+
transaction: (fn) => this.lifecycle.storage.transactionSync(fn),
|
|
2026
|
+
emit: (type, payload) => this.lifecycle.events.emit(type, payload)
|
|
2027
|
+
}));
|
|
2028
|
+
}
|
|
2029
|
+
/** Migrate session storage during Lifecycle startup. */
|
|
2030
|
+
async onStart() {
|
|
2031
|
+
const storage = this.lifecycle.storage;
|
|
2032
|
+
const version = await storage.get(SESSIONS_SCHEMA_VERSION_KEY) ?? 0;
|
|
2033
|
+
_classPrivateFieldGet2(_core, this).ensureTables();
|
|
2034
|
+
if (version < CURRENT_SESSIONS_SCHEMA_VERSION) {
|
|
2035
|
+
if (_classPrivateFieldGet2(_core, this).migrateLegacy()) await storage.put(SESSIONS_SCHEMA_VERSION_KEY, CURRENT_SESSIONS_SCHEMA_VERSION);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
/**
|
|
2039
|
+
* The handle for one session. The default (empty) id is the primary path:
|
|
2040
|
+
* in the one-object-per-conversation model a Durable Object holds exactly
|
|
2041
|
+
* one session. Handles are cached, so per-session configuration (the
|
|
2042
|
+
* compaction trigger) survives repeated calls.
|
|
2043
|
+
*/
|
|
2044
|
+
session(sessionId = "") {
|
|
2045
|
+
const existing = _classPrivateFieldGet2(_handles, this).get(sessionId);
|
|
2046
|
+
if (existing) return existing;
|
|
2047
|
+
const handle = new Session(sessionId, _classPrivateFieldGet2(_core, this), () => this.lifecycle.ready());
|
|
2048
|
+
_classPrivateFieldGet2(_handles, this).set(sessionId, handle);
|
|
2049
|
+
return handle;
|
|
2050
|
+
}
|
|
2051
|
+
/**
|
|
2052
|
+
* Subscribe to the change feed: ordered dispatch after every durable
|
|
2053
|
+
* write, with the stored message. The host cache mirror lives here;
|
|
2054
|
+
* telemetry additionally flows through capability events.
|
|
2055
|
+
*/
|
|
2056
|
+
subscribe(listener) {
|
|
2057
|
+
return _classPrivateFieldGet2(_core, this).subscribe(listener);
|
|
2058
|
+
}
|
|
2059
|
+
};
|
|
2060
|
+
//#endregion
|
|
2061
|
+
export { COMPACTION_PREFIX, Session, Sessions, createCompactFunction, isCompactionMessage };
|
|
2062
|
+
|
|
2063
|
+
//# sourceMappingURL=index.js.map
|