@yeaft/webchat-agent 1.0.613 → 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/local-runtime/server/handlers/client-person.js +32 -5
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +301 -191
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/person/bridge.js +5 -1
- package/yeaft/person/capabilities.js +21 -7
- package/yeaft/person/contracts.js +34 -3
- package/yeaft/person/inspection.js +30 -4
- package/yeaft/person/outputs.js +153 -0
- package/yeaft/person/runtime.js +2 -2
- package/yeaft/person/service.js +6 -1
- package/yeaft/person/sqlite-repository.js +4 -0
- package/yeaft/person/sqlite-schema.js +16 -0
- package/yeaft/person/sqlite-store.js +72 -7
- package/yeaft/person/task-host.js +48 -6
|
Binary file
|
package/package.json
CHANGED
package/yeaft/person/bridge.js
CHANGED
|
@@ -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',
|
|
@@ -2,12 +2,14 @@ import { NATIVE_TOOL_MANIFESTS, NATIVE_TOOL_IDS, isNativeTool } from './native-t
|
|
|
2
2
|
import { bytes, digest, fail, identifier, object, page, text } from './contracts.js';
|
|
3
3
|
import { createdCapabilityRecord, validateCreatedDefinition } from './created-capability-contract.js';
|
|
4
4
|
import { runPersonScript, scriptInput, testPersonScript } from './script-executor.js';
|
|
5
|
-
import { createdSkillView, inspectionPage } from './inspection.js';
|
|
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);
|
|
@@ -83,15 +87,24 @@ export function inspectCapabilities(created, { cursor, limit }, nativeToolIds =
|
|
|
83
87
|
contract.evidence = item.evidence;
|
|
84
88
|
entries.push(item);
|
|
85
89
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
const boundary = inspectionCursor(cursor, 'skills');
|
|
91
|
+
// IDs are ASCII identifiers. Binary ordering matches SQLite. Only custom
|
|
92
|
+
// versions have creation dates; builtins follow as an undated ID catalogue.
|
|
93
|
+
const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
94
|
+
if (boundary?.legacy) return inspectionPage(entries.filter(entry => entry.id > boundary.id).sort(byId), limit, 'id');
|
|
95
|
+
const custom = entries.filter(entry => entry.source.kind === 'person-created')
|
|
96
|
+
.filter(entry => !boundary || (boundary.stage === 'c' && (inspectionTime(entry.createdAt) < boundary.time ||
|
|
97
|
+
(inspectionTime(entry.createdAt) === boundary.time && entry.id > boundary.id))))
|
|
98
|
+
.sort((a, b) => inspectionTime(b.createdAt) - inspectionTime(a.createdAt) || byId(a, b));
|
|
99
|
+
const builtin = entries.filter(entry => entry.source.kind !== 'person-created')
|
|
100
|
+
.filter(entry => boundary?.stage !== 'b' || entry.id > boundary.id).sort(byId);
|
|
101
|
+
return inspectionPage([...custom, ...builtin], limit,
|
|
102
|
+
record => chronologicalCursor('skills', record, record.source.kind === 'person-created' ? 'c' : 'b'));
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
export class PersonCapabilities {
|
|
93
|
-
constructor(repository, ownerId, { experience = [], triggerKind = 'think', now = Date.now(), created = [], episode, toolHost } = {}) {
|
|
94
|
-
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;
|
|
95
108
|
this.scripts = new Map(created.map(record => [record.id, record]));
|
|
96
109
|
this.nativeToolIds = toolHost?.allowedToolIds?.() ?? NATIVE_TOOL_IDS;
|
|
97
110
|
const allowed = new Set(this.nativeToolIds);
|
|
@@ -172,6 +185,7 @@ export class PersonCapabilities {
|
|
|
172
185
|
object(args, Object.keys(entry.args.properties ?? {}), entry.args.required ?? []);
|
|
173
186
|
return this.toolHost.execute(id, args, { signal, callId, episodeId: this.episode?.id });
|
|
174
187
|
}
|
|
188
|
+
if (id === 'Output.publish') return publishOutput(this.repository, this.episode, this.workDir, args, { signal, callId });
|
|
175
189
|
if (id === 'Capability.create') {
|
|
176
190
|
if (!this.episode || !callId) fail('UNSUPPORTED');
|
|
177
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
|
};
|
|
@@ -66,12 +70,39 @@ export function page(payload = {}) {
|
|
|
66
70
|
}
|
|
67
71
|
/** Task APIs are explicit owner actions, not cognitive admissions. Logs are raw
|
|
68
72
|
* owner-scoped output; list/control responses never include native runtime/log/result.
|
|
69
|
-
*
|
|
73
|
+
* Empty list payloads retain the legacy active-first capped inventory. Explicit
|
|
74
|
+
* {limit,cursor?} pages each collection newest-created first, with immutable
|
|
75
|
+
* creation-time/ID keysets and one scope-bound continuation for both collections.
|
|
76
|
+
* Paged responses also carry a bounded active control snapshot independent of
|
|
77
|
+
* history position (including terminal children with pending actual execution).
|
|
70
78
|
* Log offsets count raw UTF-8 bytes; an omitted offset starts at the beginning.
|
|
71
79
|
*/
|
|
72
80
|
export const PERSON_TASK_LIMITS = Object.freeze({ records: 100, logBytes: 16384, maxLogBytes: 65536 });
|
|
81
|
+
export function taskInspectionCursor(cursor) {
|
|
82
|
+
if (typeof cursor !== 'string' || !/^t1:[A-Za-z0-9_-]{1,2048}$/.test(cursor)) fail('INVALID_REQUEST');
|
|
83
|
+
let value;
|
|
84
|
+
try { value = JSON.parse(Buffer.from(cursor.slice(3), 'base64url').toString('utf8')); } catch { fail('INVALID_REQUEST'); }
|
|
85
|
+
object(value, ['scope', 'tasks', 'agents']);
|
|
86
|
+
if (typeof value.scope !== 'string' || !/^[a-f0-9]{64}$/.test(value.scope)) fail('INVALID_REQUEST');
|
|
87
|
+
for (const key of ['tasks', 'agents']) {
|
|
88
|
+
const boundary = value[key];
|
|
89
|
+
if (boundary === null) continue;
|
|
90
|
+
object(boundary, ['time', 'id']);
|
|
91
|
+
if (!Number.isSafeInteger(boundary.time) || boundary.time < 0 || typeof boundary.id !== 'string' ||
|
|
92
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(boundary.id)) fail('INVALID_REQUEST');
|
|
93
|
+
}
|
|
94
|
+
if ((value.tasks === null && value.agents === null) || Buffer.from(JSON.stringify(value)).toString('base64url') !== cursor.slice(3)) fail('INVALID_REQUEST');
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
73
97
|
export function personTaskRequest(op, payload = {}) {
|
|
74
|
-
if (op === 'tasks') {
|
|
98
|
+
if (op === 'tasks') {
|
|
99
|
+
object(payload, ['cursor', 'limit'], []);
|
|
100
|
+
if (!Object.keys(payload).length) return {};
|
|
101
|
+
const limit = payload.limit === undefined ? PERSON_TASK_LIMITS.records : payload.limit, cursor = payload.cursor ?? null;
|
|
102
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > PERSON_TASK_LIMITS.records) fail('INVALID_REQUEST');
|
|
103
|
+
if (cursor !== null) taskInspectionCursor(cursor);
|
|
104
|
+
return { cursor, limit };
|
|
105
|
+
}
|
|
75
106
|
const idKey = op === 'agent_close' ? 'agentId' : 'taskId';
|
|
76
107
|
object(payload, op === 'task_log' ? ['taskId', 'offset', 'maxBytes'] : [idKey], [idKey]);
|
|
77
108
|
// Native TaskStore normalizes punctuation. Reject aliases instead of letting
|
|
@@ -1,12 +1,38 @@
|
|
|
1
1
|
import { bytes, fail, identifier, object, page, text } from './contracts.js';
|
|
2
2
|
|
|
3
|
+
/** Timestamp + binary ID keysets survive insertion, deletion of the boundary
|
|
4
|
+
* record and same-millisecond writes. Builtins have no invented chronology.
|
|
5
|
+
* Bare ID cursors remain readable for pre-keyset clients' ID-ascending pages. */
|
|
6
|
+
export function inspectionCursor(value, section) {
|
|
7
|
+
if (value == null) return null;
|
|
8
|
+
if (typeof value !== 'string' || value.length > 512) fail('INVALID_REQUEST');
|
|
9
|
+
const prefix = section === 'memory' ? 'm1' : 's1';
|
|
10
|
+
const match = /^(m1|s1):(c|b):(\d{1,16}):([A-Za-z0-9_-]+)$/.exec(value);
|
|
11
|
+
if (!match) {
|
|
12
|
+
if (/^(m1|s1):/.test(value)) fail('INVALID_REQUEST');
|
|
13
|
+
identifier(value); return { legacy: true, id: value };
|
|
14
|
+
}
|
|
15
|
+
const [, type, stage, rawTime, encoded] = match;
|
|
16
|
+
const time = Number(rawTime), id = Buffer.from(encoded, 'base64url').toString('utf8');
|
|
17
|
+
identifier(id);
|
|
18
|
+
if (type !== prefix || (section === 'memory' && stage !== 'c') || !Number.isSafeInteger(time) || time < 0 ||
|
|
19
|
+
String(time) !== rawTime || (stage === 'b' && time !== 0) || Buffer.from(id).toString('base64url') !== encoded) fail('INVALID_REQUEST');
|
|
20
|
+
return { stage, time, id };
|
|
21
|
+
}
|
|
22
|
+
export const inspectionTime = value => {
|
|
23
|
+
const time = value == null ? NaN : new Date(value).getTime();
|
|
24
|
+
return Number.isSafeInteger(time) && time >= 0 ? time : 0;
|
|
25
|
+
};
|
|
26
|
+
export const chronologicalCursor = (section, record, stage = 'c') =>
|
|
27
|
+
`${section === 'memory' ? 'm1' : 's1'}:${stage}:${stage === 'b' ? 0 : inspectionTime(section === 'memory' ? record.updatedAt : record.createdAt)}:${Buffer.from(record.id).toString('base64url')}`;
|
|
28
|
+
|
|
3
29
|
/** Public read APIs use keyset cursors, never offsets or model-ranked recall. */
|
|
4
30
|
export function inspectRequest(payload = {}) {
|
|
5
31
|
object(payload, ['section', 'cursor', 'limit'], ['section']);
|
|
6
32
|
if (!['memory', 'skills'].includes(payload.section)) fail('INVALID_REQUEST');
|
|
7
33
|
const { limit } = page({ limit: payload.limit });
|
|
8
34
|
const cursor = payload.cursor ?? null;
|
|
9
|
-
|
|
35
|
+
inspectionCursor(cursor, payload.section);
|
|
10
36
|
return { section: payload.section, cursor, limit };
|
|
11
37
|
}
|
|
12
38
|
export function searchRequest(payload = {}) {
|
|
@@ -48,13 +74,13 @@ export function createdSkillView(record, contract) {
|
|
|
48
74
|
/** Complete records only. Continuation follows the last returned key even when the
|
|
49
75
|
* byte budget, rather than the requested count, ends a page. No attachment bytes. */
|
|
50
76
|
export function inspectionPage(records, limit, key, view = value => value) {
|
|
51
|
-
const items = []; let size = 0;
|
|
77
|
+
const items = []; let size = 0, lastRecord;
|
|
52
78
|
for (const record of records) {
|
|
53
79
|
if (items.length === limit) break;
|
|
54
80
|
const item = view(record), cost = bytes(item);
|
|
55
81
|
if (cost > 256 * 1024) fail('OUTPUT_LIMIT');
|
|
56
82
|
if (items.length && size + cost > 256 * 1024) break;
|
|
57
|
-
items.push(item); size += cost;
|
|
83
|
+
items.push(item); size += cost; lastRecord = record;
|
|
58
84
|
}
|
|
59
|
-
return { items, nextCursor: records.length > items.length ? String(items.at(-1)[key]) : null };
|
|
85
|
+
return { items, nextCursor: records.length > items.length ? (typeof key === 'function' ? key(lastRecord) : String(items.at(-1)[key])) : null };
|
|
60
86
|
}
|
|
@@ -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
|
+
}
|
package/yeaft/person/runtime.js
CHANGED
|
@@ -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();
|
package/yeaft/person/service.js
CHANGED
|
@@ -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
|
|
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)
|
|
@@ -8,9 +8,10 @@ import { CREATED_CAPABILITY_LIMITS, createdCapabilityRecord, validateCreatedCapa
|
|
|
8
8
|
import { SCHEMA, TABLES } from './sqlite-schema.js';
|
|
9
9
|
import { attachmentMetadata, attachmentRequestHash, validateFiles } from './attachments.js';
|
|
10
10
|
import { capabilityExperienceView, recordCapabilityExperience } from './capability-experience.js';
|
|
11
|
-
import { conceptView, inspectionPage, inspectRequest, messageView, personName, searchRequest, settingsView, stateView } from './inspection.js';
|
|
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
|
|
56
|
-
|
|
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');
|
|
@@ -476,8 +488,15 @@ export class SqlitePersonStore {
|
|
|
476
488
|
this.getPerson(ownerId);
|
|
477
489
|
const scope = this.scope(ownerId);
|
|
478
490
|
if (section === 'skills') return inspectCapabilities(this.rows('created_capabilities', scope, ' ORDER BY id ASC LIMIT 32'), { cursor, limit }, nativeToolIds);
|
|
479
|
-
const
|
|
480
|
-
|
|
491
|
+
const boundary = inspectionCursor(cursor, section);
|
|
492
|
+
if (boundary?.legacy) {
|
|
493
|
+
const records = this.rows('concepts', scope, ' AND id > ? ORDER BY id ASC LIMIT ?', [boundary.id, limit + 1]);
|
|
494
|
+
return inspectionPage(records, limit, 'id', conceptView);
|
|
495
|
+
}
|
|
496
|
+
const records = this.rows('concepts', scope,
|
|
497
|
+
' AND (? IS NULL OR updatedAt < ? OR (updatedAt = ? AND id > ?)) ORDER BY updatedAt DESC, id ASC LIMIT ?',
|
|
498
|
+
[boundary?.time ?? null, boundary?.time ?? null, boundary?.time ?? null, boundary?.id ?? null, limit + 1]);
|
|
499
|
+
return inspectionPage(records, limit, record => chronologicalCursor(section, record), conceptView);
|
|
481
500
|
}
|
|
482
501
|
search(ownerId, options) {
|
|
483
502
|
const { query, cursor, limit } = searchRequest(options);
|
|
@@ -536,6 +555,52 @@ export class SqlitePersonStore {
|
|
|
536
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]);
|
|
537
556
|
return { items: docs.slice(0, limit).map(conceptView), nextCursor: docs.length > limit ? docs[limit - 1].id : null };
|
|
538
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
|
+
}
|
|
539
604
|
snapshot(ownerId) {
|
|
540
605
|
this.recover(ownerId);
|
|
541
606
|
const scope = this.scope(ownerId), p = this.getPerson(ownerId), state = this.one('states', scope);
|
|
@@ -546,7 +611,7 @@ export class SqlitePersonStore {
|
|
|
546
611
|
...(episode.terminalCode ? { terminalCode: episode.terminalCode } : {}), ...(episode.endedAt ? { endedAt: episode.endedAt } : {}) } : null;
|
|
547
612
|
return { latestEpisode, person: this.personView(p), state: stateView(state), concepts: this.focused(scope, state.focusConceptIds).slice(0, 12).map(conceptView),
|
|
548
613
|
messages: messages.slice(0, 20).reverse().map(messageView), nextMessagesCursor: messages.length > 20 ? String(messages[19].seq) : null,
|
|
549
|
-
busy: Boolean(p.activeEpisodeId), episodeId: p.activeEpisodeId };
|
|
614
|
+
outputs: this.outputs(ownerId), busy: Boolean(p.activeEpisodeId), episodeId: p.activeEpisodeId };
|
|
550
615
|
}
|
|
551
616
|
searchChanges(ownerId, { after = 0, limit = 100 } = {}) {
|
|
552
617
|
this.getPerson(ownerId); after = sequence(after); boundedLimit(limit, 1000);
|