dsh-context 0.63.0 → 0.65.0

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/lib/index.js CHANGED
@@ -4,6 +4,7 @@ import { SessionLogOffset, deriveEventMessage, interruptedTurnClosers } from "@d
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { dirname, isAbsolute, join, normalize, relative } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { getHeapStatistics } from "node:v8";
7
8
  import z$1 from "@deepseek-ai/schemastery";
8
9
  import { randomUUID } from "node:crypto";
9
10
  //#region src/shared/providers.ts
@@ -2584,6 +2585,71 @@ function watchBalanceChannel(ctx) {
2584
2585
  });
2585
2586
  }
2586
2587
  //#endregion
2588
+ //#region src/host/coldRead.ts
2589
+ /**
2590
+ * The host-wide cold-read governor (issue #121).
2591
+ *
2592
+ * Every cold read — the warm-up pass's per-session fold (backfill.ts) and
2593
+ * the detail route's cold rung (detail.ts) — materializes one session's
2594
+ * whole durable log: the persistence backend decodes every frame before it
2595
+ * can slice a single event, so a 10k-event session lands as a several-
2596
+ * hundred-MB object graph, and the harness pins its own floors on top (the
2597
+ * backend's two-entry decoded-log memo, the observation reader's five-entry
2598
+ * prepared-session cache). Those floors are the harness's to tune; what the
2599
+ * PLUGIN owns is how many of its cold reads run at once and whether a new
2600
+ * one starts on an already-hot heap. Before this gate the answer to both
2601
+ * was "unbounded": the dashboard's first open fires the corpus pass and the
2602
+ * session cards' / Agent network's per-session detail fan-out together, and
2603
+ * on a capped-heap host (an Electron main whose V8 ceiling cannot move)
2604
+ * that stacking was enough to push the process over its limit.
2605
+ *
2606
+ * The gate therefore admits one cold read at a time (FIFO) and only while
2607
+ * the heap leaves headroom for it. A skipped read never fails visibly: the
2608
+ * pass stops early (its folded rows persist; the rest refold on their next
2609
+ * live activity or a later process's pass) and the detail route answers its
2610
+ * typed null (the client keeps its last detail, the cards their no-data
2611
+ * note) — every degradation already designed, no functionality removed.
2612
+ */
2613
+ /**
2614
+ * The high-water mark as a fraction of the process's own heap ceiling: a
2615
+ * cold read starts only below two-thirds full, leaving the top third for
2616
+ * the read itself plus the harness's floors. The ceiling is V8's real
2617
+ * limit, so every host class — a default-heap CLI, a capped Electron
2618
+ * main — gets its own mark from the same constant.
2619
+ */
2620
+ const HIGH_WATER = 2 / 3;
2621
+ /** The process's own V8 reading. */
2622
+ function v8Reading() {
2623
+ const stats = getHeapStatistics();
2624
+ return {
2625
+ used: stats.used_heap_size,
2626
+ limit: stats.heap_size_limit
2627
+ };
2628
+ }
2629
+ const noop = () => {};
2630
+ /**
2631
+ * One governor per host process, shared by both cold paths. `reading` is
2632
+ * injectable for tests; a throwing or garbage probe fails OPEN — the meter
2633
+ * must never block a read (the version probe's philosophy).
2634
+ */
2635
+ function makeColdReadGate(reading = v8Reading) {
2636
+ const clear = () => {
2637
+ try {
2638
+ const { used, limit } = reading();
2639
+ if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0) return true;
2640
+ return used < limit * HIGH_WATER;
2641
+ } catch {
2642
+ return true;
2643
+ }
2644
+ };
2645
+ let tail = Promise.resolve();
2646
+ return { admit(read) {
2647
+ const run = tail.then(() => clear() ? read() : void 0);
2648
+ tail = run.then(noop, noop);
2649
+ return run;
2650
+ } };
2651
+ }
2652
+ //#endregion
2587
2653
  //#region src/host/backfill.ts
2588
2654
  /** The plugin's warm-up trigger route, under the authenticated `/api` fence. */
2589
2655
  const BACKFILL_ROUTE = "/api/dsh-context/backfill";
