@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/local-runtime/server/handlers/client-person.js +10 -0
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +286 -129
- 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/workbench/file-ops.js +34 -8
- package/yeaft/cli-session-runner.js +1 -0
- package/yeaft/cli.js +4 -0
- package/yeaft/conversation/persist.js +39 -18
- package/yeaft/engine.js +82 -5
- package/yeaft/person/capabilities.js +13 -4
- package/yeaft/person/contracts.js +2 -2
- package/yeaft/person/inspection.js +5 -0
- package/yeaft/person/presentation.js +32 -0
- package/yeaft/person/provider.js +1 -0
- package/yeaft/person/runtime.js +174 -53
- package/yeaft/person/service.js +22 -6
- package/yeaft/person/sqlite-repository.js +2 -0
- package/yeaft/person/sqlite-store.js +84 -17
- package/yeaft/person/task-host.js +20 -5
- package/yeaft/person/turn-diagnostics.js +7 -3
- package/yeaft/response-presentation.js +81 -0
- package/yeaft/stdio-protocol.js +6 -0
- package/yeaft/tools/activation.js +1 -0
- package/yeaft/tools/index.js +2 -0
- package/yeaft/tools/present-response.js +27 -0
- package/yeaft/web-bridge.js +71 -3
|
Binary file
|
package/package.json
CHANGED
package/workbench/file-ops.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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.#
|
|
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
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
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]) <=
|
|
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
|
-
|
|
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]) <=
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|
package/yeaft/person/provider.js
CHANGED
|
@@ -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
|
}
|