@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.
- package/package.json +4 -4
- package/src/attachments/channel-links.ts +74 -7
- package/src/attachments/channel.ts +15 -4
- package/src/attachments/content-blocks.ts +19 -16
- package/src/attachments/download.ts +173 -50
- package/src/attachments/index.ts +6 -5
- package/src/attachments/pipeline.ts +117 -49
- package/src/attachments/resize.ts +142 -0
- package/src/attachments/retention.ts +26 -70
- package/src/attachments/room-media.ts +643 -0
- package/src/attachments/route.ts +47 -4
- package/src/attachments/sandbox-archive.ts +15 -4
- package/src/attachments/sanitize.ts +24 -13
- package/src/attachments/view.ts +82 -38
- package/src/channels/replies.ts +8 -4
- package/src/core/capability-expiry.ts +62 -0
- package/src/core/capability-router.ts +126 -95
- package/src/core/context-budget.ts +116 -4
- package/src/core/domain-context/document.ts +74 -2
- package/src/core/domain-context/index.ts +1 -0
- package/src/core/domain-context/resolver.ts +9 -6
- package/src/core/domain-context/turn-context.ts +62 -13
- package/src/core/domain-context/types.ts +17 -0
- package/src/core/env.ts +21 -2
- package/src/core/index.ts +4 -0
- package/src/core/llm.ts +29 -0
- package/src/core/main-agent-types.ts +53 -4
- package/src/core/main-agent.ts +228 -125
- package/src/core/manifest.ts +109 -24
- package/src/core/mcp-result.ts +56 -0
- package/src/core/meta-tools.ts +258 -110
- package/src/core/middlewares/attachment-recall.ts +502 -0
- package/src/core/middlewares/capability-gate.ts +77 -28
- package/src/core/middlewares/context-guard.ts +154 -21
- package/src/core/middlewares/index.ts +5 -0
- package/src/core/middlewares/result-cap.ts +1 -1
- package/src/core/middlewares/summarization.ts +9 -0
- package/src/core/middlewares/tool-marks.ts +11 -2
- package/src/core/middlewares/turn-boundary.ts +22 -12
- package/src/core/operating-guides.ts +126 -0
- package/src/core/plugins/skills/skills-tools.ts +6 -31
- package/src/core/plugins/skills/skills.plugin.ts +4 -3
- package/src/core/plugins/weather/weather.plugin.ts +1 -0
- package/src/core/prompt-composer.ts +188 -111
- package/src/core/read-result-tool.ts +48 -14
- package/src/core/registries.ts +13 -5
- package/src/core/state.ts +4 -3
- package/src/core/sub-agent-fallback.ts +1 -0
- package/src/core/subagent-as-tool.ts +4 -1
- package/src/core/tool-access.ts +3 -32
- package/src/core/wrap-plugin-tool.ts +2 -1
- package/src/do/contracts.ts +64 -24
- package/src/do/room-mirror.ts +144 -20
- package/src/do/transcript.ts +41 -12
- package/src/do/turn-attachments.ts +45 -6
- package/src/do/turn-metadata.ts +16 -4
- package/src/do/user-oracle-do.ts +639 -152
- package/src/index.ts +1 -0
- package/src/llm/provider-error.ts +5 -2
- package/src/llm/stream-liveness.ts +3 -2
- package/src/matrix/gateway-do.ts +164 -102
- package/src/matrix/ingest.ts +12 -3
- package/src/matrix/media-deadline.ts +5 -47
- package/src/memory/session-history-indexer.ts +60 -14
- package/src/plugin-api/oracle-plugin.ts +4 -5
- package/src/plugin-api/types.ts +32 -3
- package/src/plugins/action-proposals/action-proposals.plugin.ts +7 -2
- package/src/plugins/agui/agui.plugin.ts +1 -0
- package/src/plugins/attachments/attachments.plugin.ts +3 -3
- package/src/plugins/attachments/view-attachment-tool.ts +6 -4
- package/src/plugins/compact-schema.ts +80 -0
- package/src/plugins/composio/composio.plugin.ts +1 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +4 -3
- package/src/plugins/editor/editor.plugin.ts +4 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +2 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +1 -0
- package/src/plugins/flows/flows.plugin.ts +37 -6
- package/src/plugins/flows/prompts.ts +1 -1
- package/src/plugins/flows/tools/authoring.ts +5 -7
- package/src/plugins/flows/tools/discovery.ts +1 -4
- package/src/plugins/flows/tools/forms.ts +2 -8
- package/src/plugins/flows/tools/linkage.ts +1 -4
- package/src/plugins/flows/tools/settings.ts +1 -4
- package/src/plugins/flows/translator.ts +16 -5
- package/src/plugins/flows/types.ts +27 -48
- package/src/plugins/hub-actions/hub-actions.plugin.ts +30 -4
- package/src/plugins/index.ts +2 -2
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +29 -5
- package/src/plugins/ixo-chain/kinds/core.ts +2 -2
- package/src/plugins/ixo-chain/kinds/types.ts +34 -5
- package/src/plugins/ixo-chain/queries.ts +10 -1
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +1 -0
- package/src/plugins/matrix-group-chats/index.ts +1 -0
- package/src/plugins/memory/memory-tools.ts +4 -4
- package/src/plugins/memory/memory.plugin.ts +27 -1
- package/src/plugins/memory/oracle-document-tools.ts +24 -49
- package/src/plugins/pod-creator/pod-creator.plugin.ts +1 -0
- package/src/plugins/portal/portal.plugin.ts +1 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +3 -15
- package/src/plugins/sandbox/sandbox.plugin.ts +19 -2
- package/src/plugins/tasks/manifest.ts +1 -0
- package/src/plugins/tasks/tasks-tools.ts +14 -19
- package/src/plugins/user-preferences/user-preferences-tool.ts +11 -37
- package/src/plugins/user-preferences/user-preferences.plugin.ts +4 -0
- package/src/plugins/vfs/vfs-tools.ts +6 -6
- package/src/plugins/vfs/vfs.plugin.ts +4 -5
- package/src/plugins/yoma/yoma.plugin.ts +1 -0
- package/src/sqlite/inline-payloads.ts +121 -0
- package/src/sqlite/serialization.ts +12 -0
- package/src/sqlite/sqlite-saver.ts +258 -42
- package/src/tasks/schedule-input.ts +5 -22
package/src/attachments/route.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
61
|
-
...(
|
|
62
|
-
...(
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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:
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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(
|
|
58
|
+
return cutUnits(visibleText(value).replace(/"/g, ''), max);
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
/**
|
package/src/attachments/view.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Re-fetch one attachment of a session on demand — the `view_attachment`
|
|
3
|
-
* lane
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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 "${
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 "${
|
|
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 } =
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
|
|
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 "${
|
|
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(
|
|
329
|
-
|
|
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 "${
|
|
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 "${
|
|
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 "${
|
|
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
|
}
|
package/src/channels/replies.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
+
}
|