@khorsheed/dsh-local-agent-kimi 0.1.0-rc.6

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.
Files changed (50) hide show
  1. package/README.en.md +132 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +132 -0
  4. package/cordis.patch.yml +34 -0
  5. package/lib/client.js +1066 -0
  6. package/lib/index.js +4716 -0
  7. package/lib/invariant.js +31 -0
  8. package/lib/types/client/SettingsCard.d.ts +77 -0
  9. package/lib/types/client/SettingsCard.js +197 -0
  10. package/lib/types/client/icons.d.ts +18 -0
  11. package/lib/types/client/icons.js +2 -0
  12. package/lib/types/client/index.d.ts +42 -0
  13. package/lib/types/client/index.js +89 -0
  14. package/lib/types/client/locales.d.ts +64 -0
  15. package/lib/types/client/locales.js +55 -0
  16. package/lib/types/credential-guard.d.ts +29 -0
  17. package/lib/types/credential-guard.js +107 -0
  18. package/lib/types/index.d.ts +69 -0
  19. package/lib/types/index.js +270 -0
  20. package/lib/types/invariant.d.ts +16 -0
  21. package/lib/types/invariant.js +33 -0
  22. package/lib/types/kimi-cli-provider.d.ts +215 -0
  23. package/lib/types/kimi-cli-provider.js +855 -0
  24. package/lib/types/live-driver.d.ts +245 -0
  25. package/lib/types/live-driver.js +1312 -0
  26. package/lib/types/live-switch.d.ts +78 -0
  27. package/lib/types/live-switch.js +112 -0
  28. package/lib/types/member-bridge-config.d.ts +43 -0
  29. package/lib/types/member-bridge-config.js +80 -0
  30. package/lib/types/model-broker.d.ts +97 -0
  31. package/lib/types/model-broker.js +205 -0
  32. package/lib/types/model-catalog.d.ts +26 -0
  33. package/lib/types/model-catalog.js +86 -0
  34. package/lib/types/model-configuration.d.ts +11 -0
  35. package/lib/types/model-configuration.js +55 -0
  36. package/lib/types/preset-tools.d.ts +20 -0
  37. package/lib/types/preset-tools.js +36 -0
  38. package/lib/types/provision.d.ts +131 -0
  39. package/lib/types/provision.js +363 -0
  40. package/lib/types/records.d.ts +35 -0
  41. package/lib/types/records.js +85 -0
  42. package/lib/types/session-configuration.d.ts +5 -0
  43. package/lib/types/session-configuration.js +26 -0
  44. package/lib/types/session-mirror.d.ts +180 -0
  45. package/lib/types/session-mirror.js +446 -0
  46. package/lib/types/session-view.d.ts +121 -0
  47. package/lib/types/session-view.js +423 -0
  48. package/locale/en.json +6 -0
  49. package/locale/zh.json +6 -0
  50. package/package.json +107 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Mirror a kimi session's transcript into a dsh subagent session, so the
