@yeaft/webchat-agent 1.0.622 → 1.0.624

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.622",
3
+ "version": "1.0.624",
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",
@@ -1,5 +1,6 @@
1
1
  import { open, readFile, realpath, writeFile, readdir, stat, unlink, rename, mkdir, rm, copyFile, cp } from 'fs/promises';
2
2
  import { existsSync } from 'fs';
3
+ import { execFile } from 'node:child_process';
3
4
  import { join, basename, dirname, extname, isAbsolute, relative, resolve } from 'path';
4
5
  import { platform } from 'os';
5
6
  import ctx from '../context.js';
@@ -308,6 +309,37 @@ export async function handleWriteFile(msg) {
308
309
  }
309
310
  }
310
311
 
312
+ let pendingWindowsDrives = null;
313
+
314
+ function listWindowsFixedDrives() {
315
+ if (pendingWindowsDrives) return pendingWindowsDrives;
316
+ // Drive types do not probe root accessibility or wait for disconnected mappings.
317
+ const command = "$ErrorActionPreference = 'Stop'; [System.IO.DriveInfo]::GetDrives()"
318
+ + ' | Where-Object { $_.DriveType -eq [System.IO.DriveType]::Fixed }'
319
+ + ' | ForEach-Object { $_.Name }';
320
+ pendingWindowsDrives = new Promise((resolve, reject) => {
321
+ execFile('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], {
322
+ windowsHide: true,
323
+ timeout: 5000,
324
+ maxBuffer: 16 * 1024,
325
+ encoding: 'utf8',
326
+ }, (error, stdout, stderr) => {
327
+ if (error) reject(error);
328
+ else if (stderr.trim()) reject(new Error(stderr.trim()));
329
+ else resolve(stdout);
330
+ });
331
+ }).then(stdout => {
332
+ const drives = stdout.trim() ? stdout.trim().split(/\r?\n/).map(line => line.trim()) : [];
333
+ if (drives.some(drive => !/^[A-Za-z]:\\$/.test(drive))) {
334
+ throw new Error('Invalid Windows drive listing');
335
+ }
336
+ return drives.sort().map(drive => ({ name: drive.slice(0, 2), type: 'directory', size: 0 }));
337
+ }).finally(() => {
338
+ pendingWindowsDrives = null;
339
+ });
340
+ return pendingWindowsDrives;
341
+ }
342
+
311
343
  export async function handleListDirectory(msg) {
312
344
  const { conversationId, requestId, _requestUserId, _requestClientId } = msg;
313
345
  const dirPath = msg.dirPath || (msg.workbenchRoute?.runtimeProvider === 'work-center' ? msg.workDir : msg.dirPath);
@@ -315,17 +347,11 @@ export async function handleListDirectory(msg) {
315
347
  const conv = ctx.conversations.get(conversationId);
316
348
  const workDir = msg.workDir || conv?.workDir || ctx.CONFIG.workDir;
317
349
 
318
- // 空路径:列出驱动器(Windows)或根目录(Unix)
350
+ // 空路径:列出固定磁盘(Windows)或根目录(Unix)
319
351
  if (!dirPath || dirPath === '') {
320
352
  try {
321
353
  if (platform() === 'win32') {
322
- const drives = [];
323
- for (const letter of 'CDEFGHIJKLMNOPQRSTUVWXYZ'.split('')) {
324
- const drivePath = letter + ':\\';
325
- if (existsSync(drivePath)) {
326
- drives.push({ name: letter + ':', type: 'directory', size: 0 });
327
- }
328
- }
354
+ const drives = await listWindowsFixedDrives();
329
355
  sendWorkbenchResult(ctx, msg, {
330
356
  type: 'directory_listing',
331
357
  conversationId,
@@ -277,6 +277,7 @@ export function createCliSessionRunner({
277
277
  try {
278
278
  for await (const event of engine.query(queryOptions)) {
279
279
  if (event.type === 'text_delta') resultText += event.text || '';
280
+ else if (event.type === 'response_presentation') resultText += event.message?.content || '';
280
281
  else if (event.type === 'error' && !failed) {
281
282
  failed = event.error instanceof Error
282
283
  ? event.error
package/yeaft/cli.js CHANGED
@@ -721,6 +721,9 @@ async function runREPL(config, args) {
721
721
  if (event.type === 'text_delta') {
722
722
  responseText += event.text || '';
723
723
  process.stdout.write(`\n[${vpId}] ${event.text || ''}`);
724
+ } else if (event.type === 'response_presentation') {
725
+ responseText += event.message?.content || '';
726
+ process.stdout.write(`\n[${vpId}] ${event.message?.content || ''}`);
724
727
  } else if (event.type === 'tool_start') {
725
728
  process.stderr.write(`\n[${vpId}] ${event.name}...\n`);
726
729
  } else if (event.type === 'error') {
@@ -1251,6 +1254,7 @@ async function runOnce(config, args) {
1251
1254
  modelEffort: args.modelEffort || session.config.modelEffort || null,
1252
1255
  onEvent: ({ vpId, event }) => {
1253
1256
  if (event.type === 'text_delta') process.stdout.write(`\n[${vpId}] ${event.text || ''}`);
1257
+ else if (event.type === 'response_presentation') process.stdout.write(`\n[${vpId}] ${event.message?.content || ''}`);
1254
1258
  else if (event.type === 'tool_start') process.stderr.write(`\n[${vpId}] ${event.name}...\n`);
1255
1259
  else if (event.type === 'error') process.stderr.write(`\n[${vpId}] Error: ${event.error?.message || event.error}\n`);
1256
1260
  },
@@ -377,6 +377,7 @@ function maybeWarnHistoryTruncated(sessionId, storeDir, recentTurnsLimit) {
377
377
  */
378
378
  let _permissionWarned = false;
379
379
  let _historyIndexMutationWarned = false;
380
+ const _segmentIndexWarned = new Set();
380
381
 
381
382
  /** Rough token estimation: ~4 chars per token. */
382
383
  export function estimateTokens(text) {
@@ -885,7 +886,7 @@ class SegmentStore {
885
886
  || normalizedSource.streamId !== this.lineage.streamId
886
887
  || !Number.isFinite(Number(normalizedSource?.revision))
887
888
  || Number(normalizedSource.revision) !== this.lineage.revision;
888
- if ((!existsSync(this.indexPath) || indexWasStale || needsMetadataUpgrade) && this.hasData()) this.saveIndex();
889
+ if ((!existsSync(this.indexPath) || indexWasStale || needsMetadataUpgrade) && this.hasData()) this.#saveRecoverableIndex();
889
890
  return this.index;
890
891
  }
891
892
 
@@ -894,6 +895,19 @@ class SegmentStore {
894
895
  writeFileSync(this.indexPath, `${JSON.stringify(this.index || emptySegmentIndex(), null, 2)}\n`, { encoding: 'utf8', mode: 0o644 });
895
896
  }
896
897
 
898
+ #saveRecoverableIndex() {
899
+ try {
900
+ this.saveIndex();
901
+ } catch (error) {
902
+ // JSONL owns the transcript; lineage already owns its identity/fence.
903
+ // Even a fresh read can rebuild this cache while its target is unwritable.
904
+ if (!_segmentIndexWarned.has(this.rootDir)) {
905
+ console.warn(`[Yeaft] Cannot save conversation index ${this.indexPath}: ${error?.message || error} — recoverable from transcript`);
906
+ _segmentIndexWarned.add(this.rootDir);
907
+ }
908
+ }
909
+ }
910
+
897
911
  metadata() {
898
912
  const idx = this.loadIndex();
899
913
  return {
@@ -918,6 +932,18 @@ class SegmentStore {
918
932
  segment = null;
919
933
  }
920
934
  const line = `${JSON.stringify(msg)}\n`;
935
+ const invalidatesEarlierRows = msg._reflection
936
+ || (Array.isArray(msg.foldedMessageIds) && msg.foldedMessageIds.length > 0);
937
+ // Prepare durable identity and the mutation fence BEFORE the commit point.
938
+ // A failed JSONL write can conservatively advance revision, but a committed
939
+ // fold must never be replayed with an older revision after index recovery.
940
+ this.#persistCurrentLineage({
941
+ revision: (Number(idx.revision) || 0) + (invalidatesEarlierRows ? 1 : 0),
942
+ anchor: establishesAnchor ? this.#lineageAnchorForLine(line) : undefined,
943
+ });
944
+ // A complete successful JSONL append is the transcript commit point. Never
945
+ // report a derived index failure as an uncommitted strict append afterward:
946
+ // callers would retry/publicize the same response under a second message id.
921
947
  appendFileSync(activePath, line, { encoding: 'utf8', mode: 0o644 });
922
948
 
923
949
  const seq = parseSeqFromId(msg.id);
@@ -933,25 +959,13 @@ class SegmentStore {
933
959
  idx.totalMessages = (idx.totalMessages || 0) + 1;
934
960
  idx.lastMessageId = msg.id || null;
935
961
  idx.nextSeq = Math.max(Number(idx.nextSeq) || 1, seq + 1);
936
- const invalidatesEarlierRows = msg._reflection
937
- || (Array.isArray(msg.foldedMessageIds) && msg.foldedMessageIds.length > 0);
938
- // Ordinary appends are recoverable through afterSeq. Only an append that
939
- // invalidates older visible rows (fold reflection/tombstones) advances the
940
- // mutation revision and forces the browser to rebuild its snapshot.
941
- if (invalidatesEarlierRows) {
942
- idx.revision = (Number(idx.revision) || 0) + 1;
943
- }
944
962
  if (msg._reflection && Array.isArray(msg.foldedMessageIds)) {
945
963
  idx.foldedMessageIds = Array.from(new Set([
946
964
  ...(Array.isArray(idx.foldedMessageIds) ? idx.foldedMessageIds : []),
947
965
  ...msg.foldedMessageIds.filter(id => typeof id === 'string' && id),
948
966
  ]));
949
967
  }
950
- this.#persistCurrentLineage({
951
- revision: idx.revision,
952
- anchor: establishesAnchor ? this.#lineageAnchorForLine(line) : undefined,
953
- });
954
- this.saveIndex();
968
+ this.#saveRecoverableIndex();
955
969
  }
956
970
 
957
971
  /**
@@ -1136,7 +1150,9 @@ class SegmentStore {
1136
1150
  revision: idx.revision,
1137
1151
  anchor: updatesAnchor ? this.#lineageAnchorForLine(JSON.stringify(updated)) : undefined,
1138
1152
  });
1139
- this.saveIndex();
1153
+ // The rewritten transcript and its revision are committed. A cache write
1154
+ // failure must not turn an accepted terminal update into a failed turn.
1155
+ this.#saveRecoverableIndex();
1140
1156
  return updated;
1141
1157
  }
1142
1158
 
@@ -1490,15 +1506,20 @@ export class ConversationStore {
1490
1506
  /**
1491
1507
  * Append a single message to the conversation.
1492
1508
  *
1509
+ * Full JSONL append is the commit point; a derived index failure afterward
1510
+ * returns the committed id and is recoverable on read. Identity/revision
1511
+ * preparation and transcript write failures still reject in strict mode.
1512
+ *
1493
1513
  * @param {object} msg — { role, content, mode?, model?, turnNumber?, toolCalls?, toolCallId?, isError? }
1514
+ * @param {{ strict?: boolean }} [options] — reject rather than return an unpersisted fallback on pre-commit permission errors
1494
1515
  * @returns {object} — the persisted message with id assigned
1495
1516
  */
1496
- append(msg) {
1517
+ append(msg, { strict = false } = {}) {
1497
1518
  let seq;
1498
1519
  try {
1499
1520
  seq = this.#reserveSeqRange(1);
1500
1521
  } catch (err) {
1501
- if (!isPermissionError(err)) throw err;
1522
+ if (strict || !isPermissionError(err)) throw err;
1502
1523
  // Live chat remains best-effort when its data root is read-only. The
1503
1524
  // returned row is usable by the current turn but is intentionally not
1504
1525
  // persisted; strict operations such as Session copy bypass this path.
@@ -1525,7 +1546,7 @@ export class ConversationStore {
1525
1546
  try {
1526
1547
  this.#segmentStoreFor(fullMsg, { create: true }).append(fullMsg);
1527
1548
  } catch (err) {
1528
- if (isPermissionError(err)) {
1549
+ if (!strict && isPermissionError(err)) {
1529
1550
  if (!_permissionWarned) {
1530
1551
  console.warn(`[Yeaft] Cannot write message ${id}: ${err.code} — message not persisted`);
1531
1552
  _permissionWarned = true;
package/yeaft/engine.js CHANGED
@@ -1095,6 +1095,7 @@ export class Engine {
1095
1095
  }
1096
1096
  const defs = [];
1097
1097
  for (const [, tool] of this.#tools) {
1098
+ if (activeToolNames && !activeToolNames.has(tool.name)) continue;
1098
1099
  defs.push({
1099
1100
  name: tool.name,
1100
1101
  description: tool.description,
@@ -1645,6 +1646,8 @@ export class Engine {
1645
1646
  if (message.responseKind === 'progress' || message.responseKind === 'result') {
1646
1647
  record.responseKind = message.responseKind;
1647
1648
  }
1649
+ if (message.presentation) record.presentation = message.presentation;
1650
+ if (message.presentationCallId) record.presentationCallId = message.presentationCallId;
1648
1651
  if (Array.isArray(message.foldedMessageIds) && message.foldedMessageIds.length > 0) {
1649
1652
  record.foldedMessageIds = [...message.foldedMessageIds];
1650
1653
  }
@@ -1671,7 +1674,7 @@ export class Engine {
1671
1674
  const hasToolCalls = Array.isArray(message.toolCalls) && message.toolCalls.length > 0;
1672
1675
  const hasThinking = Array.isArray(message.thinkingBlocks) && message.thinkingBlocks.length > 0;
1673
1676
  if (!hasContent && !hasToolCalls && !hasThinking && !message.providerState && message.role !== 'tool') return null;
1674
- return this.#conversationStore.append(this.#conversationRecord(message, context));
1677
+ return this.#conversationStore.append(this.#conversationRecord(message, context), { strict: context.strict === true });
1675
1678
  }
1676
1679
 
1677
1680
  #persistFoldedRange(foldedMessages, reflection, context = {}) {
@@ -1885,8 +1888,10 @@ export class Engine {
1885
1888
  let terminalEmitted = false;
1886
1889
  let terminalStopReason = 'error';
1887
1890
  let lastTurnNumber = 0;
1891
+ const publications = [];
1888
1892
  try {
1889
1893
  for await (const event of this.#queryLifecycle(params)) {
1894
+ if (event?.type === 'response_presentation') publications.push(event.message);
1890
1895
  if (Number.isFinite(event?.turnNumber)) lastTurnNumber = event.turnNumber;
1891
1896
  if (event?.type === 'turn_end' && event.terminal === true) {
1892
1897
  terminalEmitted = true;
@@ -1916,6 +1921,16 @@ export class Engine {
1916
1921
  console.warn('[Engine] post-turn maintenance failed:', err?.message || err);
1917
1922
  }
1918
1923
  } finally {
1924
+ // A delivery intent is not a successful terminal state. Persist failures
1925
+ // (including consumers closing the generator) for reconnect/history replay.
1926
+ if (!terminalEmitted || terminalStopReason !== 'end_turn') {
1927
+ for (const message of publications) {
1928
+ try { this.#conversationStore?.update?.(message, {
1929
+ responseKind: 'progress', incomplete: true,
1930
+ stopReason: terminalEmitted ? terminalStopReason : 'aborted',
1931
+ }); } catch (error) { console.warn('[Engine] publication status update failed:', error?.message || error); }
1932
+ }
1933
+ }
1919
1934
  if (queryTraceId && typeof this.#trace?.finalizeQuery === 'function') {
1920
1935
  this.#trace.finalizeQuery(queryTraceId, {
1921
1936
  sessionId: params.sessionId || null,
@@ -2375,12 +2390,15 @@ export class Engine {
2375
2390
  && typeof this.#taskManager.listActiveTasks === 'function'
2376
2391
  ? this.#taskManager.listActiveTasks(runtimeSessionId)
2377
2392
  : [];
2378
- const registeredToolNames = this.#toolRegistry
2393
+ const canPublishResponse = Boolean(runtimeSessionId) && this.#canPersistConversation()
2394
+ && !isSubAgent && scenario === 'chat' && !vpPersona?.subAgent;
2395
+ const registeredToolNames = (this.#toolRegistry
2379
2396
  ? this.#toolRegistry.getToolNames({
2380
2397
  collabToolPolicy: effectiveCollabToolPolicy,
2381
2398
  plugins: this.#config?.plugins,
2382
2399
  })
2383
- : Array.from(this.#tools.keys());
2400
+ : Array.from(this.#tools.keys()))
2401
+ .filter(name => name !== 'PresentResponse' || canPublishResponse);
2384
2402
  const registeredToolNameSet = new Set(registeredToolNames);
2385
2403
  const resolveCurrentActiveToolNames = () => this.#toolRegistry
2386
2404
  ? resolveActiveToolNames({
@@ -2398,7 +2416,7 @@ export class Engine {
2398
2416
  imageGenerationConfigured: typeof this.#config?.imageApiUrl === 'string'
2399
2417
  && this.#config.imageApiUrl.trim().length > 0,
2400
2418
  })
2401
- : null;
2419
+ : new Set(registeredToolNames);
2402
2420
  const discoveredToolNames = new Set();
2403
2421
  const discoveryTraversals = new Map();
2404
2422
  const currentDiscoverableTools = () => this.#toolRegistry
@@ -2615,6 +2633,7 @@ export class Engine {
2615
2633
  let displayImageAnchorMessage = null;
2616
2634
  let lastPersistedAssistantMessage = null;
2617
2635
  let lastPersistedAssistantTextMessage = null;
2636
+ const publishedResponses = new Map();
2618
2637
  let lastSuccessfulModel = null;
2619
2638
  let lastSuccessfulEffort = null;
2620
2639
  // `refreshConfig()` may publish a new Session model while a stream or a
@@ -2982,6 +3001,13 @@ export class Engine {
2982
3001
  let wireMessages = stripMetaForWire(pendingContinuationForRequest
2983
3002
  ? [...requestHistory, pendingContinuationForRequest]
2984
3003
  : requestHistory);
3004
+ // Publication metadata belongs to transcript/UI, including history
3005
+ // supplied on later turns. Only its plain summary enters model input.
3006
+ wireMessages = wireMessages.map(message => {
3007
+ if (!message.presentation && !message.presentationCallId) return message;
3008
+ const { presentation, presentationCallId, ...modelMessage } = message;
3009
+ return modelMessage;
3010
+ });
2985
3011
 
2986
3012
  if (scenario !== 'work-item' && this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
2987
3013
  try {
@@ -4089,6 +4115,7 @@ export class Engine {
4089
4115
  }
4090
4116
  if (stopReason === 'end_turn'
4091
4117
  && lastPersistedAssistantTextMessage
4118
+ && (!lastPersistedAssistantTextMessage.presentation || lastPersistedAssistantTextMessage.presentation.phase === 'final')
4092
4119
  && typeof this.#conversationStore?.update === 'function') {
4093
4120
  const resultMessage = this.#conversationStore.update(lastPersistedAssistantTextMessage, {
4094
4121
  responseKind: 'result',
@@ -4181,6 +4208,7 @@ export class Engine {
4181
4208
  /** @type {{ kind?: string, message?: string, sourceToolCallId?: string, sourceToolName?: string } | null} */
4182
4209
  let toolBatchBarrier = null;
4183
4210
  let currentToolCallForAsyncTask = null;
4211
+ const pendingPresentations = new Map();
4184
4212
  // task-707: requestEndTurn is a per-batch closure that lets a tool
4185
4213
  // signal "end this turn after the current batch — no adapter retry".
4186
4214
  // We re-create the closure each iteration because endTurnRequested
@@ -4335,6 +4363,13 @@ export class Engine {
4335
4363
  return {
4336
4364
  ...toolCtx,
4337
4365
  fileReadObservations,
4366
+ publishResponse: canPublishResponse ? presentation => {
4367
+ if (signal?.aborted || this.#currentQueryTurnId !== queryTurnId) {
4368
+ throw new Error('Response publication was cancelled.');
4369
+ }
4370
+ if (!publishedResponses.has(toolCall.id)) pendingPresentations.set(toolCall.id, presentation);
4371
+ return { callId: toolCall.id, sessionId: runtimeSessionId, turnId: queryTurnId, vpId: requestIdentity.vpId };
4372
+ } : null,
4338
4373
  currentToolCall: () => ({ ...stableToolCall }),
4339
4374
  askUser: typeof askUser === 'function'
4340
4375
  ? input => askUser(input, { ...stableToolCall })
@@ -4651,7 +4686,10 @@ export class Engine {
4651
4686
  output = stripDisplayImageData(output, displayImages);
4652
4687
  }
4653
4688
  isError = toolErrorOutput === 'json-error-envelope' && isToolErrorOutput(output);
4654
- yield { type: 'tool_end', id: tc.id, name: tc.name, output, displayImages, isError, threadId: this.currentThreadId };
4689
+ // Publication acknowledgement waits for its strict durable write below.
4690
+ if (!pendingPresentations.has(tc.id)) {
4691
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, displayImages, isError, threadId: this.currentThreadId };
4692
+ }
4655
4693
  if (displayImages.some(image => image.deliveryQueued === true)
4656
4694
  && lastPersistedAssistantMessage
4657
4695
  && typeof this.#conversationStore?.update === 'function') {
@@ -4837,6 +4875,45 @@ export class Engine {
4837
4875
  if (fatalToolError) throw fatalToolError;
4838
4876
  }
4839
4877
 
4878
+ // Complete the result batch before adding assistant publications so both
4879
+ // persisted history and provider requests retain contiguous tool pairs.
4880
+ for (const [callId, presentation] of pendingPresentations) {
4881
+ if (signal?.aborted || this.#currentQueryTurnId !== queryTurnId) break;
4882
+ if (publishedResponses.has(callId)) continue;
4883
+ const toolMessage = conversationMessages.findLast(row => row.role === 'tool' && row.toolCallId === callId);
4884
+ const message = {
4885
+ role: 'assistant', content: presentation.summary, presentation,
4886
+ presentationCallId: callId, responseKind: 'progress',
4887
+ speakerVpId: requestIdentity.vpId,
4888
+ };
4889
+ let record;
4890
+ try {
4891
+ record = this.#persistConversationMessage(message, {
4892
+ sessionId: runtimeSessionId, turnId: queryTurnId, model: currentModel, executionOrigin, strict: true,
4893
+ });
4894
+ if (!record?.id) throw new Error('Response publication could not be persisted.');
4895
+ } catch (error) {
4896
+ // A storage error is a recoverable tool failure, not a delivery or a
4897
+ // successful final answer. Let the model correct it or use plain text.
4898
+ const output = JSON.stringify({ error: `Response publication failed: ${error.message}` });
4899
+ if (toolMessage) Object.assign(toolMessage, { content: output, isError: true });
4900
+ const toolRecord = this.#persistedToolMessages.get(callId);
4901
+ try { this.#conversationStore?.update?.(toolRecord, { content: output, isError: true }); } catch { /* original storage failure */ }
4902
+ yield { type: 'tool_end', id: callId, name: 'PresentResponse', output, isError: true, threadId: runtimeThreadId };
4903
+ continue;
4904
+ }
4905
+ publishedResponses.set(callId, record);
4906
+ lastPersistedAssistantTextMessage = record;
4907
+ // Metadata is transcript/UI-only. Providers receive the summary as a
4908
+ // regular assistant string, never a generic unknown content block.
4909
+ conversationMessages.push({ role: 'assistant', content: message.content, _persistedMessageId: record.id });
4910
+ fullResponseText += presentation.summary;
4911
+ // Do not yield an acknowledgement between the cancellation fence/write
4912
+ // and the publication: a consumer can abort at every yielded event.
4913
+ yield { type: 'response_presentation', message: record, threadId: runtimeThreadId };
4914
+ yield { type: 'tool_end', id: callId, name: 'PresentResponse', output: toolMessage?.content || '', isError: false, threadId: runtimeThreadId };
4915
+ }
4916
+
4840
4917
  // Preload rules for observed read paths before the next provider input.
4841
4918
  // No tool from the just-completed response can benefit retroactively.
4842
4919
  projectDocSource = this.#getProjectDocBlock(workDir);
@@ -4,10 +4,13 @@ import { createdCapabilityRecord, validateCreatedDefinition } from './created-ca
4
4
  import { runPersonScript, scriptInput, testPersonScript } from './script-executor.js';
5
5
  import { chronologicalCursor, createdSkillView, inspectionCursor, inspectionPage, inspectionTime } from './inspection.js';
6
6
  import { publishOutput } from './outputs.js';
7
+ import { PRESENTATION_SCHEMA, PRESENT_RESPONSE_DESCRIPTION } from '../response-presentation.js';
8
+ import { publishPresentation } from './presentation.js';
7
9
 
8
10
  // Intrinsic cognition, bounded pure scripts and supported native host tools.
9
11
  // No shell/filesystem/network/VP authority is exposed to generated code.
10
12
  const entries = [
13
+ { id: 'PresentResponse', version: 1, domain: 'delivery', description: 'Publish a structured user-visible answer with a searchable plain-text summary.', keywords: '展示 回答 图表 表格 卡片 时间线 present response chart table cards timeline rich', useWhen: 'Rich structure improves an answer; ordinary reply remains valid for simple text.', avoidWhen: 'Do not expose secrets, fabricate evidence or repeat an already published summary.', instructions: `${PRESENT_RESPONSE_DESCRIPTION} Invoke via next.capability; set reply:null for this delivery. Publication never commits state/concepts or ends the episode, even with phase=final; reserve a later next:null for the cognitive commit.`, args: PRESENTATION_SCHEMA, access: 'publish-owner-message' },
11
14
  { 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: 'Advance an unresolved question: test, integrate a result or distill an insight. Record the change, not a status recap. Follow the progress/continuation rules; quiet rest is valid. This method makes no model call or external action.', args: {} },
12
15
  { 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' },
13
16
  { 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' } },
@@ -20,7 +23,11 @@ const manifests = [...entries.map(entry => {
20
23
  return { ...contract, revision: digest(contract) };
21
24
  }), ...NATIVE_TOOL_MANIFESTS];
22
25
  const foundationIds = new Set(['Think', 'Recall', 'Capability.create']);
23
- export const CAPABILITY_LIMITS = Object.freeze({ familiar: 2, familiarMaxAgeMs: 30 * 24 * 60 * 60 * 1000, activeBytes: 8192, searchContractBytes: 4096 });
26
+ export const CAPABILITY_LIMITS = Object.freeze({ familiar: 2, familiarMaxAgeMs: 30 * 24 * 60 * 60 * 1000, activeBytes: 8192, presentationBytes: 12288, searchContractBytes: 4096 });
27
+ // The complete shared rich schema is demand-loaded. Keep ordinary/script
28
+ // budgets unchanged while allowing this larger, bounded contract alongside foundation.
29
+ export const capabilityContextBudget = items => items.some(item => item.id === 'PresentResponse')
30
+ ? CAPABILITY_LIMITS.presentationBytes : CAPABILITY_LIMITS.activeBytes;
24
31
  export const CAPABILITY_MAP = Object.freeze({
25
32
  domains: [...new Set(manifests.map(m => m.domain)), 'script'], total: manifests.length,
26
33
  nativeTools: NATIVE_TOOL_IDS,
@@ -111,7 +118,7 @@ export class PersonCapabilities {
111
118
  this.byId = new Map([...manifests.filter(m => !isNativeTool(m.id) || allowed.has(m.id)), ...created.map(scriptManifest)].map(m => [m.id, m]));
112
119
  this.prepared = new Map(foundationCapabilities().map(m => [m.id, m]));
113
120
  for (const item of familiarCapabilities(experience, triggerKind, now, this.byId)) {
114
- if (bytes([...this.prepared.values(), item]) <= CAPABILITY_LIMITS.activeBytes) this.prepared.set(item.id, item);
121
+ if (bytes([...this.prepared.values(), item]) <= capabilityContextBudget([...this.prepared.values(), item])) this.prepared.set(item.id, item);
115
122
  }
116
123
  this.active = new Map(this.prepared);
117
124
  }
@@ -132,7 +139,8 @@ export class PersonCapabilities {
132
139
  }
133
140
  canPrepareAll(requested) {
134
141
  const optional = requested.filter(m => !foundationIds.has(m.id)).map(m => available(m, 'discovered', 'explicitly-requested'));
135
- return bytes([...foundationCapabilities(), ...optional]) <= CAPABILITY_LIMITS.activeBytes;
142
+ const items = [...foundationCapabilities(), ...optional];
143
+ return bytes(items) <= capabilityContextBudget(items);
136
144
  }
137
145
  prepare(manifest) { this.prepareAll([manifest]); }
138
146
  prepareAll(requested) {
@@ -142,7 +150,7 @@ export class PersonCapabilities {
142
150
  const next = [...items, ...this.prepared.values()].filter((m, i, all) => all.findIndex(n => n.id === m.id) === i);
143
151
  const kept = next.filter(m => foundationIds.has(m.id));
144
152
  for (const candidate of next.filter(m => !foundationIds.has(m.id))) {
145
- if (bytes([...kept, candidate]) <= CAPABILITY_LIMITS.activeBytes) kept.push(candidate);
153
+ if (bytes([...kept, candidate]) <= capabilityContextBudget([...kept, candidate])) kept.push(candidate);
146
154
  }
147
155
  if (requested.some(m => !kept.some(n => n.id === m.id))) fail('CONTEXT_LIMIT');
148
156
  this.prepared = new Map(kept.map(m => [m.id, m]));
@@ -186,6 +194,7 @@ export class PersonCapabilities {
186
194
  return this.toolHost.execute(id, args, { signal, callId, episodeId: this.episode?.id });
187
195
  }
188
196
  if (id === 'Output.publish') return publishOutput(this.repository, this.episode, this.workDir, args, { signal, callId });
197
+ if (id === 'PresentResponse') return publishPresentation(this.repository, this.episode, args, { signal, callId });
189
198
  if (id === 'Capability.create') {
190
199
  if (!this.episode || !callId) fail('UNSUPPORTED');
191
200
  const definition = validateCreatedDefinition(args);
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto';
3
3
  // 只允许应用层显式产物;不要求或保存 provider 的隐藏推理。
4
4
  // Bytes protect the in-memory collector and persisted public records, not model
5
5
  // token capability. Token limits come exclusively from the native model resolver.
6
- export const LIMITS = Object.freeze({ inputBytes: 8192, outputBytes: 2 * 1024 * 1024, responseBytes: 6 * 1024 * 1024, calls: 16, leaseMs: 15000 });
6
+ export const LIMITS = Object.freeze({ inputBytes: 8192, outputBytes: 2 * 1024 * 1024, responseBytes: 6 * 1024 * 1024, leaseMs: 15000 });
7
7
  // Feedback cadence is not an execution deadline. Slow providers/tools remain cancellable.
8
8
  export const FEEDBACK = Object.freeze({ minMs: 30000, maxMs: 60000 });
9
9
  export const FEEDBACK_INSTRUCTIONS = "feedback describes user-visible communication, not a deadline. After about 30 seconds without a substantive reply, use reply for a concise, useful progress update at a natural execution boundary; aim for 30\u201360 seconds according to actual progress. Explain verified findings, limitations and what you are checking next, in the user's language. Do not repeat feedback.lastReply, invent progress, expose internal deliberation/decision/selfCheck, or claim an unexecuted tool succeeded. If no meaningful update exists, reply:null is appropriate; the host shows an honest waiting status around 60 seconds without interrupting the current request. With next non-null, a validated reply may be published as progress independently of cognitive state, and retained on cancellation/failure. Only next:null commits cognition and supplies the final reply. Do not call a tool or an extra model just to report progress. Each final reply should stand on its own and note unfinished work.";
@@ -195,7 +195,7 @@ export const PROPOSAL_INSTRUCTIONS = `Return ONLY strict JSON: concise applicati
195
195
  Schema (all fields required except optional state.continuation below; no extra keys):
196
196
  {baseStateVersion:number, activity:{kind:"think|recall|reorganize|associate|rethink|imagine|respond|rest",summary:string,sourceRefs:string[]}, decision:{summary:string,uncertainties:string[],selfCheck:string}, concepts:[{id:string,expectedRevision:number,kind:"claim|question|method|self-model|scenario|interest",statement:string,epistemicState:"reported|hypothesis|imagined|uncertain",sourceRefs:string[],associations:[{targetId:string,relation:"related|supports|contradicts|questions|imagines"}]}], state:{summary:string,focusConceptIds:string[],appraisal:string}, reply:string|null, next:null|{model:string,effort:string|null,reason:string,capability:null|{id:string,args:object}}}.
197
197
  New concepts: stable ID, expectedRevision:0; revisions use the revision read. Max 12 concepts/associations/focus/uncertainties, 24 sourceRefs/report roots. UTF-8 bytes: statement/summary <=4000, selfCheck/appraisal <=2000, uncertainty <=500, next reason <=1000; reply/native args <=budget.maxOutputBytes. Use exact supplied sourceRefs: provenance, not proof. Scenario is imagined; new Dream concepts are imagined/hypothesis/uncertain. Every reported concept (including Dream revisions) needs a user message, nonempty send/think trigger or reported concept preserving reportedSourceRefs. Assistant/tool/generated text and Dream/empty Think triggers are not user reports. Cite prior reported concepts when revising to preserve lineage. Reported means attributed input, not verified fact. External observations can support hypotheses/uncertainty, never permission. Native effects are not rolled back by failure, cancellation or rejected commit.
198
- budget.remainingCalls includes this call; reserve the last for next:null. Recall + final needs 2; search + use + final needs 3. No extra calls.
198
+ No call quota; finish with next:null when complete or blocked.
199
199
  Use capabilities.active directly, otherwise search/view; only rendered current contracts execute. Familiarity is execution, not usefulness/permission; assess in decision.selfCheck. Follow active contracts. Capability.create publishes pure QuickJS scripts; tests are limited evidence, publication survives failure. Scripts have no host access/automatic execution. Avoid duplicates/private data.
200
200
  Progress, not status narration: Think advances an unresolved question, accepted continuation or actual result: verify, execute an authorized step, integrate an answer/artifact, or distill a reusable method/judgment/question. activity.summary states what changed; decision.summary gives its value and continue/stop reason. Rewording state, listing abilities or promises is not progress. Revise concepts only when evidence changes them; otherwise leave concepts empty. No forced novelty or compulsory tools.
201
201
  Dream explores associations into a hypothesis, discriminating check, scenario or insight, not a status recap. Imagination is not evidence/permission. Further calls should add value; reply:null and quiet rest are valid. Not every conversation is a task.
@@ -1,4 +1,5 @@
1
1
  import { bytes, fail, identifier, object, page, text } from './contracts.js';
2
+ import { presentationView } from './presentation.js';
2
3
 
3
4
  /** Timestamp + binary ID keysets survive insertion, deletion of the boundary
4
5
  * record and same-millisecond writes. Builtins have no invented chronology.
@@ -47,6 +48,10 @@ const pick = (value, keys) => value == null ? null : Object.fromEntries(keys.fil
47
48
  export const settingsView = value => pick(value, ['autonomyEnabled', 'modelCandidates', 'defaultModel']);
48
49
  export function messageView(value) {
49
50
  const result = pick(value, ['id', 'schemaVersion', 'revision', 'seq', 'episodeId', 'clientMessageId', 'callId', 'replyKind', 'role', 'text', 'createdAt']);
51
+ if (result && value.role === 'assistant') {
52
+ const presentation = presentationView(value.presentation);
53
+ if (presentation) result.presentation = presentation;
54
+ }
50
55
  if (result && Array.isArray(value.attachments)) result.attachments = value.attachments.slice(0, 4)
51
56
  .map(file => pick(file, ['id', 'name', 'mimeType', 'size', 'sha256', 'kind']));
52
57
  return result;
@@ -0,0 +1,32 @@
1
+ import { normalizePresentation } from '../response-presentation.js';
2
+ import { fail, identifier } from './contracts.js';
3
+
4
+ /** Validate the shared payload at both execution and durable authority boundaries. */
5
+ export function personPresentation(args) {
6
+ try { return normalizePresentation(args); }
7
+ catch { fail('INVALID_REQUEST'); }
8
+ }
9
+
10
+ /** Older/invalid JSON records still have their ordinary searchable text fallback. */
11
+ export function presentationView(value) {
12
+ if (!value || value.version !== 1) return null;
13
+ const { version, ...input } = value;
14
+ if (input.title === '') delete input.title;
15
+ try { return normalizePresentation(input); } catch { return null; }
16
+ }
17
+
18
+ /** Publication is joined, not raced against abort: an accepted message is durable
19
+ * even if cancellation arrives before the worker's receipt. No cognitive commit. */
20
+ export async function publishPresentation(repository, episode, args, { signal, callId } = {}) {
21
+ signal?.throwIfAborted();
22
+ try { personPresentation(args); }
23
+ catch { return { ok: false, code: 'INVALID_REQUEST', published: false,
24
+ notice: 'Nothing was published. Correct the structured response or use an ordinary reply.' }; }
25
+ if (!episode || !callId || !repository.savePresentation) fail('UNSUPPORTED');
26
+ identifier(callId);
27
+ signal?.throwIfAborted();
28
+ const message = await repository.savePresentation(episode, { callId, args });
29
+ return { ok: true, published: true, messageId: message.id, summary: message.text,
30
+ phase: message.presentation.phase, createdAt: message.createdAt,
31
+ notice: 'User-visible communication only, not a cognitive commit or episode termination. Do not repeat this summary in reply. Publication survives subsequent cancellation or failure.' };
32
+ }
@@ -150,6 +150,7 @@ export async function collectOutput(adapter, params, onEffort) {
150
150
  else if (event.type === 'tool_call') fail('INVALID_PROPOSAL');
151
151
  else if (event.type === 'usage') {
152
152
  usage = addUsage(usage, event, params.usageCacheIncluded);
153
+ params.onUsage?.(usage);
153
154
  }
154
155
  // Hidden thinking, signatures, opaque provider state and raw HTTP exchanges are deliberately not archived.
155
156
  }