@yeaft/webchat-agent 1.0.615 → 1.0.616

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.615",
3
+ "version": "1.0.616",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -69,7 +69,11 @@ export function createPersonBridge({
69
69
  IMAGE_MODEL: 'No permitted selected model supports image input',
70
70
  MODEL_SELECTION: 'Choose models from this Agent configured catalog',
71
71
  NOT_OPEN: 'Open the digital person first',
72
- NOT_FOUND: 'Digital person task or child not found',
72
+ NOT_FOUND: 'Digital person output, task or child not found',
73
+ OUTPUT_PLATFORM: 'File publication requires Linux with accessible procfs and no-follow directory descriptors; links and existing snapshots remain available',
74
+ OUTPUT_PATH: 'Output must be a regular file strictly within workDir; symlinks are not permitted',
75
+ OUTPUT_QUOTA: 'Maximum 10 MiB per file, 100 MiB or 200 outputs per person',
76
+ OUTPUT_NOT_FILE: 'Only published file snapshots can be read; links are not fetched',
73
77
  TASK_SCOPE_DENIED: 'Digital person task access denied',
74
78
  TASK_CONTROL_UNAVAILABLE: 'Task control could not be confirmed; refresh before retrying',
75
79
  STALE: 'Digital person state changed; refresh and try again',
@@ -3,11 +3,13 @@ import { bytes, digest, fail, identifier, object, page, text } from './contracts
3
3
  import { createdCapabilityRecord, validateCreatedDefinition } from './created-capability-contract.js';
4
4
  import { runPersonScript, scriptInput, testPersonScript } from './script-executor.js';
5
5
  import { chronologicalCursor, createdSkillView, inspectionCursor, inspectionPage, inspectionTime } from './inspection.js';
6
+ import { publishOutput } from './outputs.js';
6
7
 
7
8
  // Intrinsic cognition, bounded pure scripts and supported native host tools.
8
9
  // No shell/filesystem/network/VP authority is exposed to generated code.
9
10
  const entries = [
10
11
  { id: 'Think', version: 1, domain: 'cognition', description: 'Intrinsic deliberate reflection, reorganization, association and reconsideration.', keywords: '思考 整理 反思 think reflection', useWhen: 'Think with the evidence already present, or rest when nothing warrants further work.', avoidWhen: 'Do not invent evidence or call a tool merely to appear thoughtful.', instructions: 'Revisit supplied experiences and current judgments; separate report from hypothesis, propose new concepts or associations, test a counterexample, and record the explicit finding with uncertainty. It is fine to rest. This is a method, not another model call or external action.', args: {} },
12
+ { id: 'Output.publish', version: 1, domain: 'delivery', description: 'Deliver a meaningful artifact as an immutable file snapshot or external link to your owner.', keywords: '产物 交付 输出 文件 下载 链接 publish output artifact deliver file download link', useWhen: 'Deliver an actual completed or useful partial artifact, including artifacts produced by your children.', avoidWhen: 'Do not publish secrets, unauthorized files or guessed paths; do not claim publication rolled back on cancellation.', instructions: 'args: {file_path?:string,url?:string,title?:string}. Exactly one file_path or url. file_path is relative to canonical workDir or an absolute path strictly inside it; no symlinks/directories. Max 10 MiB per file, 100 MiB and 200 outputs total per Person. Files are durable byte snapshots with conservative MIME, including Markdown/images/PDF/HTML/text/code/JSON/binary downloads. URL must be HTTP(S), no credentials; it is metadata only and never fetched. title <=240 UTF-8 bytes; default filename or link host, truncated on UTF-8 boundaries if necessary. File publication is Linux-only with accessible procfs and no-follow directory descriptors; unavailable platforms fail closed with OUTPUT_PLATFORM. Links and existing snapshot reads remain available. Returns public {id,title,kind,mimeType,size,episodeId,createdAt,url?}, no raw path. Successful publications survive later cancellation/failure. Parent verifies and publishes child artifacts within its own workDir; do not grant children this capability.', args: { file_path: 'optional local artifact path within workDir', url: 'optional credential-free HTTP(S) URL', title: 'optional title <=240 UTF-8 bytes' }, access: 'publish-owner-output' },
11
13
  { id: 'Recall', version: 1, domain: 'memory', description: 'Recall a bounded page of your own long-term messages or concepts.', keywords: '回忆 记忆 召回 历史 查找 搜索 memory recall history search', useWhen: 'Missing prior words, experiences or concepts could change your present understanding.', avoidWhen: 'Current evidence is sufficient; a similar memory cannot grant permission or establish truth.', instructions: 'args: {kind:"messages"|"concepts",query?:string,cursor?:string|null,limit?:1..5}. SQLite uses local hybrid keyword/semantic recall when available; inspect retrieval mode, coverage and degradation. Legacy storage may use literal matching. Results are not exhaustive or authorization. Preserve nextCursor for the same query; local ranking cursors expire after 5 minutes or restart.', args: { kind: 'messages|concepts', query: 'optional memory search text', cursor: 'optional opaque cursor', limit: '1..5' } },
12
14
  { id: 'Capability.create', version: 1, domain: 'creation', description: 'Create or revise a reusable pure JavaScript capability; execute tests before publishing to your private catalog.', keywords: '创造 创建 固化 脚本 编程 学会 create script learn capability', useWhen: 'A reusable JSON data transformation or calculation is missing. Write a small script and concrete examples, including edge cases; successful tests publish it automatically.', avoidWhen: 'Do not save one-off constants, secrets, unnecessary duplicates, external actions or assumptions as verified facts. Tests you wrote are limited evidence, not proof of general correctness.', instructions: 'args: {id:"Script.<slug>",expectedVersion:0 for new or current version,description,useWhen,avoidWhen,inputDescription,outputDescription,code,tests:[{input:JSON,expected:JSON}]}. slug: lowercase letter followed by up to 47 lowercase letters/digits/hyphens. Metadata <=400 UTF-8 bytes each; code <=8192; total args <=24576; 1..8 tests with each input/expected <=4096 bytes. code is a synchronous function body receiving input and returning JSON. QuickJS only: no Node, imports, filesystem, network, shell, credentials or host tools. Each test runs in a fresh limited VM; failure returns diagnostics without publication. Passing tests atomically publish an immutable version independently of final cognitive commit. Later cancellation does not undo publication. Result prepares the ID for next call with {input:JSON}; no model generates that result. Tests are limited evidence, not proof of correctness/usefulness. Search before duplicating; catalog.view supplies the saved definition for revision. Preserve useful tests. Max 32 capabilities, 32 versions each.', args: { id: 'Script.<slug>', expectedVersion: 'integer >= 0', description: 'string', useWhen: 'string', avoidWhen: 'string', inputDescription: 'string', outputDescription: 'string', code: 'JavaScript function body', tests: 'array of {input,expected}' }, access: 'create-pure-capability' },
13
15
  { id: 'Skill.reconsider', version: 1, domain: 'method', description: 'Read-only method for revising a previous judgment.', keywords: '反思 复核 反例 修正 重新审视 reconsider revise counterexample', useWhen: 'A prior judgment meets conflicting evidence or may have overlooked an alternative.', avoidWhen: 'Do not force a revision or treat a hypothetical counterexample as observed evidence.', instructions: 'Identify one prior claim, list a concrete counterexample or missing evidence, and decide whether to retain, qualify or revise it. Mark what remains unresolved. Do not present the counterexample as an observed event without evidence.', args: {} },
@@ -25,6 +27,8 @@ export const CAPABILITY_MAP = Object.freeze({
25
27
  nativeToolNotice: 'Default native host tools are in this catalog, not QuickJS. View a known nativeTools ID to prepare its complete schema, or search. Bash supports background tasks and SpawnAgent supports independent parallel threads in the private Person task namespace. Collect results explicitly; completion does not start cognition. Session-specific tools are not registered.',
26
28
  discover: { id: 'catalog.search', args: '{query?:string,cursor?:string|null,limit?:1..5}', description: 'Browse/search summaries with budgeted complete contracts. Returned contracts are prepared for the next call; omitted contracts require catalog.view. All entries remain reachable by pagination.' },
27
29
  inspect: { id: 'catalog.view', args: '{id:string}', description: 'Load a known ID directly, or a contract omitted by search. No search is required first.' },
30
+ // Discoverable entry only; full delivery/safety instructions are demand-loaded.
31
+ delivery: { id: 'Output.publish' },
28
32
  intrinsic: 'Think is always available as a cognitive activity; no tool call is required.',
29
33
  });
30
34
  export const catalogRevision = digest(manifests);
@@ -99,8 +103,8 @@ export function inspectCapabilities(created, { cursor, limit }, nativeToolIds =
99
103
  }
100
104
 
101
105
  export class PersonCapabilities {
102
- constructor(repository, ownerId, { experience = [], triggerKind = 'think', now = Date.now(), created = [], episode, toolHost } = {}) {
103
- this.repository = repository; this.ownerId = ownerId; this.episode = episode; this.toolHost = toolHost;
106
+ constructor(repository, ownerId, { experience = [], triggerKind = 'think', now = Date.now(), created = [], episode, toolHost, workDir } = {}) {
107
+ this.repository = repository; this.ownerId = ownerId; this.episode = episode; this.toolHost = toolHost; this.workDir = workDir;
104
108
  this.scripts = new Map(created.map(record => [record.id, record]));
105
109
  this.nativeToolIds = toolHost?.allowedToolIds?.() ?? NATIVE_TOOL_IDS;
106
110
  const allowed = new Set(this.nativeToolIds);
@@ -181,6 +185,7 @@ export class PersonCapabilities {
181
185
  object(args, Object.keys(entry.args.properties ?? {}), entry.args.required ?? []);
182
186
  return this.toolHost.execute(id, args, { signal, callId, episodeId: this.episode?.id });
183
187
  }
188
+ if (id === 'Output.publish') return publishOutput(this.repository, this.episode, this.workDir, args, { signal, callId });
184
189
  if (id === 'Capability.create') {
185
190
  if (!this.episode || !callId) fail('UNSUPPORTED');
186
191
  const definition = validateCreatedDefinition(args);
@@ -23,7 +23,11 @@ const ERRORS = {
23
23
  ATTACHMENT_LIMIT: 'Attachments exceed the limit: 4 files, 5 MiB each, 10 MiB total; text plus extracted content must fit 24 KiB.',
24
24
  IMAGE_MODEL: 'The selected model does not permit image input.',
25
25
  CLOSED: 'The digital person service is closed.',
26
- NOT_FOUND: 'Digital person task or child not found.',
26
+ NOT_FOUND: 'Digital person output, task or child not found.',
27
+ OUTPUT_PLATFORM: 'File publication requires Linux with accessible procfs and no-follow directory descriptors; links and existing snapshots remain available.',
28
+ OUTPUT_PATH: 'Output must be a regular, unchanged file strictly within workDir; symlinks are not permitted.',
29
+ OUTPUT_QUOTA: 'Outputs exceed the limit: 10 MiB per file, 100 MiB or 200 outputs per person.',
30
+ OUTPUT_NOT_FILE: 'Only published file snapshots can be read; links are not fetched.',
27
31
  TASK_SCOPE_DENIED: 'Digital person task access denied.',
28
32
  TASK_CONTROL_UNAVAILABLE: 'Task control could not be confirmed; refresh before retrying.',
29
33
  };
@@ -0,0 +1,153 @@
1
+ import { constants, openSync, closeSync, fstatSync, readSync, realpathSync } from 'node:fs';
2
+ import { basename, extname, relative, resolve, sep } from 'node:path';
3
+ import { fail, object, page, PersonError, text } from './contracts.js';
4
+
5
+ export const OUTPUT_LIMITS = Object.freeze({ fileBytes: 10 * 1024 * 1024, personBytes: 100 * 1024 * 1024, personCount: 200, readBytes: 65536 });
6
+ const BINARY = 'application/octet-stream';
7
+ const ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
8
+
9
+ export function outputArgs(args) {
10
+ object(args, ['file_path', 'url', 'title'], []);
11
+ if (Object.hasOwn(args, 'file_path') === Object.hasOwn(args, 'url')) fail('INVALID_REQUEST');
12
+ if (Object.hasOwn(args, 'title')) text(args.title, 240);
13
+ if (Object.hasOwn(args, 'file_path')) text(args.file_path, 4096);
14
+ else outputUrl(args.url);
15
+ return args;
16
+ }
17
+ export function outputUrl(value) {
18
+ text(value, 4096);
19
+ // WHATWG parsing silently trims controls and tolerates ambiguous backslashes.
20
+ if (!/^https?:\/\//i.test(value) || /[\s\u0000-\u001f\u007f\\]/u.test(value)) fail('INVALID_REQUEST');
21
+ let url; try { url = new URL(value); } catch { fail('INVALID_REQUEST'); }
22
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || /^https?:\/\/[^/?#]*@/i.test(value)) fail('INVALID_REQUEST');
23
+ return url.href; // Metadata only. Never fetched by the Agent or Server.
24
+ }
25
+ export function outputsPage(payload = {}) {
26
+ if (Object.hasOwn(payload ?? {}, 'limit') && payload.limit == null) fail('INVALID_REQUEST');
27
+ return page(payload);
28
+ }
29
+ export function outputReadRequest(payload = {}) {
30
+ object(payload, ['outputId', 'offset', 'maxBytes'], ['outputId']);
31
+ if (typeof payload.outputId !== 'string' || !ID.test(payload.outputId)) fail('INVALID_REQUEST');
32
+ const offset = payload.offset === undefined ? 0 : payload.offset;
33
+ const maxBytes = payload.maxBytes === undefined ? OUTPUT_LIMITS.readBytes : payload.maxBytes;
34
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > OUTPUT_LIMITS.readBytes) fail('INVALID_REQUEST');
35
+ return { outputId: payload.outputId, offset, maxBytes };
36
+ }
37
+ export function outputView(record) {
38
+ return { id: record.id, title: record.title, kind: record.kind, mimeType: record.mimeType, size: record.size,
39
+ episodeId: record.episodeId, createdAt: record.createdAt, ...(record.kind === 'link' ? { url: record.url } : {}) };
40
+ }
41
+
42
+ /** Renderer selection is byte-checked and conservative. SVG and unknown/binary
43
+ * formats are downloads only. Signatures are not a full decoder or malware scan. */
44
+ export function outputMime(name, data) {
45
+ const ext = extname(name).toLowerCase();
46
+ const magic = hex => data.subarray(0, hex.length / 2).equals(Buffer.from(hex, 'hex'));
47
+ if (ext === '.png') return magic('89504e470d0a1a0a') ? 'image/png' : BINARY;
48
+ if (['.jpg', '.jpeg', '.jfif'].includes(ext)) return magic('ffd8ff') ? 'image/jpeg' : BINARY;
49
+ if (ext === '.gif') return ['GIF87a', 'GIF89a'].includes(data.subarray(0, 6).toString('ascii')) ? 'image/gif' : BINARY;
50
+ if (ext === '.webp') return data.subarray(0, 4).toString('ascii') === 'RIFF' && data.subarray(8, 12).toString('ascii') === 'WEBP' ? 'image/webp' : BINARY;
51
+ if (ext === '.pdf') return data.subarray(0, 5).toString('ascii') === '%PDF-' ? 'application/pdf' : BINARY;
52
+ if (ext === '.svg') return BINARY;
53
+ let content;
54
+ try { content = new TextDecoder('utf-8', { fatal: true }).decode(data); } catch { return BINARY; }
55
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(content)) return BINARY;
56
+ if (['.html', '.htm'].includes(ext)) return 'text/html'; // UI must use script/network-blocked sandbox.
57
+ if (['.md', '.markdown'].includes(ext)) return 'text/markdown';
58
+ if (ext === '.json') { try { JSON.parse(content); return 'application/json'; } catch { return 'text/plain'; } }
59
+ return 'text/plain'; // Code/plain UTF-8 is never executed, regardless of extension.
60
+ }
61
+
62
+ /** Deliberately Linux-only until another platform has a verified race-safe
63
+ * descriptor-relative implementation. Link publication / durable reads are portable. */
64
+ export function outputFilePublicationSupport() {
65
+ const unavailable = { supported: false, reason: 'File publication requires Linux with accessible procfs and no-follow directory descriptors; links and existing snapshots remain available.' };
66
+ if (process.platform !== 'linux' || !constants.O_NOFOLLOW || !constants.O_DIRECTORY) return unavailable;
67
+ let root, probe;
68
+ try {
69
+ root = openSync('/', constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
70
+ probe = openSync(`/proc/self/fd/${root}/.`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
71
+ if (!fstatSync(probe).isDirectory() || realpathSync(`/proc/self/fd/${probe}`) !== '/') return unavailable;
72
+ return { supported: true, reason: null };
73
+ } catch { return unavailable; }
74
+ finally { for (const fd of [probe, root]) if (fd !== undefined) { try { closeSync(fd); } catch { /* best effort close */ } } }
75
+ }
76
+
77
+ // A filesystem basename can legally exceed our public title limit. Keep a valid
78
+ // UTF-8 prefix (and short extension) rather than rejecting an otherwise valid file.
79
+ function defaultTitle(value) {
80
+ value = value.trim() || 'Output';
81
+ if (Buffer.byteLength(value) <= 240) return value;
82
+ const ext = extname(value), suffix = `…${Buffer.byteLength(ext) <= 32 ? ext : ''}`;
83
+ let prefix = '';
84
+ for (const char of value) {
85
+ if (Buffer.byteLength(prefix + char + suffix) > 240) break;
86
+ prefix += char;
87
+ }
88
+ return prefix + suffix;
89
+ }
90
+
91
+ /** Read through pinned directory descriptors: each component is O_NOFOLLOW.
92
+ * Resolving then opening the full path would leave an ancestor-symlink race.
93
+ * Linux procfs provides openat-like anchoring without native dependencies.
94
+ * Missing platform support, fd filesystem or O_NOFOLLOW fails closed. */
95
+ export function snapshotOutput(workDir, filePath) {
96
+ const descriptors = [];
97
+ try {
98
+ if (!outputFilePublicationSupport().supported) fail('OUTPUT_PLATFORM');
99
+ const root = realpathSync(resolve(workDir ?? process.cwd()));
100
+ const requested = resolve(root, filePath), rel = relative(root, requested);
101
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || resolve(root, rel) !== requested) fail('OUTPUT_PATH');
102
+ const fdRoot = '/proc/self/fd';
103
+ let parent = openSync(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
104
+ descriptors.push(parent);
105
+ // Revalidate the root descriptor, so a swapped canonical root is not trusted.
106
+ if (realpathSync(`${fdRoot}/${parent}`) !== root) fail('OUTPUT_PATH');
107
+ const parts = rel.split(sep);
108
+ for (const component of parts.slice(0, -1)) {
109
+ parent = openSync(`${fdRoot}/${parent}/${component}`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
110
+ descriptors.push(parent);
111
+ }
112
+ // NONBLOCK prevents a malicious FIFO/device path from hanging before fstat.
113
+ const fd = openSync(`${fdRoot}/${parent}/${parts.at(-1)}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
114
+ descriptors.push(fd);
115
+ const stat = fstatSync(fd);
116
+ if (!stat.isFile()) fail('OUTPUT_PATH');
117
+ const opened = realpathSync(`${fdRoot}/${fd}`), openedRel = relative(root, opened);
118
+ if (!openedRel || openedRel === '..' || openedRel.startsWith(`..${sep}`) || opened !== requested) fail('OUTPUT_PATH');
119
+ if (stat.size > OUTPUT_LIMITS.fileBytes) fail('OUTPUT_QUOTA');
120
+ // Read at most the limit + 1, even if another process grows the original.
121
+ const data = Buffer.alloc(stat.size + 1); let size = 0;
122
+ while (size < data.length) { const n = readSync(fd, data, size, data.length - size, size); if (!n) break; size += n; }
123
+ const after = fstatSync(fd);
124
+ if (after.size > OUTPUT_LIMITS.fileBytes || size > OUTPUT_LIMITS.fileBytes) fail('OUTPUT_QUOTA');
125
+ if (size !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) fail('OUTPUT_PATH');
126
+ return { data: data.subarray(0, size), name: basename(requested) };
127
+ } catch (error) {
128
+ if (error instanceof PersonError) throw error;
129
+ fail('OUTPUT_PATH'); // Never leak a filesystem error or raw path.
130
+ } finally { for (const fd of descriptors.reverse()) { try { closeSync(fd); } catch { /* best effort close */ } } }
131
+ }
132
+
133
+ export async function publishOutput(repository, episode, workDir, args, { signal, callId } = {}) {
134
+ outputArgs(args);
135
+ if (!episode || !callId) fail('UNSUPPORTED');
136
+ // Read permission is derived from the started current Person invocation, not
137
+ // from browser arguments or a child/Session tool registry.
138
+ await repository.checkOutputPublication(episode, { callId, args });
139
+ signal?.throwIfAborted();
140
+ let record, data = null;
141
+ if (Object.hasOwn(args, 'file_path')) {
142
+ const snapshot = snapshotOutput(workDir, args.file_path); data = snapshot.data;
143
+ record = { kind: 'file', title: args.title?.trim() ?? defaultTitle(snapshot.name), mimeType: outputMime(snapshot.name, data), size: data.length };
144
+ } else {
145
+ const url = outputUrl(args.url);
146
+ record = { kind: 'link', title: args.title?.trim() ?? defaultTitle(new URL(url).hostname), mimeType: null, size: 0, url };
147
+ }
148
+ text(record.title, 240);
149
+ signal?.throwIfAborted();
150
+ // SQLite checks the invocation/fence again in the same transaction as insert.
151
+ // Once committed this is an honest durable side effect, even after cancellation.
152
+ return repository.saveOutput(episode, { callId, args, record, data });
153
+ }
@@ -235,7 +235,7 @@ export class PersonRuntime {
235
235
  getContext: async ctx => this.tasks()?.context({ ...ctx, episode, provider,
236
236
  selection: toolSelection, effortDecision: toolEffortDecision, parentToolRegistry: this.parentToolRegistry }),
237
237
  onResult: result => finalizeNativeResult(result) });
238
- const capabilities = new PersonCapabilities(this.repository, episode.ownerId, { experience: snapshot.capabilityExperience, triggerKind: episode.kind, created, episode, toolHost });
238
+ const capabilities = new PersonCapabilities(this.repository, episode.ownerId, { experience: snapshot.capabilityExperience, triggerKind: episode.kind, created, episode, toolHost, workDir: this.toolOptions.workDir });
239
239
  let previous = null, capabilityResult = null, dependencyRefs = [];
240
240
  // Validation retains actual reads across calls, independently of the bounded rendered request.
241
241
  // Candidate proposals never enter this read-set or establish new provenance.
@@ -344,7 +344,7 @@ export class PersonRuntime {
344
344
  signal.throwIfAborted();
345
345
  // Native host and script execution join their actual work before close.
346
346
  const executionPromise = capabilities.execute(invocation, { signal, callId });
347
- capabilityResult = isNativeTool(invocation.id) || invocation.id === 'Capability.create' || invocation.id.startsWith('Script.')
347
+ capabilityResult = isNativeTool(invocation.id) || invocation.id === 'Output.publish' || invocation.id === 'Capability.create' || invocation.id.startsWith('Script.')
348
348
  ? await executionPromise : await abortable(executionPromise, signal);
349
349
  if (!finalized && !await finalize(capabilityResult)) fail('STALE');
350
350
  signal.throwIfAborted();
@@ -7,6 +7,7 @@ import { loadConfig } from '../config.js';
7
7
  import { validateFiles } from './attachments.js';
8
8
  import { createPersonProvider, resolveAgentDefaultModel, selectPersonModels, validateDefaultModel, validateModelCandidates } from './provider.js';
9
9
  import { fail, identifier, LIMITS, object, page, personTaskRequest, safeError, text } from './contracts.js';
10
+ import { outputFilePublicationSupport, outputReadRequest } from './outputs.js';
10
11
  import { turnsPage } from './turn-diagnostics.js';
11
12
  import { inspectRequest, personName, searchRequest } from './inspection.js';
12
13
 
@@ -54,7 +55,9 @@ export function createPersonService(options = {}) {
54
55
  object(payload, []);
55
56
  let agentDefaultModel = null;
56
57
  try { agentDefaultModel = resolveAgentDefaultModel(config ?? loadConfig({ dir: yeaftDir })); } catch { /* Invalid/missing native config. */ }
57
- const status = { renameSupported: true, defaultModelSupported: true, configured, storage: 'sqlite', modelReady: false,
58
+ const filePublication = outputFilePublicationSupport();
59
+ const status = { outputsSupported: true, outputsFileSupported: filePublication.supported, outputsFileReason: filePublication.reason,
60
+ renameSupported: true, defaultModelSupported: true, configured, storage: 'sqlite', modelReady: false,
58
61
  defaultModel: null, agentDefaultModel, modelCandidates: [], effectiveModelCandidates: [], effectiveDefaultModel: null };
59
62
  if (!configured) return { ...status, reason: 'Digital person storage configuration is missing.', storageReady: false };
60
63
  try { await repository.init(); }
@@ -96,6 +99,8 @@ export function createPersonService(options = {}) {
96
99
  if (!host) fail('UNSUPPORTED');
97
100
  return host.request({ ownerId, personId: person.personId, namespace, op, payload: args });
98
101
  }
102
+ case 'outputs': return repository.outputs(ownerId, payload);
103
+ case 'output_read': return repository.outputRead(ownerId, outputReadRequest(payload));
99
104
  case 'snapshot': object(payload, []); return repository.snapshot(ownerId);
100
105
  case 'receipt': {
101
106
  object(payload, ['clientMessageId', 'requestHash'], ['clientMessageId']);
@@ -107,6 +107,10 @@ export class SqlitePersonRepository {
107
107
  async settings(ownerId, settings, expectedControlVersion) { return this.request('settings', [ownerId, settings, expectedControlVersion]); }
108
108
  async list(ownerId, collection, options, filter) { return this.request('list', [ownerId, collection, options, filter]); }
109
109
  async turns(ownerId, options) { return this.request('turns', [ownerId, options]); }
110
+ async checkOutputPublication(episode, input) { return this.request('checkOutputPublication', [episode, input]); }
111
+ async saveOutput(episode, input) { return this.request('saveOutput', [episode, input]); }
112
+ async outputs(ownerId, options = {}) { return this.request('outputs', [ownerId, options]); }
113
+ async outputRead(ownerId, options) { return this.request('outputRead', [ownerId, options]); }
110
114
  async snapshot(ownerId) { return this.request('snapshot', [ownerId]); }
111
115
  async inspect(ownerId, options, nativeToolIds) { return this.request('inspect', [ownerId, options, nativeToolIds]); }
112
116
  async search(ownerId, options) { return this.request('search', [ownerId, options]); }
@@ -14,6 +14,22 @@ export const TABLES = Object.freeze({
14
14
  });
15
15
 
16
16
  export const SCHEMA = `
17
+ -- Additive migration: old schemaVersion/user_version=1 readers remain compatible.
18
+ -- Output bytes have exactly one authority, this instance SQLite database.
19
+ CREATE TABLE IF NOT EXISTS outputs (
20
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
22
+ id TEXT NOT NULL, episodeId TEXT NOT NULL, callId TEXT NOT NULL,
23
+ size INTEGER NOT NULL CHECK(size BETWEEN 0 AND 10485760),
24
+ record TEXT NOT NULL CHECK(json_valid(record)), data BLOB,
25
+ UNIQUE(namespace, ownerId, personId, id),
26
+ UNIQUE(namespace, ownerId, personId, episodeId, callId),
27
+ FOREIGN KEY(namespace, ownerId, personId, episodeId) REFERENCES episodes(namespace, ownerId, personId, id),
28
+ CHECK((json_extract(record, '$.kind') = 'file' AND data IS NOT NULL AND length(data) = size)
29
+ OR (json_extract(record, '$.kind') = 'link' AND data IS NULL AND size = 0))
30
+ ) STRICT;
31
+ CREATE INDEX IF NOT EXISTS outputs_recent ON outputs(namespace, ownerId, personId, seq DESC);
32
+
17
33
  CREATE TABLE IF NOT EXISTS persons (
18
34
  namespace TEXT NOT NULL, ownerId TEXT NOT NULL, personId TEXT NOT NULL,
19
35
  record TEXT NOT NULL CHECK(json_valid(record)), PRIMARY KEY(namespace, ownerId, personId)
@@ -11,6 +11,7 @@ import { capabilityExperienceView, recordCapabilityExperience } from './capabili
11
11
  import { chronologicalCursor, conceptView, inspectionCursor, inspectionPage, inspectRequest, messageView, personName, searchRequest, settingsView, stateView } from './inspection.js';
12
12
  import { TOKEN_FIELDS, turnsPage, turnView } from './turn-diagnostics.js';
13
13
  import { inspectCapabilities } from './capabilities.js';
14
+ import { OUTPUT_LIMITS, outputArgs, outputMime, outputReadRequest, outputsPage, outputUrl, outputView } from './outputs.js';
14
15
 
15
16
  const SCOPE = 'namespace = ? AND ownerId = ? AND personId = ?';
16
17
  const scopeValues = s => [s.namespace, s.ownerId, s.personId];
@@ -52,8 +53,19 @@ const publicOutput = (output, failed) => {
52
53
  return failed ? { text: output.text, retainedBytes, observedBytes, complete: false, accepted: false, availability: 'captured', usage, stopReason }
53
54
  : { text: output.text, bytes: retainedBytes, complete: true, usage, stopReason };
54
55
  };
55
- const READS = new Set(['receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
56
- const WRITES = new Set(['open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
56
+ const objectOutput = (record, data) => {
57
+ if (!record || !['file', 'link'].includes(record.kind)) fail('INVALID_REQUEST');
58
+ text(record.title, 240);
59
+ if (!Number.isSafeInteger(record.size) || record.size < 0 || record.size > OUTPUT_LIMITS.fileBytes) fail('OUTPUT_QUOTA');
60
+ if (record.kind === 'link') {
61
+ if (record.mimeType !== null || record.size !== 0 || data !== null || outputUrl(record.url) !== record.url) fail('INVALID_REQUEST');
62
+ } else {
63
+ const mimes = ['text/plain', 'text/markdown', 'text/html', 'application/json', 'application/pdf', 'application/octet-stream', 'image/png', 'image/jpeg', 'image/gif', 'image/webp'];
64
+ if (!mimes.includes(record.mimeType) || !(data instanceof Uint8Array) || data.length !== record.size) fail('INVALID_REQUEST');
65
+ }
66
+ };
67
+ const READS = new Set(['outputs', 'outputRead', 'checkOutputPublication', 'receipt', 'turns', 'inspect', 'search', 'getPerson', 'context', 'recall', 'list', 'searchChanges', 'resolveMemories', 'createdCapabilities', 'episodeAttachments']);
68
+ const WRITES = new Set(['saveOutput', 'open', 'recover', 'admit', 'heartbeat', 'append', 'startCall', 'finalizeCall', 'startCapability', 'finalizeCapability', 'commit', 'publishProgress', 'finish', 'cancel', 'settings', 'snapshot', 'saveCreatedCapability']);
57
69
  const memoryKind = kind => { if (!['messages', 'concepts'].includes(kind)) fail('INVALID_REQUEST'); return kind; };
58
70
  const boundedLimit = (limit, max = 100) => { if (!Number.isSafeInteger(limit) || limit < 1 || limit > max) fail('INVALID_REQUEST'); return limit; };
59
71
  const sequence = value => {
@@ -271,7 +283,7 @@ export class SqlitePersonStore {
271
283
  }
272
284
  append(episode, kind, data) {
273
285
  // Call proof/publication events are emitted only by their transactional methods.
274
- if (['call_started', 'call_output', 'call_failed', 'capability_created', 'capability_finalized'].includes(kind)) fail('INVALID_REQUEST');
286
+ if (['call_started', 'call_output', 'call_failed', 'capability_created', 'capability_finalized', 'output_published'].includes(kind)) fail('INVALID_REQUEST');
275
287
  const p = this.own(episode);
276
288
  const record = this.one('episodes', this.episodeScope(episode), ' AND id = ?', [episode.id]);
277
289
  if (!record || record.status !== 'running') fail('STALE');
@@ -543,6 +555,52 @@ export class SqlitePersonStore {
543
555
  const docs = this.rows('concepts', this.scope(ownerId), ' AND (? IS NULL OR id > ?) AND instr(statement, ?) > 0 ORDER BY id ASC LIMIT ?', [cursor, cursor, query.toLowerCase(), limit + 1]);
544
556
  return { items: docs.slice(0, limit).map(conceptView), nextCursor: docs.length > limit ? docs[limit - 1].id : null };
545
557
  }
558
+ checkOutputPublication(episode, { callId, args }) {
559
+ outputArgs(args); this.own(episode);
560
+ const scope = this.episodeScope(episode), active = this.one('episodes', scope, ' AND id = ?', [episode.id]);
561
+ if (!active || active.status !== 'running' || active.openCapability?.callId !== callId ||
562
+ active.openCapability.invocationHash !== digest({ id: 'Output.publish', args })) fail('STALE');
563
+ return true;
564
+ }
565
+ saveOutput(episode, { callId, args, record, data }) {
566
+ this.checkOutputPublication(episode, { callId, args });
567
+ const scope = this.episodeScope(episode);
568
+ objectOutput(record, data);
569
+ // Recheck MIME against the invocation filename/bytes at the authority boundary.
570
+ if ((record.kind === 'file' && (!Object.hasOwn(args, 'file_path') || outputMime(args.file_path, Buffer.from(data)) !== record.mimeType)) ||
571
+ (record.kind === 'link' && (!Object.hasOwn(args, 'url') || outputUrl(args.url) !== record.url))) fail('INVALID_REQUEST');
572
+ const totals = this.sql(`SELECT count(*) AS count, coalesce(sum(size), 0) AS size FROM outputs WHERE ${SCOPE}`).get(...scopeValues(scope));
573
+ if (totals.count >= OUTPUT_LIMITS.personCount || totals.size + record.size > OUTPUT_LIMITS.personBytes) fail('OUTPUT_QUOTA');
574
+ // Exactly one durable publication per started invocation, even before finalize.
575
+ if (this.sql(`SELECT 1 FROM outputs WHERE ${SCOPE} AND episodeId = ? AND callId = ?`).get(...scopeValues(scope), episode.id, callId)) fail('STALE');
576
+ const item = outputView({ ...record, id: randomUUID(), episodeId: episode.id, createdAt: this.now.toISOString() });
577
+ this.sql('INSERT INTO outputs(namespace, ownerId, personId, id, episodeId, callId, size, record, data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
578
+ .run(...scopeValues(scope), item.id, item.episodeId, callId, item.size, JSON.stringify(item), data == null ? null : Buffer.from(data));
579
+ this.trace(episode.ownerId, episode.id, 'output_published', { callId, output: item });
580
+ return item;
581
+ }
582
+ outputs(ownerId, options = {}) {
583
+ const { cursor, limit } = outputsPage(options);
584
+ this.getPerson(ownerId);
585
+ const rows = this.sql(`SELECT seq, record FROM outputs WHERE ${SCOPE} AND (? IS NULL OR seq < ?) ORDER BY seq DESC LIMIT ?`)
586
+ .all(...scopeValues(this.scope(ownerId)), cursor, cursor, limit + 1);
587
+ return { items: rows.slice(0, limit).map(row => outputView(JSON.parse(row.record))), nextCursor: rows.length > limit ? String(rows[limit - 1].seq) : null };
588
+ }
589
+ outputRead(ownerId, options) {
590
+ const { outputId, offset, maxBytes } = outputReadRequest(options);
591
+ this.getPerson(ownerId);
592
+ const scope = this.scope(ownerId);
593
+ const row = this.sql(`SELECT size, record FROM outputs WHERE ${SCOPE} AND id = ?`).get(...scopeValues(scope), outputId);
594
+ if (!row) fail('NOT_FOUND');
595
+ const item = outputView(JSON.parse(row.record));
596
+ if (item.kind !== 'file') fail('OUTPUT_NOT_FILE');
597
+ if (offset > row.size) fail('INVALID_REQUEST');
598
+ // SQLite substr uses 1-based byte indices for BLOBs; never load the whole file.
599
+ const chunk = this.sql(`SELECT substr(data, ?, ?) AS data FROM outputs WHERE ${SCOPE} AND id = ?`)
600
+ .get(offset + 1, maxBytes, ...scopeValues(scope), outputId).data;
601
+ const data = Buffer.from(chunk), nextOffset = offset + data.length;
602
+ return { outputId, data: data.toString('base64'), offset, nextOffset, eof: nextOffset === row.size, totalBytes: row.size, mimeType: item.mimeType };
603
+ }
546
604
  snapshot(ownerId) {
547
605
  this.recover(ownerId);
548
606
  const scope = this.scope(ownerId), p = this.getPerson(ownerId), state = this.one('states', scope);
@@ -553,7 +611,7 @@ export class SqlitePersonStore {
553
611
  ...(episode.terminalCode ? { terminalCode: episode.terminalCode } : {}), ...(episode.endedAt ? { endedAt: episode.endedAt } : {}) } : null;
554
612
  return { latestEpisode, person: this.personView(p), state: stateView(state), concepts: this.focused(scope, state.focusConceptIds).slice(0, 12).map(conceptView),
555
613
  messages: messages.slice(0, 20).reverse().map(messageView), nextMessagesCursor: messages.length > 20 ? String(messages[19].seq) : null,
556
- busy: Boolean(p.activeEpisodeId), episodeId: p.activeEpisodeId };
614
+ outputs: this.outputs(ownerId), busy: Boolean(p.activeEpisodeId), episodeId: p.activeEpisodeId };
557
615
  }
558
616
  searchChanges(ownerId, { after = 0, limit = 100 } = {}) {
559
617
  this.getPerson(ownerId); after = sequence(after); boundedLimit(limit, 1000);