@@ -2702,9 +2768,12 @@ function messageOf(error) {
2702
2768
  * when the dashboard first opens; the cold-path faces compose `launch`.
2703
2769
  * Either half may land first; the first request after both launches the one
2704
2770
  * pass, and every later request is a no-op. Returns the deferred injects'
2705
- * disposer (abort on unload).
2771
+ * disposer (abort on unload). Every cold read runs through the shared
2772
+ * host-wide gate (coldRead.ts): one read at a time, and the pass stops
2773
+ * early when the gate skips under heap pressure — folded rows persist and
2774
+ * every remaining session keeps its on-demand refold paths.
2706
2775
  */
2707
- function watchActivityBackfill(ctx) {
2776
+ function watchActivityBackfill(ctx, coldReads = makeColdReadGate()) {
2708
2777
  let requested = false;
2709
2778
  let started = false;
2710
2779
  let launch = null;
@@ -2752,6 +2821,7 @@ function watchActivityBackfill(ctx) {
2752
2821
  if (!Array.isArray(listed)) return;
2753
2822
  let folded = 0;
2754
2823
  let refused = 0;
2824
+ let paused = false;
2755
2825
  for (const record of listed) {
2756
2826
  if (abort.signal.aborted) return;
2757
2827
  const header = headerOf(record);
@@ -2759,8 +2829,14 @@ function watchActivityBackfill(ctx) {
2759
2829
  if (isLive(sessions, header.id)) continue;
2760
2830
  if (servesRows(cache, header)) continue;
2761
2831
  try {
2762
- const log = await readColdLog(persistence, header.id, abort.signal);
2763
- cache.coldSnapshot(log.header, log.inheritedEventCount, log.events);
2832
+ if (await coldReads.admit(async () => {
2833
+ const log = await readColdLog(persistence, header.id, abort.signal);
2834
+ cache.coldSnapshot(log.header, log.inheritedEventCount, log.events);
2835
+ return true;
2836
+ }) === void 0) {
2837
+ paused = true;
2838
+ break;
2839
+ }
2764
2840
  folded++;
2765
2841
  } catch (error) {
2766
2842
  if (isUnsupportedFormat(error)) {
@@ -2771,6 +2847,7 @@ function watchActivityBackfill(ctx) {
2771
2847
  await new Promise((resolve) => setTimeout(resolve, YIELD_MS));
2772
2848
  }
2773
2849
  if (folded > 0) ctx.logger.info(`dsh-context: projection rows backfilled for ${folded} session(s)`);
2850
+ if (paused) ctx.logger.info("dsh-context: projection backfill stopped early under heap pressure (remaining sessions refold on their next live activity)");
2774
2851
  if (refused > 0) ctx.logger.info(`dsh-context: projection backfill skipped ${refused} session(s) (legacy log format is not migratable; sources left unchanged)`);
2775
2852
  };
2776
2853
  launch = () => {
@@ -2893,7 +2970,7 @@ function failure(code, message) {
2893
2970
  * registration rides the injected fiber: either service unloading withdraws
2894
2971
  * the route and closes the gate.
2895
2972
  */
2896
- function watchDetailChannel(ctx, bounds) {
2973
+ function watchDetailChannel(ctx, bounds, coldReads = makeColdReadGate()) {
2897
2974
  const gate = { live: false };
2898
2975
  ctx.inject(["connection", "sessions"], (c) => {
2899
2976
  const connection = c.get("connection");
@@ -2930,22 +3007,21 @@ function watchDetailChannel(ctx, bounds) {
2930
3007
  ok: true,
2931
3008
  value: null
2932
3009
  });
2933
- const observation = await observe(sessionId, { projectionMode: "none" });
2934
- const events = observation?.events;
2935
- if (!Array.isArray(events)) return reply({
2936
- ok: true,
2937
- value: null
2938
- });
2939
- let state = createTimelineState();
2940
- try {
2941
- for (const ev of events) state = applyTimeline(state, ev, bounds);
2942
- } finally {
2943
- const dispose = observation?.[Symbol.dispose];
2944
- if (typeof dispose === "function") dispose.call(observation);
2945
- }
2946
3010
  return reply({
2947
3011
  ok: true,
2948
- value: buildTimelineDetail(state, bounds)
3012
+ value: await coldReads.admit(async () => {
3013
+ const observation = await observe(sessionId, { projectionMode: "none" });
3014
+ const events = observation?.events;
3015
+ if (!Array.isArray(events)) return null;
3016
+ let state = createTimelineState();
3017
+ try {
3018
+ for (const ev of events) state = applyTimeline(state, ev, bounds);
3019
+ } finally {
3020
+ const dispose = observation?.[Symbol.dispose];
3021
+ if (typeof dispose === "function") dispose.call(observation);
3022
+ }
3023
+ return buildTimelineDetail(state, bounds);
3024
+ }) ?? null
2949
3025
  });
2950
3026
  } catch (err) {
2951
3027
  return failure("gateway/internal", err instanceof Error ? err.message : String(err));
@@ -3912,11 +3988,12 @@ function apply(ctx, config) {
3912
3988
  }
3913
3989
  const attribution = createToolAttribution(ctx);
3914
3990
  watchStepIdentity(ctx);
3915
- const gate = watchDetailChannel(ctx, resolveBounds(config));
3991
+ const coldReads = makeColdReadGate();
3992
+ const gate = watchDetailChannel(ctx, resolveBounds(config), coldReads);
3916
3993
  ctx.sessionProjections.register(createContextTimelineDefinition(config, () => gate.live));
3917
3994
  ctx.sessionProjections.register(createContextHeadersDefinition((name) => attribution.ownerOf(name)));
3918
3995
  ctx.sessionProjections.register(createContextActivityDefinition());
3919
- watchActivityBackfill(ctx);
3996
+ watchActivityBackfill(ctx, coldReads);
3920
3997
  installSettings(ctx);
3921
3998
  }
3922
3999
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-context",
3
- "version": "0.63.0",
3
+ "version": "0.65.0",
4
4
  "description": "A DeepSeek Harness plugin for context insight and management, with context dashboard and context command, for understanding how the context is made of, and how it evolves.",
5
5
  "icon": "icon.svg",
6
6
  "author": "bowenliang123",