3
+ * delegation shows up in the standard 子代理 surface and opening the child
4
+ * session shows the full conversation (user prompts, kimi replies, thinking,
5
+ * and tool activity with arguments and results) instead of a silent one-shot
6
+ * call.
7
+ * @module @khorsheed/dsh-local-agent-kimi/session-mirror
8
+ */
9
+ import type { Context } from '@deepseek-ai/cordis';
10
+ import type { ContentBlock, TokenUsage } from '@deepseek-ai/dsh-llm';
11
+ import type { Session } from '@deepseek-ai/dsh-session';
12
+ import type { LocalAgentToolCalls } from '@khorsheed/dsh-local-agent/types';
13
+ import { type KimiTranscriptLine } from './session-view.ts';
14
+ /** One assistant-role message event, attributed to the kimi route. */
15
+ export declare function assistantEvent(blocks: readonly ContentBlock[], model?: string): import("@deepseek-ai/dsh-llm").AssistantMessage;
16
+ /**
17
+ * The step the next fold pass would assign in one turn (the max existing
18
+ * step + 1). The token-granularity live
19
+ * driver reserves its stream merge key here LAZILY at the first delta, so
20
+ * tool cards folded before the stream started keep their chronological
21
+ * place below it instead of the stream squatting on step 1.
22
+ * @param childSession - the run's child session.
23
+ * @param turn - the round's turn number.
24
+ * @returns the next free step in the turn.
25
+ */
26
+ export declare function nextKimiSessionStep(childSession: Session, turn: number): number;
27
+ /** The run-progress delta text for one transcript line (any kind). */
28
+ export declare function kimiLineProgressText(line: KimiTranscriptLine): string;
29
+ /** The result of one mirror pass: the new offset plus what was newly mirrored. */
30
+ export interface KimiMirrorDelta {
31
+ /** The new total transcript-line count mirrored into the child session. */
32
+ total: number;
33
+ /**
34
+ * The folded text of each transcript line THIS pass newly mirrored, in
35
+ * order — the payload source for `delta` run-progress reports. Empty when
36
+ * the offset already covered the transcript (the settle pass after a live
37
+ * mirror is therefore a no-op).
38
+ */
39
+ texts: string[];
40
+ /**
41
+ * The usage this pass computed for its window. Attached to the window's
42
+ * last folded assistant message when one exists (see `usageAttached`);
43
+ * otherwise returned unattached, so the caller (the token-granularity live
44
+ * driver) can carry it on the stream's final snapshot instead.
45
+ */
46
+ usage?: TokenUsage;
47
+ /**
48
+ * True when this pass's usage rode a folded assistant message — the caller
49
+ * must never attach that accounting a second time.
50
+ */
51
+ usageAttached?: boolean;
52
+ /**
53
+ * The model identifier the wire named (a `usage.record` or `llm.request`
54
+ * `model` field, last one seen) — the delegation's observed-model source.
55
+ * Absent when the wire carried none.
56
+ */
57
+ model?: string;
58
+ /**
59
+ * The ROUND's tool-call accounting, counted from the transcript's own
60
+ * `tool.call` lines (`options.turn` selects the round). Keyed by the name
61
+ * the wire gave the tool, never normalized. Absent when no turn was named
62
+ * or the round called none.
63
+ */
64
+ toolCalls?: LocalAgentToolCalls;
65
+ }
66
+ /**
67
+ * The live stream ledger a fold pass coordinates with.
68
+ * Absent on the exec path, where no streams reserve steps.
69
+ */
70
+ export interface KimiMirrorStreams {
71
+ /**
72
+ * Steps in-flight streams reserved for this turn (their snapshot messages
73
+ * append there). Sequential folds must skip them, so a tool card folded
74
+ * mid-stream lands ABOVE the stream's step and the projection renders the
75
+ * round's chronological order.
76
+ */
77
+ reservedSteps(turn: number): readonly number[];
78
+ /** Reserve chronological placement from the ACP tool notification before its file fold. */
79
+ completeTool?(line: KimiTranscriptLine & {
80
+ kind: 'tool';
81
+ }): {
82
+ readonly step: number;
83
+ readonly opened: boolean;
84
+ } | undefined;
85
+ /**
86
+ * Pair one completed think/assistant transcript line with its in-flight
87
+ * stream. Returns the reservation — consuming it — when this line finalizes
88
+ * a stream: the caller folds the line's message at the reserved step,
89
+ * opening the step boundary only when `opened` is false (no snapshot ever
90
+ * landed), and closes it. Undefined folds the line sequentially. A
91
+ * (turn, step) NEVER takes a second step/start.
92
+ */
93
+ completeStream(line: KimiTranscriptLine & {
94
+ kind: 'assistant' | 'think';
95
+ }): {
96
+ readonly step: number;
97
+ readonly opened: boolean;
98
+ } | undefined;
99
+ }
100
+ /** Mirror behavior switches shared by the exec and live paths. */
101
+ export interface KimiMirrorOptions {
102
+ /**
103
+ * The round this pass belongs to (the wire's 1-based turn, which matches
104
+ * the provider's own turn numbering). Given, the delta reports that ROUND's
105
+ * tool-call accounting — counted over the transcript lines carrying this
106
+ * turn, not over the mirror window, so a settle pass that a live poll
107
+ * already drained still reports the round's real count. Absent, no
108
+ * accounting is reported.
109
+ */
110
+ turn?: number;
111
+ /**
112
+ * The token-granularity live round's stream ledger: folds pair completed
113
+ * think/assistant lines with their streams and skip reserved steps.
114
+ */
115
+ streams?: KimiMirrorStreams;
116
+ }
117
+ /**
118
+ * Mirror one kimi session's transcript into the dsh subagent session: each
119
+ * user prompt becomes a `user/message`, each assistant reply (including
120
+ * folded thinking and tool activity) an `assistant/message`, then the events
121
+ * are appended to persistence so the child session is viewable.
122
+ *
123
+ * A resumed round (or a live poll) passes the already-mirrored
124
+ * transcript-line count so only the delta is appended — re-mirroring earlier
125
+ * lines would duplicate their messages. Turn numbers come from the transcript
126
+ * lines themselves (the wire's 1-based turn, matching the provider's
127
+ * `turn/start` numbering); step numbers CONTINUE from the assistant messages
128
+ * already in the child session, so a round mirrored across several live polls
129
+ * does not restart step 1 per poll.
130
+ *
131
+ * Every folded step is wrapped in the `step/start`–`step/end` boundary pair
132
+ * the live conversation assembler requires: without a boundary the step never
133
+ * enters the location index, the assistant message resolves to a turn-level
134
+ * location, and the real-time view renders nothing (only a full rebuild,
135
+ * which mints step drafts from the explicit coordinates, recovers it). A
136
+ * think/assistant line that completes an in-flight stream folds at the
137
+ * stream's reserved step instead (see {@link KimiMirrorStreams}), and a
138
+ * sequential fold never takes a step a stream reserved.
139
+ *
140
+ * Usage accounting is exactly-once ACROSS passes: each `usage.record` is
141
+ * tagged with the transcript position it occurred at, and a pass attaches the
142
+ * records in the half-open range `(fromLines, newTotal]` — the records that
143
+ * trail THIS pass's lines — summed onto the delta's final assistant message.
144
+ * (A record flushed only after its line was already mirrored belongs to no
145
+ * later pass and is dropped; kimi flushes content and usage together per
146
+ * request, so this window is a flush race, not the norm.)
147
+ * @param ctx - host context carrying the session persistence service.
148
+ * @param childSession - the dsh subagent session created for this delegation.
149
+ * @param homeDir - the `kimi` harness's scoped home.
150
+ * @param kimiSessionId - the kimi session to mirror; omitted mirrors the most
151
+ * recent session (a one-shot `kimi -p` run always creates a fresh one).
152
+ * @param fromLines - transcript lines already mirrored into the child session
153
+ * (0 for the first round); only lines after this offset are appended.
154
+ * @returns the new total and the newly mirrored lines' texts.
155
+ */
156
+ export declare function mirrorKimiSessionDelta(ctx: Context, childSession: Session, homeDir: string, kimiSessionId?: string, fromLines?: number, options?: KimiMirrorOptions): Promise<KimiMirrorDelta>;
157
+ /**
158
+ * Count one round's tool calls out of a kimi transcript, keyed by the name the
159
+ * wire gave each tool. No turn named, or no `tool.call` line carrying it,
160
+ * yields undefined — a round reports nothing rather than a zero it did not
161
+ * observe. Reads only the transcript lines the mirror already folded.
162
+ * @param lines - the whole session transcript.
163
+ * @param turn - the round's 1-based turn, when the caller named one.
164
+ * @returns the round's accounting, or undefined.
165
+ */
166
+ export declare function roundToolCalls(lines: readonly KimiTranscriptLine[], turn: number | undefined): LocalAgentToolCalls | undefined;
167
+ /**
168
+ * Mirror one kimi session's transcript into the dsh subagent session,
169
+ * returning only the new total transcript-line count. Thin wrapper over
170
+ * {@link mirrorKimiSessionDelta} for callers that do not report progress.
171
+ * @param ctx - host context carrying the session persistence service.
172
+ * @param childSession - the dsh subagent session created for this delegation.
173
+ * @param homeDir - the `kimi` harness's scoped home.
174
+ * @param kimiSessionId - the kimi session to mirror; omitted mirrors the most
175
+ * recent session.
176
+ * @param fromLines - transcript lines already mirrored into the child session.
177
+ * @returns the new total transcript-line count mirrored into the child session.
178
+ */
179
+ export declare function mirrorKimiSession(ctx: Context, childSession: Session, homeDir: string, kimiSessionId?: string, fromLines?: number): Promise<number>;
180
+ //# sourceMappingURL=session-mirror.d.ts.map
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Mirror a kimi session's transcript into a dsh subagent session, so the
3
+ * delegation shows up in the standard 子代理 surface and opening the child
4
+ * session shows the full conversation (user prompts, kimi replies, thinking,
5
+ * and tool activity with arguments and results) instead of a silent one-shot
6
+ * call.
7
+ * @module @khorsheed/dsh-local-agent-kimi/session-mirror
8
+ */
9
+ import { join } from 'node:path';
10
+ import { readdir, stat } from 'node:fs/promises';
11
+ import { createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm';
12
+ import { persistChildSession } from '@khorsheed/dsh-local-agent';
13
+ import { readKimiTranscript, sumUsageRecords } from './session-view.js';
14
+ /** One user-role message event. */
15
+ function userEvent(text) {
16
+ return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } });
17
+ }
18
+ /** One assistant-role message event, attributed to the kimi route. */
19
+ export function assistantEvent(blocks, model) {
20
+ return createAssistantMessage({
21
+ content: blocks,
22
+ source: { provider: 'kimi-cli', model: model ?? 'unobserved' },
23
+ });
24
+ }
25
+ /**
26
+ * The step the next fold pass would assign in one turn (the max existing
27
+ * step + 1). The token-granularity live
28
+ * driver reserves its stream merge key here LAZILY at the first delta, so
29
+ * tool cards folded before the stream started keep their chronological
30
+ * place below it instead of the stream squatting on step 1.
31
+ * @param childSession - the run's child session.
32
+ * @param turn - the round's turn number.
33
+ * @returns the next free step in the turn.
34
+ */
35
+ export function nextKimiSessionStep(childSession, turn) {
36
+ let next = 1;
37
+ for (const event of childSession.snapshotEvents()) {
38
+ const data = event.data;
39
+ if (data.turn === turn && typeof data.step === 'number')
40
+ next = Math.max(next, data.step + 1);
41
+ }
42
+ return next;
43
+ }
44
+ /**
45
+ * Fold one non-tool transcript line into message blocks. Thinking maps to
46
+ * the native `reasoning` block so the standard conversation renders it as
47
+ * thinking rather than a `[思考]` text prefix; reply text stays text (content
48
+ * is never filtered — kimi's own injections remain visible). Tool lines are
49
+ * NOT folded here: they become native `tool/call`/`tool/result` event pairs
50
+ * (see the mirror loop), so the standard conversation renders them as tool
51
+ * cards instead of `[工具 X]` text.
52
+ */
53
+ function lineBlocks(line) {
54
+ if (line.kind === 'think') {
55
+ return [{ type: 'reasoning', text: line.text }];
56
+ }
57
+ return [{ type: 'text', text: line.text }];
58
+ }
59
+ /** The run-progress delta text for one transcript line (any kind). */
60
+ export function kimiLineProgressText(line) {
61
+ if (line.kind !== 'tool')
62
+ return line.text;
63
+ const call = `[工具 ${line.name}]${line.args !== undefined ? ` ${line.args}` : ''}`;
64
+ return `${call}${line.result !== undefined ? ` → ${line.result}` : ''}`;
65
+ }
66
+ /**
67
+ * Mirror one kimi session's transcript into the dsh subagent session: each
68
+ * user prompt becomes a `user/message`, each assistant reply (including
69
+ * folded thinking and tool activity) an `assistant/message`, then the events
70
+ * are appended to persistence so the child session is viewable.
71
+ *
72
+ * A resumed round (or a live poll) passes the already-mirrored
73
+ * transcript-line count so only the delta is appended — re-mirroring earlier
74
+ * lines would duplicate their messages. Turn numbers come from the transcript
75
+ * lines themselves (the wire's 1-based turn, matching the provider's
76
+ * `turn/start` numbering); step numbers CONTINUE from the assistant messages
77
+ * already in the child session, so a round mirrored across several live polls
78
+ * does not restart step 1 per poll.
79
+ *
80
+ * Every folded step is wrapped in the `step/start`–`step/end` boundary pair
81
+ * the live conversation assembler requires: without a boundary the step never
82
+ * enters the location index, the assistant message resolves to a turn-level
83
+ * location, and the real-time view renders nothing (only a full rebuild,
84
+ * which mints step drafts from the explicit coordinates, recovers it). A
85
+ * think/assistant line that completes an in-flight stream folds at the
86
+ * stream's reserved step instead (see {@link KimiMirrorStreams}), and a
87
+ * sequential fold never takes a step a stream reserved.
88
+ *
89
+ * Usage accounting is exactly-once ACROSS passes: each `usage.record` is
90
+ * tagged with the transcript position it occurred at, and a pass attaches the
91
+ * records in the half-open range `(fromLines, newTotal]` — the records that
92
+ * trail THIS pass's lines — summed onto the delta's final assistant message.
93
+ * (A record flushed only after its line was already mirrored belongs to no
94
+ * later pass and is dropped; kimi flushes content and usage together per
95
+ * request, so this window is a flush race, not the norm.)
96
+ * @param ctx - host context carrying the session persistence service.
97
+ * @param childSession - the dsh subagent session created for this delegation.
98
+ * @param homeDir - the `kimi` harness's scoped home.
99
+ * @param kimiSessionId - the kimi session to mirror; omitted mirrors the most
100
+ * recent session (a one-shot `kimi -p` run always creates a fresh one).
101
+ * @param fromLines - transcript lines already mirrored into the child session
102
+ * (0 for the first round); only lines after this offset are appended.
103
+ * @returns the new total and the newly mirrored lines' texts.
104
+ */
105
+ export async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSessionId, fromLines = 0, options) {
106
+ let workspaces;
107
+ try {
108
+ workspaces = await readdir(join(homeDir, 'sessions'));
109
+ }
110
+ catch {
111
+ // No kimi sessions at all; nothing to mirror.
112
+ return { total: 0, texts: [] };
113
+ }
114
+ let transcript;
115
+ if (kimiSessionId !== undefined) {
116
+ // Records may be bare uuids (the convention) or carry the ACP directory
117
+ // prefix (legacy live records): normalize instead of double-prefixing.
118
+ const dirName = kimiSessionId.startsWith('session_') ? kimiSessionId : `session_${kimiSessionId}`;
119
+ for (const workspace of workspaces) {
120
+ const dir = join(homeDir, 'sessions', workspace, dirName);
121
+ try {
122
+ transcript = await readKimiTranscript(dir);
123
+ }
124
+ catch {
125
+ // Not this workspace's session; keep looking.
126
+ continue;
127
+ }
128
+ // A missing wire log reads as an empty transcript, so keep looking —
129
+ // the named session may live in a later workspace.
130
+ if (transcript.lines.length > 0)
131
+ break;
132
+ }
133
+ }
134
+ else {
135
+ // Most recent session across workspaces: a one-shot `kimi -p` run just
136
+ // created the newest one.
137
+ let newest;
138
+ for (const workspace of workspaces) {
139
+ let sessions;
140
+ try {
141
+ sessions = await readdir(join(homeDir, 'sessions', workspace));
142
+ }
143
+ catch {
144
+ continue;
145
+ }
146
+ for (const name of sessions) {
147
+ if (!name.startsWith('session_'))
148
+ continue;
149
+ const dir = join(homeDir, 'sessions', workspace, name);
150
+ const meta = await stat(dir);
151
+ if (newest === undefined || meta.mtimeMs > newest.time) {
152
+ newest = { dir, time: meta.mtimeMs };
153
+ }
154
+ }
155
+ }
156
+ if (newest !== undefined) {
157
+ try {
158
+ transcript = await readKimiTranscript(newest.dir);
159
+ }
160
+ catch {
161
+ transcript = undefined;
162
+ }
163
+ }
164
+ }
165
+ if (transcript === undefined || transcript.lines.length === 0)
166
+ return { total: 0, texts: [] };
167
+ const newTotal = transcript.lines.length;
168
+ const delta = transcript.lines.slice(fromLines);
169
+ const observedModel = options?.turn === undefined || transcript.modelTurn === options.turn ? transcript.model : undefined;
170
+ // The ROUND's tool calls, counted over the transcript lines carrying this
171
+ // round's turn — deliberately NOT over the mirror window: a settle pass
172
+ // whose delta a live poll already drained still owes the round its real
173
+ // count, and a resume round must not inherit the earlier rounds' calls.
174
+ const toolCalls = roundToolCalls(transcript.lines, options?.turn);
175
+ // No early return on an empty delta: a result that merged into an
176
+ // already-mirrored tool line does not change the line count, and the
177
+ // backfill below still owes that call its `tool/result` event.
178
+ // The pass attaches the usage records whose content ITS lines carry. kimi
179
+ // writes a request's `usage.record` BEFORE the content parts it accounts
180
+ // for, so a record sitting exactly on the delta boundary (its content is
181
+ // entirely inside this pass) must attach here — otherwise incremental
182
+ // folds (live mid-run + settle) lose the round's accounting entirely.
183
+ const deltaUsage = sumUsageRecords(transcript.usageRecords.filter(record => record.line >= fromLines && record.line <= newTotal));
184
+ // Attach the summed usage to the delta's LAST assistant message (text or
185
+ // think both carry the round's accounting; tool lines do not).
186
+ let lastAssistant = -1;
187
+ for (let index = 0; index < delta.length; index += 1) {
188
+ const line = delta[index];
189
+ if (line !== undefined && line.kind !== 'user' && line.kind !== 'tool')
190
+ lastAssistant = index;
191
+ }
192
+ // Continue step numbering from the steps already mirrored (assistant
193
+ // messages AND tool events both consume steps), so a round mirrored across
194
+ // several live polls keeps 1, 2, 3… instead of restarting per poll.
195
+ const steps = new Map();
196
+ // The child session's own events are the ledger of mirrored tool calls:
197
+ // callId → its event (for pairing late results) and the already-settled
198
+ // call ids (so a backfill never duplicates a result).
199
+ const openCalls = new Map();
200
+ const settledCalls = new Set();
201
+ for (const event of childSession.snapshotEvents()) {
202
+ const data = event.data;
203
+ if (typeof data.turn === 'number' && typeof data.step === 'number') {
204
+ steps.set(data.turn, Math.max(steps.get(data.turn) ?? 0, data.step + 1));
205
+ }
206
+ if (event.type === 'tool/call') {
207
+ const call = event.data;
208
+ openCalls.set(call.callId, { turn: call.turn, step: call.step, seq: event.seq });
209
+ }
210
+ else if (event.type === 'tool/result') {
211
+ // The call id moved with the V4 message shape: rc.1's native tool-role
212
+ // message carries it at the top level, the pre-V4 user-role shape
213
+ // wrapped it in the single content block. Read both — a session
214
+ // mirrored before the upgrade still owes its old-shape results pairing.
215
+ const message = event.data.message;
216
+ const id = message?.toolCallId ?? message?.content?.[0]?.toolCallId;
217
+ if (id !== undefined) {
218
+ openCalls.delete(id);
219
+ settledCalls.add(id);
220
+ }
221
+ }
222
+ }
223
+ /**
224
+ * The next sequential fold step in one turn: the running counter, shifted
225
+ * past every step an in-flight stream reserved (a stream's snapshots and
226
+ * its completion fold live at the reserved coordinate — a sequential fold
227
+ * landing there would write a second step/start, which the assembler
228
+ * rejects).
229
+ */
230
+ const nextFoldStep = (turn) => {
231
+ let step = steps.get(turn) ?? 1;
232
+ const reserved = options?.streams?.reservedSteps(turn);
233
+ if (reserved !== undefined && reserved.length > 0) {
234
+ const skip = new Set(reserved);
235
+ while (skip.has(step))
236
+ step += 1;
237
+ }
238
+ steps.set(turn, step + 1);
239
+ return step;
240
+ };
241
+ /** Whether this pass attached its window's usage to a folded message. */
242
+ let usageAttached = false;
243
+ const texts = [];
244
+ // Backfill results that landed after their call was mirrored in an earlier
245
+ // pass: the delta window never revisits those lines, so a result that
246
+ // arrives late (parallel calls settle out of order) pairs here. The result
247
+ // reuses its call's turn/step — no new step is consumed.
248
+ for (let index = 0; index < fromLines && index < transcript.lines.length; index += 1) {
249
+ const line = transcript.lines[index];
250
+ if (line === undefined || line.kind !== 'tool' || line.result === undefined)
251
+ continue;
252
+ const call = openCalls.get(line.id);
253
+ if (call === undefined)
254
+ continue;
255
+ openCalls.delete(line.id);
256
+ settledCalls.add(line.id);
257
+ // No second boundary pair here: the call's fold already opened AND closed
258
+ // this (turn, step), and the live assembler throws on a duplicate start
259
+ // match for the same context. The step stays registered in the location
260
+ // index, so the bare result still resolves to the step location — and the
261
+ // tool definition never consults it anyway.
262
+ childSession.append('tool/result', {
263
+ turn: call.turn,
264
+ step: call.step,
265
+ message: createToolResultMessage({
266
+ callId: line.id,
267
+ content: [{ type: 'text', text: line.result }],
268
+ isError: false,
269
+ }),
270
+ }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
271
+ texts.push(kimiLineProgressText(line));
272
+ }
273
+ if (delta.length === 0) {
274
+ // No new lines this pass: only the late-result backfill above could have
275
+ // produced events. Persist those and skip the empty delta loop.
276
+ if (texts.length > 0) {
277
+ await persistChildSession(ctx, childSession);
278
+ }
279
+ return {
280
+ total: newTotal,
281
+ texts,
282
+ ...deltaUsage !== undefined ? { usage: deltaUsage } : {},
283
+ ...usageAttached ? { usageAttached: true } : {},
284
+ ...observedModel !== undefined ? { model: observedModel } : {},
285
+ ...toolCalls !== undefined ? { toolCalls } : {},
286
+ };
287
+ }
288
+ for (let index = 0; index < delta.length; index += 1) {
289
+ const line = delta[index];
290
+ if (line === undefined)
291
+ continue;
292
+ // The wire's turn number (1-based, matching the provider's turn/start
293
+ // numbering) is the dsh round; no offset is needed because both count the
294
+ // same rounds.
295
+ const turn = line.turn;
296
+ if (line.kind === 'user') {
297
+ // The live driver appends the round's user/message at turn start (so the
298
+ // question renders before the streamed think/text instead of after it).
299
+ // Skip the wire's copy of the same prompt — scoped to THIS turn so two
300
+ // rounds with identical prompts still fold independently.
301
+ if (userAlreadyAppended(childSession, turn, line.text))
302
+ continue;
303
+ steps.set(turn, 1);
304
+ childSession.append('user/message', userEvent(line.text), { surfaceOp: 'append' });
305
+ texts.push(line.text);
306
+ }
307
+ else if (line.kind === 'tool') {
308
+ // Native tool card: the call event now, the result event when the wire
309
+ // already carries it (else the backfill above pairs it in a later pass).
310
+ const reserved = options?.streams?.completeTool?.(line);
311
+ const step = reserved?.step ?? nextFoldStep(turn);
312
+ steps.set(turn, Math.max(steps.get(turn) ?? 1, step + 1));
313
+ if (reserved?.opened !== true)
314
+ childSession.append('step/start', { turn, step });
315
+ const call = childSession.append('tool/call', {
316
+ turn,
317
+ step,
318
+ callId: line.id,
319
+ name: line.name,
320
+ arguments: line.args ?? '',
321
+ });
322
+ texts.push(kimiLineProgressText(line));
323
+ if (line.result !== undefined) {
324
+ childSession.append('tool/result', {
325
+ turn,
326
+ step,
327
+ message: createToolResultMessage({
328
+ callId: line.id,
329
+ content: [{ type: 'text', text: line.result }],
330
+ isError: false,
331
+ }),
332
+ }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
333
+ }
334
+ childSession.append('step/end', { turn, step });
335
+ }
336
+ else {
337
+ // A completed think/assistant line pairs with its in-flight stream when
338
+ // one is live (the token granularity's per-kind stream): the fold lands
339
+ // at the stream's reserved step, finalizing the snapshots — the step
340
+ // opens only when no snapshot ever landed (a fast item that stayed
341
+ // under the throttle), and closes here. A (turn, step) never takes a
342
+ // second step/start.
343
+ const completion = options?.streams?.completeStream(line);
344
+ if (completion !== undefined) {
345
+ steps.set(turn, Math.max(steps.get(turn) ?? 1, completion.step + 1));
346
+ if (!completion.opened)
347
+ childSession.append('step/start', { turn, step: completion.step });
348
+ if (index === lastAssistant && deltaUsage !== undefined)
349
+ usageAttached = true;
350
+ childSession.append('assistant/message', {
351
+ turn,
352
+ step: completion.step,
353
+ message: assistantEvent(lineBlocks(line), line.model),
354
+ stream: [],
355
+ ...index === lastAssistant && deltaUsage !== undefined ? { usage: deltaUsage } : {},
356
+ }, { surfaceOp: 'append' });
357
+ childSession.append('step/end', { turn, step: completion.step });
358
+ texts.push(kimiLineProgressText(line));
359
+ continue;
360
+ }
361
+ const step = nextFoldStep(turn);
362
+ childSession.append('step/start', { turn, step });
363
+ if (index === lastAssistant && deltaUsage !== undefined)
364
+ usageAttached = true;
365
+ childSession.append('assistant/message', {
366
+ turn,
367
+ step,
368
+ message: assistantEvent(lineBlocks(line), line.model),
369
+ stream: [],
370
+ ...index === lastAssistant && deltaUsage !== undefined ? { usage: deltaUsage } : {},
371
+ }, { surfaceOp: 'append' });
372
+ childSession.append('step/end', { turn, step });
373
+ texts.push(kimiLineProgressText(line));
374
+ }
375
+ }
376
+ await persistChildSession(ctx, childSession);
377
+ return {
378
+ total: newTotal,
379
+ texts,
380
+ ...deltaUsage !== undefined ? { usage: deltaUsage } : {},
381
+ ...usageAttached ? { usageAttached: true } : {},
382
+ ...observedModel !== undefined ? { model: observedModel } : {},
383
+ ...toolCalls !== undefined ? { toolCalls } : {},
384
+ };
385
+ }
386
+ /**
387
+ * Count one round's tool calls out of a kimi transcript, keyed by the name the
388
+ * wire gave each tool. No turn named, or no `tool.call` line carrying it,
389
+ * yields undefined — a round reports nothing rather than a zero it did not
390
+ * observe. Reads only the transcript lines the mirror already folded.
391
+ * @param lines - the whole session transcript.
392
+ * @param turn - the round's 1-based turn, when the caller named one.
393
+ * @returns the round's accounting, or undefined.
394
+ */
395
+ export function roundToolCalls(lines, turn) {
396
+ if (turn === undefined)
397
+ return undefined;
398
+ const byName = {};
399
+ let count = 0;
400
+ for (const line of lines) {
401
+ if (line.kind !== 'tool' || line.turn !== turn)
402
+ continue;
403
+ count += 1;
404
+ byName[line.name] = (byName[line.name] ?? 0) + 1;
405
+ }
406
+ return count === 0 ? undefined : { count, byName };
407
+ }
408
+ /**
409
+ * Whether the turn already carries a user/message with this exact text (the
410
+ * live driver's round-start append). Scoped to the events at or after this
411
+ * turn's turn/start so identical prompts across rounds stay independent.
412
+ */
413
+ function userAlreadyAppended(childSession, turn, text) {
414
+ let turnStartSeq = -1;
415
+ for (const event of childSession.snapshotEvents()) {
416
+ if (event.type === 'turn/start' && event.data.turn === turn) {
417
+ turnStartSeq = event.seq;
418
+ }
419
+ }
420
+ if (turnStartSeq < 0)
421
+ return false;
422
+ for (const event of childSession.snapshotEvents()) {
423
+ if (event.seq < turnStartSeq || event.type !== 'user/message')
424
+ continue;
425
+ const data = event.data;
426
+ const existing = (data.content ?? []).map(block => block.text ?? '').join('');
427
+ if (existing === text)
428
+ return true;
429
+ }
430
+ return false;
431
+ }
432
+ /**
433
+ * Mirror one kimi session's transcript into the dsh subagent session,
434
+ * returning only the new total transcript-line count. Thin wrapper over
435
+ * {@link mirrorKimiSessionDelta} for callers that do not report progress.
436
+ * @param ctx - host context carrying the session persistence service.
437
+ * @param childSession - the dsh subagent session created for this delegation.
438
+ * @param homeDir - the `kimi` harness's scoped home.
439
+ * @param kimiSessionId - the kimi session to mirror; omitted mirrors the most
440
+ * recent session.
441
+ * @param fromLines - transcript lines already mirrored into the child session.
442
+ * @returns the new total transcript-line count mirrored into the child session.
443
+ */
444
+ export async function mirrorKimiSession(ctx, childSession, homeDir, kimiSessionId, fromLines = 0) {
445
+ return (await mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSessionId, fromLines)).total;
446
+ }