@ixo/oracle-runtime-workers 0.37.2 → 0.39.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.
Files changed (111) hide show
  1. package/package.json +4 -4
  2. package/src/attachments/channel-links.ts +74 -7
  3. package/src/attachments/channel.ts +15 -4
  4. package/src/attachments/content-blocks.ts +19 -16
  5. package/src/attachments/download.ts +173 -50
  6. package/src/attachments/index.ts +6 -5
  7. package/src/attachments/pipeline.ts +117 -49
  8. package/src/attachments/resize.ts +142 -0
  9. package/src/attachments/retention.ts +26 -70
  10. package/src/attachments/room-media.ts +643 -0
  11. package/src/attachments/route.ts +47 -4
  12. package/src/attachments/sandbox-archive.ts +15 -4
  13. package/src/attachments/sanitize.ts +24 -13
  14. package/src/attachments/view.ts +82 -38
  15. package/src/channels/replies.ts +8 -4
  16. package/src/core/capability-expiry.ts +62 -0
  17. package/src/core/capability-router.ts +126 -95
  18. package/src/core/context-budget.ts +116 -4
  19. package/src/core/domain-context/document.ts +74 -2
  20. package/src/core/domain-context/index.ts +1 -0
  21. package/src/core/domain-context/resolver.ts +9 -6
  22. package/src/core/domain-context/turn-context.ts +62 -13
  23. package/src/core/domain-context/types.ts +17 -0
  24. package/src/core/env.ts +21 -2
  25. package/src/core/index.ts +4 -0
  26. package/src/core/llm.ts +29 -0
  27. package/src/core/main-agent-types.ts +53 -4
  28. package/src/core/main-agent.ts +228 -125
  29. package/src/core/manifest.ts +109 -24
  30. package/src/core/mcp-result.ts +56 -0
  31. package/src/core/meta-tools.ts +258 -110
  32. package/src/core/middlewares/attachment-recall.ts +502 -0
  33. package/src/core/middlewares/capability-gate.ts +77 -28
  34. package/src/core/middlewares/context-guard.ts +154 -21
  35. package/src/core/middlewares/index.ts +5 -0
  36. package/src/core/middlewares/result-cap.ts +1 -1
  37. package/src/core/middlewares/summarization.ts +9 -0
  38. package/src/core/middlewares/tool-marks.ts +11 -2
  39. package/src/core/middlewares/turn-boundary.ts +22 -12
  40. package/src/core/operating-guides.ts +126 -0
  41. package/src/core/plugins/skills/skills-tools.ts +6 -31
  42. package/src/core/plugins/skills/skills.plugin.ts +4 -3
  43. package/src/core/plugins/weather/weather.plugin.ts +1 -0
  44. package/src/core/prompt-composer.ts +188 -111
  45. package/src/core/read-result-tool.ts +48 -14
  46. package/src/core/registries.ts +13 -5
  47. package/src/core/state.ts +4 -3
  48. package/src/core/sub-agent-fallback.ts +1 -0
  49. package/src/core/subagent-as-tool.ts +4 -1
  50. package/src/core/tool-access.ts +3 -32
  51. package/src/core/wrap-plugin-tool.ts +2 -1
  52. package/src/do/contracts.ts +64 -24
  53. package/src/do/room-mirror.ts +144 -20
  54. package/src/do/transcript.ts +41 -12
  55. package/src/do/turn-attachments.ts +45 -6
  56. package/src/do/turn-metadata.ts +16 -4
  57. package/src/do/user-oracle-do.ts +639 -152
  58. package/src/index.ts +1 -0
  59. package/src/llm/provider-error.ts +5 -2
  60. package/src/llm/stream-liveness.ts +3 -2
  61. package/src/matrix/gateway-do.ts +164 -102
  62. package/src/matrix/ingest.ts +12 -3
  63. package/src/matrix/media-deadline.ts +5 -47
  64. package/src/memory/session-history-indexer.ts +60 -14
  65. package/src/plugin-api/oracle-plugin.ts +4 -5
  66. package/src/plugin-api/types.ts +32 -3
  67. package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
  68. package/src/plugins/agui/agui.plugin.ts +1 -0
  69. package/src/plugins/attachments/attachments.plugin.ts +3 -3
  70. package/src/plugins/attachments/view-attachment-tool.ts +6 -4
  71. package/src/plugins/compact-schema.ts +80 -0
  72. package/src/plugins/composio/composio.plugin.ts +1 -0
  73. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
  74. package/src/plugins/editor/editor.plugin.ts +4 -0
  75. package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
  76. package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
  77. package/src/plugins/flows/flows.plugin.ts +37 -6
  78. package/src/plugins/flows/prompts.ts +1 -1
  79. package/src/plugins/flows/tools/authoring.ts +5 -7
  80. package/src/plugins/flows/tools/discovery.ts +1 -4
  81. package/src/plugins/flows/tools/forms.ts +2 -8
  82. package/src/plugins/flows/tools/linkage.ts +1 -4
  83. package/src/plugins/flows/tools/settings.ts +1 -4
  84. package/src/plugins/flows/translator.ts +16 -5
  85. package/src/plugins/flows/types.ts +27 -48
  86. package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
  87. package/src/plugins/index.ts +2 -2
  88. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
  89. package/src/plugins/ixo-chain/kinds/core.ts +2 -2
  90. package/src/plugins/ixo-chain/kinds/types.ts +34 -5
  91. package/src/plugins/ixo-chain/queries.ts +10 -1
  92. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
  93. package/src/plugins/matrix-group-chats/index.ts +1 -0
  94. package/src/plugins/memory/memory-tools.ts +4 -4
  95. package/src/plugins/memory/memory.plugin.ts +27 -1
  96. package/src/plugins/memory/oracle-document-tools.ts +24 -49
  97. package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
  98. package/src/plugins/portal/portal.plugin.ts +1 -0
  99. package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
  100. package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
  101. package/src/plugins/tasks/manifest.ts +1 -0
  102. package/src/plugins/tasks/tasks-tools.ts +14 -19
  103. package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
  104. package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
  105. package/src/plugins/vfs/vfs-tools.ts +6 -6
  106. package/src/plugins/vfs/vfs.plugin.ts +4 -5
  107. package/src/plugins/yoma/yoma.plugin.ts +1 -0
  108. package/src/sqlite/inline-payloads.ts +121 -0
  109. package/src/sqlite/serialization.ts +12 -0
  110. package/src/sqlite/sqlite-saver.ts +258 -42
  111. package/src/tasks/schedule-input.ts +5 -22
