@khorsheed/dsh-local-agent-kimi 0.2.0 → 0.2.2

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.
@@ -12,6 +12,8 @@ import { readFileSync, writeFileSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  /** Kimi's user-level MCP config filename inside the scoped home. */
14
14
  const MCP_CONFIG_FILENAME = 'mcp.json';
15
+ /** The member bridge's per-call bound: as long as a question card waits for the human (30 min). */
16
+ export const MEMBER_TOOL_TIMEOUT_MS = 30 * 60_000;
15
17
  /** Server-key prefix marking the family's per-run bridge entries. */
16
18
  export const MEMBER_BRIDGE_SERVER_PREFIX = 'dsh-member-';
17
19
  /**
@@ -60,7 +62,9 @@ export function injectMemberBridge(homeDir, key, command, env) {
60
62
  if (name.startsWith(MEMBER_BRIDGE_SERVER_PREFIX) && name !== key)
61
63
  delete servers[name];
62
64
  }
63
- servers[key] = { command: command.command, args: command.args, env };
65
+ // A bridge call may wait for the human (ask_user_question, a mode tool's
66
+ // approval): kimi's 60s MCP default fails it first and the answer is lost.
67
+ servers[key] = { command: command.command, args: command.args, env, toolTimeoutMs: MEMBER_TOOL_TIMEOUT_MS };
64
68
  writeMcpConfig(file, { ...config, mcpServers: servers });
65
69
  }
66
70
  /**
@@ -71,6 +71,14 @@ export function nextWrittenKimiStep(childSession, turn) {
71
71
  }
72
72
  return last + 1;
73
73
  }
74
+ /** Wire usage records already counted, per child session (see mirrorKimiSessionDelta). */
75
+ const countedUsage = new WeakMap();
76
+ const usageSeen = (childSession) => {
77
+ let seen = countedUsage.get(childSession);
78
+ if (seen === undefined)
79
+ countedUsage.set(childSession, seen = new Set());
80
+ return seen;
81
+ };
74
82
  /** The run-progress delta text for one transcript line (any kind). */
75
83
  export function kimiLineProgressText(line) {
76
84
  if (line.kind !== 'tool')
@@ -245,6 +253,13 @@ export async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSes
245
253
  // step-tagged unit claims its own line span's records; records no tagged
246
254
  // unit claims (untagged spans on older wires, or the boundary) sum onto the
247
255
  // pass's last folded content unit — the pre-tagging shape.
256
+ // A record counts once per child session, ever: the boundary is inclusive, so
257
+ // a pass whose offset cannot advance (a held unit) re-reads the same boundary
258
+ // record on every poll — one 320k-token record was carried ten times onto a
259
+ // cancelled round's step (3.2M). Wire line numbers only grow, so the line
260
+ // identifies the record across passes and rounds.
261
+ const seen = usageSeen(childSession);
262
+ const usageRecords = transcript.usageRecords.filter(record => !seen.has(record.line));
248
263
  const unitUsage = [];
249
264
  const claimed = [];
250
265
  let unitStart = fromLines;
@@ -254,15 +269,18 @@ export async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSes
254
269
  const head = unit[0];
255
270
  if (head.kind !== 'user' && head.wireStep !== undefined) {
256
271
  claimed.push({ from: start, to: unitStart });
257
- unitUsage.push(sumUsageRecords(transcript.usageRecords.filter(record => record.line >= start && record.line < unitStart)));
272
+ unitUsage.push(sumUsageRecords(usageRecords.filter(record => record.line >= start && record.line < unitStart)));
258
273
  }
259
274
  else {
260
275
  unitUsage.push(undefined);
261
276
  }
262
277
  }
263
- const unclaimedUsage = sumUsageRecords(transcript.usageRecords.filter(record => record.line >= fromLines && record.line <= consumedTotal
278
+ const unclaimedUsage = sumUsageRecords(usageRecords.filter(record => record.line >= fromLines && record.line <= consumedTotal
264
279
  && !claimed.some(span => record.line >= span.from && record.line < span.to)));
265
- const deltaUsage = sumUsageRecords(transcript.usageRecords.filter(record => record.line >= fromLines && record.line <= consumedTotal));
280
+ const windowRecords = usageRecords.filter(record => record.line >= fromLines && record.line <= consumedTotal);
281
+ const deltaUsage = sumUsageRecords(windowRecords);
282
+ for (const record of windowRecords)
283
+ seen.add(record.line);
266
284
  // The steps a fold may NOT take: every step already written (the session's
267
285
  // own event ledger), consulted per turn. A round mirrored across several
268
286
  // live polls keeps its earlier writes, so later polls never reuse them.
@@ -296,6 +314,20 @@ export async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSes
296
314
  written.add(step);
297
315
  takenSteps.set(turn, written);
298
316
  };
317
+ // Turns the log has already closed take no more writes: the released-log
318
+ // reader admits events only inside the open turn. A backlog the offset never
319
+ // covered (a fold that failed for rounds, then an offset lost on restart)
320
+ // would otherwise land old content inside a finished turn — or, after the
321
+ // next turn opened, corrupt the whole log. Those units are skipped; the
322
+ // offset still moves past them.
323
+ const closedTurns = new Set();
324
+ for (const event of childSession.snapshotEvents()) {
325
+ if (event.type !== 'turn/end')
326
+ continue;
327
+ const closed = event.data.turn;
328
+ if (typeof closed === 'number')
329
+ closedTurns.add(closed);
330
+ }
299
331
  /** The window's accounting that no fold carried (dropped units' sums). */
300
332
  let carryUsage;
301
333
  // The pass's last content unit carries the records no tagged unit claimed.
@@ -319,6 +351,8 @@ export async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSes
319
351
  // numbering) is the dsh round; no offset is needed because both count the
320
352
  // same rounds.
321
353
  const turn = head.turn;
354
+ if (closedTurns.has(turn))
355
+ continue;
322
356
  if (head.kind === 'user') {
323
357
  // The live driver appends the round's user/message at turn start (so the
324
358
  // question renders before the streamed think/text instead of after it).
@@ -104,6 +104,17 @@ export declare function findKimiSessionDir(homeDir: string, sessionId: string):
104
104
  * @returns the transcript.
105
105
  */
106
106
  export declare function readKimiTranscript(sessionDir: string): Promise<KimiSessionTranscript>;
107
+ /**
108
+ * A wire tool result's output as display text. Most tools report a string;
109
+ * a media read (ReadMediaFile) reports content parts — text parts keep their
110
+ * text, an image part becomes a short marker (its bytes stay in kimi's blob
111
+ * store). Anything else reads as no output, never a parse failure: one
112
+ * unreadable result must not stop the whole transcript (and the round's
113
+ * usage) from folding.
114
+ * @param raw - the result's `output` field.
115
+ * @returns the text, or undefined when there is none.
116
+ */
117
+ export declare function toolOutputText(raw: unknown): string | undefined;
107
118
  /**
108
119
  * Map a wire `usage.record` payload onto the shared usage contract. kimi
109
120
  * reports input (uncached), cache read, cache creation, and output; the
@@ -105,6 +105,8 @@ function editArgsOf(name, record) {
105
105
  return patch;
106
106
  return undefined;
107
107
  }
108
+ /** Cap on a structured tool call's mirrored JSON arguments. */
109
+ const STRUCTURED_ARGS_MAX = 16_000;
108
110
  /** Render a tool call's arguments into a compact summary. */
109
111
  function argsOf(name, args) {
110
112
  if (args === undefined)
@@ -119,6 +121,13 @@ function argsOf(name, args) {
119
121
  const edit = editArgsOf(name, record);
120
122
  if (edit !== undefined)
121
123
  return edit;
124
+ // Structured arguments (an MCP tool's nested questions, options, lists)
125
+ // keep their full JSON: the one-line summary below keeps only scalars and
126
+ // dropped them, so the member's ask_user_question read as called with nothing.
127
+ if (Object.values(record).some(value => typeof value === 'object' && value !== null)) {
128
+ const json = JSON.stringify(record);
129
+ return json.length <= STRUCTURED_ARGS_MAX ? json : `${json.slice(0, STRUCTURED_ARGS_MAX)}…`;
130
+ }
122
131
  // WebSearch/FetchURL expose a query/url; prefer the most meaningful scalar.
123
132
  for (const key of ['query', 'url', 'path', 'command']) {
124
133
  const value = record[key];
@@ -168,6 +177,8 @@ export async function readKimiTranscript(sessionDir) {
168
177
  // turn/start numbering.
169
178
  let turn = 0;
170
179
  let turnSeen = false;
180
+ /** The wire marks rounds with turn.prompt (current kimi): a user message it did not prompt is an injection. */
181
+ let promptsMarkTurns = false;
171
182
  // Tool calls by uuid and toolCallId, so a result attaches to its own call
172
183
  // even when several calls run in parallel within one step.
173
184
  const callsByUuid = new Map();
@@ -228,6 +239,7 @@ export async function readKimiTranscript(sessionDir) {
228
239
  continue;
229
240
  }
230
241
  if (event.type === 'turn.prompt') {
242
+ promptsMarkTurns = true;
231
243
  lineModel = undefined;
232
244
  // Each round's user prompt opens the next turn; the matching
233
245
  // context.append_message below is deduped against this text.
@@ -254,6 +266,13 @@ export async function readKimiTranscript(sessionDir) {
254
266
  // Duplicate of the prompt; skip.
255
267
  continue;
256
268
  }
269
+ // A wire that opens every round with turn.prompt: any other user
270
+ // message is kimi's own injection inside the round (a Skill's loaded
271
+ // instructions, a steer) — its loop events keep the same turnId. It
272
+ // opens no turn (the dsh round is still running: a turn here wrote
273
+ // steps into a turn the log never opened) and is not the human's.
274
+ if (promptsMarkTurns)
275
+ continue;
257
276
  turnSeen = true;
258
277
  turn += 1;
259
278
  lineModel = undefined;
@@ -309,7 +328,7 @@ export async function readKimiTranscript(sessionDir) {
309
328
  }
310
329
  else if (loop.type === 'tool.result') {
311
330
  const result = loop;
312
- const output = result.result?.output;
331
+ const output = toolOutputText(result.result?.output);
313
332
  let target = typeof result.parentUuid === 'string'
314
333
  ? callsByUuid.get(result.parentUuid)
315
334
  : undefined;
@@ -346,6 +365,31 @@ export async function readKimiTranscript(sessionDir) {
346
365
  }
347
366
  return { sessionId, lines, usageRecords, closedSteps, ...model === undefined ? {} : { model }, ...modelTurn === undefined ? {} : { modelTurn } };
348
367
  }
368
+ /**
369
+ * A wire tool result's output as display text. Most tools report a string;
370
+ * a media read (ReadMediaFile) reports content parts — text parts keep their
371
+ * text, an image part becomes a short marker (its bytes stay in kimi's blob
372
+ * store). Anything else reads as no output, never a parse failure: one
373
+ * unreadable result must not stop the whole transcript (and the round's
374
+ * usage) from folding.
375
+ * @param raw - the result's `output` field.
376
+ * @returns the text, or undefined when there is none.
377
+ */
378
+ export function toolOutputText(raw) {
379
+ if (typeof raw === 'string')
380
+ return raw;
381
+ if (!Array.isArray(raw))
382
+ return undefined;
383
+ const parts = raw.map((part) => {
384
+ if (part === null || typeof part !== 'object')
385
+ return '';
386
+ const entry = part;
387
+ if (entry.type === 'text' && typeof entry.text === 'string')
388
+ return entry.text;
389
+ return typeof entry.type === 'string' && entry.type !== '' ? `[${entry.type}]` : '';
390
+ });
391
+ return parts.join('');
392
+ }
349
393
  /**
350
394
  * Map a wire `usage.record` payload onto the shared usage contract. kimi
351
395
  * reports input (uncached), cache read, cache creation, and output; the
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "local-agent",
9
9
  "kimi"
10
10
  ],
11
- "version": "0.2.0",
11
+ "version": "0.2.2",
12
12
  "type": "module",
13
13
  "main": "lib/index.js",
14
14
  "types": "lib/types/index.d.ts",
@@ -69,7 +69,7 @@
69
69
  "dsh": ">=0.1.5-rc.1 <0.3.0-0"
70
70
  },
71
71
  "dependencies": {
72
- "@khorsheed/dsh-local-agent": "^0.2.0"
72
+ "@khorsheed/dsh-local-agent": "^0.2.2"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@deepseek-ai/cordis": "^4.0.1",