paseo-dsh-direct 0.2.10 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-dsh-direct",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "DeepSeek Harness Direct Provider Plugin for Paseo",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,6 +17,15 @@ import type {
17
17
  type Method = keyof BridgeMethods;
18
18
  type Notification = keyof BridgeNotifications;
19
19
 
20
+ /**
21
+ * Default deadline for one bridge request. `session.list` title folds decode a
22
+ * whole session log per row, so callers list large histories with a wider
23
+ * deadline instead of raising this for every method.
24
+ */
25
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
26
+ /** Deadline for listing sessions: a cold title fold costs seconds per hundred sessions. */
27
+ export const LIST_SESSIONS_TIMEOUT_MS = 120_000;
28
+
20
29
  export interface DshBridge {
21
30
  readonly initialized: BridgeInitializeResult;
22
31
  /** The resolved `dsh` executable this bridge was launched with (see `resolveDshLaunch`). */
@@ -26,6 +35,7 @@ export interface DshBridge {
26
35
  request<Name extends Method>(
27
36
  method: Name,
28
37
  params: BridgeMethods[Name]["params"],
38
+ timeoutMs?: number,
29
39
  ): Promise<BridgeMethods[Name]["result"]>;
30
40
  on<Name extends Notification>(
31
41
  method: Name,
@@ -471,6 +481,7 @@ class JsonlBridgeClient implements DshBridge {
471
481
  request<Name extends Method>(
472
482
  method: Name,
473
483
  params: BridgeMethods[Name]["params"],
484
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
474
485
  ): Promise<BridgeMethods[Name]["result"]> {
475
486
  if (this.terminalError) return Promise.reject(this.terminalError);
476
487
  if (this.closing && method !== "bridge.shutdown") {
@@ -481,7 +492,7 @@ class JsonlBridgeClient implements DshBridge {
481
492
  const timeout = setTimeout(() => {
482
493
  this.pending.delete(id);
483
494
  reject(new Error(`DSH bridge request timed out: ${method}`));
484
- }, 30_000);
495
+ }, timeoutMs);
485
496
  this.pending.set(id, { resolve, reject, timeout });
486
497
  const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
487
498
  this.child.stdin.write(payload, (error) => {
@@ -140,6 +140,8 @@ export class BridgeService {
140
140
  private readonly subscriptions: Array<() => void> = [];
141
141
  /** Last usage payload sent per session, so an unchanged fold is not re-sent. */
142
142
  private readonly lastUsage = new Map<string, string>();
143
+ /** Folded titles per session id, so repeated import listings stay cheap. */
144
+ private readonly titles = new Map<string, string>();
143
145
  private stopping = false;
144
146
  private stopTask?: Promise<{}>;
145
147
 
@@ -262,7 +264,11 @@ export class BridgeService {
262
264
  }
263
265
  | undefined;
264
266
  const pressure = values.contextPressure as
265
- | { contextWindow?: number; pressureTokens?: number }
267
+ | {
268
+ contextWindow?: number;
269
+ pressureTokens?: number;
270
+ projectedTokens?: number;
271
+ }
266
272
  | undefined;
267
273
  const usage: BridgeUsage = {};
268
274
  if (typeof tokens?.uncachedInputTokens === "number")
@@ -271,8 +277,16 @@ export class BridgeService {
271
277
  usage.cachedInputTokens = tokens.cacheReadTokens;
272
278
  if (typeof tokens?.outputTokens === "number")
273
279
  usage.outputTokens = tokens.outputTokens;
274
- if (typeof pressure?.pressureTokens === "number")
275
- usage.contextWindowUsedTokens = pressure.pressureTokens;
280
+ // `projectedTokens` folds the shadow price of an adjacent compaction into
281
+ // the last request's prompt size, so the meter drops as soon as a
282
+ // compaction lands instead of waiting for the next request. dsh before
283
+ // 0.2.1 writes no shadow price and the field is absent: fall back to the
284
+ // raw prompt size.
285
+ const used =
286
+ typeof pressure?.projectedTokens === "number"
287
+ ? pressure.projectedTokens
288
+ : pressure?.pressureTokens;
289
+ if (typeof used === "number") usage.contextWindowUsedTokens = used;
276
290
  if (typeof pressure?.contextWindow === "number")
277
291
  usage.contextWindowMaxTokens = pressure.contextWindow;
278
292
  // The units materialize at zero before any provider usage lands, so a
@@ -453,29 +467,9 @@ export class BridgeService {
453
467
  } satisfies BridgeInitializeResult;
454
468
  break;
455
469
  }
456
- case "session.list": {
457
- const query = this.ctx.get("sessionQuery");
458
- const records = await query.listSessions();
459
- const sessions: SessionSummary[] = records.map((record) => ({
460
- ...record.header,
461
- }));
462
- // The official bulk title read owns live/cold source and validation.
463
- if (typeof query.readTitleSnapshots === "function") {
464
- const titles = await query.readTitleSnapshots(
465
- sessions.map((session) => session.id),
466
- );
467
- for (let i = 0; i < sessions.length; i++) {
468
- const result = titles[i];
469
- const title =
470
- result?.status === "fulfilled"
471
- ? result.value.title?.title
472
- : undefined;
473
- if (typeof title === "string") sessions[i]!.title = title;
474
- }
475
- }
476
- result = { sessions };
470
+ case "session.list":
471
+ result = { sessions: await this.listSessions(listParams(params)) };
477
472
  break;
478
- }
479
473
  case "session.read":
480
474
  result = await this.ctx
481
475
  .get("sessionQuery")
@@ -622,6 +616,72 @@ export class BridgeService {
622
616
  );
623
617
  }
624
618
 
619
+ /**
620
+ * List native sessions for Paseo's import picker.
621
+ *
622
+ * `readTitleSnapshots` decodes each requested session's log to fold its
623
+ * title, which costs seconds per hundred sessions on a large history, so
624
+ * titles are folded only for the rows this call returns: cwd and query
625
+ * filter first, then newest first, then the limit. Folded titles are cached
626
+ * for this bridge's lifetime; a title renamed outside Paseo keeps its cached
627
+ * value until the bridge is replaced, while live sessions carry their header
628
+ * title regardless.
629
+ */
630
+ private async listSessions(
631
+ params: { cwd?: string; query?: string; limit?: number },
632
+ ): Promise<SessionSummary[]> {
633
+ const query = this.ctx.get("sessionQuery");
634
+ const records = await query.listSessions();
635
+ let sessions = records
636
+ .map((record) => ({ ...record.header }))
637
+ .filter((session) => !params.cwd || session.cwd === params.cwd);
638
+ if (params.query) {
639
+ // A title query needs every title, so this path folds the whole history.
640
+ await this.foldTitles(query, sessions);
641
+ const needle = params.query.toLowerCase();
642
+ sessions = sessions.filter(
643
+ (session) =>
644
+ session.title?.toLowerCase().includes(needle) ||
645
+ session.id.toLowerCase().includes(needle),
646
+ );
647
+ }
648
+ sessions.sort((left, right) => right.createdAt - left.createdAt);
649
+ if (params.limit !== undefined && sessions.length > params.limit)
650
+ sessions = sessions.slice(0, params.limit);
651
+ await this.foldTitles(query, sessions);
652
+ return sessions;
653
+ }
654
+
655
+ /** Fill in cached titles, reading only the sessions this bridge has not folded yet. */
656
+ private async foldTitles(
657
+ query: HostServices["sessionQuery"],
658
+ sessions: SessionSummary[],
659
+ ): Promise<void> {
660
+ if (typeof query.readTitleSnapshots !== "function") return;
661
+ // The official bulk title read owns live/cold source and validation. A
662
+ // header that already carries a title is live and needs no fold at all.
663
+ const missing = sessions.filter(
664
+ (session) => !session.title && !this.titles.has(session.id),
665
+ );
666
+ if (missing.length > 0) {
667
+ const titles = await query.readTitleSnapshots(
668
+ missing.map((session) => session.id),
669
+ );
670
+ for (let i = 0; i < missing.length; i++) {
671
+ const result = titles[i];
672
+ const title =
673
+ result?.status === "fulfilled"
674
+ ? result.value.title?.title
675
+ : undefined;
676
+ if (typeof title === "string") this.titles.set(missing[i]!.id, title);
677
+ }
678
+ }
679
+ for (const session of sessions) {
680
+ const title = this.titles.get(session.id);
681
+ if (title !== undefined) session.title = title;
682
+ }
683
+ }
684
+
625
685
  /** Registered commands first; a skill never shadows a command of the same name. */
626
686
  private async slashCommands(agent: HostAgent): Promise<SlashCommand[]> {
627
687
  const commands: SlashCommand[] = (this.ctx.get("commands")?.list(agent) ?? []).map(
@@ -971,6 +1031,31 @@ function validateContent(value: unknown): PromptPart[] {
971
1031
  return parts;
972
1032
  }
973
1033
 
1034
+ function listParams(params: Record<string, unknown>): {
1035
+ cwd?: string;
1036
+ query?: string;
1037
+ limit?: number;
1038
+ } {
1039
+ const result: { cwd?: string; query?: string; limit?: number } = {};
1040
+ if (params.cwd !== undefined) result.cwd = nonempty(params.cwd, "cwd");
1041
+ if (params.query !== undefined) {
1042
+ if (typeof params.query !== "string")
1043
+ throw new Error("query must be a string");
1044
+ const query = params.query.trim();
1045
+ if (query) result.query = query;
1046
+ }
1047
+ if (params.limit !== undefined) {
1048
+ if (
1049
+ typeof params.limit !== "number" ||
1050
+ !Number.isSafeInteger(params.limit) ||
1051
+ params.limit < 1
1052
+ )
1053
+ throw new Error("limit must be a positive integer");
1054
+ result.limit = params.limit;
1055
+ }
1056
+ return result;
1057
+ }
1058
+
974
1059
  function validateOpen(params: Record<string, unknown>): SessionOpenParams {
975
1060
  const result: SessionOpenParams = { cwd: nonempty(params.cwd, "cwd") };
976
1061
  if (params.sessionId !== undefined)
@@ -27,6 +27,7 @@ import { slashLine } from "../shared/bridge-protocol.js";
27
27
  import {
28
28
  DEFAULT_PROFILE,
29
29
  describeDshLaunch,
30
+ LIST_SESSIONS_TIMEOUT_MS,
30
31
  resolveDshLaunch,
31
32
  type BridgeMcpServer,
32
33
  type DshBridge,
@@ -404,8 +405,18 @@ async function listSessions(
404
405
  input: Extract<ProviderInput, { type: "sessions" }>,
405
406
  state: DispatchState,
406
407
  ): Promise<void> {
407
- const result = await state.discoveryBridge.request("session.list", {});
408
408
  const query = input.query?.trim().toLowerCase();
409
+ // Filtering and truncation run in the bridge so title folds only cover the
410
+ // rows Paseo will actually show; the local filters stay as a second net.
411
+ const result = await state.discoveryBridge.request(
412
+ "session.list",
413
+ {
414
+ ...(input.cwd ? { cwd: input.cwd } : {}),
415
+ ...(query ? { query } : {}),
416
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
417
+ },
418
+ LIST_SESSIONS_TIMEOUT_MS,
419
+ );
409
420
  const rows = result.sessions
410
421
  .filter((session): session is typeof session & { cwd: string } => Boolean(session.cwd))
411
422
  .filter((session) => !input.cwd || session.cwd === input.cwd)
@@ -160,7 +160,10 @@ export interface CommandResult {
160
160
  }
161
161
  export interface BridgeMethods {
162
162
  "bridge.initialize": { params: {}; result: BridgeInitializeResult };
163
- "session.list": { params: {}; result: { sessions: SessionSummary[] } };
163
+ "session.list": {
164
+ params: { cwd?: string; query?: string; limit?: number };
165
+ result: { sessions: SessionSummary[] };
166
+ };
164
167
  "session.read": { params: { sessionId: string }; result: SessionSnapshot };
165
168
  "session.open": { params: SessionOpenParams; result: SessionOpenResult };
166
169
  "session.prompt": {