@@ -2,26 +2,59 @@ import type { ModelInputCapabilities } from '../core/llm';
2
2
  import type { AttachmentKind } from './classify';
3
3
 
4
4
  /**
5
- * How an attachment reaches the model (verbatim port of the Node rule):
5
+ * How an attachment reaches the model:
6
6
  * - `parse-local` — read to text ourselves (free). Plain-text files only.
7
7
  * - `send-native` — attach the raw bytes to the user message; the selected
8
8
  * model reads it directly. Only when the model accepts that
9
- * modality.
9
+ * modality and, for a document, the file is not declared
10
+ * too large (the caller checks the downloaded length).
10
11
  * - `model-extract` — the helper (vision) model turns it into text first. Used
11
- * for anything the selected model can't accept natively.
12
+ * for anything the selected model can't accept natively,
13
+ * and for documents over {@link NATIVE_DOCUMENT_MAX_BYTES}.
12
14
  */
13
15
  export type AttachmentStrategy =
14
16
  | 'parse-local'
15
17
  | 'send-native'
16
18
  | 'model-extract';
17
19
 
20
+ /**
21
+ * The largest document sent to the model as a native file block (20 KiB).
22
+ * The block is the file's base64, which the runtime counts as tokens at
23
+ * chars/4: 20,480 bytes are ~27k base64 chars ≈ 7k tokens by that estimate.
24
+ * Providers charge a native PDF per page, so this keeps a native document
25
+ * to a few pages. A larger document
26
+ * always goes through the extract lane: its text from the helper model (cut
27
+ * at 50k chars), a sandbox copy and a 500-char inline excerpt with the
28
+ * sandbox path.
29
+ */
30
+ export const NATIVE_DOCUMENT_MAX_BYTES = 20_480;
31
+
18
32
  /**
19
33
  * Decide, for one attachment, how to get it to the selected model — the single
20
34
  * cost-aware, capability-aware routing rule. Pure and total.
35
+ *
36
+ * Documents are routed by SIZE (`opts.size`, the declared byte length), the
37
+ * same for every source (Portal, Matrix, chat channels): a native file block
38
+ * is replayed to the model on every later turn and held in the user's
39
+ * checkpoint, so its cost grows with the file, not with where it came from.
40
+ * A document is a native candidate when the model reads files AND its
41
+ * declared size is unknown or at most {@link NATIVE_DOCUMENT_MAX_BYTES}; only
42
+ * a KNOWN size over the limit goes straight to `model-extract`, which skips
43
+ * the native download. For a candidate the downloaded byte length decides
44
+ * (the pipeline and `view.ts`): at most the limit is sent natively, more goes
45
+ * to the extract lane with the bytes already loaded. So an unsized small
46
+ * file still goes natively, and a declared size that is wrong cannot slip a
47
+ * large one through.
48
+ *
49
+ * Images go native whenever the model reads images, whatever their size:
50
+ * they are downscaled before the model sees them (`resize.ts`) when the
51
+ * oracle binds Cloudflare Images. Audio, video, text and unknown files are
52
+ * routed by capability alone.
21
53
  */
