@ixo/oracle-runtime-workers 0.35.1 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,26 +1,52 @@
1
1
  import type { DeliveryProfile } from './types';
2
2
 
3
+ /**
4
+ * Whether the surface delivers a file as an attachment. A Matrix room has no
5
+ * gateway to fetch it, so a file arrives there as a link line.
6
+ */
7
+ export function deliversAttachments(surface: string): boolean {
8
+ return surface !== 'matrix';
9
+ }
10
+
11
+ /** `maxBubbles` as the prompt asks for it: "two or three messages". */
12
+ function preferredMessages(maxBubbles: number): string {
13
+ if (maxBubbles <= 1) return 'one message';
14
+ if (maxBubbles === 2) return 'one or two messages';
15
+ if (maxBubbles === 3) return 'two or three messages';
16
+ return `up to ${maxBubbles} messages`;
17
+ }
18
+
3
19
  /**
4
20
  * The prompt section a chat turn gets: write for the surface. Empty on the
5
- * Portal. `artifacts` says whether `create_artifact` is bound this turn.
21
+ * Portal. `artifacts` says whether `create_artifact` is bound this turn,
22
+ * `files` whether `send_file` is. Agrees with the shaper and the tools' own
23
+ * descriptions: ordinary answers stay in chat, only document-length output
24
+ * becomes an artefact, and a made file reaches the user as an attachment.
6
25
  */
