dsh-ops 0.2.3 → 0.2.5-beta.1
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/CHANGELOG.md +18 -0
- package/PROVENANCE.md +24 -0
- package/README.en.md +51 -9
- package/README.md +36 -11
- package/README.zh.md +36 -11
- package/cordis.patch.yml +27 -155
- package/docs/auto-compact.md +102 -0
- package/docs/manual-validation.md +10 -5
- package/docs/schema-current.json +20 -13
- package/docs/schema-measurement.md +29 -9
- package/dsh-plugin.json +40 -7
- package/lib/components/background/icon.png +0 -0
- package/lib/components/background/index.js +3 -0
- package/lib/components/background/locale/en.json +6 -0
- package/lib/components/background/locale/zh.json +6 -0
- package/lib/components/background/package.json +6 -0
- package/lib/components/file/icon.png +0 -0
- package/lib/components/file/index.js +3 -0
- package/lib/components/file/locale/en.json +6 -0
- package/lib/components/file/locale/zh.json +6 -0
- package/lib/components/file/package.json +6 -0
- package/lib/components/shell/icon.png +0 -0
- package/lib/components/shell/index.js +3 -0
- package/lib/components/shell/locale/en.json +6 -0
- package/lib/components/shell/locale/zh.json +6 -0
- package/lib/components/shell/package.json +6 -0
- package/lib/index.js +46 -32
- package/lib/policy.js +16 -8
- package/lib/profile-install.js +1 -1
- package/lib/runtime-pool.js +42 -0
- package/lib/session-pwsh.js +31 -0
- package/lib/tools.js +64 -16
- package/node_modules/@dsh-ops/auto-compact/LICENSE +30 -0
- package/node_modules/@dsh-ops/auto-compact/README.md +23 -0
- package/node_modules/@dsh-ops/auto-compact/lib/client.js +79 -0
- package/node_modules/@dsh-ops/auto-compact/lib/config.js +60 -0
- package/node_modules/@dsh-ops/auto-compact/lib/index.js +132 -0
- package/node_modules/@dsh-ops/auto-compact/locale/en.json +6 -0
- package/node_modules/@dsh-ops/auto-compact/locale/zh.json +6 -0
- package/node_modules/@dsh-ops/auto-compact/package.json +31 -0
- package/package.json +27 -4
- package/packages/README.md +87 -0
- package/packages/auto-compact/LICENSE +30 -0
- package/packages/auto-compact/README.md +23 -0
- package/packages/auto-compact/lib/client.js +79 -0
- package/packages/auto-compact/lib/config.js +60 -0
- package/packages/auto-compact/lib/index.js +132 -0
- package/packages/auto-compact/locale/en.json +6 -0
- package/packages/auto-compact/locale/zh.json +6 -0
- package/packages/auto-compact/package.json +31 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** One plugin-owned FastCtx connection shared by independently toggled components. */
|
|
2
|
+
import { McpStdioClient } from './handshake.js'
|
|
3
|
+
const pools = new WeakMap()
|
|
4
|
+
export async function acquireRuntime(ctx, runtime, env) {
|
|
5
|
+
const root = ctx.root
|
|
6
|
+
let pool = pools.get(root)
|
|
7
|
+
if (!pool) { pool = { clients: new Map(), closing: Promise.resolve() }; pools.set(root, pool) }
|
|
8
|
+
await pool.closing
|
|
9
|
+
let entry = pool.clients.get(runtime.file)
|
|
10
|
+
if (!entry) {
|
|
11
|
+
const client = McpStdioClient.start({ file: runtime.file, args: ['serve', '--enable-shell'], env, timeoutMs: 60000 })
|
|
12
|
+
entry = { client, refs: 0, ready: undefined }
|
|
13
|
+
pool.clients.set(runtime.file, entry)
|
|
14
|
+
entry.ready = (async () => {
|
|
15
|
+
try {
|
|
16
|
+
const handshake = await client.initialize('dsh-ops-components')
|
|
17
|
+
const tools = await client.listTools()
|
|
18
|
+
return { client, handshake, tools }
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (pool.clients.get(runtime.file) === entry) pool.clients.delete(runtime.file)
|
|
21
|
+
await client.close()
|
|
22
|
+
throw error
|
|
23
|
+
}
|
|
24
|
+
})()
|
|
25
|
+
void client.exited.then(() => { if (pool.clients.get(runtime.file) === entry) pool.clients.delete(runtime.file) })
|
|
26
|
+
}
|
|
27
|
+
entry.refs += 1
|
|
28
|
+
let released = false
|
|
29
|
+
const release = async () => {
|
|
30
|
+
if (released) return
|
|
31
|
+
released = true
|
|
32
|
+
entry.refs -= 1
|
|
33
|
+
if (!entry.refs) {
|
|
34
|
+
if (pool.clients.get(runtime.file) === entry) pool.clients.delete(runtime.file)
|
|
35
|
+
const closing = pool.closing.then(() => entry.client.close())
|
|
36
|
+
pool.closing = closing.catch(() => {})
|
|
37
|
+
await closing
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
try { return { ...await entry.ready, release } }
|
|
41
|
+
catch (error) { await release(); throw error }
|
|
42
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** A reversible runtime-only config overlay; never edits profile files or services. */
|
|
2
|
+
export function mountBundledPwsh(ctx, shells) {
|
|
3
|
+
if (!shells.pwsh.available || process.platform !== 'win32') return
|
|
4
|
+
ctx.inject(['loader'], loaderCtx => {
|
|
5
|
+
const loader = loaderCtx.get('loader')
|
|
6
|
+
const matches = entry => entry?.options?.id === 'pwsh-sandbox'
|
|
7
|
+
&& entry.options.name === '@deepseek-ai/dsh-pwsh-sandbox'
|
|
8
|
+
const refresh = () => {
|
|
9
|
+
for (const entry of loader.entries()) {
|
|
10
|
+
if (matches(entry) && entry.fiber?.state === 2) {
|
|
11
|
+
// Public Fiber.update with noSave recomputes from the owner's latest
|
|
12
|
+
// raw config. No stored config, volatile reference, or method is changed.
|
|
13
|
+
entry.fiber.update(entry.options.config, true)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const unhook = loaderCtx.on('internal/config', function (_raw, next) {
|
|
18
|
+
const config = next()
|
|
19
|
+
if (!matches(this.entry) || this.parent?.fiber?.entry === this.entry) return config
|
|
20
|
+
return { ...config, pwshPath: shells.pwsh.file }
|
|
21
|
+
}, { global: true, prepend: true })
|
|
22
|
+
let stopped = false
|
|
23
|
+
loaderCtx.effect(() => () => {
|
|
24
|
+
if (stopped) return
|
|
25
|
+
stopped = true
|
|
26
|
+
unhook()
|
|
27
|
+
refresh()
|
|
28
|
+
}, 'dsh-ops: restore owning PowerShell executor config')
|
|
29
|
+
refresh()
|
|
30
|
+
})
|
|
31
|
+
}
|
package/lib/tools.js
CHANGED
|
@@ -23,6 +23,8 @@ import { McpStdioClient } from './handshake.js'
|
|
|
23
23
|
import { FILE_TOOLS, SHELL_TOOLS, publicToolName } from './policy.js'
|
|
24
24
|
import { projectTool } from './presentation.js'
|
|
25
25
|
import { cleanBackground, jobEntries, startedJob } from './jobs.js'
|
|
26
|
+
import { acquireRuntime } from './runtime-pool.js'
|
|
27
|
+
import { resolveShells } from './shells.js'
|
|
26
28
|
|
|
27
29
|
/** Deadline for one connection attempt: spawn, initialize, and `tools/list`. */
|
|
28
30
|
const CONNECT_TIMEOUT_MS = 60_000
|
|
@@ -216,10 +218,16 @@ export class FastCtxTools {
|
|
|
216
218
|
* @param {{file: string, source: string, version: string}} options.runtime - the resolved FastCtx executable.
|
|
217
219
|
* @param {(message: string, level?: string) => void} [options.report] - where asynchronous events are reported.
|
|
218
220
|
*/
|
|
219
|
-
constructor({ ctx, config, runtime, report }) {
|
|
221
|
+
constructor({ ctx, config, runtime, report, fileTools = true, commandTools = SHELL_TOOLS, shared = false }) {
|
|
220
222
|
this.ctx = ctx
|
|
221
223
|
this.config = config
|
|
222
224
|
this.runtime = runtime
|
|
225
|
+
this.fileTools = fileTools
|
|
226
|
+
this.commandTools = commandTools
|
|
227
|
+
this.shared = shared
|
|
228
|
+
this.leases = new Map()
|
|
229
|
+
this.pendingCalls = new Set()
|
|
230
|
+
this.shuttingDown = false
|
|
223
231
|
this.report = report ?? (() => {})
|
|
224
232
|
/** The live connection, or undefined while the surface is down. */
|
|
225
233
|
this.client = undefined
|
|
@@ -282,7 +290,7 @@ export class FastCtxTools {
|
|
|
282
290
|
if (!entry?.ctx) return
|
|
283
291
|
for (const dispose of entry.disposers) dispose()
|
|
284
292
|
entry.disposers = []
|
|
285
|
-
if (this.stopped) return
|
|
293
|
+
if (this.stopped || this.shuttingDown) return
|
|
286
294
|
if (!this.shellAllowed(agent.session)) {
|
|
287
295
|
// A child must not inherit an authorized ancestor's command surface.
|
|
288
296
|
const names = (this.ctx.get('tools')?.schemas(agent) ?? []).map(tool => tool.name)
|
|
@@ -293,8 +301,8 @@ export class FastCtxTools {
|
|
|
293
301
|
}
|
|
294
302
|
return
|
|
295
303
|
}
|
|
296
|
-
const commandTools = this.tools.filter(tool =>
|
|
297
|
-
if (commandTools.length !==
|
|
304
|
+
const commandTools = this.tools.filter(tool => this.commandTools.includes(tool.name))
|
|
305
|
+
if (commandTools.length !== this.commandTools.length) return
|
|
298
306
|
try {
|
|
299
307
|
for (const tool of commandTools) {
|
|
300
308
|
entry.disposers.push(entry.ctx.tools.register(toolDefinition({
|
|
@@ -367,6 +375,20 @@ export class FastCtxTools {
|
|
|
367
375
|
* @returns {Promise<void>} resolves once the child is gone.
|
|
368
376
|
*/
|
|
369
377
|
async stop() {
|
|
378
|
+
this.shuttingDown = true
|
|
379
|
+
this.refreshAgents()
|
|
380
|
+
// Drain launches before killing only this component's owned jobs. Closing
|
|
381
|
+
// FastCtx alone does not stop its durable supervisors.
|
|
382
|
+
await Promise.allSettled([...this.pendingCalls])
|
|
383
|
+
const cleanupClient = this.client
|
|
384
|
+
if (cleanupClient) {
|
|
385
|
+
for (const ids of this.ownedJobs.values()) for (const job_id of ids) {
|
|
386
|
+
try {
|
|
387
|
+
const result = await cleanupClient.callTool('job_kill', { job_id }, { timeoutMs: 15000 })
|
|
388
|
+
if (result?.isError) this.report(`dsh-ops: owned job ${job_id} could not be stopped during component shutdown.`, 'warn')
|
|
389
|
+
} catch (error) { this.report(`dsh-ops: owned job ${job_id} shutdown failed: ${messageOf(error)}`, 'warn') }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
370
392
|
this.stopped = true
|
|
371
393
|
if (this.timer !== undefined) {
|
|
372
394
|
clearTimeout(this.timer)
|
|
@@ -380,7 +402,14 @@ export class FastCtxTools {
|
|
|
380
402
|
this.tools = []
|
|
381
403
|
this.ownedJobs.clear()
|
|
382
404
|
await Promise.all([...this.agents.keys()].map(agent => this.detachAgent(agent)))
|
|
383
|
-
if (client !== undefined) await client
|
|
405
|
+
if (client !== undefined) await this.#release(client)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async #release(client) {
|
|
409
|
+
const lease = this.leases.get(client)
|
|
410
|
+
this.leases.delete(client)
|
|
411
|
+
if (lease) await lease.release()
|
|
412
|
+
else if (!this.shared) await client.close()
|
|
384
413
|
}
|
|
385
414
|
|
|
386
415
|
/**
|
|
@@ -388,7 +417,16 @@ export class FastCtxTools {
|
|
|
388
417
|
* @returns {Promise<void>} resolves once the generation is live.
|
|
389
418
|
*/
|
|
390
419
|
async #connect() {
|
|
391
|
-
|
|
420
|
+
let lease
|
|
421
|
+
if (this.shared) {
|
|
422
|
+
const env = childEnv()
|
|
423
|
+
const bash = resolveShells(this.config).bash
|
|
424
|
+
if (bash.available) env.FASTCTX_BASH = bash.file
|
|
425
|
+
lease = await acquireRuntime(this.ctx, this.runtime, env)
|
|
426
|
+
if (this.shuttingDown || this.stopped) { await lease.release(); throw new StoppedError() }
|
|
427
|
+
this.leases.set(lease.client, lease)
|
|
428
|
+
}
|
|
429
|
+
const client = lease?.client ?? McpStdioClient.start({
|
|
392
430
|
file: this.runtime.file,
|
|
393
431
|
args: serverArgs(this.config.enableShellTools),
|
|
394
432
|
env: childEnv(),
|
|
@@ -401,9 +439,9 @@ export class FastCtxTools {
|
|
|
401
439
|
// already replaced or abandoned.
|
|
402
440
|
void client.exited.then(({ code, signal, error }) => this.#onExit(client, code, signal, error))
|
|
403
441
|
try {
|
|
404
|
-
const handshake = await client.initialize('dsh-ops')
|
|
405
|
-
const tools = await client.listTools()
|
|
406
|
-
if (this.stopped || client !== this.client) throw new StoppedError()
|
|
442
|
+
const handshake = lease?.handshake ?? await client.initialize('dsh-ops')
|
|
443
|
+
const tools = lease?.tools ?? await client.listTools()
|
|
444
|
+
if (this.stopped || this.shuttingDown || client !== this.client) throw new StoppedError()
|
|
407
445
|
this.ownedJobs.clear()
|
|
408
446
|
this.#publish(tools)
|
|
409
447
|
this.serverInfo = handshake?.serverInfo
|
|
@@ -411,7 +449,7 @@ export class FastCtxTools {
|
|
|
411
449
|
this.serverInstructions = ''
|
|
412
450
|
this.connectedAt = Date.now()
|
|
413
451
|
} catch (error) {
|
|
414
|
-
await client
|
|
452
|
+
await this.#release(client)
|
|
415
453
|
if (this.client === client) this.client = undefined
|
|
416
454
|
throw error
|
|
417
455
|
}
|
|
@@ -426,7 +464,8 @@ export class FastCtxTools {
|
|
|
426
464
|
* @returns {void}
|
|
427
465
|
*/
|
|
428
466
|
#onExit(client, code, signal, error) {
|
|
429
|
-
if (this.stopped || client !== this.client) return
|
|
467
|
+
if (this.stopped || this.shuttingDown || client !== this.client) return
|
|
468
|
+
void this.#release(client)
|
|
430
469
|
this.client = undefined
|
|
431
470
|
this.ownedJobs.clear()
|
|
432
471
|
this.tools = []
|
|
@@ -449,7 +488,7 @@ export class FastCtxTools {
|
|
|
449
488
|
#scheduleReconnect() {
|
|
450
489
|
// One pending timer at a time: a crash and a failed first connect can both
|
|
451
490
|
// land here, and two loops would double every later attempt.
|
|
452
|
-
if (this.stopped || this.timer !== undefined) return
|
|
491
|
+
if (this.stopped || this.shuttingDown || this.timer !== undefined) return
|
|
453
492
|
// A connection that stayed up long enough closed the previous outage.
|
|
454
493
|
if (this.connectedAt !== undefined && Date.now() - this.connectedAt >= RECONNECT.maxDelayMs) {
|
|
455
494
|
this.attempts = 0
|
|
@@ -486,12 +525,12 @@ export class FastCtxTools {
|
|
|
486
525
|
* @returns {Promise<void>} resolves once this attempt settled.
|
|
487
526
|
*/
|
|
488
527
|
async #reconnect() {
|
|
489
|
-
if (this.stopped) return
|
|
528
|
+
if (this.stopped || this.shuttingDown) return
|
|
490
529
|
try {
|
|
491
530
|
await this.#connect()
|
|
492
531
|
this.report(`dsh-ops: ${this.#describe()} reconnected with ${this.names().length} tool(s).`)
|
|
493
532
|
} catch (error) {
|
|
494
|
-
if (this.stopped) return
|
|
533
|
+
if (this.stopped || this.shuttingDown) return
|
|
495
534
|
this.report(
|
|
496
535
|
`dsh-ops: FastCtx reconnect attempt ${this.attempts}/${RECONNECT.maxAttempts} `
|
|
497
536
|
+ `failed (${messageOf(error)}).`,
|
|
@@ -521,7 +560,7 @@ export class FastCtxTools {
|
|
|
521
560
|
throw new Error(`${this.#describe()} listed the tool "${String(tool?.name)}" more than once`)
|
|
522
561
|
}
|
|
523
562
|
seen.add(name)
|
|
524
|
-
if (!FILE_TOOLS.includes(tool.name)) continue
|
|
563
|
+
if (!this.fileTools || !FILE_TOOLS.includes(tool.name)) continue
|
|
525
564
|
definitions.push(toolDefinition({
|
|
526
565
|
tool,
|
|
527
566
|
call: (args, exec) => this.#call(tool.name, args, exec),
|
|
@@ -556,7 +595,15 @@ export class FastCtxTools {
|
|
|
556
595
|
* @param {{signal?: AbortSignal, agent?: {session?: object}}} [exec] - the host execution context.
|
|
557
596
|
* @returns {Promise<{content: unknown[], structuredContent?: unknown}>} the canonical value.
|
|
558
597
|
*/
|
|
559
|
-
|
|
598
|
+
#call(rawName, args, exec) {
|
|
599
|
+
if (this.shuttingDown || this.stopped) return Promise.reject(new Error('This dsh-ops component is shutting down.'))
|
|
600
|
+
const call = this.#callActive(rawName, args, exec)
|
|
601
|
+
this.pendingCalls.add(call)
|
|
602
|
+
void call.then(() => this.pendingCalls.delete(call), () => this.pendingCalls.delete(call))
|
|
603
|
+
return call
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async #callActive(rawName, args, exec) {
|
|
560
607
|
const label = publicToolName(rawName)
|
|
561
608
|
const session = exec?.agent?.session
|
|
562
609
|
if (SHELL_TOOLS.includes(rawName) && !this.shellAllowed(session)) {
|
|
@@ -569,6 +616,7 @@ export class FastCtxTools {
|
|
|
569
616
|
throw new Error('This is an image; use host read_image.')
|
|
570
617
|
}
|
|
571
618
|
}
|
|
619
|
+
if (rawName === 'run_background') args = { ...args, cwd: args?.cwd ?? session?.header?.cwd }
|
|
572
620
|
const client = this.client
|
|
573
621
|
if (client === undefined) {
|
|
574
622
|
// A dead server must be an immediate, explicit failure rather than a
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 T-Auto and the dsh-ops contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
-------------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
This MIT license covers the dsh-ops plugin: everything in this repository
|
|
26
|
+
except `vendor/fastctx/`, which is FastCtx under the Apache License 2.0. The
|
|
27
|
+
distribution as a whole is therefore licensed under the composite
|
|
28
|
+
`MIT AND Apache-2.0`. See `NOTICE` for the full statement and the attribution
|
|
29
|
+
FastCtx requires, and `vendor/fastctx/LICENSE-APACHE` for the Apache License
|
|
30
|
+
2.0 text.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @dsh-ops/auto-compact
|
|
2
|
+
|
|
3
|
+
Internal, bundled companion of dsh-ops. It adds one independently toggled row to
|
|
4
|
+
**Plugins → dsh-ops → Included components**, not a second marketplace bundle.
|
|
5
|
+
The package-root entry is required by DSH rc.2's browser module discovery; plugin
|
|
6
|
+
subpaths are not browser entry points. No separate installation is required.
|
|
7
|
+
|
|
8
|
+
Defaults: threshold 50% (integer 1–99), cooldown 120s (30–3600), timeout 120s
|
|
9
|
+
(10–600). Configuration belongs to this row and uses host profile settings with
|
|
10
|
+
revision fencing and restore defaults. The policy calls the same public idle
|
|
11
|
+
maintenance API as `/compact` only above the rounded Web context percentage and
|
|
12
|
+
after a non-cancelled turn. Running tools are not interrupted. No new model-facing
|
|
13
|
+
tool, polling, or host auto-policy modification is introduced.
|
|
14
|
+
|
|
15
|
+
One owned operation at a time, cooldown, unchanged-surface suppression and
|
|
16
|
+
abort/drain teardown bound work. Commit/save failures suspend this session until
|
|
17
|
+
component reload and require inspection. Timeouts rely on cooperative cancellation.
|
|
18
|
+
Summarization may call the host's configured model and incur cost; it is not
|
|
19
|
+
lossless. Missing samples/capacity and busy admission skip work.
|
|
20
|
+
|
|
21
|
+
This companion is carried by the dsh-ops 0.2.5-beta.1 prerelease candidate, not the
|
|
22
|
+
stable 0.2.4. Check registry metadata for publication status. See the main repository's
|
|
23
|
+
`docs/auto-compact.md` for safety limits and manual acceptance requirements.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Browser artifact in the host's closure-factory module format; no globals except its loader. */
|
|
2
|
+
window.__ModuleLoader__.load({ id: '@dsh-ops/auto-compact', factory: require => {
|
|
3
|
+
const { createElement: h, useState, useEffect, useRef } = require('react')
|
|
4
|
+
const NS = 'dshOps.autoCompact'
|
|
5
|
+
const dictionaries = {
|
|
6
|
+
en: {
|
|
7
|
+
title: 'Settings', threshold: 'Context threshold (%)', cooldown: 'Cooldown (seconds)', timeout: 'Compaction timeout (seconds)',
|
|
8
|
+
save: 'Save', reset: 'Restore defaults', saved: 'Saved', failed: 'Not saved. Configuration may have changed; reload and retry.',
|
|
9
|
+
unavailable: 'Enable this component to configure it. This client must have host settings write access.',
|
|
10
|
+
hint: 'When the Web context meter is strictly above the threshold, compact at the next idle boundary using the same service as /compact. Running tools are not interrupted. Summarization may call your configured model and incur cost. No extra model-facing tool is added.',
|
|
11
|
+
invalid: 'Enter integers: threshold 1–99, cooldown 30–3600, timeout 10–600.',
|
|
12
|
+
},
|
|
13
|
+
zh: {
|
|
14
|
+
title: '设置', threshold: '上下文阈值(%)', cooldown: '冷却时间(秒)', timeout: '压缩超时(秒)',
|
|
15
|
+
save: '保存', reset: '恢复默认', saved: '已保存', failed: '未保存,配置可能已变化,请重新加载后重试。',
|
|
16
|
+
unavailable: '请先启用此组件;当前客户端还需具有宿主设置写入权限。',
|
|
17
|
+
hint: 'Web 上下文占用严格高于阈值后,在下一次空闲边界调用与 /compact 相同的服务;不会打断正在运行的工具。摘要可能调用已配置模型并产生费用,不额外增加模型可调用工具。',
|
|
18
|
+
invalid: '请输入整数:阈值 1–99,冷却 30–3600 秒,超时 10–600 秒。',
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
const defaults = { thresholdPercent: 50, cooldownSeconds: 120, timeoutSeconds: 120 }
|
|
22
|
+
const fields = [
|
|
23
|
+
['thresholdPercent', 'threshold', 1, 99],
|
|
24
|
+
['cooldownSeconds', 'cooldown', 30, 3600],
|
|
25
|
+
['timeoutSeconds', 'timeout', 10, 600],
|
|
26
|
+
]
|
|
27
|
+
function Settings({ view, form, t }) {
|
|
28
|
+
const [draft, setDraft] = useState(defaults)
|
|
29
|
+
const [revision, setRevision] = useState(undefined)
|
|
30
|
+
const [busy, setBusy] = useState(false)
|
|
31
|
+
const [message, setMessage] = useState('')
|
|
32
|
+
const alive = useRef(false)
|
|
33
|
+
useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (form?.state.status === 'ready') {
|
|
36
|
+
setDraft({ ...defaults, ...form.state.value })
|
|
37
|
+
setRevision(form.state.revision)
|
|
38
|
+
}
|
|
39
|
+
}, [form?.state.revision, form?.state.status])
|
|
40
|
+
if (view === 'summary') return t('hint')
|
|
41
|
+
const writable = form?.state.status === 'ready' && form.state.writable && form.state.mode === 'host'
|
|
42
|
+
async function save(reset) {
|
|
43
|
+
if (!writable || busy || revision === undefined) return
|
|
44
|
+
if (!reset && fields.some(([key, , min, max]) => !Number.isInteger(Number(draft[key])) || String(draft[key]).trim() === '' || Number(draft[key]) < min || Number(draft[key]) > max)) {
|
|
45
|
+
setMessage(t('invalid')); return
|
|
46
|
+
}
|
|
47
|
+
setBusy(true); setMessage('')
|
|
48
|
+
try {
|
|
49
|
+
const ops = fields.map(([key]) => reset
|
|
50
|
+
? { op: 'unset', path: [key] }
|
|
51
|
+
: { op: 'set', path: [key], value: Number(draft[key]) })
|
|
52
|
+
const ok = await form.mutate(ops, revision)
|
|
53
|
+
if (alive.current) setMessage(t(ok ? 'saved' : 'failed'))
|
|
54
|
+
} catch { if (alive.current) setMessage(t('failed')) }
|
|
55
|
+
finally { if (alive.current) setBusy(false) }
|
|
56
|
+
}
|
|
57
|
+
return h('section', { 'data-dsh-ops-auto-compact-settings': true },
|
|
58
|
+
h('h4', null, t('title')),
|
|
59
|
+
h('p', null, t('hint')),
|
|
60
|
+
!writable ? h('p', { role: 'status' }, t('unavailable')) : null,
|
|
61
|
+
...fields.map(([key, label, min, max]) => h('label', { key, style: { display: 'block', marginBottom: 12 } },
|
|
62
|
+
t(label), ' ', h('input', {
|
|
63
|
+
type: 'number', min, max, step: 1, value: draft[key], disabled: !writable || busy,
|
|
64
|
+
onChange: event => setDraft(current => ({ ...current, [key]: event.target.value })),
|
|
65
|
+
}))),
|
|
66
|
+
h('button', { type: 'button', disabled: !writable || busy, onClick: () => save(false) }, t('save')), ' ',
|
|
67
|
+
h('button', { type: 'button', disabled: !writable || busy, onClick: () => save(true) }, t('reset')),
|
|
68
|
+
h('p', { role: 'status' }, message))
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
inject: ['slots', 'locale'],
|
|
72
|
+
apply(ctx) {
|
|
73
|
+
ctx.effect(() => ctx.get('locale').register(NS, dictionaries), 'dsh-ops settings locales')
|
|
74
|
+
ctx.get('slots').inject('plugins.row.config', () => ctx.get('slots').register({
|
|
75
|
+
name: 'plugins.row.config', key: 'dsh-ops#dsh-ops-auto-compact', locale: NS,
|
|
76
|
+
}, Settings))
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
} })
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Dependency-free strict settings and structural host form descriptor. */
|
|
2
|
+
export const AUTO_COMPACT_DEFAULTS = Object.freeze({ thresholdPercent: 50, cooldownSeconds: 120, timeoutSeconds: 120 })
|
|
3
|
+
const LIMITS = Object.freeze({ thresholdPercent: [1, 99], cooldownSeconds: [30, 3600], timeoutSeconds: [10, 600] })
|
|
4
|
+
const WRITE = Symbol.for('cosmokit.volatile.write')
|
|
5
|
+
|
|
6
|
+
export function resolveAutoCompactConfig(input = {}) {
|
|
7
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)
|
|
8
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(input))) {
|
|
9
|
+
throw new TypeError('dsh-ops auto-compact config must be a plain object')
|
|
10
|
+
}
|
|
11
|
+
for (const key of Object.keys(input)) {
|
|
12
|
+
if (!Object.hasOwn(LIMITS, key)) throw new TypeError(`dsh-ops auto-compact: unknown config key ${key}`)
|
|
13
|
+
}
|
|
14
|
+
return Object.fromEntries(Object.entries(AUTO_COMPACT_DEFAULTS).map(([key, fallback]) => {
|
|
15
|
+
const raw = input[key]
|
|
16
|
+
const value = raw !== null && typeof raw === 'object' && WRITE in raw ? raw.get() : raw === undefined ? fallback : raw
|
|
17
|
+
const [min, max] = LIMITS[key]
|
|
18
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
19
|
+
throw new TypeError(`dsh-ops auto-compact: ${key} must be an integer between ${min} and ${max}`)
|
|
20
|
+
}
|
|
21
|
+
return [key, value]
|
|
22
|
+
}))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Standard Schema validation plus a serializable structural descriptor, not a
|
|
27
|
+
* Schemastery instance or vendor impersonation. The host rehydrates with its OWN
|
|
28
|
+
* library. No loading-time host values are imported. Live references implement
|
|
29
|
+
* the shared cosmokit.volatile.write protocol and are validated before updates.
|
|
30
|
+
*/
|
|
31
|
+
export function createAutoCompactConfigSchema() {
|
|
32
|
+
const dict = Object.fromEntries(Object.entries(AUTO_COMPACT_DEFAULTS).map(([key, value]) => [key, {
|
|
33
|
+
type: 'number',
|
|
34
|
+
meta: { default: value, min: LIMITS[key][0], max: LIMITS[key][1], step: 1, volatile: true },
|
|
35
|
+
toJSON() { return { type: this.type, meta: { ...this.meta } } },
|
|
36
|
+
}]))
|
|
37
|
+
return {
|
|
38
|
+
type: 'object', meta: { default: {} }, dict,
|
|
39
|
+
'~standard': {
|
|
40
|
+
version: 1, vendor: 'dsh-ops',
|
|
41
|
+
validate(input) {
|
|
42
|
+
try {
|
|
43
|
+
const value = Object.fromEntries(Object.entries(resolveAutoCompactConfig(input)).map(([key, initial]) => {
|
|
44
|
+
let current = initial
|
|
45
|
+
return [key, Object.freeze({ get: () => current, [WRITE]: next => { current = next } })]
|
|
46
|
+
}))
|
|
47
|
+
return { value }
|
|
48
|
+
} catch (error) { return { issues: [{ message: error.message }] } }
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
toJSON() {
|
|
52
|
+
const refs = { 0: { type: 'object', meta: { default: {} }, dict: {} } }
|
|
53
|
+
Object.entries(dict).forEach(([key, node], index) => {
|
|
54
|
+
refs[0].dict[key] = index + 1
|
|
55
|
+
refs[index + 1] = { type: node.type, meta: { ...node.meta } }
|
|
56
|
+
})
|
|
57
|
+
return { uid: 0, refs }
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/** Idle-only policy over the same public compaction seam as /compact. */
|
|
2
|
+
import { createAutoCompactConfigSchema, resolveAutoCompactConfig } from './config.js'
|
|
3
|
+
|
|
4
|
+
export const name = 'dsh-ops-auto-compact'
|
|
5
|
+
export const inject = ['compaction', 'sessionProjections']
|
|
6
|
+
export const Config = createAutoCompactConfigSchema()
|
|
7
|
+
|
|
8
|
+
/** Match the Web context meter; absent/invalid samples never trigger work. */
|
|
9
|
+
export function contextPercent(pressure) {
|
|
10
|
+
const used = pressure?.projectedTokens ?? pressure?.pressureTokens
|
|
11
|
+
const capacity = pressure?.contextWindow
|
|
12
|
+
if (!Number.isFinite(used) || used < 0 || !Number.isFinite(capacity) || capacity <= 0) return null
|
|
13
|
+
return Math.min(100, Math.round(used / capacity * 100))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Surface changes, not compaction bookkeeping, permit a fresh attempt. */
|
|
17
|
+
function surfaceKey(session) {
|
|
18
|
+
return JSON.stringify([session.surface.contentGeneration, session.surface.nodes])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Bounded, event-driven supervisor. It never publishes a model-callable tool. */
|
|
22
|
+
export function createAutoCompactor(ctx, rawConfig, {
|
|
23
|
+
now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout,
|
|
24
|
+
} = {}) {
|
|
25
|
+
const states = new WeakMap()
|
|
26
|
+
const active = new Map()
|
|
27
|
+
const disposedAgents = new WeakSet()
|
|
28
|
+
let closed = false
|
|
29
|
+
// One summary at a time for this component, across all sessions.
|
|
30
|
+
let globalNextAt = 0
|
|
31
|
+
|
|
32
|
+
function check(agent) {
|
|
33
|
+
if (closed || disposedAgents.has(agent) || agent.status !== 'idle' || active.size > 0) return
|
|
34
|
+
let config, percent, key
|
|
35
|
+
try {
|
|
36
|
+
config = resolveAutoCompactConfig(rawConfig)
|
|
37
|
+
percent = contextPercent(ctx.get('sessionProjections').snapshot(agent.session, ['contextPressure']).values.contextPressure)
|
|
38
|
+
key = surfaceKey(agent.session)
|
|
39
|
+
} catch {
|
|
40
|
+
// No guessed capacity, no admission when a required service disappears.
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
if (percent === null || percent <= config.thresholdPercent) return
|
|
44
|
+
const state = states.get(agent.session)
|
|
45
|
+
const time = now()
|
|
46
|
+
if (time < globalNextAt || time < (state?.nextAt ?? 0)
|
|
47
|
+
|| state?.blocked
|
|
48
|
+
|| state?.key === key && state.threshold === config.thresholdPercent) return
|
|
49
|
+
|
|
50
|
+
const controller = new AbortController()
|
|
51
|
+
const slot = { controller, done: undefined }
|
|
52
|
+
// Install before calling compactNow: service admission may emit synchronously.
|
|
53
|
+
active.set(agent, slot)
|
|
54
|
+
states.set(agent.session, { key, threshold: config.thresholdPercent, nextAt: time + config.cooldownSeconds * 1000 })
|
|
55
|
+
globalNextAt = time + config.cooldownSeconds * 1000
|
|
56
|
+
const timer = setTimer(() => controller.abort(new Error('Automatic compaction timed out')), config.timeoutSeconds * 1000)
|
|
57
|
+
timer.unref?.()
|
|
58
|
+
let blocked = false
|
|
59
|
+
slot.done = (async () => {
|
|
60
|
+
try {
|
|
61
|
+
const result = await ctx.get('compaction').compactNow(agent, controller.signal)
|
|
62
|
+
if (result !== null) ctx.logger.info('dsh-ops auto-compact: compacted idle session history')
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const code = error?.code
|
|
65
|
+
if (code === 'commit' || code === 'persistence') {
|
|
66
|
+
blocked = true
|
|
67
|
+
ctx.logger.warn('dsh-ops auto-compact: session requires inspection after a commit/save failure; automatic compaction is suspended until this component is reloaded')
|
|
68
|
+
} else if (!controller.signal.aborted && code !== 'busy' && code !== 'cancelled') {
|
|
69
|
+
// Do not log history, provider errors, paths or credentials.
|
|
70
|
+
ctx.logger.warn('dsh-ops auto-compact: compaction failed; automatic retry is backed off')
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimer(timer)
|
|
74
|
+
// Suppress retries against our own replacement or bookkeeping records.
|
|
75
|
+
try {
|
|
76
|
+
states.set(agent.session, {
|
|
77
|
+
key: surfaceKey(agent.session), threshold: config.thresholdPercent, blocked,
|
|
78
|
+
nextAt: now() + config.cooldownSeconds * 1000,
|
|
79
|
+
})
|
|
80
|
+
} catch { /* disposed session */ }
|
|
81
|
+
globalNextAt = now() + config.cooldownSeconds * 1000
|
|
82
|
+
if (active.get(agent) === slot) active.delete(agent)
|
|
83
|
+
}
|
|
84
|
+
})()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function retire(agent) {
|
|
88
|
+
disposedAgents.add(agent)
|
|
89
|
+
active.get(agent)?.controller.abort(new Error('Agent disposed'))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function dispose() {
|
|
93
|
+
closed = true
|
|
94
|
+
const slots = [...active.values()]
|
|
95
|
+
for (const slot of slots) slot.controller.abort(new Error('Automatic compaction component disabled'))
|
|
96
|
+
await Promise.allSettled(slots.map(slot => slot.done))
|
|
97
|
+
}
|
|
98
|
+
return { check, retire, dispose }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Keep this plugin's validated live references stable through Fiber.update. */
|
|
102
|
+
export function installLiveConfigUpdates(ctx, config) {
|
|
103
|
+
ctx.on('internal/update', (candidate, _noSave, _next) => {
|
|
104
|
+
const values = resolveAutoCompactConfig(candidate)
|
|
105
|
+
const write = Symbol.for('cosmokit.volatile.write')
|
|
106
|
+
for (const [key, value] of Object.entries(values)) config[key][write](value)
|
|
107
|
+
try { ctx.emit('loader/volatile-update', Object.keys(values).map(key => [key])) }
|
|
108
|
+
catch { ctx.logger.warn('dsh-ops auto-compact: live config notification failed') }
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Scoped listeners, abort and drain follow the host's teardown lifecycle. */
|
|
113
|
+
export function apply(ctx, config = {}, { liveUpdates = true } = {}) {
|
|
114
|
+
resolveAutoCompactConfig(config)
|
|
115
|
+
if (liveUpdates) installLiveConfigUpdates(ctx, config)
|
|
116
|
+
const policy = createAutoCompactor(ctx, config)
|
|
117
|
+
const lastTurnSignals = new WeakMap()
|
|
118
|
+
ctx.effect(function* () {
|
|
119
|
+
yield () => policy.dispose()
|
|
120
|
+
yield ctx.on('agent/status', ({ agent, status }) => {
|
|
121
|
+
if (status === 'idle' && !lastTurnSignals.get(agent)?.aborted) policy.check(agent)
|
|
122
|
+
})
|
|
123
|
+
yield ctx.on('agent/turn-stopping', ({ agent, signal }) => {
|
|
124
|
+
lastTurnSignals.set(agent, signal)
|
|
125
|
+
})
|
|
126
|
+
yield ctx.on('agent/disposed', ({ agent }) => policy.retire(agent))
|
|
127
|
+
// Hot-enabling also covers already loaded idle sessions, without waking them.
|
|
128
|
+
ctx.inject(['agents'], inner => {
|
|
129
|
+
for (const agent of inner.get('agents').list()) policy.check(agent)
|
|
130
|
+
})
|
|
131
|
+
}, 'dsh-ops auto-compact lifecycle')
|
|
132
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dsh-ops/auto-compact",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Idle-only automatic context compaction and row settings for dsh-ops.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./lib/index.js",
|
|
10
|
+
"./client": "./lib/client.js",
|
|
11
|
+
"./package.json": "./package.json",
|
|
12
|
+
"./locale/*.json": "./locale/*.json"
|
|
13
|
+
},
|
|
14
|
+
"dsh": {
|
|
15
|
+
"client": {
|
|
16
|
+
"platform": "web",
|
|
17
|
+
"inject": [
|
|
18
|
+
"@deepseek-ai/dsh-client-locale",
|
|
19
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
20
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": ["lib", "locale", "LICENSE", "README.md"],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/T-Auto/dsh-ops.git",
|
|
28
|
+
"directory": "packages/auto-compact"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": { "access": "public" }
|
|
31
|
+
}
|