@olares/lares-core 0.15.6 → 0.15.9
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/README.md +2 -1
- package/brand/identity.js +33 -3
- package/drive/cat.js +62 -0
- package/drive/download.js +3 -3
- package/drive/execute.js +8 -2
- package/drive/files-path.js +33 -0
- package/drive/flowstudio-files.js +87 -0
- package/drive/ls.js +3 -3
- package/drive/paths.js +23 -13
- package/drive/tools.js +4 -2
- package/entrance/surface.js +96 -0
- package/files/app-link.js +64 -0
- package/files/backend-client.js +266 -0
- package/files/deliverables.js +55 -1
- package/files/intake.js +0 -63
- package/files/mention.js +1 -51
- package/files/preview-groups.js +71 -3
- package/files/preview-local.js +69 -0
- package/files/preview-workspace.js +114 -13
- package/files/preview.js +171 -16
- package/files/produced-gate.js +197 -0
- package/files/produced-mentions.js +19 -0
- package/files/published-tools.js +69 -0
- package/files/published-turn-data.js +84 -0
- package/i18n/chat-model.js +5 -5
- package/i18n/mcp.js +83 -0
- package/i18n/mobile.js +100 -2
- package/i18n/preview.js +10 -0
- package/i18n/search.js +13 -7
- package/i18n/skills.js +31 -0
- package/i18n/voice.js +88 -19
- package/larepass/chat.js +9 -0
- package/larepass/host.js +7 -2
- package/larepass/mux.js +210 -2
- package/larepass/process-fold.js +239 -0
- package/larepass/queue.js +44 -0
- package/larepass/rpc.js +50 -3
- package/larepass/runtime.js +215 -83
- package/larepass/session-cache.js +6 -0
- package/larepass/settings.js +15 -7
- package/larepass/transcript.js +49 -11
- package/mcp/approval.js +152 -0
- package/mcp/client.js +38 -0
- package/mcp/config.js +139 -0
- package/media/catalog-row.js +244 -0
- package/media/generation.js +707 -0
- package/media/tool.js +92 -0
- package/olares/entrance.js +67 -15
- package/olares/identity.js +13 -136
- package/olares/session-identity.js +12 -12
- package/olares/trusted-host.js +0 -29
- package/package.json +48 -4
- package/router/catalog-cache.d.ts +1 -1
- package/router/catalog-cache.js +25 -14
- package/router/catalog-sync.js +8 -3
- package/router/catalog.d.ts +6 -1
- package/router/catalog.js +18 -2
- package/router/gateway.d.ts +9 -1
- package/router/gateway.js +40 -4
- package/router/gemini-schema.js +125 -0
- package/router/generation-guard.js +82 -0
- package/router/models.d.ts +6 -0
- package/router/models.js +22 -3
- package/router/search.js +10 -2
- package/router/session-model.js +53 -0
- package/router/shim.js +104 -3
- package/router/sref.js +126 -0
- package/router/stt.js +43 -12
- package/router/tts.js +118 -0
- package/search/client.js +5 -3
- package/search/config.js +53 -39
- package/search/menu.js +45 -10
- package/skills/client.js +42 -0
- package/skills/packs.js +40 -0
- package/skills/state.d.ts +22 -0
- package/skills/state.js +142 -0
- package/tools/crypto-uuid.js +3 -6
- package/tools/head-script.js +15 -0
- package/voice/agent.js +50 -0
- package/voice/capture.js +250 -0
- package/voice/client.js +126 -24
- package/voice/config.js +11 -1
- package/voice/duplex.js +44 -0
- package/voice/endpoint.js +57 -0
- package/voice/host/bluetooth-ready.py +1196 -0
- package/voice/host/install-host.sh +102 -0
- package/voice/input.js +4 -0
- package/voice/install.js +146 -0
- package/voice/languages.js +11 -1
- package/voice/link.js +86 -0
- package/voice/pcm.js +100 -0
- package/voice/playback.js +7 -0
- package/voice/problem.js +46 -0
- package/voice/recorder.js +79 -4
- package/voice/script.js +35 -0
- package/voice/service.js +253 -10
- package/voice/session.js +128 -0
- package/voice/speech.js +93 -0
- package/files/preview-cache.js +0 -100
package/i18n/voice.js
CHANGED
|
@@ -8,28 +8,63 @@ export const ZH = {
|
|
|
8
8
|
"error.voice_no_input": "没有检测到声音",
|
|
9
9
|
"error.voice_failed": "转写失败,请重试",
|
|
10
10
|
"error.voice_unsupported": "当前浏览器不支持录音",
|
|
11
|
+
"error.voice_bluetooth_not_ready": "蓝牙麦克风还没连上,请重试",
|
|
12
|
+
"error.voice_bluetooth_unavailable": "这台机器的蓝牙还没就绪",
|
|
13
|
+
"error.voice_bluetooth_scan_failed": "扫描附近设备失败",
|
|
14
|
+
"error.voice_bluetooth_connect_failed": "连接失败,请确认设备仍在等待连接",
|
|
15
|
+
"error.voice_bluetooth_invalid": "蓝牙地址无效",
|
|
16
|
+
"error.voice_tts_unavailable": "没有可用的语音播报模型,请先在语音设置里安装",
|
|
17
|
+
"error.voice_speak_disabled": "语音播报是关着的",
|
|
18
|
+
"error.voice_speak_failed": "语音播报失败,请重试",
|
|
19
|
+
"error.voice_speak_timeout": "语音播报超时,请重试",
|
|
20
|
+
"error.voice_speak_unready": "当前不能保存语音播报开关",
|
|
21
|
+
"mic.preparing": "正在连接蓝牙麦克风…",
|
|
11
22
|
"mic.stop": "点击结束录音",
|
|
12
23
|
"mic.transcribing": "转写中…",
|
|
13
24
|
"mic.idle": "语音输入",
|
|
14
25
|
"mic.blocked": "当前无法输入",
|
|
15
26
|
"lang.auto": "自动检测",
|
|
16
27
|
"status.auto": "自动",
|
|
17
|
-
"settings.title": "
|
|
18
|
-
"settings.intro": "
|
|
28
|
+
"settings.title": "语音设置",
|
|
29
|
+
"settings.intro": "点击麦克风开始说话,说完自动转成文字。语音播报开启后,只朗读这一轮的最终回答。",
|
|
19
30
|
"settings.refresh": "刷新",
|
|
20
31
|
"settings.refreshing": "刷新中…",
|
|
21
32
|
"settings.router": "Router 控制台",
|
|
22
|
-
"settings.loading": "
|
|
23
|
-
"settings.loadFailed": "
|
|
33
|
+
"settings.loading": "正在读取语音设置…",
|
|
34
|
+
"settings.loadFailed": "读取语音设置失败",
|
|
24
35
|
"settings.refreshFailed": "刷新语音模型列表失败",
|
|
25
|
-
"settings.status.ready": "
|
|
26
|
-
"settings.status.notReady": "
|
|
27
|
-
"settings.
|
|
28
|
-
"settings.
|
|
29
|
-
"settings.
|
|
36
|
+
"settings.status.ready": "识别就绪 · {model}",
|
|
37
|
+
"settings.status.notReady": "还没有语音识别模型",
|
|
38
|
+
"settings.stt.title": "语音识别",
|
|
39
|
+
"settings.stt.hint": "把麦克风里的话转成文字",
|
|
40
|
+
"settings.tts.title": "语音播报",
|
|
41
|
+
"settings.tts.hint": "只朗读最终回答,思考和工具调用不播报",
|
|
42
|
+
"settings.speak.label": "开启语音播报",
|
|
43
|
+
"settings.install": "安装",
|
|
44
|
+
"settings.installing": "安装中…",
|
|
45
|
+
"settings.installStarted": "已开始安装,模型下载完成后点刷新",
|
|
46
|
+
"settings.installFailed": "安装失败",
|
|
47
|
+
"settings.probe": "检测",
|
|
48
|
+
"settings.probing": "检测中…",
|
|
49
|
+
"settings.probeOk": "连通正常",
|
|
50
|
+
"settings.probeFailed": "连通失败",
|
|
51
|
+
"settings.stop": "停用",
|
|
52
|
+
"settings.stopping": "停用中…",
|
|
53
|
+
"settings.stopFailed": "停用失败",
|
|
54
|
+
"settings.resume": "启用",
|
|
55
|
+
"settings.resuming": "启用中…",
|
|
56
|
+
"settings.resumeFailed": "启用失败",
|
|
30
57
|
"settings.language.title": "识别语言",
|
|
31
58
|
"settings.language.hint": "指定语言可提升准确度与速度",
|
|
32
59
|
"settings.saveFailed": "保存失败",
|
|
60
|
+
"settings.bluetooth.title": "蓝牙设备",
|
|
61
|
+
"settings.bluetooth.hint": "连上后可以用这台设备说话,回答也会从它播出来",
|
|
62
|
+
"settings.bluetooth.scan": "扫描",
|
|
63
|
+
"settings.bluetooth.scanning": "扫描中…",
|
|
64
|
+
"settings.bluetooth.connect": "连接",
|
|
65
|
+
"settings.bluetooth.connecting": "连接中…",
|
|
66
|
+
"settings.bluetooth.connected": "已连接",
|
|
67
|
+
"settings.bluetooth.empty": "附近没有正在等待连接的设备",
|
|
33
68
|
};
|
|
34
69
|
|
|
35
70
|
export const EN = {
|
|
@@ -43,28 +78,62 @@ export const EN = {
|
|
|
43
78
|
"error.voice_no_input": "No voice detected",
|
|
44
79
|
"error.voice_failed": "Transcription failed, please retry",
|
|
45
80
|
"error.voice_unsupported": "This browser does not support recording",
|
|
81
|
+
"error.voice_bluetooth_not_ready": "Bluetooth microphone is not ready, please retry",
|
|
82
|
+
"error.voice_bluetooth_unavailable": "Bluetooth is not ready on this machine",
|
|
83
|
+
"error.voice_bluetooth_scan_failed": "Failed to scan for nearby devices",
|
|
84
|
+
"error.voice_bluetooth_connect_failed": "Connection failed. Confirm the device is still waiting to connect",
|
|
85
|
+
"error.voice_bluetooth_invalid": "Invalid Bluetooth address",
|
|
86
|
+
"error.voice_tts_unavailable": "No speech model is available. Install one in voice settings first",
|
|
87
|
+
"error.voice_speak_disabled": "Speech playback is off",
|
|
88
|
+
"error.voice_speak_failed": "Couldn't speak the answer, please retry",
|
|
89
|
+
"error.voice_speak_timeout": "Speech timed out, please retry",
|
|
90
|
+
"error.voice_speak_unready": "Speech playback can't be saved right now",
|
|
91
|
+
"mic.preparing": "Connecting the Bluetooth microphone…",
|
|
46
92
|
"mic.stop": "Click to stop recording",
|
|
47
93
|
"mic.transcribing": "Transcribing…",
|
|
48
94
|
"mic.idle": "Voice input",
|
|
49
95
|
"mic.blocked": "Input unavailable",
|
|
50
96
|
"lang.auto": "Auto-detect",
|
|
51
97
|
"status.auto": "auto",
|
|
52
|
-
"settings.title": "Voice
|
|
98
|
+
"settings.title": "Voice settings",
|
|
53
99
|
"settings.intro":
|
|
54
|
-
"Click the microphone
|
|
100
|
+
"Click the microphone to start speaking; the take is transcribed into the composer. With playback on, only the turn's final answer is spoken.",
|
|
55
101
|
"settings.refresh": "Refresh",
|
|
56
102
|
"settings.refreshing": "Refreshing…",
|
|
57
103
|
"settings.router": "Router console",
|
|
58
|
-
"settings.loading": "Loading voice
|
|
59
|
-
"settings.loadFailed": "Failed to load voice
|
|
104
|
+
"settings.loading": "Loading voice settings…",
|
|
105
|
+
"settings.loadFailed": "Failed to load voice settings",
|
|
60
106
|
"settings.refreshFailed": "Failed to refresh voice models",
|
|
61
|
-
"settings.status.ready": "
|
|
62
|
-
"settings.status.notReady":
|
|
63
|
-
|
|
64
|
-
"settings.
|
|
65
|
-
"settings.
|
|
66
|
-
"settings.
|
|
107
|
+
"settings.status.ready": "Recognition ready · {model}",
|
|
108
|
+
"settings.status.notReady": "No speech-recognition model yet",
|
|
109
|
+
"settings.stt.title": "Speech recognition",
|
|
110
|
+
"settings.stt.hint": "Turn the microphone into text",
|
|
111
|
+
"settings.tts.title": "Speech playback",
|
|
112
|
+
"settings.tts.hint": "Read only the final answer, not reasoning or tool calls",
|
|
113
|
+
"settings.speak.label": "Speak the final answer",
|
|
114
|
+
"settings.install": "Install",
|
|
115
|
+
"settings.installing": "Installing…",
|
|
116
|
+
"settings.installStarted": "Install started. Refresh after the model finishes downloading",
|
|
117
|
+
"settings.installFailed": "Install failed",
|
|
118
|
+
"settings.probe": "Check",
|
|
119
|
+
"settings.probing": "Checking…",
|
|
120
|
+
"settings.probeOk": "Connected",
|
|
121
|
+
"settings.probeFailed": "Unreachable",
|
|
122
|
+
"settings.stop": "Stop",
|
|
123
|
+
"settings.stopping": "Stopping…",
|
|
124
|
+
"settings.stopFailed": "Stop failed",
|
|
125
|
+
"settings.resume": "Start",
|
|
126
|
+
"settings.resuming": "Starting…",
|
|
127
|
+
"settings.resumeFailed": "Start failed",
|
|
67
128
|
"settings.language.title": "Recognition language",
|
|
68
129
|
"settings.language.hint": "Specifying a language improves accuracy and speed",
|
|
69
130
|
"settings.saveFailed": "Failed to save",
|
|
131
|
+
"settings.bluetooth.title": "Bluetooth devices",
|
|
132
|
+
"settings.bluetooth.hint": "After connecting, speak into this device and hear answers from it",
|
|
133
|
+
"settings.bluetooth.scan": "Scan",
|
|
134
|
+
"settings.bluetooth.scanning": "Scanning…",
|
|
135
|
+
"settings.bluetooth.connect": "Connect",
|
|
136
|
+
"settings.bluetooth.connecting": "Connecting…",
|
|
137
|
+
"settings.bluetooth.connected": "Connected",
|
|
138
|
+
"settings.bluetooth.empty": "No nearby device is waiting to connect",
|
|
70
139
|
};
|
package/larepass/chat.js
CHANGED
|
@@ -29,6 +29,13 @@ export async function callRpc(request, method, payload = {}) {
|
|
|
29
29
|
}
|
|
30
30
|
const parsed = unwrapServerResponse(res.body);
|
|
31
31
|
if (!parsed.ok) return { ...parsed, status: "rpc-error", http };
|
|
32
|
+
// dsh 0.2 session/page returns records. Callers still read `events`.
|
|
33
|
+
if (Array.isArray(parsed.value?.records) && !Array.isArray(parsed.value?.events)) {
|
|
34
|
+
parsed.value = {
|
|
35
|
+
...parsed.value,
|
|
36
|
+
events: parsed.value.records.map((record) => record?.event).filter(Boolean),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
32
39
|
return { ...parsed, status: "ok", http };
|
|
33
40
|
}
|
|
34
41
|
|
|
@@ -96,6 +103,8 @@ export function summarizeSession(row) {
|
|
|
96
103
|
...(projections.contextBreakdown ? { contextBreakdown: projections.contextBreakdown } : {}),
|
|
97
104
|
...(projections.goal ? { goal: projections.goal } : {}),
|
|
98
105
|
...(Array.isArray(projections.todos) ? { todos: projections.todos } : {}),
|
|
106
|
+
...(projections.modelSelection ? { modelSelection: projections.modelSelection } : {}),
|
|
107
|
+
...(Number.isInteger(row?.projections?.asOfSeq) ? { asOfSeq: row.projections.asOfSeq } : {}),
|
|
99
108
|
};
|
|
100
109
|
}
|
|
101
110
|
|
package/larepass/host.js
CHANGED
|
@@ -51,7 +51,7 @@ export function hostConfigFromEnv(env = {}) {
|
|
|
51
51
|
* object, so a key that exists in one place and not the other is dropped in
|
|
52
52
|
* silence — which is how `socketProtocol` shipped inert once already.
|
|
53
53
|
*/
|
|
54
|
-
export const HOST_PORT_KEYS = ["baseUrl", "proxyPrefix", "env", "request", "socketProtocol"];
|
|
54
|
+
export const HOST_PORT_KEYS = ["baseUrl", "proxyPrefix", "env", "request", "socketProtocol", "missing"];
|
|
55
55
|
|
|
56
56
|
/** Collect the port keys off any carrier (a component instance included). */
|
|
57
57
|
export function pickHostPorts(source = {}) {
|
|
@@ -86,7 +86,12 @@ export function laresPortsFromAccount({ env, apps } = {}) {
|
|
|
86
86
|
return { env, proxyPrefix: fromEnv.proxyPrefix, baseUrl: undefined };
|
|
87
87
|
}
|
|
88
88
|
const baseUrl = findLaresEntrance(apps);
|
|
89
|
-
|
|
89
|
+
if (!baseUrl) {
|
|
90
|
+
// LarePass lives on another origin. No myApps hit means there is no Host
|
|
91
|
+
// to call. The entrance page never uses this function: it is already there.
|
|
92
|
+
return { baseUrl: undefined, env: undefined, proxyPrefix: undefined, missing: true };
|
|
93
|
+
}
|
|
94
|
+
return { baseUrl, env: undefined, proxyPrefix: undefined };
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
export function hostUrl({ baseUrl = "", proxyPrefix = "", path }) {
|
package/larepass/mux.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { normalizeQueueItems } from "./queue.js";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/** Gateway WebSocket. A bare socket stays up on ping/pong; logical streams are separate. */
|
|
4
|
+
export const MUX_PATH = "/api/remote.mux";
|
|
4
5
|
|
|
5
6
|
export const MUX_FETCH_HEADERS = {
|
|
6
7
|
accept: "text/event-stream",
|
|
@@ -14,6 +15,213 @@ export function muxWsUrl(httpUrl, origin = "http://localhost") {
|
|
|
14
15
|
return url.href;
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/** One logical stream on `/api/remote.mux`. `workspace/follow` has no arguments. */
|
|
19
|
+
export function remoteStreamOpen(streamId, endpoint, args = {}) {
|
|
20
|
+
return {
|
|
21
|
+
type: "open",
|
|
22
|
+
streamId,
|
|
23
|
+
endpoint,
|
|
24
|
+
payload: { args },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Same `session/follow` open the dsh web client sends on this socket. */
|
|
29
|
+
export function sessionFollowOpen(streamId, sessionId) {
|
|
30
|
+
return remoteStreamOpen(streamId, "session/follow", {
|
|
31
|
+
request: {
|
|
32
|
+
address: { kind: "session", sessionId },
|
|
33
|
+
assistantStream: true,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Host-forwarded Cordis events; questions and approvals arrive here as waterfalls. */
|
|
39
|
+
export function remoteEventsOpen(streamId) {
|
|
40
|
+
return remoteStreamOpen(streamId, "$events");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One `$events` item. A waterfall this client does not answer passes as
|
|
45
|
+
* `next` so the Host is not left waiting on it; `agentId` is the session.
|
|
46
|
+
*/
|
|
47
|
+
export function classifyRemoteEvent(value) {
|
|
48
|
+
if (!value || typeof value !== "object") return null;
|
|
49
|
+
if (value.type === "ready" && typeof value.clientId === "string") {
|
|
50
|
+
return { kind: "ready", clientId: value.clientId };
|
|
51
|
+
}
|
|
52
|
+
if (value.type === "cancel" && typeof value.eventId === "string") {
|
|
53
|
+
return { kind: "cancel", eventId: value.eventId };
|
|
54
|
+
}
|
|
55
|
+
if (value.type !== "waterfall" || typeof value.eventId !== "string") return null;
|
|
56
|
+
const request = value.request && typeof value.request === "object" ? value.request : {};
|
|
57
|
+
const sessionId = typeof value.agentId === "string" ? value.agentId : "";
|
|
58
|
+
if (value.event === "user-questions/request" && Array.isArray(request.questions) && sessionId) {
|
|
59
|
+
return {
|
|
60
|
+
kind: "question",
|
|
61
|
+
eventId: value.eventId,
|
|
62
|
+
sessionId,
|
|
63
|
+
callId: request.wait?.callId,
|
|
64
|
+
questions: request.questions,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (value.event === "approval/request" && typeof request.toolName === "string" && sessionId) {
|
|
68
|
+
return {
|
|
69
|
+
kind: "approval",
|
|
70
|
+
eventId: value.eventId,
|
|
71
|
+
sessionId,
|
|
72
|
+
toolName: request.toolName,
|
|
73
|
+
callId: request.callId,
|
|
74
|
+
reason: request.displayReason ?? request.reason,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { kind: "pass", eventId: value.eventId };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function liveChunkEvent(seq, turn, step, chunk) {
|
|
81
|
+
if (!chunk || typeof chunk !== "object") return null;
|
|
82
|
+
if (chunk.type !== "text-delta" && chunk.type !== "reasoning-delta" && chunk.type !== "block-end") {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
type: "assistant/chunk",
|
|
87
|
+
seq,
|
|
88
|
+
live: true,
|
|
89
|
+
data: { turn, step, chunk },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fold one `session/follow` downlink into transcript events.
|
|
95
|
+
* Snapshot and durable `event` frames are the session log. `assistant-stream`
|
|
96
|
+
* frames are the in-progress think and reply, placed just after the latest
|
|
97
|
+
* durable seq so a later `assistant/message` replaces them.
|
|
98
|
+
*/
|
|
99
|
+
export function createSessionFollow() {
|
|
100
|
+
let cursor = -1;
|
|
101
|
+
let liveCount = 0;
|
|
102
|
+
let attempt = null;
|
|
103
|
+
|
|
104
|
+
const note = (event) => {
|
|
105
|
+
if (typeof event?.seq !== "number" || event.seq <= cursor) return;
|
|
106
|
+
cursor = event.seq;
|
|
107
|
+
liveCount = 0;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const nextLiveSeq = () => {
|
|
111
|
+
liveCount += 1;
|
|
112
|
+
return cursor + 1 - 1 / (liveCount + 1);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const fromChunk = (turn, step, chunk) => liveChunkEvent(nextLiveSeq(), turn, step, chunk);
|
|
116
|
+
|
|
117
|
+
const fromCompact = (turn, step, stream, limit) => {
|
|
118
|
+
const events = [];
|
|
119
|
+
let seen = 0;
|
|
120
|
+
const push = (chunk) => {
|
|
121
|
+
if (seen >= limit) return false;
|
|
122
|
+
seen += 1;
|
|
123
|
+
const event = fromChunk(turn, step, chunk);
|
|
124
|
+
if (event) events.push(event);
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
for (const record of Array.isArray(stream) ? stream : []) {
|
|
128
|
+
if (record?.type === "text-chunks" || record?.type === "reasoning-chunks") {
|
|
129
|
+
const type = record.type === "text-chunks" ? "text-delta" : "reasoning-delta";
|
|
130
|
+
for (const text of record.texts ?? []) {
|
|
131
|
+
if (!push({ type, index: record.index, text: String(text ?? "") })) return events;
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (record?.type === "chunk" && !push(record.chunk)) return events;
|
|
136
|
+
}
|
|
137
|
+
return events;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
reset() {
|
|
142
|
+
cursor = -1;
|
|
143
|
+
liveCount = 0;
|
|
144
|
+
attempt = null;
|
|
145
|
+
},
|
|
146
|
+
push(frame) {
|
|
147
|
+
if (!frame || typeof frame !== "object") return null;
|
|
148
|
+
if (frame.type === "snapshot") {
|
|
149
|
+
const events = [];
|
|
150
|
+
for (const record of Array.isArray(frame.records) ? frame.records : []) {
|
|
151
|
+
const event = record?.event;
|
|
152
|
+
if (!event || typeof event.seq !== "number") continue;
|
|
153
|
+
note(event);
|
|
154
|
+
events.push(event);
|
|
155
|
+
}
|
|
156
|
+
const opening = frame.assistantStream?.activeAttempt;
|
|
157
|
+
attempt = opening
|
|
158
|
+
? {
|
|
159
|
+
attemptId: opening.attemptId,
|
|
160
|
+
turn: opening.turn,
|
|
161
|
+
step: opening.step,
|
|
162
|
+
nextIndex: opening.nextIndex ?? 0,
|
|
163
|
+
}
|
|
164
|
+
: null;
|
|
165
|
+
if (opening) {
|
|
166
|
+
const limit = Number.isInteger(opening.nextIndex) ? opening.nextIndex : Number.POSITIVE_INFINITY;
|
|
167
|
+
events.push(...fromCompact(opening.turn, opening.step, opening.stream, limit));
|
|
168
|
+
}
|
|
169
|
+
return { events, ready: true, projections: frame.projections };
|
|
170
|
+
}
|
|
171
|
+
if (frame.type === "event" && frame.event && typeof frame.event.seq === "number") {
|
|
172
|
+
const event = frame.event;
|
|
173
|
+
note(event);
|
|
174
|
+
const settles = event.type === "assistant/message" || event.type === "turn/end";
|
|
175
|
+
if (settles) attempt = null;
|
|
176
|
+
return { events: [event], dropLive: settles };
|
|
177
|
+
}
|
|
178
|
+
if (frame.type !== "assistant-stream" || !frame.frame || typeof frame.frame !== "object") return null;
|
|
179
|
+
const live = frame.frame;
|
|
180
|
+
if (live.type === "start") {
|
|
181
|
+
attempt = {
|
|
182
|
+
attemptId: live.attemptId,
|
|
183
|
+
turn: live.turn,
|
|
184
|
+
step: live.step,
|
|
185
|
+
nextIndex: 0,
|
|
186
|
+
};
|
|
187
|
+
return { events: [] };
|
|
188
|
+
}
|
|
189
|
+
if (live.type === "chunk") {
|
|
190
|
+
if (!attempt || attempt.attemptId !== live.attemptId) return { events: [] };
|
|
191
|
+
if (live.index !== attempt.nextIndex) {
|
|
192
|
+
attempt = null;
|
|
193
|
+
return { events: [], resync: true };
|
|
194
|
+
}
|
|
195
|
+
attempt.nextIndex += 1;
|
|
196
|
+
const event = fromChunk(attempt.turn, attempt.step, live.chunk);
|
|
197
|
+
return { events: event ? [event] : [] };
|
|
198
|
+
}
|
|
199
|
+
if (live.type === "end") {
|
|
200
|
+
const abandoned = attempt?.attemptId === live.attemptId && live.outcome?.kind === "abandoned";
|
|
201
|
+
attempt = null;
|
|
202
|
+
return { events: [], dropLive: abandoned };
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function classifyStreamFrame(message) {
|
|
210
|
+
if (!message || typeof message.streamId !== "string" || !message.streamId) return null;
|
|
211
|
+
if (message.type === "item") return { kind: "stream", streamId: message.streamId, value: message.value };
|
|
212
|
+
if (message.type === "end") return { kind: "stream-end", streamId: message.streamId };
|
|
213
|
+
if (message.type === "error") return { kind: "stream-error", streamId: message.streamId, error: message.error };
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The first `workspace/follow` item is the full workspace projection. */
|
|
218
|
+
export function workspaceBaseline(message) {
|
|
219
|
+
if (message?.type !== "item") return null;
|
|
220
|
+
const frame = message.value;
|
|
221
|
+
if (frame?.type !== "baseline" || !frame.value || typeof frame.value !== "object") return null;
|
|
222
|
+
return frame.value;
|
|
223
|
+
}
|
|
224
|
+
|
|
17
225
|
export function splitSseBlocks(buffer) {
|
|
18
226
|
const blocks = [];
|
|
19
227
|
let rest = String(buffer ?? "").replace(/\r\n/g, "\n");
|
|
@@ -210,7 +418,7 @@ export async function* consumeMuxInbox(source) {
|
|
|
210
418
|
? iterateSseJson(bytesOf(source))
|
|
211
419
|
: iterateWsJson(source);
|
|
212
420
|
for await (const envelope of envelopes) {
|
|
213
|
-
const item = classifyMuxEnvelope(envelope);
|
|
421
|
+
const item = classifyMuxEnvelope(envelope) ?? classifyStreamFrame(envelope);
|
|
214
422
|
if (item) yield item;
|
|
215
423
|
}
|
|
216
424
|
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One disclosure for a turn's reasoning and tool calls, matching dsh chat.
|
|
3
|
+
* A settled turn uses the "completed in" control. While the turn is still
|
|
4
|
+
* running, the same rows sit behind one live activity line (or a finished
|
|
5
|
+
* activity summary once the reply has started). The person's message and the
|
|
6
|
+
* final answer stay outside. A turn that ended in error or abort stays open.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const DETAIL_LIMIT = 160;
|
|
10
|
+
const DETAIL_KEYS = [
|
|
11
|
+
"title", "description", "objective", "task", "task_name", "name",
|
|
12
|
+
"question", "questions", "prompt", "message", "command", "cmd",
|
|
13
|
+
"queries", "query", "pattern", "url", "uri", "file_path", "path",
|
|
14
|
+
"target", "action", "status",
|
|
15
|
+
];
|
|
16
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
17
|
+
|
|
18
|
+
const PROCESS = new Set(["reasoning", "tool", "retry", "context"]);
|
|
19
|
+
|
|
20
|
+
function lastAssistant(entries) {
|
|
21
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
22
|
+
if (entries[i].row?.type === "assistant") return i;
|
|
23
|
+
}
|
|
24
|
+
return -1;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isWaitingQuestion(row) {
|
|
28
|
+
return row?.type === "tool" && row.name === "ask_user_question" && row.status === "running";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isProcess(row, index, answerAt) {
|
|
32
|
+
if (isWaitingQuestion(row)) return false;
|
|
33
|
+
if (row?.type === "assistant") return index !== answerAt;
|
|
34
|
+
return PROCESS.has(row?.type);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function activity(name) {
|
|
38
|
+
if (name === "read") return "read";
|
|
39
|
+
if (name === "read_image") return "readImage";
|
|
40
|
+
if (name === "grep" || name === "glob" || name?.endsWith("_inspect")) return "search";
|
|
41
|
+
if (name === "write") return "write";
|
|
42
|
+
if (name === "edit" || name === "apply_patch") return "edit";
|
|
43
|
+
if (["bash", "pwsh", "exec_command", "write_stdin"].includes(name) || name?.startsWith("terminal_")) return "commands";
|
|
44
|
+
if (name === "run_code") return "code";
|
|
45
|
+
if (name === "web_search") return "webSearch";
|
|
46
|
+
if (name === "web_fetch") return "webFetch";
|
|
47
|
+
if (name === "subagent" || name?.startsWith("subagent_")) return "subagents";
|
|
48
|
+
if (["todo_write", "create_goal", "update_goal", "get_goal"].includes(name)) return "plan";
|
|
49
|
+
if (name === "ask_user_question" || name === "request_user_input") return "questions";
|
|
50
|
+
return "tools";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function clipDetail(value) {
|
|
54
|
+
const normalized = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
55
|
+
const chars = Array.from(segmenter.segment(normalized), (part) => part.segment);
|
|
56
|
+
if (chars.length <= DETAIL_LIMIT) return normalized;
|
|
57
|
+
return `${chars.slice(0, DETAIL_LIMIT - 1).join("").trimEnd()}…`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toolDetail(row) {
|
|
61
|
+
let args;
|
|
62
|
+
try {
|
|
63
|
+
args = JSON.parse(row?.argsRaw ?? "");
|
|
64
|
+
} catch {
|
|
65
|
+
return clipDetail(row?.name);
|
|
66
|
+
}
|
|
67
|
+
if (args === null || typeof args !== "object") return clipDetail(row?.name);
|
|
68
|
+
for (const key of DETAIL_KEYS) {
|
|
69
|
+
if (!(key in args)) continue;
|
|
70
|
+
const value = args[key];
|
|
71
|
+
if (key === "questions" && Array.isArray(value)) {
|
|
72
|
+
const question = value.find((item) => item && typeof item.question === "string")?.question;
|
|
73
|
+
const detail = clipDetail(question);
|
|
74
|
+
if (detail) return detail;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const detail = clipDetail(typeof value === "string" || (Array.isArray(value) && value.every((item) => typeof item === "string"))
|
|
78
|
+
? (Array.isArray(value) ? value.join(", ") : value)
|
|
79
|
+
: "");
|
|
80
|
+
if (detail) return detail;
|
|
81
|
+
}
|
|
82
|
+
return clipDetail(row?.name);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function reasoningDetail(members) {
|
|
86
|
+
for (let i = members.length - 1; i >= 0; i--) {
|
|
87
|
+
if (members[i].row?.type !== "reasoning") continue;
|
|
88
|
+
const paragraphs = String(members[i].row.text ?? "").split(/\r?\n[\t ]*\r?\n/);
|
|
89
|
+
for (let p = paragraphs.length - 1; p >= 0; p--) {
|
|
90
|
+
const detail = clipDetail(paragraphs[p].replaceAll("**", ""));
|
|
91
|
+
if (detail) return detail;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function runningTool(entries) {
|
|
98
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
99
|
+
const row = entries[i].row;
|
|
100
|
+
if (row?.type === "tool" && row.status === "running") return row;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toolCounts(members) {
|
|
106
|
+
const counts = new Map();
|
|
107
|
+
const order = [];
|
|
108
|
+
for (const { row } of members) {
|
|
109
|
+
if (row?.type !== "tool") continue;
|
|
110
|
+
const kind = activity(row.name);
|
|
111
|
+
if (!counts.has(kind)) order.push(kind);
|
|
112
|
+
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
|
113
|
+
}
|
|
114
|
+
return order.map((kind) => ({ kind, count: counts.get(kind) }));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function processTitle(t, counts) {
|
|
118
|
+
const ranked = [...counts].sort((left, right) => right.count - left.count);
|
|
119
|
+
const labels = ranked.slice(0, 3).map(({ kind }) => t(`message.stepProcess.done.${kind}`));
|
|
120
|
+
const first = labels[0];
|
|
121
|
+
if (first === undefined) return t("message.stepProcess.done.thinking");
|
|
122
|
+
const continuation = (label) => label.charAt(0).toLowerCase() + label.slice(1);
|
|
123
|
+
if (labels.length === 1) return first;
|
|
124
|
+
if (labels.length === 2) {
|
|
125
|
+
const prefix = t("message.stepProcess.sharedPrefix");
|
|
126
|
+
const second = labels[1];
|
|
127
|
+
const trimmed = prefix !== "" && first.startsWith(prefix) && second.startsWith(prefix)
|
|
128
|
+
? second.slice(prefix.length)
|
|
129
|
+
: second;
|
|
130
|
+
return t("message.stepProcess.joinTwo", { first, second: continuation(trimmed) });
|
|
131
|
+
}
|
|
132
|
+
const title = [first, ...labels.slice(1).map(continuation)].join(t("message.stepProcess.comma"));
|
|
133
|
+
return ranked.length > 3 ? t("message.stepProcess.more", { title }) : title;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function liveTitle(t, block) {
|
|
137
|
+
const tool = block.runningTool;
|
|
138
|
+
const kind = tool ? activity(tool.name) : "thinking";
|
|
139
|
+
const label = t(`message.stepProcess.${kind}`);
|
|
140
|
+
const detail = tool ? toolDetail(tool) : reasoningDetail(block.members);
|
|
141
|
+
if (!detail) return label;
|
|
142
|
+
return `${label}${t("message.turnProcess.separator")}${detail}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function runMsOf(entries, answerAt) {
|
|
146
|
+
if (answerAt >= 0 && entries[answerAt].row.metrics?.runMs != null) {
|
|
147
|
+
return entries[answerAt].row.metrics.runMs;
|
|
148
|
+
}
|
|
149
|
+
return entries.find((entry) => entry.row.turnRunMs != null)?.row.turnRunMs;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Wall-clock run length the way dsh prints it: hours, then minutes once a minute is reached, then seconds. */
|
|
153
|
+
export function formatRunDuration(ms, unit) {
|
|
154
|
+
const total = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
|
|
155
|
+
const hours = Math.floor(total / 3600);
|
|
156
|
+
const minutes = Math.floor(total / 60) % 60;
|
|
157
|
+
const seconds = total % 60;
|
|
158
|
+
let text = "";
|
|
159
|
+
if (hours > 0) text += `${hours}${unit.hour}`;
|
|
160
|
+
if (total >= 60) text += `${minutes}${unit.minute}`;
|
|
161
|
+
text += `${seconds}${unit.second}`;
|
|
162
|
+
return text;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function processFoldLabel(t, block = {}) {
|
|
166
|
+
if (block.phase === "live") return liveTitle(t, block);
|
|
167
|
+
if (block.phase === "summary") return processTitle(t, toolCounts(block.members ?? []));
|
|
168
|
+
const reason = block.reason ?? "completed";
|
|
169
|
+
if (reason === "aborted") return t("message.stopped");
|
|
170
|
+
if (reason === "error") return t("message.turnProcess.failed");
|
|
171
|
+
if (block.runMs == null) return t("message.turnProcess.worked");
|
|
172
|
+
const duration = formatRunDuration(Math.max(1000, block.runMs), {
|
|
173
|
+
hour: t("duration.hourUnit"),
|
|
174
|
+
minute: t("duration.minuteUnit"),
|
|
175
|
+
second: t("duration.secondUnit"),
|
|
176
|
+
});
|
|
177
|
+
return t("message.turnProcess.took", { duration });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The live activity line replaces the bottom running clock. */
|
|
181
|
+
export function hidesRunningClock(blocks) {
|
|
182
|
+
return (Array.isArray(blocks) ? blocks : []).some((block) =>
|
|
183
|
+
block?.phase === "live"
|
|
184
|
+
|| (block?.row?.type === "tool" && block.row.name === "ask_user_question" && block.row.status === "running"));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function transcriptBlocks(items, { running = false } = {}) {
|
|
188
|
+
const list = Array.isArray(items) ? items : [];
|
|
189
|
+
const groups = [];
|
|
190
|
+
for (let index = 0; index < list.length; index++) {
|
|
191
|
+
const row = list[index];
|
|
192
|
+
const prev = groups[groups.length - 1];
|
|
193
|
+
const turn = Number.isInteger(row?.turn) ? row.turn : null;
|
|
194
|
+
const split = !prev
|
|
195
|
+
|| row?.type === "user"
|
|
196
|
+
|| (turn != null && prev.turn != null && turn !== prev.turn);
|
|
197
|
+
if (split) groups.push({ turn, entries: [] });
|
|
198
|
+
const group = groups[groups.length - 1];
|
|
199
|
+
if (group.turn == null && turn != null) group.turn = turn;
|
|
200
|
+
group.entries.push({ row, index });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const blocks = [];
|
|
204
|
+
groups.forEach((group, groupIndex) => {
|
|
205
|
+
const closed = groupIndex < groups.length - 1 || !running;
|
|
206
|
+
const answerAt = lastAssistant(group.entries);
|
|
207
|
+
const members = group.entries.filter((entry, index) => isProcess(entry.row, index, answerAt));
|
|
208
|
+
const hasWork = members.some((entry) => entry.row.type !== "context");
|
|
209
|
+
const reason = group.entries.find((entry) => entry.row.turnReason)?.row.turnReason || "completed";
|
|
210
|
+
const runMs = runMsOf(group.entries, answerAt);
|
|
211
|
+
const showHeader = hasWork;
|
|
212
|
+
const openTurn = !closed;
|
|
213
|
+
const tool = openTurn ? runningTool(group.entries) : null;
|
|
214
|
+
const phase = !showHeader ? "" : openTurn ? (tool || answerAt < 0 ? "live" : "summary") : "turn";
|
|
215
|
+
const foldable = showHeader && phase !== "turn"
|
|
216
|
+
? true
|
|
217
|
+
: showHeader && reason !== "error" && reason !== "aborted";
|
|
218
|
+
const memberSet = new Set(members);
|
|
219
|
+
let folded = false;
|
|
220
|
+
for (const entry of group.entries) {
|
|
221
|
+
if (showHeader && !folded && memberSet.has(entry)) {
|
|
222
|
+
blocks.push({
|
|
223
|
+
kind: "fold",
|
|
224
|
+
key: `fold-${group.turn ?? `g${groupIndex}`}-${entry.index}`,
|
|
225
|
+
phase,
|
|
226
|
+
foldable,
|
|
227
|
+
reason,
|
|
228
|
+
runMs,
|
|
229
|
+
members,
|
|
230
|
+
runningTool: tool,
|
|
231
|
+
});
|
|
232
|
+
folded = true;
|
|
233
|
+
}
|
|
234
|
+
if (memberSet.has(entry) && showHeader) continue;
|
|
235
|
+
blocks.push({ kind: "row", key: `row-${entry.index}`, index: entry.index, row: entry.row });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
return blocks;
|
|
239
|
+
}
|