22
54
  export function routeAttachment(
23
55
  kind: AttachmentKind,
24
56
  caps: ModelInputCapabilities,
57
+ opts: { size?: number } = {},
25
58
  ): AttachmentStrategy {
26
59
  switch (kind) {
27
60
  case 'text':
@@ -29,7 +62,9 @@ export function routeAttachment(
29
62
  case 'image':
30
63
  return caps.image ? 'send-native' : 'model-extract';
31
64
  case 'document':
32
- return caps.file ? 'send-native' : 'model-extract';
65
+ return caps.file && mayFitNativeDocument(opts.size)
66
+ ? 'send-native'
67
+ : 'model-extract';
33
68
  case 'audio':
34
69
  return caps.audio ? 'send-native' : 'model-extract';
35
70
  case 'video':
@@ -39,3 +74,11 @@ export function routeAttachment(
39
74
  return 'model-extract';
40
75
  }
41
76
  }
77
+
78
+ /**
79
+ * Whether a document declared at `size` bytes may go as a native file block:
80
+ * unknown counts as maybe, and the downloaded length decides.
81
+ */
82
+ function mayFitNativeDocument(size: number | undefined): boolean {
83
+ return size === undefined || size <= NATIVE_DOCUMENT_MAX_BYTES;
84
+ }
@@ -5,6 +5,11 @@
5
5
  * to `<SANDBOX_MCP_URL without /mcp>/artifacts/upload`, authenticated with
6
6
  * the same `Authorization: Bearer <ucan>` + `X-Auth-Type: ucan` the sandbox
7
7
  * MCP client sends.
8
+ *
9
+ * The endpoint parses the whole multipart body (`Request.formData()`) and
10
+ * refuses a file over 25 MB with `413`, so the pipeline never reads a file
11
+ * over `MAX_FILE_SIZE` only to archive it, and a streamed body would buy
12
+ * nothing: every archived file is one the turn already holds in memory.
8
13
  */
9
14
  import { sanitizeSandboxPath } from './sanitize';
10
15
 
@@ -55,11 +60,17 @@ export async function uploadToSandbox(
55
60
  );
56
61
  }
57
62
  const raw: unknown = await response.json();
58
- const result = raw as { path?: string; url?: string; previewUrl?: string };
63
+ const field = (name: string): string | undefined => {
64
+ const value: unknown =
65
+ typeof raw === 'object' && raw !== null ? Reflect.get(raw, name) : null;
66
+ return typeof value === 'string' && value ? value : undefined;
67
+ };
68
+ const url = field('url');
69
+ const previewUrl = field('previewUrl');
59
70
  return {
60
- path: typeof result.path === 'string' ? result.path : safePath,
61
- ...(result.url ? { url: result.url } : {}),
62
- ...(result.previewUrl ? { previewUrl: result.previewUrl } : {}),
71
+ path: field('path') ?? safePath,
72
+ ...(url ? { url } : {}),
73
+ ...(previewUrl ? { previewUrl } : {}),
63
74
  };
64
75
  }