7
26
  export function renderSurfaceSection(
8
27
  profile: DeliveryProfile | undefined,
9
28
  artifacts: boolean,
29
+ files: boolean,
10
30
  ): string {
11
31
  if (!profile || profile.kind !== 'chat') return '';
12
- const { label, limits } = profile;
32
+ const { label, limits, surface } = profile;
33
+ const preferred = preferredMessages(limits.maxBubbles);
13
34
  return [
14
35
  '## Where this conversation is happening',
15
36
  '',
16
- `You are replying in ${label}. The user reads your reply as chat messages, not as a document.`,
37
+ `You are replying in ${label}. The user reads your reply as chat messages, often on a phone with a chat-only data plan and no browser, so the answer itself belongs in the messages.`,
17
38
  '',
18
39
  '- Lead with the answer. Write like a sharp person texting: short sentences, no preamble, no sign-off.',
19
- `- Aim for one to three short messages. A blank line starts a new message; keep each under about ${limits.bubbleTarget} characters.`,
20
- `- No headings, tables or horizontal rules. Short lists (up to ${limits.maxListItems} items) and **bold** for the key fact are fine.`,
40
+ `- Prefer ${preferred}. A blank line marks where a message may break; short paragraphs are grouped so the reply reads as ${preferred}. Keep each paragraph under about ${limits.bubbleTarget} characters. Use more messages when the answer needs them; never leave content out to fit.`,
41
+ `- Ordinary answers stay in chat, including explanations, comparisons, short plans and step-by-step instructions. No headings or horizontal rules. Lists up to ${limits.maxListItems} items, code up to ${limits.maxCodeLines} lines and **bold** for the key fact are fine in chat. Write a small comparison as one short line per item, not a table.`,
42
+ ...(files
43
+ ? [
44
+ `- Deliver every file meant for the user (a Word document, PDF, spreadsheet, slide deck, image, CSV or anything else) with \`send_file\`: ${deliversAttachments(surface) ? 'the user receives the file itself here, as an attachment' : 'a link to the file is posted here for you'}. When the user asks for a file in a particular format, build it in the sandbox and send it this way. Never paste a download link, and do not describe how to open or download it. Do not send helper scripts or intermediate files. Call it once per file, after the sandbox call that saved the file has finished.`,
45
+ ]
46
+ : []),
21
47
  artifacts
22
- ? '- Anything longer, such as a plan, report, comparison, draft, table or code, goes in `create_artifact`: the full Markdown as the document, a one-line message, and at most one follow-up question. The user gets the message, a link that opens the document in their browser, then the question.'
23
- : '- When something is too long for chat, send the short version and offer the rest.',
48
+ ? `- Use \`create_artifact\` ${files ? 'for a document with no particular file format' : 'only when the user asks for a document or file'}, or when the output is genuinely document-length: several screens of text, long code, a long report. Even then, its message carries the conclusions, decisions and next actions, and the document holds the detail. Call it last: the user gets the message, the document, then at most one follow-up question.`
49
+ : '- Everything you write is delivered as messages; when something is genuinely long, put the essential part first.',
24
50
  '- The user sees a typing indicator while you work. Do not narrate your steps or announce tool calls.',
25
51
  '- End with at most one question.',
26
52
  ].join('\n');
@@ -1,7 +1,7 @@
1
1
  import { ActionReferenceSchema } from '@ixo/ixo-transaction/action-proposals';
2
2
  import { parseReference, type ActionReferenceV1 } from '@ixo/action-protocol';
3
3
  import { z } from 'zod';
4
- import type { ReplyContent, ReplyPlan } from './types';
4
+ import type { ReplyContent, ReplyFile, ReplyPlan } from './types';
5
5
 
6
6
  const ArtifactRefSchema = z.object({
7
7
  artifactId: z.string(),
@@ -12,6 +12,33 @@ const ArtifactRefSchema = z.object({
12
12
  expiresAt: z.string(),
13
13
  });
14
14
 
15
+ function isSignedLink(value: string): boolean {
16
+ try {
17
+ const url = new URL(value);
18
+ return url.protocol === 'https:' && !url.username && !url.password;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * The most a gateway takes (`maxFileUrlChars`): with room for its caption the
26
+ * link must still fit one 4,096-character text message.
27
+ */
28
+ export const REPLY_FILE_URL_MAX = 3800;
29
+
30
+ export const ReplyFileSchema = z.object({
31
+ url: z
32
+ .string()
33
+ .max(REPLY_FILE_URL_MAX)
34
+ .refine(isSignedLink, 'must be an https URL without credentials'),
35
+ filename: z.string().min(1),
36
+ mimetype: z.string().min(1),
37
+ bytes: z.number().int().nonnegative().optional(),
38
+ caption: z.string().max(1024).optional(),
39
+ expiresAt: z.string().optional(),
40
+ });
41
+
15
42
  export const ReplyPlanSchema = z.object({
16
43
  v: z.literal(1),
17
44
  parts: z.array(
@@ -44,6 +71,11 @@ export const ReplyPlanSchema = z.object({
44
71
  kind: z.literal('artifact'),
45
72
  artifact: ArtifactRefSchema,
46
73
  }),
74
+ z.object({
75
+ partId: z.string(),
76
+ kind: z.literal('file'),
77
+ file: ReplyFileSchema,
78
+ }),
47
79
  ]),
48
80
  ),
49
81
  });
@@ -65,10 +97,17 @@ function linkLabel(title: string): string {
65
97
  return title.replace(/[[\]]/g, '');
66
98
  }
67
99
 
100
+ function fileLine(file: ReplyFile): string {
101
+ const link = `[${linkLabel(file.filename)}](${file.url})`;
102
+ return file.caption ? `${file.caption}\n\n${link}` : link;
103
+ }
104
+
68
105
  /**
69
- * The plan as one Markdown message: what the user's oracle room mirror of a
70
- * channel turn and gateways that predate plans receive. Never stored as a
71
- * run's text, which stays the model's own.
106
+ * The plan as one Markdown message: what gateways that predate plans receive.
107
+ * A file is a link line, so a gateway that cannot read the `file` part
108
+ * degrades instead of losing the file. Never stored as a run's text, which
109
+ * stays the model's own, and never shown to the model or mirrored to a room:
110
+ * see `planTextForModel`.
72
111
  */
73
112
  export function planText(plan: { parts: readonly ReplyContent[] }): string {
74
113
  return plan.parts
@@ -79,7 +118,32 @@ export function planText(plan: { parts: readonly ReplyContent[] }): string {
79
118
  ? `[${linkLabel(part.action.title)}](${part.action.url})`
80
119
  : part.kind === 'hub-action'
81
120
  ? `[${linkLabel(part.summary)}](${part.actionReference.url})`
82
- : `[${linkLabel(part.artifact.title)}](${part.artifact.url})`,
121
+ : part.kind === 'file'
122
+ ? fileLine(part.file)
123
+ : `[${linkLabel(part.artifact.title)}](${part.artifact.url})`,
83
124
  )
84
125
  .join('\n\n');
85
126
  }
127
+
128
+ /**
129
+ * `planText` without a file's signed link, which is named instead: for the
130
+ * model, as when a user quotes an earlier reply, for the room mirror, and for
131
+ * the transcript, all of which outlive the link.
132
+ */
133
+ export function planTextForModel(plan: {
134
+ parts: readonly ReplyContent[];
135
+ }): string {
136
+ return planText({
137
+ parts: plan.parts.map(
138
+ (part): ReplyContent =>
139
+ part.kind === 'file'
140
+ ? {
141
+ kind: 'text',
142
+ text: [part.file.caption, `[File sent: ${part.file.filename}]`]
143
+ .filter(Boolean)
144
+ .join('\n\n'),
145
+ }
146
+ : part,
147
+ ),
148
+ });
149
+ }