@neutrome/lilsdk 0.4.0 → 0.4.2
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 +2 -2
- package/src/managed/attachment-to-text.ts +176 -0
- package/src/managed/index.ts +5 -0
- package/test/lilsdk-ts.test.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neutrome/lilsdk",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"./managed": "./src/managed/index.ts"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@neutrome/lil-engine": "0.4.
|
|
16
|
+
"@neutrome/lil-engine": "0.4.1"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@types/node": "^25.9.3",
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import {
|
|
2
|
+
contentText,
|
|
3
|
+
insertAfter,
|
|
4
|
+
isAttachmentOpcode,
|
|
5
|
+
messages,
|
|
6
|
+
programAttachments,
|
|
7
|
+
type Program,
|
|
8
|
+
type ProgramAttachment,
|
|
9
|
+
} from "@neutrome/lil-engine";
|
|
10
|
+
import {
|
|
11
|
+
appendToolInteraction,
|
|
12
|
+
prependSystemPrompt,
|
|
13
|
+
} from "../synthetic/index.ts";
|
|
14
|
+
import type { Executor, ExecutorContext, ExecutorInput } from "../types.ts";
|
|
15
|
+
|
|
16
|
+
const toolName = "attachment_to_text";
|
|
17
|
+
const defaultPrompt =
|
|
18
|
+
"Read the attached file carefully. Return a detailed factual description of its content for another assistant to use. Include all relevant visible text, structure, and details. Do not discuss this instruction.";
|
|
19
|
+
|
|
20
|
+
export type AttachmentReaderRule = {
|
|
21
|
+
mimeTypes: readonly string[];
|
|
22
|
+
executor: ExecutorInput;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type AttachmentToTextOptions = {
|
|
26
|
+
systemPrompt?: string;
|
|
27
|
+
cacheKey?: (attachment: ProgramAttachment, ctx: ExecutorContext) => string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function createAttachmentToTextExecutor(
|
|
31
|
+
inner: ExecutorInput,
|
|
32
|
+
rules: readonly AttachmentReaderRule[],
|
|
33
|
+
options: AttachmentToTextOptions = {},
|
|
34
|
+
): Executor {
|
|
35
|
+
if (rules.length === 0)
|
|
36
|
+
throw new Error("attachment-to-text requires at least one reader rule");
|
|
37
|
+
|
|
38
|
+
const prompt = options.systemPrompt ?? defaultPrompt;
|
|
39
|
+
return {
|
|
40
|
+
async execute(request, ctx) {
|
|
41
|
+
return ctx.invoke(
|
|
42
|
+
inner,
|
|
43
|
+
await prepareRequest(request, ctx, rules, prompt, options),
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
async *stream(request, ctx) {
|
|
47
|
+
yield* ctx.invokeStream(
|
|
48
|
+
inner,
|
|
49
|
+
await prepareRequest(request, ctx, rules, prompt, options),
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function prepareRequest(
|
|
56
|
+
request: Program,
|
|
57
|
+
ctx: ExecutorContext,
|
|
58
|
+
rules: readonly AttachmentReaderRule[],
|
|
59
|
+
prompt: string,
|
|
60
|
+
options: AttachmentToTextOptions,
|
|
61
|
+
): Promise<Program> {
|
|
62
|
+
const attachments = programAttachments(request);
|
|
63
|
+
if (attachments.length === 0) return request;
|
|
64
|
+
const latestUserStart = [...attachments]
|
|
65
|
+
.filter((attachment) => attachment.message.role === "user")
|
|
66
|
+
.at(-1)?.message.start;
|
|
67
|
+
let result = stripAttachments(request);
|
|
68
|
+
const sourceMessages = messages(request);
|
|
69
|
+
|
|
70
|
+
for (const attachment of [...attachments].reverse()) {
|
|
71
|
+
const rule = rules.find((candidate) =>
|
|
72
|
+
candidate.mimeTypes.some((mime) =>
|
|
73
|
+
matchesMime(mime, attachment.mimeType),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
if (!rule) {
|
|
77
|
+
if (attachment.message.start === latestUserStart) {
|
|
78
|
+
throw new Error(`No attachment reader matches ${attachment.mimeType}`);
|
|
79
|
+
}
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Missing cached attachment description for historical ${attachment.mimeType}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const key = await attachmentCacheKey(attachment, ctx, options);
|
|
85
|
+
let description = await ctx.cache.get(key);
|
|
86
|
+
if (!description) {
|
|
87
|
+
if (attachment.message.start !== latestUserStart) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Missing cached attachment description for historical ${attachment.mimeType}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const readerRequest = readerProgram(request, attachment, prompt);
|
|
93
|
+
description = contentText(await ctx.invoke(rule.executor, readerRequest));
|
|
94
|
+
if (!description.trim())
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Attachment reader returned no text for ${attachment.mimeType}`,
|
|
97
|
+
);
|
|
98
|
+
await ctx.cache.put(key, description);
|
|
99
|
+
}
|
|
100
|
+
const interaction = {
|
|
101
|
+
callId: `attachment_${key.slice(-24)}`,
|
|
102
|
+
name: toolName,
|
|
103
|
+
args: {
|
|
104
|
+
mimeType: attachment.mimeType,
|
|
105
|
+
...(attachment.name ? { name: attachment.name } : {}),
|
|
106
|
+
context:
|
|
107
|
+
"The user attached this file. Its binary media was replaced with the reconstructed text result below.",
|
|
108
|
+
},
|
|
109
|
+
result: `Reconstructed content for the file the user attached. The original binary media is not present in this context.\n\n${description}`,
|
|
110
|
+
};
|
|
111
|
+
const messageIndex = sourceMessages.findIndex(
|
|
112
|
+
(message) => message.start === attachment.message.start,
|
|
113
|
+
);
|
|
114
|
+
const targetMessage = messages(result)[messageIndex];
|
|
115
|
+
if (!targetMessage)
|
|
116
|
+
throw new Error("Attachment message was removed from context");
|
|
117
|
+
const synthetic = appendToolInteraction(
|
|
118
|
+
{ code: [], buffers: [] },
|
|
119
|
+
interaction,
|
|
120
|
+
);
|
|
121
|
+
result = insertAfter(result, targetMessage.end, synthetic.code);
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readerProgram(
|
|
127
|
+
request: Program,
|
|
128
|
+
attachment: ProgramAttachment,
|
|
129
|
+
prompt: string,
|
|
130
|
+
): Program {
|
|
131
|
+
return prependSystemPrompt(
|
|
132
|
+
{
|
|
133
|
+
code: request.code.slice(
|
|
134
|
+
attachment.message.start,
|
|
135
|
+
attachment.message.end + 1,
|
|
136
|
+
),
|
|
137
|
+
buffers: request.buffers.map((buffer) => buffer.slice()),
|
|
138
|
+
},
|
|
139
|
+
prompt,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function stripAttachments(request: Program): Program {
|
|
144
|
+
return {
|
|
145
|
+
code: request.code.filter(
|
|
146
|
+
(instruction) => !isAttachmentOpcode(instruction.opcode),
|
|
147
|
+
),
|
|
148
|
+
buffers: request.buffers.map((buffer) => buffer.slice()),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function attachmentCacheKey(
|
|
153
|
+
attachment: ProgramAttachment,
|
|
154
|
+
ctx: ExecutorContext,
|
|
155
|
+
options: AttachmentToTextOptions,
|
|
156
|
+
): Promise<string> {
|
|
157
|
+
if (options.cacheKey)
|
|
158
|
+
return `attachment-to-text:${options.cacheKey(attachment, ctx)}`;
|
|
159
|
+
const bytes = new TextEncoder().encode(
|
|
160
|
+
JSON.stringify({
|
|
161
|
+
mimeType: attachment.mimeType,
|
|
162
|
+
url: attachment.url,
|
|
163
|
+
name: attachment.name,
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
|
167
|
+
return `attachment-to-text:${Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("")}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function matchesMime(pattern: string, mimeType: string): boolean {
|
|
171
|
+
return (
|
|
172
|
+
pattern === "*" ||
|
|
173
|
+
pattern === mimeType ||
|
|
174
|
+
(pattern.endsWith("/*") && mimeType.startsWith(pattern.slice(0, -1)))
|
|
175
|
+
);
|
|
176
|
+
}
|
package/src/managed/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { createCapabilitiesExecutor } from "./capabilities.ts";
|
|
2
2
|
export type { CapabilitiesExecutorOptions } from "./capabilities.ts";
|
|
3
|
+
export { createAttachmentToTextExecutor } from "./attachment-to-text.ts";
|
|
4
|
+
export type {
|
|
5
|
+
AttachmentReaderRule,
|
|
6
|
+
AttachmentToTextOptions,
|
|
7
|
+
} from "./attachment-to-text.ts";
|
|
3
8
|
|
|
4
9
|
export {
|
|
5
10
|
appendInternalDraft,
|
package/test/lilsdk-ts.test.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
getModel,
|
|
8
8
|
parseChatCompletionsRequest,
|
|
9
9
|
parseChatCompletionsStreamChunk,
|
|
10
|
+
programAttachments,
|
|
11
|
+
viewProgram,
|
|
10
12
|
setModel,
|
|
11
13
|
Opcode,
|
|
12
14
|
type Program,
|
|
@@ -36,12 +38,64 @@ import {
|
|
|
36
38
|
createTwoPassExecutor,
|
|
37
39
|
INTERNAL_DRAFT_TOOL_NAME,
|
|
38
40
|
} from "../src/managed/two-pass.ts";
|
|
41
|
+
import { createAttachmentToTextExecutor } from "../src/managed/attachment-to-text.ts";
|
|
39
42
|
import type { Executor, ExecutorContext, OutputSink } from "../src/types.ts";
|
|
40
43
|
|
|
41
44
|
const encoder = new TextEncoder();
|
|
42
45
|
const decoder = new TextDecoder();
|
|
43
46
|
|
|
44
47
|
describe("@neutrome/lilsdk", () => {
|
|
48
|
+
it("describes the latest attachment and removes it before the inner executor", async () => {
|
|
49
|
+
const request = parseChatCompletionsRequest(
|
|
50
|
+
encoder.encode(
|
|
51
|
+
JSON.stringify({
|
|
52
|
+
messages: [
|
|
53
|
+
{
|
|
54
|
+
role: "user",
|
|
55
|
+
content: [
|
|
56
|
+
{ type: "text", text: "What is this?" },
|
|
57
|
+
{
|
|
58
|
+
type: "image_url",
|
|
59
|
+
image_url: { url: "data:image/png;base64,aGVsbG8=" },
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
let innerRequest: Program | undefined;
|
|
68
|
+
const executor = createAttachmentToTextExecutor(
|
|
69
|
+
{
|
|
70
|
+
async execute(value) {
|
|
71
|
+
innerRequest = value;
|
|
72
|
+
return value;
|
|
73
|
+
},
|
|
74
|
+
async *stream(value) {
|
|
75
|
+
yield value;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
[
|
|
79
|
+
{
|
|
80
|
+
mimeTypes: ["*"],
|
|
81
|
+
executor: {
|
|
82
|
+
async execute() {
|
|
83
|
+
return appendAssistantMessage(
|
|
84
|
+
{ code: [], buffers: [] },
|
|
85
|
+
"A tiny image.",
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
async *stream() {},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
);
|
|
93
|
+
await executor.execute(request, buildExecutorContext());
|
|
94
|
+
expect(programAttachments(innerRequest!)).toHaveLength(0);
|
|
95
|
+
expect(
|
|
96
|
+
viewProgram(innerRequest!).messages.at(-1)?.toolResult?.text,
|
|
97
|
+
).toContain("The original binary media is not present in this context.");
|
|
98
|
+
});
|
|
45
99
|
it("provides clone-safe structural primitives", () => {
|
|
46
100
|
const needle = {
|
|
47
101
|
opcode: Opcode.MSG_START,
|