65
76
 
@@ -17,18 +17,28 @@ function cutUnits(value: string, max: number): string {
17
17
  }
18
18
 
19
19
  /**
20
- * Strip control characters and bracket sequences from a filename to prevent
21
- * prompt injection when interpolated into LLM context. The archive writes
22
- * under this exact name.
20
+ * `value` without what can disguise text: Unicode controls (Cc), format
21
+ * characters (Cf: bidi overrides such as U+202E, zero-width joiners), line
22
+ * and paragraph separators (Zl, Zp), and `[` / `]` (they would forge a
23
+ * `[source: …]` note).
24
+ */
25
+ function visibleText(value: string): string {
26
+ return value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}[\]]/gu, '');
27
+ }
28
+
29
+ /**
30
+ * Clean a client-supplied filename before it reaches LLM context, a log
31
+ * line, a room event or an archive path (the archive writes under this exact
32
+ * name): `visibleText`, then only the last path segment is kept (`/` and
33
+ * `\` both separate) and any remaining `..` becomes `_`, cut to 255 UTF-16
34
+ * units without splitting a surrogate pair.
23
35
  */
24
36
  export function sanitizeAttachmentFilename(filename: string): string {
25
- return cutUnits(
26
- filename
27
- // eslint-disable-next-line no-control-regex
28
- .replace(/[\x00-\x1f\x7f]/g, '')
29
- .replace(/[[\]]/g, ''),
30
- 255,
37
+ const visible = visibleText(filename);
38
+ const basename = visible.slice(
39
+ Math.max(visible.lastIndexOf('/'), visible.lastIndexOf('\\')) + 1,
31
40
  );
41
+ return cutUnits(basename.replace(/\.\.+/g, '_'), 255);
32
42
  }
33
43
 
34
44
  /** Longest filename / mimetype / event id / mxc URI written into a hidden note. */
@@ -39,12 +49,13 @@ export const NOTE_URI_MAX = 512;
39
49
 
40
50
  /**
41
51
  * Clean a client-supplied value (filename, mimetype, media event id, mxc
42
- * URI) for the hidden notes the model reads: the filename cleaner plus
43
- * quotes removed (the notes wrap names and refs in `"…"`), cut to `max`
44
- * UTF-16 units without splitting a surrogate pair.
52
+ * URI) for the hidden notes the model reads: `visibleText` (no path
53
+ * handling: a mimetype or a URI keeps its slashes) plus quotes removed
54
+ * (the notes wrap names and refs in `"…"`), cut to `max` UTF-16 units
55
+ * without splitting a surrogate pair.
45
56
  */
46
57
  export function sanitizeNoteField(value: string, max: number): string {
47
- return cutUnits(sanitizeAttachmentFilename(value).replace(/"/g, ''), max);
58
+ return cutUnits(visibleText(value).replace(/"/g, ''), max);
48
59
  }
49
60
 
50
61
  /**
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * Re-fetch one attachment of a session on demand — the `view_attachment`
3
- * lane behind payload retention (`retention.ts`). The same routing as the
4
- * turn pipeline applies: when the current model reads the modality the bytes
5
- * come back as a native content block, otherwise the helper (`vision` role)
6
- * model turns them into text; plain text is decoded locally. Text results
7
- * are cached per session (`deps.cache`); native views are not (they are the
8
- * bytes themselves).
3
+ * lane: attachment bytes are never stored with the conversation (the
4
+ * checkpointer keeps a placeholder, `src/sqlite/inline-payloads.ts`), so a
5
+ * file of an earlier turn is read again from its source. The same routing as
6
+ * the turn pipeline applies (`route.ts`: capability, and size for a
7
+ * document): when the current model reads the modality the bytes come back
8
+ * as a native content block (an image downscaled when `deps.images` is
9
+ * bound, see `resize.ts`), otherwise the helper (`vision` role) model turns
10
+ * them into text; plain text is decoded locally. A document whose downloaded
11
+ * length is over `NATIVE_DOCUMENT_MAX_BYTES`, or an image the resize step
12
+ * could not make fit (`dropped`), comes back as text from the bytes already
13
+ * loaded. Text results are cached per session (`deps.cache`); native views
14
+ * are not (they are the bytes themselves). Log lines name a file by its
15
+ * cleaned name (`sanitizeAttachmentFilename`).
9
16
  */
10
17
  import type { ChannelMediaSource } from './channel';
11
18
  import { classifyAttachment } from './classify';
@@ -20,10 +27,12 @@ import {
20
27
  import { categorizeFile, isPlainTextType, verifyMagicBytes } from './magic';
21
28
  import { extractPdfText } from './pdf-text';
22
29
  import { attachmentMetas, attachmentRef } from './retention';
23
- import { routeAttachment } from './route';
30
+ import { resizeForModel } from './resize';
31
+ import { NATIVE_DOCUMENT_MAX_BYTES, routeAttachment } from './route';
24
32
  import {
25
33
  NOTE_FILENAME_MAX,
26
34
  NOTE_MIMETYPE_MAX,
35
+ sanitizeAttachmentFilename,
27
36
  sanitizeNoteField,
28
37
  } from './sanitize';
29
38
  import {
@@ -47,6 +56,8 @@ export interface ViewAttachmentDeps {
47
56
  /** Where `channel:` references are read (the owner object's links). */
48
57
  channel?: ChannelMediaSource;
49
58
  extraction: ExtractionProvider | null;
59
+ /** Cloudflare Images: downscales native images for the model; absent = sent as they are. */
60
+ images?: ImagesBinding;
50
61
  /** Capabilities of the model that will read the result. */
51
62
  caps: ModelInputCapabilities;
52
63
  roomId?: string;
@@ -129,7 +140,10 @@ export async function viewAttachment(
129
140
  ): Promise<AttachmentView> {
130
141
  const attachment = toInput(meta);
131
142
  const kind = classifyAttachment(attachment);
132
- const strategy = routeAttachment(kind, deps.caps);
143
+ const strategy = routeAttachment(kind, deps.caps, { size: meta.size });
144
+ const name = sanitizeAttachmentFilename(attachment.filename);
145
+ // Downloaded for a native view but unfit for one: read as text instead.
146
+ let preloaded: { bytes: Uint8Array; mimetype: string } | undefined;
133
147
  if (strategy === 'send-native') {
134
148
  const { bytes, mimetype, sniffed } = await loadAttachmentBytes(
135
149
  attachment,
@@ -138,7 +152,7 @@ export async function viewAttachment(
138
152
  downloadOptions(deps),
139
153
  );
140
154
  deps.logger.log(
141
- `[attachments] view "${attachment.filename}" (${mimetype}) kind=${kind} → ${strategy} (${bytes.length} bytes)`,
155
+ `[attachments] view "${name}" (${mimetype}) kind=${kind} size=${meta.size ?? 'unknown'} → ${strategy} (${bytes.length} bytes)`,
142
156
  );
143
157
  const loaded = classifyAttachment({
144
158
  mimetype,
@@ -149,15 +163,37 @@ export async function viewAttachment(
149
163
  throw new Error(
150
164
  `File content mismatch: claimed ${attachment.mimetype} but detected ${mimetype}`,
151
165
  );
152
- return {
153
- kind: 'native',
154
- native: {
155
- kind: loaded === 'image' ? 'image' : 'file',
156
- mimeType: mimetype,
157
- base64: bytesToBase64(bytes),
158
- filename: attachment.filename,
159
- },
166
+ let sent: { bytes: Uint8Array; mimetype: string } | null = {
167
+ bytes,
168
+ mimetype,
160
169
  };
170
+ if (loaded === 'document' && bytes.length > NATIVE_DOCUMENT_MAX_BYTES) {
171
+ deps.logger.log(
172
+ `[attachments] view "${name}" is ${bytes.length} bytes, over the ${NATIVE_DOCUMENT_MAX_BYTES}-byte native document limit → model-extract`,
173
+ );
174
+ sent = null;
175
+ } else if (loaded === 'image') {
176
+ const resized = await resizeForModel(bytes, mimetype, deps.images, {
177
+ logger: deps.logger,
178
+ });
179
+ if (resized.dropped) {
180
+ deps.logger.log(
181
+ `[attachments] view "${name}" cannot go to the model as an image (${resized.dropped}) → model-extract`,
182
+ );
183
+ sent = null;
184
+ } else sent = resized;
185
+ }
186
+ if (sent)
187
+ return {
188
+ kind: 'native',
189
+ native: {
190
+ kind: loaded === 'image' ? 'image' : 'file',
191
+ mimeType: sent.mimetype,
192
+ base64: bytesToBase64(sent.bytes),
193
+ filename: name,
194
+ },
195
+ };
196
+ preloaded = { bytes, mimetype };
161
197
  }
162
198
 
163
199
  const category = categorizeFile(attachment.mimetype);
@@ -178,22 +214,28 @@ export async function viewAttachment(
178
214
  const cached = await deps.cache?.get(cacheKey.ref, cacheKey.model);
179
215
  if (cached !== undefined) {
180
216
  deps.logger.log(
181
- `[attachments] view "${attachment.filename}" → cached text (${cacheKey.model})`,
217
+ `[attachments] view "${name}" → cached text (${cacheKey.model})`,
182
218
  );
183
219
  return { kind: 'text', text: cached };
184
220
  }
185
221
  }
186
- const { bytes, mimetype } = await loadAttachmentBytes(
187
- attachment,
188
- deps.roomId,
189
- deps.source,
190
- downloadOptions(deps),
191
- );
192
- deps.logger.log(
193
- `[attachments] view "${attachment.filename}" (${mimetype}) kind=${kind} → ${strategy} (${bytes.length} bytes)`,
194
- );
195
- verifyMagicBytes(bytes, category, attachment, (m) =>
196
- deps.logger.warn(`[attachments] ${m}`),
222
+ const { bytes, mimetype } =
223
+ preloaded ??
224
+ (await loadAttachmentBytes(
225
+ attachment,
226
+ deps.roomId,
227
+ deps.source,
228
+ downloadOptions(deps),
229
+ ));
230
+ if (!preloaded)
231
+ deps.logger.log(
232
+ `[attachments] view "${name}" (${mimetype}) kind=${kind} size=${meta.size ?? 'unknown'} → ${strategy} (${bytes.length} bytes)`,
233
+ );
234
+ verifyMagicBytes(
235
+ bytes,
236
+ category,
237
+ { mimetype: attachment.mimetype, filename: name },
238
+ (m) => deps.logger.warn(`[attachments] ${m}`),
197
239
  );
198
240
  const { text } = await extractText(
199
241
  bytes,
@@ -237,6 +279,7 @@ export async function extractAttachmentText(
237
279
  opts: { maxChars: number },
238
280
  ): Promise<AttachmentTextExtraction> {
239
281
  const attachment = toInput(meta);
282
+ const name = sanitizeAttachmentFilename(attachment.filename);
240
283
  const category = categorizeFile(attachment.mimetype);
241
284
  if (category === 'unsupported') {
242
285
  throw new Error(
@@ -276,7 +319,7 @@ export async function extractAttachmentText(
276
319
  const hit = deps.ingestCache?.get(ref, key);
277
320
  if (!hit) continue;
278
321
  deps.logger.log(
279
- `[attachments] ingest "${attachment.filename}" → cached text (${key}, memory)`,
322
+ `[attachments] ingest "${name}" → cached text (${key}, memory)`,
280
323
  );
281
324
  const capped = capText(hit.text, opts.maxChars);
282
325
  return result(
@@ -294,9 +337,7 @@ export async function extractAttachmentText(
294
337
  for (const [key, method] of lookups) {
295
338
  const cached = await cache.get(ref, key);
296
339
  if (cached === undefined) continue;
297
- deps.logger.log(
298
- `[attachments] ingest "${attachment.filename}" → cached text (${key})`,
299
- );
340
+ deps.logger.log(`[attachments] ingest "${name}" → cached text (${key})`);
300
341
  const capped = capText(cached, opts.maxChars);
301
342
  return result(
302
343
  capped.text,
@@ -325,11 +366,14 @@ export async function extractAttachmentText(
325
366
  deps.source,
326
367
  downloadOptions(deps),
327
368
  );
328
- verifyMagicBytes(bytes, category, attachment, (m) =>
329
- deps.logger.warn(`[attachments] ${m}`),
369
+ verifyMagicBytes(
370
+ bytes,
371
+ category,
372
+ { mimetype: attachment.mimetype, filename: name },
373
+ (m) => deps.logger.warn(`[attachments] ${m}`),
330
374
  );
331
375
  deps.logger.log(
332
- `[attachments] ingest "${attachment.filename}" (${mimetype}) (${bytes.length} bytes)`,
376
+ `[attachments] ingest "${name}" (${mimetype}) (${bytes.length} bytes)`,
333
377
  );
334
378
  const store = async (
335
379
  key: string,
@@ -378,13 +422,13 @@ export async function extractAttachmentText(
378
422
  );
379
423
  }
380
424
  deps.logger.log(
381
- `[attachments] ingest "${attachment.filename}": no text layer (${pdf.pages} page(s)), using the helper model`,
425
+ `[attachments] ingest "${name}": no text layer (${pdf.pages} page(s)), using the helper model`,
382
426
  );
383
427
  } catch (error) {
384
428
  // A cancelled turn stops here; it must not start a model call.
385
429
  if (deps.signal?.aborted) throw error;
386
430
  deps.logger.warn(
387
- `[attachments] ingest "${attachment.filename}": local PDF parse failed (${error instanceof Error ? error.message : String(error)}), using the helper model`,
431
+ `[attachments] ingest "${name}": local PDF parse failed (${error instanceof Error ? error.message : String(error)}), using the helper model`,
388
432
  );
389
433
  }
390
434
  }
@@ -1,4 +1,5 @@
1
1
  import type { BaseMessage, MessageContent } from '@langchain/core/messages';
2
+ import { TURN_GUIDE_NOTE_KWARG } from '../core/operating-guides';
2
3
  import {
3
4
  stripTurnTimeNote,
4
5
  TURN_TIME_NOTE_KWARG,
@@ -9,7 +10,7 @@ import type { ChannelFileNotice, ChannelReplyContext } from './contract';
9
10
  * `additional_kwargs` key holding the exact quoted-reply note a channel
10
11
  * message was sent with, so readers can take it off the stored content again
11
12
  * (see `userContent`). The note sits between the time note and what the
12
- * person typed: `time note \n\n reply note \n\n message`.
13
+ * person typed: `time note \n\n guide note \n\n reply note \n\n message`.
13
14
  */
14
15
  export const CHANNEL_REPLY_NOTE_KWARG = 'channel_reply_note';
15
16
 
@@ -84,8 +85,8 @@ export function isDeliveryNoticeMessage(message: BaseMessage): boolean {
84
85
  }
85
86
 
86
87
  /**
87
- * The content as the person sent it: without the runtime's time note,
88
- * without the quoted-reply note and without a delivery notice. Every reader
88
+ * The content as the person sent it: without the runtime's time and guide
89
+ * notes, without the quoted-reply note and without a delivery notice. Every reader
89
90
  * that treats a human message as the person's own words (the transcript,
90
91
  * memory indexing, the evidence a proposal is checked against) goes through
91
92
  * here, so quoted text or a gateway's notice is never taken for something
@@ -97,7 +98,10 @@ export function userContent(
97
98
  ): MessageContent {
98
99
  return stripTurnTimeNote(
99
100
  stripTurnTimeNote(
100
- stripTurnTimeNote(content, kwargs?.[TURN_TIME_NOTE_KWARG]),
101
+ stripTurnTimeNote(
102
+ stripTurnTimeNote(content, kwargs?.[TURN_TIME_NOTE_KWARG]),
103
+ kwargs?.[TURN_GUIDE_NOTE_KWARG],
104
+ ),
101
105
  kwargs?.[CHANNEL_REPLY_NOTE_KWARG],
102
106
  ),
103
107
  kwargs?.[CHANNEL_DELIVERY_NOTICE_KWARG],
@@ -0,0 +1,62 @@
1
+ /**
2
+ * How long an on-demand capability stays loaded on a thread.
3
+ *
4
+ * A loaded plugin's tools and operating guide go out with every model call,
5
+ * so a plugin the conversation has moved on from is paid for on every call.
6
+ * Taking it away changes the tool list and the system prompt, which costs a
7
+ * full cache miss on a warm thread: the request's whole prefix is read again
8
+ * at the uncached price, far more than the plugin's tools cost at the cached
9
+ * one. On a thread that has gone quiet long enough for the provider's cache
10
+ * to expire the miss has already happened, so the runtime only ever unloads
11
+ * a plugin on such a turn, and only one the last `maxIdleTurns` turns did not
12
+ * touch. The model can load it again: the capability gate's refusal says how.
13
+ *
14
+ * Use is read off the thread itself: a call to one of the plugin's tools, or
15
+ * a `load_capability` naming it. Nothing is stored, so a summary that
16
+ * condensed a plugin's last use makes it idle, which is what it is.
17
+ */
18
+ import { AIMessage, type BaseMessage } from '@langchain/core/messages';
19
+ import { z } from 'zod';
20
+ import { LOAD_CAPABILITY_TOOL_NAME } from './meta-tools';
21
+ import { turnOpenings } from './middlewares/turn-boundary';
22
+
23
+ const loadArgs = z.object({ names: z.array(z.string()) });
24
+
25
+ /** The plugins one model response touched. */
26
+ function pluginsTouched(
27
+ message: AIMessage,
28
+ pluginOf: (toolName: string) => string | undefined,
29
+ ): string[] {
30
+ return (message.tool_calls ?? []).flatMap((call) => {
31
+ if (call.name === LOAD_CAPABILITY_TOOL_NAME) {
32
+ const args = loadArgs.safeParse(call.args);
33
+ return args.success ? args.data.names : [];
34
+ }
35
+ const plugin = pluginOf(call.name);
36
+ return plugin ? [plugin] : [];
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Which of the `loaded` plugins no turn among the last `maxIdleTurns` of the
42
+ * thread touched. `messages` are the finished turns, the current one not yet
43
+ * among them; a thread with fewer turns than that unloads nothing.
44
+ */
45
+ export function idlePlugins(input: {
46
+ messages: readonly BaseMessage[];
47
+ loaded: readonly string[];
48
+ pluginOf: (toolName: string) => string | undefined;
49
+ maxIdleTurns: number;
50
+ }): string[] {
51
+ const { messages, loaded, pluginOf, maxIdleTurns } = input;
52
+ if (maxIdleTurns <= 0) return [];
53
+ const openings = turnOpenings(messages);
54
+ const recent = openings[openings.length - maxIdleTurns];
55
+ if (recent === undefined) return [];
56
+ const touched = new Set<string>();
57
+ for (const message of messages.slice(recent))
58
+ if (AIMessage.isInstance(message))
59
+ for (const plugin of pluginsTouched(message, pluginOf))
60
+ touched.add(plugin);
61
+ return loaded.filter((plugin) => !touched.has(plugin));
62
+ }