dsh-compact-saviour 0.2.7 → 0.2.9

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/src/controller.ts CHANGED
@@ -12,11 +12,12 @@ import type {} from '@deepseek-ai/dsh-session-projection';
12
12
  import { compactSurfaceRegion, selectCompactableRange } from './vendor/region.js';
13
13
  import { compress } from './compress.js';
14
14
  import { PressureMeter } from './pressure.js';
15
+ import type { CompactionRouteLease, CompactionRouteRegistry } from './routes.js';
15
16
  import type { Config, Target, State, JobView } from './shared.js';
16
17
 
17
18
  type Engine = BasicCompactionEngine;
18
19
  interface Binding { engine: Engine; ctx: Context; fiber: Fiber; restore(): void }
19
- interface Job { view: JobView; pending?: Target; abort?: AbortController; blocked: boolean; work?: Promise<unknown> }
20
+ interface Job { view: JobView; pending?: Target; pendingRoute?: CompactionRouteLease; abort?: AbortController; blocked: boolean; work?: Promise<unknown> }
20
21
  const inactive = (): Job => ({ view: { phase: 'idle', message: '', failures: 0 }, blocked: false });
21
22
 
22
23
  export class Controller {
@@ -24,7 +25,7 @@ export class Controller {
24
25
  private jobs = new Map<string, Job>();
25
26
  private disposed = false;
26
27
  private pressure: PressureMeter;
27
- constructor(readonly ctx: Context, readonly settings: SettingsScope<Config>) {
28
+ constructor(readonly ctx: Context, readonly settings: SettingsScope<Config>, private readonly routes?: CompactionRouteRegistry) {
28
29
  this.pressure = new PressureMeter(ctx);
29
30
  const scan = () => { if (!this.disposed) this.scan(); };
30
31
  ctx.on('internal/status', () => queueMicrotask(scan), { global: true });
@@ -69,7 +70,8 @@ export class Controller {
69
70
  const region: Engine['compactRegion'] = async function(this: Engine, start, end, agent, signal) {
70
71
  const job = self.job(agent);
71
72
  if (job.blocked && self.settings.get().enabled) throw new Error('压缩救援等待用户选择重试或取消。');
72
- if (self.direct()) return self.rescue(agent, { engine, ctx: fiber.ctx, fiber, restore() {} }, self.configured(), signal ?? new AbortController().signal, false, { start, end });
73
+ const route = self.settings.get().enabled ? self.routes?.resolve(agent) : undefined;
74
+ if (self.direct() || route) return self.rescue(agent, { engine, ctx: fiber.ctx, fiber, restore() {} }, route?.target ?? self.configured(), signal ?? new AbortController().signal, false, { start, end }, undefined, route);
73
75
  const generation = agent.session.surface.replaceGeneration;
74
76
  const firstEvent = agent.session.seq;
75
77
  try {
@@ -91,7 +93,7 @@ export class Controller {
91
93
  if (self.job(agent).blocked && self.settings.get().enabled) return null;
92
94
  // Prime metadata before the official synchronous pressure check. Failure
93
95
  // to resolve it leaves the original check and context-overflow recovery intact.
94
- try { await self.pressure.refresh(agent.session, undefined, signal); } catch { if (signal?.aborted) throw signal.reason; }
96
+ try { await self.pressure.refresh(agent.session, self.current(agent), signal); } catch { if (signal?.aborted) throw signal.reason; }
95
97
  return originalIfNeeded.call(this, agent, trigger, signal);
96
98
  };
97
99
  const now: Engine['compactNow'] = function(this: Engine, agent, signal, sourceCommandId) {
@@ -131,6 +133,8 @@ export class Controller {
131
133
  }
132
134
  private direct(): boolean { const config = this.settings.get(); return config.enabled && config.mode === 'direct'; }
133
135
  current(agent: Agent): Target | undefined {
136
+ const routed = this.routes?.resolve(agent);
137
+ if (routed) return routed.context;
134
138
  const projection = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection');
135
139
  const target = projection?.pending ?? projection?.lastUsed ?? agent.session.requestHeader()?.config ?? agent.options;
136
140
  return target.provider && target.model ? { provider: target.provider, model: target.model, ...(target.reasoningEffort ? { reasoningEffort: String(target.reasoningEffort) } : {}) } : undefined;
@@ -156,15 +160,16 @@ export class Controller {
156
160
  const job = this.job(agent);
157
161
  if (mode === 'cancel') {
158
162
  job.blocked = false;
159
- job.pending = undefined; job.abort?.abort(new Error('用户取消压缩。'));
163
+ job.pending = undefined; job.pendingRoute = undefined; job.abort?.abort(new Error('用户取消压缩。'));
160
164
  if (!job.abort) job.view = { phase: 'idle', message: '已取消压缩。', failures: job.view.failures };
161
165
  return this.state(id);
162
166
  }
163
167
  if (job.pending || job.abort) throw new Error('该会话已有压缩任务。');
164
168
  this.binding(agent);
165
- const target = mode === 'current' ? this.current(agent) : this.configured();
169
+ const route = this.routes?.resolve(agent);
170
+ const target = route?.target ?? (mode === 'current' ? this.current(agent) : this.configured());
166
171
  if (!target?.provider || !target.model) throw new Error('请先在设置中选择压缩模型。');
167
- job.pending = target; job.blocked = false;
172
+ job.pending = target; job.pendingRoute = route; job.blocked = false;
168
173
  job.view = { phase: 'queued', message: agent.status === 'idle' ? '正在准备压缩…' : '已排队,当前模型或工具步骤结束后压缩。', failures: job.view.failures };
169
174
  if (agent.status === 'idle') queueMicrotask(() => { void this.runIdle(agent); });
170
175
  return this.state(id);
@@ -176,31 +181,35 @@ export class Controller {
176
181
  catch (error) {
177
182
  // A turn may win admission between the click and maintenance. Keep it queued for pre-step.
178
183
  if (agent.status !== 'idle') return;
179
- job.pending = undefined; this.fail(job, error);
184
+ job.pending = undefined; job.pendingRoute = undefined; this.fail(job, error);
180
185
  }
181
186
  }
182
187
  private async runPending(agent: Agent, signal: AbortSignal, idle: boolean): Promise<void> {
183
- const job = this.job(agent), target = job.pending;
188
+ const job = this.job(agent), target = job.pending, route = job.pendingRoute;
184
189
  if (!target || this.disposed) return;
185
- job.pending = undefined;
186
- try { await this.rescue(agent, this.binding(agent), target, signal, idle); }
190
+ job.pending = undefined; job.pendingRoute = undefined;
191
+ try { await this.rescue(agent, this.binding(agent), target, signal, idle, undefined, undefined, route); }
187
192
  catch (error) { if (!signal.aborted && job.view.phase === 'queued') this.fail(job, error); }
188
193
  }
189
194
  private fail(job: Job, error: unknown): void {
190
195
  job.blocked = true;
191
196
  job.view = { ...job.view, phase: 'failed', message: errorChain(error) };
192
197
  }
193
- private async rescue(agent: Agent, binding: Binding, target: Target, outerSignal: AbortSignal, idle: boolean, selected?: { start: SessionSeq; end: SessionSeq }, sourceCommandId?: CommandId) {
198
+ private async rescue(agent: Agent, binding: Binding, target: Target, outerSignal: AbortSignal, idle: boolean, selected?: { start: SessionSeq; end: SessionSeq }, sourceCommandId?: CommandId, lease?: CompactionRouteLease) {
194
199
  const job = this.job(agent);
195
200
  if (job.abort) throw new Error('该会话已有压缩任务。');
196
201
  const abort = new AbortController(); job.abort = abort;
197
- const signal = AbortSignal.any([outerSignal, abort.signal]);
202
+ let signal = AbortSignal.any([outerSignal, abort.signal]);
198
203
  const work = (async () => {
199
204
  try {
200
205
  signal.throwIfAborted();
206
+ const route = lease ?? this.routes?.resolve(agent);
207
+ if (route) signal = AbortSignal.any([signal, route.signal]);
208
+ route?.assertCurrent();
209
+ target = route?.target ?? target;
201
210
  if (!target.provider || !target.model) throw new Error('请先在设置 → Compact Saviour 中选择压缩模型。');
202
211
  const meter = binding.ctx.tokenMeter;
203
- const current = this.current(agent);
212
+ const current = route?.context ?? this.current(agent);
204
213
  const header = agent.session.requestHeader();
205
214
  // Reprice retained images for the model selected for the next request.
206
215
  const effectiveHeader = header && current ? { ...header, config: { ...header.config, provider: current.provider, model: current.model } } : header;
@@ -215,6 +224,8 @@ export class Controller {
215
224
  const beforeTokens = fixed + before.surfaceTokens;
216
225
  job.view = { phase: 'running', message: '正在压缩上下文…', failures: job.view.failures, beforeTokens };
217
226
  const info = current ? await binding.ctx.llm.resolveModelInfo(current.provider, current.model, signal) : undefined;
227
+ signal.throwIfAborted(); route?.assertCurrent();
228
+ if (info && (info.provider !== current?.provider || info.id !== current.model)) throw new Error('上下文模型元数据与实际路由不一致。');
218
229
  const contextWindow = info?.context?.contextWindow;
219
230
  if (!contextWindow) throw new Error('当前对话模型没有可确认的上下文容量。');
220
231
  const room = Math.floor(contextWindow * .75) - fixed - (effectiveHeader?.config.maxTokens ?? info?.defaultMaxTokens ?? 8192);
@@ -229,7 +240,7 @@ export class Controller {
229
240
  const summaryBudget = Math.min(6000, maxSummaryTokens);
230
241
  const result = await compactSurfaceRegion({
231
242
  meter,
232
- summarize: (input, owner, abortSignal) => compress(binding.ctx, input, owner, { target, signal: abortSignal ?? signal, summaryBudget, maxSummaryTokens,
243
+ summarize: (input, owner, abortSignal) => compress(binding.ctx, input, owner, { target, signal: abortSignal ?? signal, summaryBudget, maxSummaryTokens, routeLease: route,
233
244
  progress: (completed, total, message) => { job.view = { ...job.view, completed, total, message: message ?? (total === 1 ? '正在生成摘要…' : `正在分块压缩 ${completed}/${total}…`) }; },
234
245
  }),
235
246
  }, agent.session, range.start, range.end, agent, { owner: idle ? null : 'current-turn', stability: idle ? 'selected-span' : 'whole-surface', ...(sourceCommandId ? { sourceCommandId } : {}), ...(idle ? { flush: async () => { await binding.ctx.sessions.flush(agent.session); } } : {}) }, signal);
@@ -6,6 +6,9 @@ import type {} from '@deepseek-ai/dsh-settings';
6
6
  import type {} from '@deepseek-ai/dsh-llm';
7
7
  import type { IncomingMessage, ServerResponse } from 'node:http';
8
8
  import { Controller } from './controller.js';
9
+ import { CompactionRouteRegistry } from './routes.js';
10
+ export { CompactionRouteRegistry } from './routes.js';
11
+ export type { CompactionRouteSnapshot, CompactionRouteLease } from './routes.js';
9
12
  import type { Config as Settings } from './shared.js';
10
13
 
11
14
  export const name = 'dsh-compact-saviour';
@@ -41,7 +44,10 @@ export function apply(ctx: Context, config: { [K in keyof Settings]-?: Volatile<
41
44
  replace: (value: Settings) => ctx.settings.update('dsh-compact-saviour', value),
42
45
  };
43
46
  ctx.effect(() => ctx.settings.configure({ auto: false }));
44
- const controller = new Controller(ctx, settings);
47
+ const routes = new CompactionRouteRegistry();
48
+ ctx.provide('compactionRoutes', routes);
49
+ ctx.effect(() => () => routes.close());
50
+ const controller = new Controller(ctx, settings, routes);
45
51
  // Versioned route also recovers a running 0.1.0 Host whose unowned legacy
46
52
  // route cannot be released through the public WebServer API. All new route
47
53
  // registrations are fiber-owned and disappear before replacement on HMR.
package/src/routes.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { Agent } from '@deepseek-ai/dsh-agent';
2
+ import type { GenerateOptions } from '@deepseek-ai/dsh-llm';
3
+ import type { Target } from './shared.js';
4
+
5
+ /** Optional plugin contract. Capacity is resolved separately for these exact routes. */
6
+ export interface CompactionRouteSnapshot {
7
+ revision: string;
8
+ context: Target;
9
+ target: Target;
10
+ maxOutputTokens?: number;
11
+ }
12
+ export interface CompactionRouteLease extends CompactionRouteSnapshot {
13
+ readonly signal: AbortSignal;
14
+ assertCurrent(): void;
15
+ prepare(request: GenerateOptions): GenerateOptions;
16
+ }
17
+ type Resolver = (agent: Agent) => CompactionRouteSnapshot | undefined;
18
+
19
+ function snapshot(value: CompactionRouteSnapshot): CompactionRouteSnapshot {
20
+ if (!value.revision || ![value.context, value.target].every(route =>
21
+ typeof route?.provider === 'string' && route.provider && typeof route.model === 'string' && route.model
22
+ && (route.reasoningEffort === undefined || typeof route.reasoningEffort === 'string' && route.reasoningEffort)))
23
+ throw new Error('Invalid session compaction route');
24
+ if (value.maxOutputTokens !== undefined && (!Number.isSafeInteger(value.maxOutputTokens) || value.maxOutputTokens <= 0))
25
+ throw new Error('Invalid session compaction output reservation');
26
+ return Object.freeze({ revision: value.revision,
27
+ context: Object.freeze({ ...value.context }), target: Object.freeze({ ...value.target }),
28
+ ...(value.maxOutputTokens === undefined ? {} : { maxOutputTokens: value.maxOutputTokens }) });
29
+ }
30
+ const key = (value: CompactionRouteSnapshot) => JSON.stringify([value.revision,
31
+ value.context.provider, value.context.model, value.context.reasoningEffort,
32
+ value.target.provider, value.target.model, value.target.reasoningEffort, value.maxOutputTokens]);
33
+
34
+ /** Fiber-owned registrations; a lease expires on route change or owner unload. */
35
+ export class CompactionRouteRegistry {
36
+ readonly apiVersion = 1;
37
+ private resolvers = new Set<{ owner: string; resolve: Resolver; lifecycle: AbortController }>();
38
+ register(owner: string, resolve: Resolver): () => void {
39
+ if (!owner) throw new Error('Compaction route owner is required');
40
+ const entry = { owner, resolve, lifecycle: new AbortController() }; this.resolvers.add(entry);
41
+ return () => { this.resolvers.delete(entry); entry.lifecycle.abort(new Error('Session compaction route owner unloaded')); };
42
+ }
43
+ resolve(agent: Agent): CompactionRouteLease | undefined {
44
+ const matches = [...this.resolvers].flatMap(entry => {
45
+ const value = entry.resolve(agent); return value ? [{ entry, value: snapshot(value) }] : [];
46
+ });
47
+ if (!matches.length) return undefined;
48
+ if (matches.length !== 1) throw new Error('Multiple plugins own this session compaction route');
49
+ const { entry, value } = matches[0]!;
50
+ const signature = key(value);
51
+ const assertCurrent = () => {
52
+ if ([...this.resolvers].some(other => other !== entry && other.resolve(agent)))
53
+ throw new Error('Multiple plugins own this session compaction route');
54
+ const current = this.resolvers.has(entry) && entry.resolve(agent);
55
+ if (!current || key(snapshot(current)) !== signature) throw new Error('Session compaction route changed; retry with the current model');
56
+ };
57
+ return Object.freeze({ ...value, signal: entry.lifecycle.signal, assertCurrent,
58
+ prepare: (request: GenerateOptions) => {
59
+ request.signal?.throwIfAborted(); assertCurrent();
60
+ if (request.purpose !== 'compaction' || request.sessionId !== agent.session.id
61
+ || request.provider !== value.target.provider || request.model !== value.target.model
62
+ || request.reasoningEffort !== value.target.reasoningEffort
63
+ || !Number.isSafeInteger(request.maxTokens) || Number(request.maxTokens) <= 0
64
+ || value.maxOutputTokens !== undefined && request.maxTokens! > value.maxOutputTokens)
65
+ throw new Error('Compaction request does not match its planned physical route');
66
+ // Host llm.stream supports frozen one-shot envelopes. Later middleware
67
+ // may validate this route, but cannot replace its model or reservation.
68
+ return Object.freeze(request);
69
+ },
70
+ });
71
+ }
72
+ close(): void {
73
+ for (const entry of this.resolvers) entry.lifecycle.abort(new Error('Session compaction route registry unloaded'));
74
+ this.resolvers.clear();
75
+ }
76
+ }
@@ -14,7 +14,7 @@ test('chunks preserve multilingual source exactly, including a single huge line'
14
14
  function fixture(text: string, finish = 'stop') {
15
15
  const requests: any[] = [];
16
16
  const ctx = { llm: {
17
- resolveModelInfo: async () => ({ context: { contextWindow: 64000 }, reasoning: { efforts: [{ id: 'low' }] } }),
17
+ resolveModelInfo: async (provider: string, model: string) => ({ provider, id: model, name: model, context: { contextWindow: 64000 }, reasoning: { efforts: [{ id: 'low' }] } }),
18
18
  async *stream(request: any) {
19
19
  requests.push(request);
20
20
  yield { type: 'block-start', index: 0, blockType: 'text' };
@@ -3,19 +3,19 @@ import { readFileSync } from 'node:fs';
3
3
  import test from 'node:test';
4
4
  import semver from 'semver';
5
5
 
6
- const RANGE = '>=0.2.0-rc.1 <0.2.1';
7
- const ACCEPT = ['0.2.0-rc.2', '0.2.0', '0.2.0-rc.2'];
8
- const REJECT = ['0.2.0-alpha', '0.2.0-alpha.1', '0.2.0-alpha.2', '0.1.7-rc.2', '0.1.7-rc.1', '0.2.1'];
6
+ const RANGE = '>=0.2.1-rc.1 <0.2.2';
7
+ const ACCEPT = ['0.2.1-rc.5', '0.2.1-rc.1', '0.2.1'];
8
+ const REJECT = ['0.2.0-rc.2', '0.2.0', '0.2.0-alpha', '0.2.0-alpha.1', '0.2.1-alpha.1', '0.1.7-rc.2', '0.1.7-rc.1', '0.2.2'];
9
9
 
10
- test('Harness peers use >=0.2.0-rc.1 <0.2.1', () => {
10
+ test('Harness peers use >=0.2.1-rc.1 <0.2.2', () => {
11
11
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
12
- assert.equal(pkg.version, '0.2.7');
13
- assert.equal(semver.satisfies('0.2.0-rc.2', RANGE), true);
12
+ assert.equal(pkg.version, '0.2.9');
13
+ assert.equal(semver.satisfies('0.2.1-rc.5', RANGE), true);
14
14
  const peers = Object.entries(pkg.peerDependencies).filter(([name]) => name.startsWith('@deepseek-ai/dsh'));
15
15
  assert.ok(peers.length >= 17);
16
16
  for (const [name, range] of peers) assert.equal(range, RANGE, name);
17
- assert.equal(pkg.devDependencies['@deepseek-ai/dsh-agent-loop'], '0.2.0-rc.2');
18
- assert.equal(pkg.devDependencies['@deepseek-ai/dsh-agent-loop-testkit'], '0.2.0-rc.2');
17
+ assert.equal(pkg.devDependencies['@deepseek-ai/dsh-agent-loop'], RANGE);
18
+ assert.equal(pkg.devDependencies['@deepseek-ai/dsh-agent-loop-testkit'], RANGE);
19
19
  assert.equal(pkg.peerDependencies['@deepseek-ai/cordis'], '^4.0.4');
20
20
  assert.equal(pkg.peerDependencies['@deepseek-ai/schemastery'], '^3.18.4');
21
21
  for (const version of ACCEPT) assert.equal(semver.satisfies(version, RANGE), true, version);
@@ -0,0 +1,67 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { CompactionRouteRegistry } from '../src/routes.js';
4
+ import { compress } from '../src/compress.js';
5
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
6
+
7
+ const agent = (id = 'a') => ({ id, session: { id } }) as never;
8
+ const value = (revision = '1') => ({ revision, context: { provider: 'p', model: 'lead' },
9
+ target: { provider: 'p', model: 'summary' }, maxOutputTokens: 2048 });
10
+
11
+ test('session leases isolate routes and expire on revision change or owner unload', () => {
12
+ const registry = new CompactionRouteRegistry(); let revision = '1';
13
+ const remove = registry.register('fixture', a => a.session.id === 'a' ? value(revision) : undefined);
14
+ const first = registry.resolve(agent())!;
15
+ assert.equal(registry.resolve(agent('ordinary')), undefined);
16
+ first.assertCurrent(); revision = '2';
17
+ assert.throws(() => first.assertCurrent(), /route changed/);
18
+ const second = registry.resolve(agent())!; remove();
19
+ assert.throws(() => second.assertCurrent(), /route changed/);
20
+ assert.equal(second.signal.aborted, true);
21
+ });
22
+ test('ambiguous owners fail instead of choosing whichever plugin registered first', () => {
23
+ const registry = new CompactionRouteRegistry();
24
+ registry.register('one', () => value());
25
+ const lease = registry.resolve(agent())!;
26
+ registry.register('two', () => value());
27
+ assert.throws(() => registry.resolve(agent()), /Multiple plugins/);
28
+ assert.throws(() => lease.assertCurrent(), /Multiple plugins/);
29
+ });
30
+ test('a planned request retains its bounded cap and cannot be rerouted', () => {
31
+ const registry = new CompactionRouteRegistry(); registry.register('fixture', () => value());
32
+ const lease = registry.resolve(agent())!;
33
+ const request = { provider: 'p', model: 'summary', sessionId: 'a', purpose: 'compaction', maxTokens: 1024, messages: [] };
34
+ const planned = lease.prepare(request as never);
35
+ assert.equal(planned.maxTokens, 1024); assert.equal(Object.isFrozen(planned), true);
36
+ assert.throws(() => { planned.model = 'other'; });
37
+ assert.throws(() => lease.prepare({ ...request, maxTokens: 4096 } as never), /planned physical route/);
38
+ assert.throws(() => lease.prepare({ ...request, model: 'other' } as never), /planned physical route/);
39
+ });
40
+ test('physical planning uses the lease model and cap before dispatch, then rejects stale metadata', async () => {
41
+ const registry = new CompactionRouteRegistry(); let revision = '1';
42
+ registry.register('fixture', () => value(revision));
43
+ const input = { messages: [createUserMessage({ content: [{ type: 'text', text: 'Preserve synthetic task facts. '.repeat(500) }] })] };
44
+ const requests: any[] = [], metadata: string[] = [];
45
+ const ctx = { llm: {
46
+ async resolveModelInfo(provider: string, model: string) {
47
+ metadata.push(model); return { provider, id: model, name: model, context: { contextWindow: 32000 }, defaultMaxTokens: 8192 };
48
+ },
49
+ async *stream(request: any) {
50
+ requests.push(request); assert.equal(Object.isFrozen(request), true);
51
+ yield { type: 'block-start', index: 0, blockType: 'text' };
52
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Preserve task facts. Next: verify.' } };
53
+ yield { type: 'finish', reason: { kind: 'stop' } };
54
+ },
55
+ } };
56
+ const lease = registry.resolve(agent())!;
57
+ await compress(ctx as never, input, agent(), { target: lease.target, routeLease: lease,
58
+ signal: new AbortController().signal, progress() {} });
59
+ assert.deepEqual(metadata, ['summary']); assert.equal(requests[0].maxTokens, 2048);
60
+ assert.equal(requests[0].model, 'summary');
61
+ ctx.llm.resolveModelInfo = async (provider, model) => {
62
+ revision = '2'; return { provider, id: model, name: model, context: { contextWindow: 32000 }, defaultMaxTokens: 8192 };
63
+ };
64
+ await assert.rejects(compress(ctx as never, input, agent(), { target: lease.target, routeLease: lease,
65
+ signal: new AbortController().signal, progress() {} }), /route changed/);
66
+ assert.equal(requests.length, 1);
67
+ });