dsh-command-context-trim 0.1.0 → 0.2.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/target.js CHANGED
@@ -40,8 +40,10 @@ export function latestRoute(session) {
40
40
  * @returns `{ provider, model, contextWindow, label }`.
41
41
  * @throws when no route can be determined, the route is unknown, or its adapter declares no window.
42
42
  */
43
- export async function resolveTarget(ctx, agent, requested, signal) {
44
- const route = requested ?? latestRoute(agent.session) ?? agentRoute(agent);
43
+ export async function resolveTarget(ctx, agent, requested, signal, options = {}) {
44
+ const primary =
45
+ options.routedOnly === true ? routedTarget(agent.session) ?? latestRoute(agent.session) : latestRoute(agent.session);
46
+ const route = requested ?? primary ?? agentRoute(agent);
45
47
  if (route === undefined) {
46
48
  throw new Error('cannot determine the target model: select a model in this session first, or pass an explicit budget (e.g. /trim 32k)');
47
49
  }
@@ -82,6 +84,20 @@ export function targetHeader(session, target) {
82
84
  });
83
85
  }
84
86
 
87
+ /**
88
+ * The route the last durable request was actually built for: the one that just
89
+ * overflowed during automatic recovery.
90
+ * @param session - session whose log is read.
91
+ * @returns `{ provider, model, source }`, or undefined when nothing was routed yet.
92
+ */
93
+ export function routedTarget(session) {
94
+ const config = session.requestHeader()?.config;
95
+ if (config === undefined) return undefined;
96
+ if (typeof config.provider !== 'string' || config.provider.length === 0) return undefined;
97
+ if (typeof config.model !== 'string' || config.model.length === 0) return undefined;
98
+ return { provider: config.provider, model: config.model, source: 'request' };
99
+ }
100
+
85
101
  /** Per-agent configured route, used only before any request was routed. */
86
102
  function agentRoute(agent) {
87
103
  const provider = agent.options?.provider;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * One trim execution, shared by the `/trim` command and the automatic
3
+ * context-overflow path.
4
+ *
5
+ * Everything session-visible happens here: measure the current request under the
6
+ * target route, plan the oldest balanced span, and apply it as two synchronous
7
+ * appends. The caller owns only *when* to invoke it (`/trim` inside an idle-agent
8
+ * reservation, the automatic path inside a failing step) and how to render the
9
+ * outcome.
10
+ *
11
+ * @module dsh-command-context-trim/trim-session
12
+ */
13
+ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction';
14
+ import { applyTrim, createMarkerMessage, provisionalMarker } from './apply.js';
15
+ import { budgetFor, retentionFor } from './config.js';
16
+ import { planTrim } from './plan.js';
17
+ import { describeNonSpan, preview, report } from './render.js';
18
+ import { replaceKeys } from './session-compat.js';
19
+ import { resolveTarget, targetHeader } from './target.js';
20
+
21
+ /**
22
+ * @typedef {object} TrimRequest
23
+ * @property {object} agent - agent owning the session to trim.
24
+ * @property {AbortSignal} [signal] - cancellation for the whole execution.
25
+ * @property {{ provider: string, model: string }} [requestedRoute] - explicit target route.
26
+ * @property {number} [explicitBudget] - explicit token budget instead of a resolved window.
27
+ * @property {boolean} [check] - plan only, mutate nothing.
28
+ * @property {boolean} [routedOnly] - target the route the last durable request used.
29
+ */
30
+
31
+ /**
32
+ * Execute one trim.
33
+ * @param ctx - plugin context (token meter, LLM service).
34
+ * @param config - resolved configuration.
35
+ * @param request - what to trim and how far.
36
+ * @returns `{ result, plan?, label?, before?, after?, replacement?, markerTokens? }`;
37
+ * `result` is a command result, and `replacement` is present only when the
38
+ * surface was actually rewritten.
39
+ */
40
+ export async function executeTrim(ctx, config, request) {
41
+ const { agent, signal, requestedRoute, explicitBudget, check = false, routedOnly = false } = request;
42
+ signal?.throwIfAborted?.();
43
+ const session = agent.session;
44
+ assertNoOpenCompaction(session);
45
+ const target =
46
+ explicitBudget === undefined ? await resolveTarget(ctx, agent, requestedRoute, signal, { routedOnly }) : undefined;
47
+ const budget = explicitBudget ?? budgetFor(target.contextWindow, config);
48
+ const label = target === undefined ? `an explicit ${budget}-token budget` : `${target.label} (window ${target.contextWindow})`;
49
+ const header = target === undefined ? undefined : targetHeader(session, target);
50
+ const measurement = ctx.tokenMeter.measure(session, header);
51
+ const nodes = measurement.nodes.map((node) => {
52
+ const type = session.eventAt(node.seq)?.type;
53
+ return {
54
+ seq: node.seq,
55
+ heuristicTokens: node.heuristicTokens,
56
+ // Harness 0.1.5+ carries the system prompt as surface node 0. It is never
57
+ // elidable, and no elided span may cross it: dropping it would strip the
58
+ // model's instructions, and letting it consume head protection would expose
59
+ // the user's original request instead.
60
+ ...(type === 'system/message' ? { barrier: true } : {}),
61
+ // The newest human prompt is the anchor the planner must never elide.
62
+ ...(type === 'user/message' ? { userMessage: true } : {})
63
+ };
64
+ });
65
+ if (nodes.length === 0) {
66
+ return { result: { kind: 'success', text: `Nothing to trim: ${session.id} has no model-visible messages yet.` } };
67
+ }
68
+ // The retained tail scales with the capacity being fitted: the target window
69
+ // when one is known, otherwise the explicit budget itself.
70
+ const retainTokens = retentionFor(target?.contextWindow ?? budget, config);
71
+ const envelopeTokens = Math.max(0, measurement.totalTokens - measurement.surfaceTokens);
72
+ const planFor = (markerTokens) =>
73
+ planTrim({
74
+ nodes,
75
+ envelopeTokens,
76
+ budget,
77
+ markerCost: markerTokens + config.markerSlackTokens,
78
+ retainTokens,
79
+ minTailTokens: config.minTailTokens,
80
+ protectHeadNodes: config.protectHeadNodes,
81
+ allowTailTrim: config.allowTailTrim,
82
+ isBalancedBefore: (seq) => toolPairingBalancedBefore(session, seq),
83
+ isBalancedAfter: (seq) => toolPairingBalancedAfter(session, seq)
84
+ });
85
+ let markerTokens = ctx.tokenMeter.estimateMessage(provisionalMarker());
86
+ let plan = planFor(markerTokens);
87
+ if (plan.kind !== 'span') return { result: describeNonSpan(plan, label), plan, label, before: measurement };
88
+ let marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
89
+ const finalMarkerTokens = ctx.tokenMeter.estimateMessage(marker);
90
+ if (finalMarkerTokens > markerTokens) {
91
+ // The marker now carries real numbers; re-plan once so its own price is exact.
92
+ plan = planFor(finalMarkerTokens);
93
+ if (plan.kind !== 'span') return { result: describeNonSpan(plan, label), plan, label, before: measurement };
94
+ marker = createMarkerMessage(plan, target?.label ?? `budget ${budget}`, budget);
95
+ markerTokens = finalMarkerTokens;
96
+ }
97
+ if (check) {
98
+ return { result: { kind: 'success', text: preview(plan, label, markerTokens) }, plan, label, before: measurement };
99
+ }
100
+ const replacement = applyTrim(session, plan, marker, replaceKeys());
101
+ const after = ctx.tokenMeter.measure(session, header);
102
+ return {
103
+ result: { kind: 'success', text: report(plan, after, label, markerTokens), sourceEventSeq: replacement.seq },
104
+ plan,
105
+ label,
106
+ before: measurement,
107
+ after,
108
+ markerTokens,
109
+ replacement
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Refuse to rewrite a surface while a compaction bracket is open.
115
+ * @param session - session whose log is inspected.
116
+ * @throws when an unmatched `compaction/start` is open in the current lifecycle.
117
+ */
118
+ export function assertNoOpenCompaction(session) {
119
+ for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
120
+ const event = session.eventAt(seq);
121
+ if (event === undefined) continue;
122
+ if (event.type === 'compaction/end') return;
123
+ // A seed boundary proves any earlier unmatched start belongs to a previous lifecycle.
124
+ if (event.type === 'session/end-seed') return;
125
+ if (event.type === 'compaction/start') {
126
+ throw new Error('a compaction is already in progress in this session; wait for it to finish, then retry');
127
+ }
128
+ }
129
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-command-context-trim",
3
- "version": "0.1.0",
4
- "description": "Model-free /trim command for DeepSeek Harness — drop the oldest, least valuable span of conversation context so a session can continue on a smaller-window model, without any model call.",
3
+ "version": "0.2.0",
4
+ "description": "Model-free /trim for DeepSeek Harness — drop the oldest, least valuable span of context on demand (the `/trim` command) or automatically when a request hits the model's context wall, without any model call.",
5
5
  "license": "MIT",
6
6
  "author": "snailium",
7
7
  "type": "module",
@@ -27,7 +27,8 @@
27
27
  "scripts": {
28
28
  "test": "node --test",
29
29
  "link:harness": "bash scripts/dev-link-harness.sh",
30
- "prepublishOnly": "npm test"
30
+ "prepublishOnly": "npm test",
31
+ "e2e:mock": "node scripts/mock-overflow-server.mjs"
31
32
  },
32
33
  "dsh": {
33
34
  "bundle": {
@@ -35,14 +36,14 @@
35
36
  }
36
37
  },
37
38
  "peerDependencies": {
38
- "@deepseek-ai/cordis": "^4.0.2",
39
- "@deepseek-ai/dsh-commands": "^0.1.2-rc.1",
40
- "@deepseek-ai/dsh-compaction": "^0.1.2-rc.1",
41
- "@deepseek-ai/dsh-invariants": "^0.1.2-rc.1",
42
- "@deepseek-ai/dsh-llm": "^0.1.2-rc.1",
43
- "@deepseek-ai/dsh-session": "^0.1.2-rc.1",
44
- "@deepseek-ai/dsh-token-meter": "^0.1.2-rc.1",
45
- "@deepseek-ai/schemastery": "^3.18.1"
39
+ "@deepseek-ai/cordis": ">=4.0.2",
40
+ "@deepseek-ai/dsh-commands": ">=0.1.2-rc.1",
41
+ "@deepseek-ai/dsh-compaction": ">=0.1.2-rc.1",
42
+ "@deepseek-ai/dsh-invariants": ">=0.1.2-rc.1",
43
+ "@deepseek-ai/dsh-llm": ">=0.1.2-rc.1",
44
+ "@deepseek-ai/dsh-session": ">=0.1.2-rc.1",
45
+ "@deepseek-ai/dsh-token-meter": ">=0.1.2-rc.1",
46
+ "@deepseek-ai/schemastery": ">=3.18.1"
46
47
  },
47
48
  "peerDependenciesMeta": {
48
49
  "@deepseek-ai/cordis": {
@@ -71,9 +72,11 @@
71
72
  }
72
73
  },
73
74
  "devDependencies": {
75
+ "@deepseek-ai/cordis": "^4.0.2",
74
76
  "@deepseek-ai/dsh-compaction": "0.1.2-rc.1",
75
77
  "@deepseek-ai/dsh-llm": "0.1.2-rc.1",
76
78
  "@deepseek-ai/dsh-session": "0.1.2-rc.1",
79
+ "@deepseek-ai/dsh-token-meter": "^0.1.2-rc.1",
77
80
  "@deepseek-ai/schemastery": "3.18.2"
78
81
  },
79
82
  "keywords": [