From 957dd6cbf9eea0d12c7b51409ff34913461cc422 Mon Sep 17 00:00:00 2001 From: Tang Bohao Date: Sun, 16 Aug 2026 09:28:18 +0800 Subject: [PATCH 1/7] feat(advisor): register tuiCommandTrees /advisor provider (dsh-tui client) --- src/index.ts | 10 ++ src/tui.ts | 136 ++++++++++++++++++++++++ tests/tui-client.test.ts | 221 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 367 insertions(+) create mode 100644 src/tui.ts create mode 100644 tests/tui-client.test.ts diff --git a/src/index.ts b/src/index.ts index 979c54b..3264f5d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,6 +57,7 @@ import { AdvisorDelivery } from './delivery.js' import { DEFAULT_ADVISOR_SYSTEM_PROMPT } from './prompts.js' import { AdvisorSessionOverrides, registerAdvisorCommands } from './commands.js' import type { AdvisorCommandController } from './commands.js' +import { installTuiClient } from './tui.js' export const name = 'dsh-advisor' @@ -487,4 +488,13 @@ export function apply(ctx: Context, config: AdvisorConfig) { ctx.inject(['commands'], (commandCtx) => { registerAdvisorCommands(commandCtx.commands, controller) }) + + // T1 (plan dsh-advisor-tui-client-n8): the dsh-tui client seam — the + // `tuiCommandTrees` /advisor provider (zh/en `/`-menu description + + // `on|off|status|config` completion). Runs AFTER the single-reviewer + // claim, so the tree registers at most once per process (duplicate-root + // registration would throw in the host registry). The inject is + // conditional like `commands`/`settings`/`typert`: profiles without the + // `dsh-tui-command-trees` row keep working (clean no-op). + installTuiClient(ctx) } diff --git a/src/tui.ts b/src/tui.ts new file mode 100644 index 0000000..e277ac0 --- /dev/null +++ b/src/tui.ts @@ -0,0 +1,136 @@ +/** + * dsh-tui client surface (plan dsh-advisor-tui-client-n8, T1) — the + * `tuiCommandTrees` /advisor provider. + * + * In a `dsh --profile dsh-tui` terminal session the plugin's `/advisor` + * command (registered through the conditional `ctx.inject(['commands'], ...)` + * child) already lands in the TUI `/` menu via the command-registry merge. + * What the TUI cannot infer is the LOCALIZED row description and the typed + * subcommand COMPLETION — those come from the optional `tuiCommandTrees` + * host service (`src/dsh-adapter/command-trees.ts` in dsh-TUI): a provider + * declares `root`, zh/en `descriptions`, and a `children(canonicalPath)` + * completion tree (root at index 0; the TUI asks at depth 2 when completing + * `/advisor ⋯`). + * + * This module is the advisor's TUI seam: `installTuiClient` conditionally + * injects `tuiCommandTrees` and registers the `/advisor` tree when the + * service exists; a profile without the `dsh-tui-command-trees` row (or any + * non-TUI host) gets a clean no-op. The provider shapes are minimal LOCAL + * structural copies of the dsh-TUI types — the advisor MUST NOT import + * `@deepseek-harness-tui/dsh-tui` (zero new peers, plan Global Constraint), + * so drift against the upstream shape is bounded to the structural cast at + * the inject boundary and pinned by `tests/tui-client.test.ts`. + * + * @module dsh-advisor/tui + */ + +import type { Context } from '@deepseek-ai/cordis' + +/** Localized (zh/en) descriptions — structural mirror of dsh-TUI's + * `LocalizedDescriptions` (the TUI `/` row + completion descriptions). */ +export type TuiLocalizedDescriptions = Readonly>> + +/** One completion node — structural mirror of dsh-TUI's + * `CommandCompletionNode` (`src/commands.ts` in dsh-TUI). */ +export interface TuiCommandCompletionNode { + name: string + aliases?: readonly string[] + description: string + descriptions?: TuiLocalizedDescriptions + tag?: string + descriptionKey?: string +} + +/** A `/`-menu command tree provider — structural mirror of dsh-TUI's + * `TuiCommandTreeProvider`. `children` receives the canonical path with the + * root at index 0. */ +export interface TuiCommandTreeProvider { + root: string + descriptions?: TuiLocalizedDescriptions + children(canonicalPath: readonly string[]): readonly TuiCommandCompletionNode[] +} + +/** The `/advisor` tree root (matches the command registry name). */ +export const ADVISOR_TUI_ROOT = 'advisor' + +/** The four typed `/advisor` subcommands surfaced as completion children. + * Bare `/advisor` (toggle) is the empty-argument default, not a completion + * child (compass S1); `USAGE` is the unknown-subcommand fallback, not a + * named command. */ +const ADVISOR_SUBCOMMANDS = ['on', 'off', 'status', 'config'] as const + +type AdvisorSubcommand = (typeof ADVISOR_SUBCOMMANDS)[number] + +/** zh/en copy for the `/` menu row (shown via the host's `descriptions(root)`). */ +const ADVISOR_TUI_DESCRIPTIONS: TuiLocalizedDescriptions = { + zh: '按会话运行的评审顾问:开启 / 关闭 / 状态 / 配置', + en: 'Per-session advisor: enable, disable, status, or config', +} + +/** zh/en copy per completion node. `description` is the plain fallback the + * node carries; `descriptions` is the localized map the TUI prefers. */ +const SUBCOMMAND_DESCRIPTIONS: Readonly> = { + on: { + description: 'Enable the advisor for this session', + descriptions: { + zh: '为本会话启用顾问', + en: 'Enable the advisor for this session', + }, + }, + off: { + description: 'Disable the advisor for this session', + descriptions: { + zh: '为本会话禁用顾问', + en: 'Disable the advisor for this session', + }, + }, + status: { + description: 'Show per-session advisor status (state, model, runtime, pending, last activity)', + descriptions: { + zh: '查看本会话顾问状态(开关、模型、运行态、待处理、最近活动)', + en: 'Show per-session advisor status (state, model, runtime, pending, last activity)', + }, + }, + config: { + description: 'Show the composed advisor config (settings readback)', + descriptions: { + zh: '查看组合后的顾问配置(设置回读)', + en: 'Show the composed advisor config (settings readback)', + }, + }, +} + +/** The `/advisor` completion tree. `children` NEVER throws: unknown paths and + * a bare `[]` return an empty list (leaves have no deeper completion — the + * TUI asks at depth 2). */ +const advisorTree: TuiCommandTreeProvider = { + root: ADVISOR_TUI_ROOT, + descriptions: ADVISOR_TUI_DESCRIPTIONS, + children(canonicalPath: readonly string[]): readonly TuiCommandCompletionNode[] { + // Root at index 0: only `['advisor']` asks for the subcommand list. + if (canonicalPath.length !== 1 || canonicalPath[0] !== ADVISOR_TUI_ROOT) return [] + return ADVISOR_SUBCOMMANDS.map((name) => ({ + name, + description: SUBCOMMAND_DESCRIPTIONS[name].description, + descriptions: SUBCOMMAND_DESCRIPTIONS[name].descriptions, + })) + }, +} + +/** + * Install the advisor's TUI client surface: register the `/advisor` + * `tuiCommandTrees` provider when the host service exists (conditional + * inject; absent service → clean no-op). Called from `apply()` AFTER the + * single-reviewer claim, so the tree registers at most once per process + * (a duplicate-root registration would throw in the host registry). The + * structural accessor keeps the inject key in the standard position: the + * cordis Context has no `tuiCommandTrees` augmentation in this repo, so the + * service is read through a local structural cast. + */ +export function installTuiClient(ctx: Context): void { + ctx.inject(['tuiCommandTrees'], (tctx) => { + const trees = (tctx as unknown as { tuiCommandTrees?: { register(p: TuiCommandTreeProvider): () => void } }).tuiCommandTrees + if (trees === undefined) return + return trees.register(advisorTree) + }) +} diff --git a/tests/tui-client.test.ts b/tests/tui-client.test.ts new file mode 100644 index 0000000..3a64615 --- /dev/null +++ b/tests/tui-client.test.ts @@ -0,0 +1,221 @@ +/** + * T1 (plan dsh-advisor-tui-client-n8) — the dsh-tui client seam: + * `installTuiClient` + the `tuiCommandTrees` /advisor provider (src/tui.ts). + * + * Contract under test (AC-1): + * ① With a `tuiCommandTrees` service — `installTuiClient` registers exactly + * one provider with root `'advisor'`, zh + en descriptions are non-empty + * strings, and the disposer returned by the inject child is exactly the + * stub registry's `register` return value (no wrapping). + * ② The provider's children contract: `children(['advisor'])` returns the + * four subcommand completion nodes (`on|off|status|config`) each carrying + * name + description + zh/en descriptions; `children(['advisor', ])` + * → `[]` (leaves have no deeper completion — the TUI asks at depth 2); + * `children([])` / unknown roots → `[]`, never throws. + * ③ No `tuiCommandTrees` service → `installTuiClient` completes without + * error and registers nothing. + * ④ Wiring-level (src/index.ts `apply`): the provider is requested only on + * the single-reviewer (claiming) fiber — a non-claiming apply returns + * before the wiring and must not ask for the service. The globalThis + * claim itself is untouched. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import { apply } from '../src/index' +import { ADVISOR_TUI_ROOT, installTuiClient } from '../src/tui' +import type { TuiCommandTreeProvider } from '../src/tui' +import type { AdvisorConfig } from '../src/config' + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +/** Full plugin-row config shape (the `apply` wiring test only needs a valid entry). */ +function entryConfig(overrides: Partial = {}): AdvisorConfig { + return { + enabled: false, + systemPrompt: '', + immuneTurns: 3, + maxDeltaMessages: 60, + ...overrides, + } +} + +/** Stub `tuiCommandTrees` registry mirroring the dsh-TUI host contract + * (`src/dsh-adapter/command-trees.ts`): `register` enforces the root regex + * `^[a-z][a-z0-9_-]*$` and throws on a duplicate root, and returns a + * disposer. Records every provider so tests can inspect the registered tree. */ +class StubCommandTrees { + readonly providers: TuiCommandTreeProvider[] = [] + + constructor(private readonly disposer: () => void = vi.fn()) {} + + register(provider: TuiCommandTreeProvider): () => void { + if (!/^[a-z][a-z0-9_-]*$/.test(provider.root)) { + throw new Error(`invalid command tree root: ${provider.root}`) + } + if (this.providers.some((p) => p.root === provider.root)) { + throw new Error(`command tree root already registered: ${provider.root}`) + } + this.providers.push(provider) + return this.disposer + } +} + +/** Stub ctx whose `inject` immediately activates the callback with the given + * services (the dsh-TUI service map shape) and captures its return value. */ +function activateCtx(services: Record): { ctx: Context; injected: () => boolean; returned: () => unknown } { + let activated = false + let returned: unknown + const ctx = { + inject(_names: readonly string[], callback: (tctx: Record) => unknown): void { + activated = true + returned = callback(services) + }, + } as unknown as Context + return { + ctx, + injected: () => activated, + returned: () => returned, + } +} + +// --------------------------------------------------------------------------- +// ① registration with a tuiCommandTrees service +// --------------------------------------------------------------------------- + +describe('installTuiClient — registration (AC-1)', () => { + it('registers exactly one provider with root advisor and non-empty zh/en descriptions', () => { + const trees = new StubCommandTrees() + const { ctx, injected } = activateCtx({ tuiCommandTrees: trees }) + + installTuiClient(ctx) + + expect(injected()).toBe(true) + expect(trees.providers).toHaveLength(1) + const provider = trees.providers[0]! + expect(provider.root).toBe(ADVISOR_TUI_ROOT) + expect(provider.descriptions?.zh).toBeTruthy() + expect(provider.descriptions?.en).toBeTruthy() + }) + + it('the inject child returns the registry disposer untouched', () => { + const dispose = vi.fn() + const trees = new StubCommandTrees(dispose) + const { ctx, returned } = activateCtx({ tuiCommandTrees: trees }) + + installTuiClient(ctx) + + expect(returned()).toBe(dispose) + }) +}) + +// --------------------------------------------------------------------------- +// ② children contract +// --------------------------------------------------------------------------- + +describe('provider children — completion tree (AC-1)', () => { + function registeredProvider(): TuiCommandTreeProvider { + const trees = new StubCommandTrees() + const { ctx } = activateCtx({ tuiCommandTrees: trees }) + installTuiClient(ctx) + expect(trees.providers).toHaveLength(1) + return trees.providers[0]! + } + + it("children(['advisor']) returns the four subcommand nodes with name + description + zh/en descriptions", () => { + const provider = registeredProvider() + + const nodes = provider.children(['advisor']) + + expect(nodes.map((node) => node.name)).toEqual(['on', 'off', 'status', 'config']) + for (const node of nodes) { + expect(node.description).toBeTruthy() + expect(node.descriptions?.zh).toBeTruthy() + expect(node.descriptions?.en).toBeTruthy() + } + }) + + it('children at depth 2 (a subcommand leaf) returns [] — no deeper completion', () => { + const provider = registeredProvider() + for (const sub of ['on', 'off', 'status', 'config'] as const) { + expect(provider.children(['advisor', sub])).toEqual([]) + } + }) + + it('children([]) and unknown roots return [] without throwing', () => { + const provider = registeredProvider() + expect(provider.children([])).toEqual([]) + expect(provider.children(['other'])).toEqual([]) + expect(() => provider.children(['other', 'x'])).not.toThrow() + }) +}) + +// --------------------------------------------------------------------------- +// ③ no service → no-op +// --------------------------------------------------------------------------- + +describe('installTuiClient — no tuiCommandTrees service (AC-1)', () => { + it('completes without error, activates the inject child, and registers nothing', () => { + const { ctx, injected, returned } = activateCtx({}) + + expect(() => installTuiClient(ctx)).not.toThrow() + + // The conditional child ran (it is the standard inject position) but the + // absent service made it a clean no-op: nothing registered, no disposer. + expect(injected()).toBe(true) + expect(returned()).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// ④ wiring — apply registers only on the claiming reviewer fiber +// --------------------------------------------------------------------------- + +describe('apply wiring — reviewer-claim gating (AC-1)', () => { + // The single-reviewer claim is process-global; reset between cases + // (production keeps first-claim-wins; integration.test.ts does the same). + beforeEach(() => { + delete (globalThis as Record)['__dshAdvisorReviewer__'] + }) + + /** Minimal apply()-shaped ctx: records inject requests WITHOUT activating + * them (no services), and no-ops the remaining surfaces `apply` touches + * before the reviewer guard (logger / reflect.provide / effect / on). */ + function makeApplyStubCtx(injectKeys: string[]): Context { + return { + inject: (names: readonly string[]) => { + injectKeys.push(...names) + }, + logger: () => ({ debug: () => {}, warn: () => {}, info: () => {}, error: () => {} }), + reflect: { provide: () => {} }, + effect: () => {}, + on: () => {}, + agents: { get: () => undefined }, + } as unknown as Context + } + + it('a non-claiming apply (claim already held) never requests tuiCommandTrees', () => { + ;(globalThis as Record)['__dshAdvisorReviewer__'] = true + const injectKeys: string[] = [] + const ctx = makeApplyStubCtx(injectKeys) + + apply(ctx, entryConfig()) + + expect(injectKeys).not.toContain('tuiCommandTrees') + // Same guard also skips the commands child — the whole reviewer-only + // wiring block is bypassed on a non-claiming fiber. + expect(injectKeys).not.toContain('commands') + }) + + it('the claiming apply requests tuiCommandTrees next to the commands child', () => { + const injectKeys: string[] = [] + const ctx = makeApplyStubCtx(injectKeys) + + apply(ctx, entryConfig()) + + expect(injectKeys).toContain('tuiCommandTrees') + expect(injectKeys).toContain('commands') + }) +}) From 9db77e21036e1d5a4d02675610cfce21bd86ace8 Mon Sep 17 00:00:00 2001 From: Tang Bohao Date: Sun, 16 Aug 2026 09:36:11 +0800 Subject: [PATCH 2/7] feat(advisor): add /advisor config read-only settings readback --- src/commands.ts | 84 +++++++++++++++- src/index.ts | 29 +++++- tests/commands.test.ts | 189 +++++++++++++++++++++++++++++++++++- tests/settings-live.test.ts | 74 ++++++++++++++ 4 files changed, 370 insertions(+), 6 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index 85153da..e4c7693 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -8,6 +8,7 @@ * - `/advisor on` — enable the advisor for this session; * - `/advisor off` — disable the advisor for this session; * - `/advisor status` — report the per-session status surface; + * - `/advisor config` — show the composed advisor config (settings readback); * - anything else — usage text. * * Toggle/on/off are **session-scoped and ephemeral**: they drive a per-session @@ -40,6 +41,7 @@ export type AdvisorCommand = | { readonly kind: 'on' } | { readonly kind: 'off' } | { readonly kind: 'status' } + | { readonly kind: 'config' } | { readonly kind: 'usage' } /** @@ -54,6 +56,7 @@ export function parseAdvisorCommand(rawInput: string): AdvisorCommand { if (argument === 'on') return { kind: 'on' } if (argument === 'off') return { kind: 'off' } if (argument === 'status') return { kind: 'status' } + if (argument === 'config') return { kind: 'config' } return { kind: 'usage' } } @@ -147,6 +150,77 @@ export function advisorStatusText(status: AdvisorSessionStatus): string { return lines.join('\n') } +// --------------------------------------------------------------------------- +// Config surface (`/advisor config` — plan dsh-advisor-tui-client-n8 T2) +// --------------------------------------------------------------------------- + +/** + * Composed-config surface consumed by `/advisor config`. **Session-less by + * design**: the wiring builds it from the same resolved config the web card + * reads (`/api/advisor/get` — schema defaults → plugin-row base → settings + * user layer, with the hard gate applied), so a per-session `/advisor off` + * override can never misreport settings.yaml. Runtime state stays owned by + * the status surface (`AdvisorSessionStatus`); config and status are separate. + */ +export interface AdvisorComposedConfig { + /** Config-level composed switch — NOT the per-session override. */ + readonly enabled: boolean + /** Present iff the composed config is disabled by the explicit gate. */ + readonly disabledReason?: string + /** Composed provider route (shown even while disabled — spec §5.2). */ + readonly provider?: string + /** Composed model id (shown even while disabled — spec §5.2). */ + readonly model?: string + /** Cooldown after a delivered interrupt (spec §6). */ + readonly immuneTurns: number + /** Delta window; 0 = unbounded (KD-3). */ + readonly maxDeltaMessages: number + /** True when the composed config carries a custom system prompt ("" = unset). */ + readonly systemPromptSet: boolean + /** + * First line of the system prompt, truncated to ≤ 80 chars (empty when + * unset — the `` marker is the renderer's job). + */ + readonly systemPromptSummary: string +} + +/** + * First line of a system prompt, truncated to ≤ 80 chars with a trailing + * ellipsis when the first line is longer — the TUI one-liner readback, never + * a full dump (AC-2). Empty when the prompt is unset ('' → the renderer shows + * ``). + */ +export function summarizeSystemPrompt(prompt: string): string { + const firstLine = prompt.split('\n')[0] ?? '' + if (firstLine.length <= 80) return firstLine + return `${firstLine.slice(0, 79)}…` +} + +/** + * Render the composed config surface. Mirrors the status renderer's minimal + * line style; the edit hint points at the two operator edit paths (profile + * patch layer + the shared `$DSH_HOME/settings.yaml` `advisor:` section the + * web card writes). + */ +export function advisorConfigText(config: AdvisorComposedConfig): string { + const lines: string[] = [] + lines.push(config.enabled ? 'Advisor config: enabled' : 'Advisor config: disabled') + if (config.provider && config.model) { + lines.push(`Model: ${config.provider}/${config.model}`) + } + lines.push(`immuneTurns: ${config.immuneTurns}`) + lines.push(`maxDeltaMessages: ${config.maxDeltaMessages === 0 ? 'unbounded' : config.maxDeltaMessages}`) + lines.push( + config.systemPromptSummary === '' + ? 'systemPrompt: ' + : `systemPrompt: "${config.systemPromptSummary}"`, + ) + if (config.disabledReason !== undefined) lines.push(`Reason: ${config.disabledReason}`) + lines.push('') + lines.push('Edit: ~/.dsh/profiles//cordis.patch.yml (plugin row) or $DSH_HOME/settings.yaml (advisor: section)') + return lines.join('\n') +} + // --------------------------------------------------------------------------- // Controller + registration // --------------------------------------------------------------------------- @@ -167,6 +241,8 @@ export interface AdvisorCommandController { setEnabled(sessionId: string, enabled: boolean, sessionLength?: number): void /** Snapshot the per-session status surface. */ getStatus(sessionId: string): AdvisorSessionStatus + /** Snapshot the composed config surface (session-less settings readback). */ + getConfig(): AdvisorComposedConfig } /** Minimal command registry surface (satisfied by the dsh `CommandService`). */ @@ -176,11 +252,12 @@ export interface AdvisorCommandRegistry { /** Usage text for an unknown `/advisor` subcommand. */ export const USAGE = [ - 'Usage: /advisor [on|off|status]', + 'Usage: /advisor [on|off|status|config]', ' /advisor toggle the advisor for this session', ' /advisor on enable the advisor for this session', ' /advisor off disable the advisor for this session', ' /advisor status show per-session advisor status (state, model, runtime, pending, last activity)', + ' /advisor config show the composed advisor config (settings readback)', ].join('\n') /** @@ -235,6 +312,9 @@ function createAdvisorCommandHandler(controller: AdvisorCommandController) { } case 'status': return { kind: 'success', text: advisorStatusText(controller.getStatus(sessionId)) } + case 'config': + // Session-less readback: the composed config, never the session state. + return { kind: 'success', text: advisorConfigText(controller.getConfig()) } case 'usage': return { kind: 'success', text: USAGE } } @@ -255,7 +335,7 @@ export function registerAdvisorCommands( return registry.register({ name: 'advisor', description: 'Toggle, enable, disable, or inspect the per-session advisor', - input: { hint: '[on|off|status]' }, + input: { hint: '[on|off|status|config]' }, handler: createAdvisorCommandHandler(controller), }) } diff --git a/src/index.ts b/src/index.ts index 3264f5d..ef0e83e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,9 @@ * (registered through the conditional `ctx.inject(['commands'], ...)` child) * drive a per-session override consulted by the runtime gate — the commands * start/stop per-session runtimes without touching the persisted config. + * T2-tui (plan dsh-advisor-tui-client-n8): `/advisor config` — a session-less + * readback of the composed `advisor` namespace (same resolved config the web + * card reads), never the per-session override. * Settings (plan dsh-advisor-settings-n2): the plugin-row config is the * composition base of the `advisor` settings namespace (`src/settings.ts`), * read live through the bridge source; committed settings edits re-apply @@ -55,7 +58,7 @@ import { AdvisorRuntime } from './advisor-runtime.js' import type { AdviceNote } from './advisor-runtime.js' import { AdvisorDelivery } from './delivery.js' import { DEFAULT_ADVISOR_SYSTEM_PROMPT } from './prompts.js' -import { AdvisorSessionOverrides, registerAdvisorCommands } from './commands.js' +import { AdvisorSessionOverrides, registerAdvisorCommands, summarizeSystemPrompt } from './commands.js' import type { AdvisorCommandController } from './commands.js' import { installTuiClient } from './tui.js' @@ -480,6 +483,30 @@ export function apply(ctx: Context, config: AdvisorConfig) { lastActivityAt: runtime?.lastActivity, } }, + // T2 (plan dsh-advisor-tui-client-n8): the composed-config readback. + // Session-less BY DESIGN — reads `safeResolved()` (schema defaults → + // plugin-row base → settings user layer, with the hard gate applied), + // the SAME bridge source the web card reads through `resolveAdvisorConfig` + // (`/api/advisor/get` has no session either). NEVER `effectiveConfig`/ + // `safeEffective` here: those bake the per-session `/advisor` override + // into `enabled`, and a `/advisor off` session toggle must never make + // the settings readback misreport settings.yaml. Runtime state stays + // owned by `status`; this read reports config only. Every field comes + // from that one resolved value; the systemPrompt summary is the first + // line (≤ 80 chars) of `resolved.systemPrompt`, '' → unset. + getConfig() { + const resolved = safeResolved() + return { + enabled: resolved.enabled, + ...(resolved.disabledReason === undefined ? {} : { disabledReason: resolved.disabledReason }), + provider: resolved.provider, + model: resolved.model, + immuneTurns: resolved.immuneTurns, + maxDeltaMessages: resolved.maxDeltaMessages, + systemPromptSet: resolved.systemPrompt !== '', + systemPromptSummary: summarizeSystemPrompt(resolved.systemPrompt), + } + }, } // T7: the command child activates ONLY when a command registry is composed diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 337dc74..6c16c93 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -26,11 +26,18 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { AdvisorSessionOverrides, USAGE, + advisorConfigText, advisorStatusText, parseAdvisorCommand, registerAdvisorCommands, + summarizeSystemPrompt, +} from '../src/commands' +import type { + AdvisorCommandController, + AdvisorCommandRegistry, + AdvisorComposedConfig, + AdvisorSessionStatus, } from '../src/commands' -import type { AdvisorCommandController, AdvisorCommandRegistry, AdvisorSessionStatus } from '../src/commands' // --------------------------------------------------------------------------- // Test doubles @@ -59,19 +66,37 @@ function baseStatus(overrides: Partial = {}): AdvisorSessi return { enabled: false, runtimeStatus: 'disabled', pendingCount: 0, ...overrides } } +/** Baseline composed config with the required fields filled in. */ +function baseConfig(overrides: Partial = {}): AdvisorComposedConfig { + return { + enabled: false, + immuneTurns: 3, + maxDeltaMessages: 60, + systemPromptSet: false, + systemPromptSummary: '', + ...overrides, + } +} + /** Stateful fake controller — records setEnabled calls and mirrors state. */ class FakeController implements AdvisorCommandController { status: AdvisorSessionStatus + config: AdvisorComposedConfig readonly setCalls: Array<{ sessionId: string; enabled: boolean; sessionLength?: number }> = [] - constructor(status: AdvisorSessionStatus) { + constructor(status: AdvisorSessionStatus, config?: AdvisorComposedConfig) { this.status = status + this.config = config ?? baseConfig() } getStatus(_sessionId: string): AdvisorSessionStatus { return this.status } + getConfig(): AdvisorComposedConfig { + return this.config + } + setEnabled(sessionId: string, enabled: boolean, sessionLength?: number): void { this.setCalls.push({ sessionId, enabled, sessionLength }) this.status = { ...this.status, enabled } @@ -132,11 +157,14 @@ describe('parseAdvisorCommand (parse of the text after /advisor)', () => { expect(parseAdvisorCommand(' ')).toEqual({ kind: 'toggle' }) }) - it('"on" / "off" / "status", tolerating separator whitespace', () => { + it('"on" / "off" / "status" / "config", tolerating separator whitespace', () => { expect(parseAdvisorCommand(' on')).toEqual({ kind: 'on' }) expect(parseAdvisorCommand('on')).toEqual({ kind: 'on' }) expect(parseAdvisorCommand('off ')).toEqual({ kind: 'off' }) expect(parseAdvisorCommand(' status')).toEqual({ kind: 'status' }) + expect(parseAdvisorCommand('config')).toEqual({ kind: 'config' }) + expect(parseAdvisorCommand(' config')).toEqual({ kind: 'config' }) + expect(parseAdvisorCommand('config ')).toEqual({ kind: 'config' }) }) it('anything else → usage (exact match, like dsh command names)', () => { @@ -144,6 +172,8 @@ describe('parseAdvisorCommand (parse of the text after /advisor)', () => { expect(parseAdvisorCommand('on extra')).toEqual({ kind: 'usage' }) expect(parseAdvisorCommand('status please')).toEqual({ kind: 'usage' }) expect(parseAdvisorCommand('STATUS')).toEqual({ kind: 'usage' }) + expect(parseAdvisorCommand('config extra')).toEqual({ kind: 'usage' }) + expect(parseAdvisorCommand('CONFIG')).toEqual({ kind: 'usage' }) }) }) @@ -198,6 +228,25 @@ describe('registerAdvisorCommands (registration function, brief: test directly)' disposer() expect(disposed).toBe(true) }) + + it('registry input.hint lists config (the TUI / row hint)', () => { + const registry = new FakeRegistry() + registerAdvisorCommands(registry, new FakeController(baseStatus())) + const hint = registry.definitions[0]!.input?.hint + expect(hint).toBe('[on|off|status|config]') + expect(hint).toContain('config') + }) +}) + +// --------------------------------------------------------------------------- +// USAGE — the unknown-subcommand fallback lists config +// --------------------------------------------------------------------------- + +describe('USAGE (unknown-subcommand fallback)', () => { + it('header and subcommand list include config', () => { + expect(USAGE).toContain('Usage: /advisor [on|off|status|config]') + expect(USAGE).toContain(' /advisor config show the composed advisor config (settings readback)') + }) }) // --------------------------------------------------------------------------- @@ -361,6 +410,140 @@ describe('advisorStatusText (the /advisor status surface, spec §6)', () => { }) }) +// --------------------------------------------------------------------------- +// Config surface (plan dsh-advisor-tui-client-n8 T2 — `/advisor config`) +// --------------------------------------------------------------------------- + +describe('advisorConfigText (the /advisor config surface, composed session-less readback)', () => { + it('renders every field of an enabled config with a custom prompt', () => { + const text = advisorConfigText(baseConfig({ + enabled: true, + provider: 'openai', + model: 'gpt-4o', + immuneTurns: 3, + maxDeltaMessages: 60, + systemPromptSet: true, + systemPromptSummary: 'You are a terse reviewer.', + })) + expect(text).toContain('Advisor config: enabled') + expect(text).toContain('Model: openai/gpt-4o') + expect(text).toContain('immuneTurns: 3') + expect(text).toContain('maxDeltaMessages: 60') + expect(text).toContain('systemPrompt: "You are a terse reviewer."') + expect(text).toContain('Edit: ~/.dsh/profiles//cordis.patch.yml (plugin row) or $DSH_HOME/settings.yaml (advisor: section)') + }) + + it('renders maxDeltaMessages 0 as unbounded', () => { + const text = advisorConfigText(baseConfig({ enabled: true, maxDeltaMessages: 0 })) + expect(text).toContain('maxDeltaMessages: unbounded') + expect(text).not.toContain('maxDeltaMessages: 0') + }) + + it('renders when the system prompt is unset', () => { + const text = advisorConfigText(baseConfig({ enabled: true })) + expect(text).toContain('systemPrompt: ') + expect(text).not.toContain('systemPrompt: "') + }) + + it('renders disabled-with-reason when the gate blocks, without a Model line', () => { + const text = advisorConfigText(baseConfig({ + disabledReason: 'enabled but provider and model are missing — configure both to enable the advisor', + })) + expect(text).toContain('Advisor config: disabled') + expect(text).toContain('Reason: enabled but provider and model are missing — configure both to enable the advisor') + expect(text).not.toContain('Model:') + expect(text).toContain('systemPrompt: ') + }) + + it('omits the Model line unless BOTH provider and model are present', () => { + const onlyProvider = advisorConfigText(baseConfig({ enabled: true, provider: 'openai' })) + expect(onlyProvider).not.toContain('Model:') + const onlyModel = advisorConfigText(baseConfig({ enabled: true, model: 'gpt-4o' })) + expect(onlyModel).not.toContain('Model:') + }) + + it('omits the Reason line when there is no gate reason', () => { + const text = advisorConfigText(baseConfig({ enabled: true })) + expect(text).not.toContain('Reason:') + }) + + it('always ends with the edit hint (both edit paths)', () => { + const text = advisorConfigText(baseConfig()) + expect(text).toContain('Edit: ~/.dsh/profiles//cordis.patch.yml (plugin row)') + expect(text).toContain('or $DSH_HOME/settings.yaml (advisor: section)') + }) +}) + +describe('summarizeSystemPrompt (first line, ≤ 80 chars, never a full dump)', () => { + it('empty prompt → empty summary', () => { + expect(summarizeSystemPrompt('')).toBe('') + }) + + it('takes only the first line of a multi-line prompt', () => { + expect(summarizeSystemPrompt('first line\nsecond line\nthird')).toBe('first line') + }) + + it('keeps a short first line unchanged', () => { + expect(summarizeSystemPrompt('You are a terse reviewer.')).toBe('You are a terse reviewer.') + // Exactly 80 chars — no ellipsis. + expect(summarizeSystemPrompt('x'.repeat(80))).toBe('x'.repeat(80)) + }) + + it('truncates a first line longer than 80 chars to 80 chars with an ellipsis', () => { + const long = 'x'.repeat(100) + const summary = summarizeSystemPrompt(long) + expect(summary).toBe(`${'x'.repeat(79)}…`) + expect(summary.length).toBe(80) + }) +}) + +describe('/advisor config subcommand (handler dispatch)', () => { + it('routes config to getConfig and returns the rendered text without touching setEnabled', () => { + const controller = new FakeController( + baseStatus({ enabled: true, provider: 'openai', model: 'gpt-4o' }), + baseConfig({ enabled: true, provider: 'openai', model: 'gpt-4o', systemPromptSet: true, systemPromptSummary: 'Be brief.' }), + ) + const handler = registerAndGetHandler(controller) + const result = invoke(handler, 'config') + expect(result.kind).toBe('success') + if (result.kind === 'success') { + expect(result.text).toBe(advisorConfigText(controller.getConfig())) + expect(result.text).toContain('Advisor config: enabled') + expect(result.text).toContain('Be brief.') + } + expect(controller.setCalls).toHaveLength(0) + }) + + it('config render is session-less: a per-session override (status) never leaks into it', () => { + // The session override is OFF (status disabled) while the composed config + // is ON — the readback must report the composed value, not the session + // state (config-vs-status separation; web-card /api/advisor/get parity). + const controller = new FakeController( + baseStatus({ enabled: false }), + baseConfig({ enabled: true, provider: 'openai', model: 'gpt-4o' }), + ) + const handler = registerAndGetHandler(controller) + const result = invoke(handler, 'config') + expect(result.kind).toBe('success') + if (result.kind === 'success') { + expect(result.text).toContain('Advisor config: enabled') + expect(result.text).toContain('Model: openai/gpt-4o') + expect(result.text).not.toContain('Advisor config: disabled') + expect(result.text).not.toContain('Runtime:') + } + expect(controller.setCalls).toHaveLength(0) + }) + + it('config with an unknown subcommand still renders USAGE', () => { + const controller = new FakeController(baseStatus()) + const handler = registerAndGetHandler(controller) + const result = invoke(handler, 'config extra') + expect(result.kind).toBe('success') + if (result.kind === 'success') expect(result.text).toBe(USAGE) + expect(controller.setCalls).toHaveLength(0) + }) +}) + // --------------------------------------------------------------------------- // Unknown subcommand // --------------------------------------------------------------------------- diff --git a/tests/settings-live.test.ts b/tests/settings-live.test.ts index b747190..e9b653a 100644 --- a/tests/settings-live.test.ts +++ b/tests/settings-live.test.ts @@ -708,3 +708,77 @@ describe('settings live re-apply — attach ordering + detach fallback (qc1 S-1 expect(inject).toHaveBeenCalledTimes(4) }) }) + +// --------------------------------------------------------------------------- +// 7. /advisor config — session-less composed readback (plan +// dsh-advisor-tui-client-n8 T2). The REAL wiring reads `safeResolved()` +// (the composed config with the hard gate applied), never the +// per-session effective config — a `/advisor off` session override must +// not misreport settings.yaml (web-card /api/advisor/get parity). +// --------------------------------------------------------------------------- + +describe('/advisor config — session-less composed readback (T2)', () => { + it('reports the composed settings through the user layer and ignores the per-session override', async () => { + const { ctx } = await composeLiveHarness( + { enabled: true, provider: 'stub', model: 'stub-model', systemPrompt: 'custom prompt\nsecond line' }, + [], + ) + const handler = await registerCommands(ctx) + const { agent } = makeFakeAgent('s1') + ctx.emit('agent/created', { agent }) + const { session } = makeSession('s1') + + // A settings user-layer edit composes over the plugin-row base — the + // observation channel (AC-3) must show the composed value. + await ctx.settings.update(ADVISOR_SETTINGS_NAMESPACE, { model: 'other-model' }) + const composed = invokeAdvisor(handler, 'config', session) + expect(composed.kind).toBe('success') + if (composed.kind === 'success') { + expect(composed.text).toContain('Advisor config: enabled') + expect(composed.text).toContain('Model: stub/other-model') + // The summary is the FIRST line of the prompt, not a full dump. + expect(composed.text).toContain('systemPrompt: "custom prompt"') + expect(composed.text).not.toContain('second line') + } + + // Flip the per-session override OFF: the status surface follows the + // override (runtime state owned by status)... + const off = invokeAdvisor(handler, 'off', session) + expect(off.kind).toBe('success') + const status = invokeAdvisor(handler, 'status', session) + expect(status.kind).toBe('success') + if (status.kind === 'success') expect(status.text).toContain('Advisor: disabled') + + // ...but the config readback stays session-less: still the composed + // enabled config with the composed provider/model — never the session + // state (config-vs-status separation). + const readback = invokeAdvisor(handler, 'config', session) + expect(readback.kind).toBe('success') + if (readback.kind === 'success') { + expect(readback.text).toContain('Advisor config: enabled') + expect(readback.text).toContain('Model: stub/other-model') + expect(readback.text).toContain('systemPrompt: "custom prompt"') + } + }) + + it('summarizes a long multi-line systemPrompt to the first line, ≤ 80 chars, never a full dump', async () => { + const longFirstLine = `line-one-${'x'.repeat(100)}` // 109 chars + const { ctx } = await composeLiveHarness( + { + enabled: true, + provider: 'stub', + model: 'stub-model', + systemPrompt: `${longFirstLine}\nsecond line must never appear`, + }, + [], + ) + const handler = await registerCommands(ctx) + const { session } = makeSession('s1') + const result = invokeAdvisor(handler, 'config', session) + expect(result.kind).toBe('success') + if (result.kind === 'success') { + expect(result.text).toContain(`systemPrompt: "${longFirstLine.slice(0, 79)}…"`) + expect(result.text).not.toContain('second line must never appear') + } + }) +}) From 0643af6241212950d3fe4127d109f76bdee848f7 Mon Sep 17 00:00:00 2001 From: Tang Bohao Date: Sun, 16 Aug 2026 09:44:25 +0800 Subject: [PATCH 3/7] docs(readme): document dsh-tui profile support --- README.i18n.yaml | 4 ++-- README.md | 27 +++++++++++++++++++++++++++ README.zh.md | 16 ++++++++++++++++ docs/install.md | 44 ++++++++++++++++++++++++++++++++++++++++---- docs/install.zh.md | 36 ++++++++++++++++++++++++++++++++---- 5 files changed, 117 insertions(+), 10 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 9da478b..518f8a3 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -3,5 +3,5 @@ # editing either side, bring the other along and re-record with: # git hash-object README.md # git hash-object README.zh.md -README.md: c0096d75da8f4c6e528b24a5bf3cdce6722f0358 -README.zh.md: 6b7c4425bb81764d4bc47a6dc417b7e352fc3769 +README.md: 26aa5e8b7ce48a9b61b90d0ad7ae3699ade6e103 +README.zh.md: a1a27cfefa7eda4333cbf456d6007e7910545cbd diff --git a/README.md b/README.md index 5ea4693..26aa5e8 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,33 @@ dsh --profile web Tarball install and uninstall are covered in [docs/install.md](docs/install.md). +### dsh-tui profile + +The advisor also runs in the terminal TUI client (`dsh --profile dsh-tui`), +installed the same way as the web profile: + +```sh +dsh plugin --profile dsh-tui add dsh-advisor # = your profile name +# Local-dir variant (from a built checkout): +dsh plugin --profile dsh-tui add . +``` + +**Settings** — the TUI has no settings page: the advisor settings compose +from the same two persisted surfaces as the web profile — the dsh-tui profile +patch layer `~/.dsh/profiles/dsh-tui/cordis.patch.yml` (plugin row) and the +global `$DSH_HOME/settings.yaml` `advisor:` section, **shared across +profiles** (the web Settings card writes to this same file). `/advisor +config` prints the composed config as a read-only readback with edit hints. + +**Commands** — open the TUI `/` menu: `/advisor` (bare toggle) and +`/advisor on|off|status|config` are listed with subcommand completion. +Discovery requires a profile with the `dsh-tui-command-trees` row — the +shipped dsh-tui bundle has it. + +**Limitation** — the web Settings card is web-only; there is no TUI settings +page and no write command. Edit settings through the profile patch or +`$DSH_HOME/settings.yaml`; `/advisor config` only reads them back. + ## Config ![Advisor card on the dsh web Settings (插件配置) page](docs/screenshots/advisor-settings-card.webp) diff --git a/README.zh.md b/README.zh.md index c540d0d..a1a27cf 100644 --- a/README.zh.md +++ b/README.zh.md @@ -43,6 +43,22 @@ dsh --profile web tarball 安装与卸载见 [docs/install.zh.md](docs/install.zh.md)。 +### dsh-tui profile + +advisor 也可以运行在终端 TUI 客户端(`dsh --profile dsh-tui`)中,安装方式与 web profile 相同: + +```sh +dsh plugin --profile dsh-tui add dsh-advisor # = 你的 profile 名 +# 本地目录变体(在已构建的 checkout 中): +dsh plugin --profile dsh-tui add . +``` + +**设置** —— TUI 没有设置页:advisor 配置由与 web profile 相同的两个持久化配置面合成——dsh-tui profile 补丁层 `~/.dsh/profiles/dsh-tui/cordis.patch.yml`(插件行)与全局 `$DSH_HOME/settings.yaml` 的 `advisor:` 段,**跨 profile 共享**(web Settings 卡片也写入这个文件)。`/advisor config` 以只读回读方式打印合成后的配置,并附编辑提示。 + +**指令** —— 打开 TUI 的 `/` 菜单:`/advisor`(裸 toggle)与 `/advisor on|off|status|config` 会列出并带子命令补全。指令发现要求 profile 带有 `dsh-tui-command-trees` 行——随附的 dsh-tui 组合包自带。 + +**限制** —— web Settings 卡片仅限 web;TUI 没有设置页,也没有写指令。设置请通过 profile 补丁或 `$DSH_HOME/settings.yaml` 修改;`/advisor config` 只读回读。 + ## 配置 ![dsh web Settings("插件配置")页上的 Advisor 卡片](docs/screenshots/advisor-settings-card.webp) diff --git a/docs/install.md b/docs/install.md index 1f494d6..c825e90 100644 --- a/docs/install.md +++ b/docs/install.md @@ -47,7 +47,7 @@ dsh plugin --profile web add . # = your profile name goes through pnpm's `link:` dependency, for which pnpm does **not** run prepare/postinstall — build the bundle with `pnpm install` (or `pnpm build`) before adding. No host patching is involved: the plugin runs entirely from its -plugin config row (see [Web Settings exposure](#4-web-settings-exposure)). +plugin config row (see [Web Settings exposure](#5-web-settings-exposure)). ## 3. Tarball install @@ -62,7 +62,43 @@ A tarball ships the built artifacts (`lib/` + `cordis.patch.yml`), so no are declared as peerDependencies and resolved by the dsh installation's flat profile module fallback — no extra install step. -## 4. Web Settings exposure +## 4. dsh-tui profile install + +The advisor also installs into the terminal TUI profile (`dsh --profile +dsh-tui`) with the same commands as the web profile: + +```sh +dsh plugin --profile dsh-tui add dsh-advisor # = your profile name +# Pin an exact version for reproducibility: +# dsh plugin --profile dsh-tui add dsh-advisor@0.1.0 +# Local-dir variant (from a built checkout): +dsh plugin --profile dsh-tui add . +``` + +The bundle inserts the same `- insert: id: advisor` row into the dsh-tui +profile's patch layer (`~/.dsh/profiles/dsh-tui/cordis.patch.yml`). The +`advisor` settings namespace is shared across profiles via the global +`$DSH_HOME/settings.yaml` `advisor:` section (the web Settings card writes +there too) — the TUI has no settings page, so `/advisor config` is the +readback (read-only, with edit hints), and `/advisor` / `on|off|status|config` +surface in the TUI `/` menu with subcommand completion (requires the +`dsh-tui-command-trees` row, shipped in the dsh-tui bundle). + +Verify: + +```sh +dsh --profile dsh-tui --dump-config # shows a "# == dsh-advisor" layer with the advisor row +dsh --profile dsh-tui +``` + +Uninstall: + +```sh +dsh plugin --profile dsh-tui remove dsh-advisor +dsh --profile dsh-tui --dump-config # confirm the dsh-advisor layer is gone +``` + +## 5. Web Settings exposure The dsh web Settings page's **"插件配置" (Plugin Configuration)** page renders one card per plugin that registers into the `settings.plugin.item` card slot. @@ -84,7 +120,7 @@ mechanism the dsh `goals` service uses), and the card calls them via exposed-namespace check, so saving works on any dsh build that ships the GatewayService channel. No host patching is applied or required. -## 5. Verify +## 6. Verify ```sh dsh --profile web --dump-config # shows a "# == dsh-advisor" layer with the advisor row @@ -96,7 +132,7 @@ card; it reads and writes the `advisor` namespace live through `/api/advisor/get` + `/api/advisor/set` — saving applies to new sessions immediately. -## 6. Uninstall +## 7. Uninstall ```sh dsh plugin --profile web remove dsh-advisor diff --git a/docs/install.zh.md b/docs/install.zh.md index 3fcf156..9838fc5 100644 --- a/docs/install.zh.md +++ b/docs/install.zh.md @@ -26,7 +26,7 @@ pnpm install # 构建组合包(prepare 自建) dsh plugin --profile web add . # = 你的 profile 名 ``` -`dsh plugin add` 会把组合包追加到 profile 的 `dsh.profile.bundles`(包声明了 `dsh.bundle`);组合包插入一行插件配置 —— `id: advisor`,`name: dsh-advisor`(见 `cordis.patch.yml`)。本地 `add .` 走 pnpm 的 `link:` 依赖,pnpm **不会**为 `link:` 依赖运行 prepare/postinstall——请先用 `pnpm install`(或 `pnpm build`)构建好组合包再添加。无需任何宿主补丁:插件完全通过插件配置行运行(见 [web Settings 暴露](#4-web-settings-暴露))。 +`dsh plugin add` 会把组合包追加到 profile 的 `dsh.profile.bundles`(包声明了 `dsh.bundle`);组合包插入一行插件配置 —— `id: advisor`,`name: dsh-advisor`(见 `cordis.patch.yml`)。本地 `add .` 走 pnpm 的 `link:` 依赖,pnpm **不会**为 `link:` 依赖运行 prepare/postinstall——请先用 `pnpm install`(或 `pnpm build`)构建好组合包再添加。无需任何宿主补丁:插件完全通过插件配置行运行(见 [web Settings 暴露](#5-web-settings-暴露))。 ## 3. tarball 安装 @@ -37,11 +37,39 @@ dsh plugin --profile web add dsh-advisor-0.1.0.tgz tarball 附带的是构建产物(`lib/` + `cordis.patch.yml`),因此不会运行 `prepare` 脚本,也无需构建权限。运行时依赖(`@deepseek-ai/cordis`、`@deepseek-ai/schemastery` 与 `@deepseek-ai/dsh-{session,agent,llm,commands,timeout}`)声明为 peerDependencies,由 dsh 安装的扁平 profile module fallback 解析——无需额外安装步骤。 -## 4. web Settings 暴露 +## 4. dsh-tui profile 安装 + +advisor 也可以用与 web profile 相同的命令装入终端 TUI profile(`dsh --profile dsh-tui`): + +```sh +dsh plugin --profile dsh-tui add dsh-advisor # = 你的 profile 名 +# 需要可复现安装时钉住精确版本: +# dsh plugin --profile dsh-tui add dsh-advisor@0.1.0 +# 本地目录变体(在已构建的 checkout 中): +dsh plugin --profile dsh-tui add . +``` + +组合包把同样的 `- insert: id: advisor` 行插入 dsh-tui profile 的补丁层(`~/.dsh/profiles/dsh-tui/cordis.patch.yml`)。`advisor` settings namespace 经全局 `$DSH_HOME/settings.yaml` 的 `advisor:` 段跨 profile 共享(web Settings 卡片也写入该文件)——TUI 没有设置页,因此 `/advisor config` 是回读手段(只读,附编辑提示),`/advisor` / `on|off|status|config` 则出现在 TUI 的 `/` 菜单中并带子命令补全(要求 `dsh-tui-command-trees` 行,随附的 dsh-tui 组合包自带)。 + +验证: + +```sh +dsh --profile dsh-tui --dump-config # 显示带 advisor 配置行的 "# == dsh-advisor" 层 +dsh --profile dsh-tui +``` + +卸载: + +```sh +dsh plugin --profile dsh-tui remove dsh-advisor +dsh --profile dsh-tui --dump-config # 确认 dsh-advisor 层已消失 +``` + +## 5. web Settings 暴露 dsh web Settings 页的**"插件配置"页**为每个注册进 `settings.plugin.item` 卡片 slot 的插件渲染一张卡片。Advisor 卡片(`id advisor`,渲染在三张上游卡片 bash / agent-loop / web-search 之后)通过 dsh 宿主的 apiproxy `describe` 读取 provider 目录(已暴露的 `llm-*` 命名空间),但 advisor 配置只通过**官方 `GatewayService` RPC 通道**读写——它不依赖 apiproxy allowlist(allowlist 仅覆盖模型提供者命名空间 + 产品命名空间:locale / permission / ui-conversation / ui-theme / ui-onboarding / agent-presets)。**上游 dsh 没有注册级 opt-in**(`exposeToWebClients` 不存在于上游 `SettingsRegisterOptions`——已在 pristine 20da39e 快照上核实),因此 `advisor` 命名空间**不在 apiproxy allowlist 上**。插件注册 `AdvisorConfigGateway`(带 `@Remote('get')`/`@Remote('set')` 方法的 `GatewayService`),宿主的 typertGateway 认领 `/api/advisor/get` + `/api/advisor/set`(与 dsh 内建 `goals` 服务同一机制),卡片经 `connection.rpc` 调用它们。进程内写入(`ctx.settings.update`)没有 exposed-namespace 检查,因此在任何提供 GatewayService 通道的 dsh 构建上保存都可用。无需也不施加任何宿主补丁。 -## 5. 验证 +## 6. 验证 ```sh dsh --profile web --dump-config # 显示带 advisor 配置行的 "# == dsh-advisor" 层 @@ -50,7 +78,7 @@ dsh --profile web 启动后,web Settings 页的"插件配置"页会渲染 Advisor 卡片;它通过 `/api/advisor/get` + `/api/advisor/set` live 读写 `advisor` 命名空间——保存后新会话立即生效。 -## 6. 卸载 +## 7. 卸载 ```sh dsh plugin --profile web remove dsh-advisor From a02540e65b1f0388af191f23dbd1dffa2c96cae8 Mon Sep 17 00:00:00 2001 From: Tang Bohao Date: Sun, 16 Aug 2026 09:56:13 +0800 Subject: [PATCH 4/7] fix(advisor): seed /advisor config fallback from raw source + tui tree dedupe + prompt summary edges (QC F-1..F-4) --- src/commands.ts | 13 ++++++++++--- src/index.ts | 27 ++++++++++++++++++++------- src/tui.ts | 8 +++++++- tests/commands.test.ts | 16 ++++++++++++++++ tests/settings-live.test.ts | 27 +++++++++++++++++++++++++++ tests/tui-client.test.ts | 23 +++++++++++++++++++++++ 6 files changed, 103 insertions(+), 11 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index e4c7693..ee7cd16 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -191,7 +191,9 @@ export interface AdvisorComposedConfig { * ``). */ export function summarizeSystemPrompt(prompt: string): string { - const firstLine = prompt.split('\n')[0] ?? '' + // CRLF prompts (schema allows any string) leave a trailing \r on the first + // line — strip it as a line-ending artifact, not content (qc2 F-3). + const firstLine = (prompt.split('\n')[0] ?? '').replace(/\r$/, '') if (firstLine.length <= 80) return firstLine return `${firstLine.slice(0, 79)}…` } @@ -210,10 +212,15 @@ export function advisorConfigText(config: AdvisorComposedConfig): string { } lines.push(`immuneTurns: ${config.immuneTurns}`) lines.push(`maxDeltaMessages: ${config.maxDeltaMessages === 0 ? 'unbounded' : config.maxDeltaMessages}`) + // The set-vs-default signal is systemPromptSet, NOT the summary: a custom + // prompt whose first line is empty (e.g. '\nsecond line') summarizes to '' + // but must still read as set, not (qc2 F-3). lines.push( - config.systemPromptSummary === '' + !config.systemPromptSet ? 'systemPrompt: ' - : `systemPrompt: "${config.systemPromptSummary}"`, + : config.systemPromptSummary === '' + ? 'systemPrompt: "(empty first line)"' + : `systemPrompt: "${config.systemPromptSummary}"`, ) if (config.disabledReason !== undefined) lines.push(`Reason: ${config.disabledReason}`) lines.push('') diff --git a/src/index.ts b/src/index.ts index ef0e83e..805ef03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -155,13 +155,26 @@ export function apply(ctx: Context, config: AdvisorConfig) { // plugin-row throw contract is unchanged: construction-time reads below // (delivery/observer latches) still use the throwing `resolved()`, so a bad // entry rejects the plugin row at load (config.test.ts ⑤). - const safeFallback = (reason: string): ResolvedAdvisorConfig => ({ - enabled: false, - systemPrompt: '', - immuneTurns: 3, - maxDeltaMessages: 60, - disabledReason: reason, - }) + const safeFallback = (reason: string): ResolvedAdvisorConfig => { + // S1 (gateway readConfig parity): when the raw source is still readable, + // seed the scalar latches from it — an invalid user layer only drops the + // offending keys, so /advisor config (and /advisor status) never + // misreport immuneTurns / maxDeltaMessages / systemPrompt vs the web + // card's /api/advisor/get readback. + let raw: AdvisorConfig | undefined + try { + raw = sourceConfig() + } catch { + // unreadable source — fall back to the schema defaults below + } + return { + enabled: false, + systemPrompt: raw?.systemPrompt ?? '', + immuneTurns: raw?.immuneTurns ?? 3, + maxDeltaMessages: raw?.maxDeltaMessages ?? 60, + disabledReason: reason, + } + } const safeResolved = (): ResolvedAdvisorConfig => { try { return resolveAdvisorConfig(sourceConfig()) diff --git a/src/tui.ts b/src/tui.ts index e277ac0..4b84822 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -131,6 +131,12 @@ export function installTuiClient(ctx: Context): void { ctx.inject(['tuiCommandTrees'], (tctx) => { const trees = (tctx as unknown as { tuiCommandTrees?: { register(p: TuiCommandTreeProvider): () => void } }).tuiCommandTrees if (trees === undefined) return - return trees.register(advisorTree) + try { + return trees.register(advisorTree) + } catch (error) { + if (!(error instanceof Error) || !error.message.includes('already registered')) throw error + tctx.logger('advisor').debug('advisor tui tree already registered — no tree on this fiber (multi-fiber dedupe)') + return () => {} + } }) } diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 6c16c93..03addc7 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -445,6 +445,15 @@ describe('advisorConfigText (the /advisor config surface, composed session-less expect(text).not.toContain('systemPrompt: "') }) + it('renders a non-default marker when the prompt is SET but its first line is empty (qc2 F-3)', () => { + // systemPrompt '\nsecond line' is set (schema allows any string) yet its + // summary is '' — the marker must follow systemPromptSet, NOT the empty + // summary, or a custom prompt would be misreported as the default. + const text = advisorConfigText(baseConfig({ enabled: true, systemPromptSet: true, systemPromptSummary: '' })) + expect(text).toContain('systemPrompt: "(empty first line)"') + expect(text).not.toContain('systemPrompt: ') + }) + it('renders disabled-with-reason when the gate blocks, without a Model line', () => { const text = advisorConfigText(baseConfig({ disabledReason: 'enabled but provider and model are missing — configure both to enable the advisor', @@ -483,6 +492,13 @@ describe('summarizeSystemPrompt (first line, ≤ 80 chars, never a full dump)', expect(summarizeSystemPrompt('first line\nsecond line\nthird')).toBe('first line') }) + it('strips a trailing CR from a CRLF first line (qc2 F-3)', () => { + expect(summarizeSystemPrompt('first line\r\nsecond line')).toBe('first line') + // The CR is a line-ending artifact, not content — an 80-char CRLF first + // line stays under the ellipsis threshold after stripping. + expect(summarizeSystemPrompt(`${'x'.repeat(80)}\r\nsecond`)).toBe('x'.repeat(80)) + }) + it('keeps a short first line unchanged', () => { expect(summarizeSystemPrompt('You are a terse reviewer.')).toBe('You are a terse reviewer.') // Exactly 80 chars — no ellipsis. diff --git a/tests/settings-live.test.ts b/tests/settings-live.test.ts index e9b653a..69467f4 100644 --- a/tests/settings-live.test.ts +++ b/tests/settings-live.test.ts @@ -781,4 +781,31 @@ describe('/advisor config — session-less composed readback (T2)', () => { expect(result.text).not.toContain('second line must never appear') } }) + + it('an unknown-key user layer: /advisor config stays disabled-with-reason and seeds the scalars from the raw source (qc2 W-1 on the config path, F-1/F-4)', async () => { + // The exact qc2 W-1 scenario already pinned for /advisor status — now on + // the config readback: the user layer gains an unknown key the resolver + // rejects, but the raw source is still readable, so the fallback must + // seed immuneTurns/maxDeltaMessages/systemPrompt from it (web-card + // readConfig S1 parity) instead of the hardcoded 3/60/'' defaults. + const { ctx } = await composeLiveHarness( + { enabled: true, provider: 'stub', model: 'stub-model', immuneTurns: 5, maxDeltaMessages: 20, systemPrompt: 'keep me' }, + [], + ) + const handler = await registerCommands(ctx) + const { session } = makeSession('s1') + + await ctx.settings.update(ADVISOR_SETTINGS_NAMESPACE, { bogus: 1 }) + + const result = invokeAdvisor(handler, 'config', session) + expect(result.kind).toBe('success') + if (result.kind === 'success') { + expect(result.text).toContain('Advisor config: disabled') + expect(result.text).toContain('unknown config key "bogus"') + // Seeded from the readable raw source — NOT the hardcoded defaults. + expect(result.text).toContain('immuneTurns: 5') + expect(result.text).toContain('maxDeltaMessages: 20') + expect(result.text).toContain('systemPrompt: "keep me"') + } + }) }) diff --git a/tests/tui-client.test.ts b/tests/tui-client.test.ts index 3a64615..7e3990a 100644 --- a/tests/tui-client.test.ts +++ b/tests/tui-client.test.ts @@ -109,6 +109,29 @@ describe('installTuiClient — registration (AC-1)', () => { expect(returned()).toBe(dispose) }) + + it('a duplicate-root registration is contained: debug log + no-op disposer, no throw (qc2 F-2)', () => { + // Another fiber already registered the /advisor root (multi-fiber + // duplication is observed in the host) — the duplicate-root throw must + // NOT propagate out of the inject child. Mirrors the sibling typert/ + // settings optional-registration pattern. + const trees = new StubCommandTrees() + trees.register({ root: ADVISOR_TUI_ROOT, children: () => [] }) + const debug = vi.fn() + const { ctx, injected, returned } = activateCtx({ + tuiCommandTrees: trees, + logger: () => ({ debug }), + }) + + expect(() => installTuiClient(ctx)).not.toThrow() + + expect(injected()).toBe(true) + // No second provider is recorded; the child returns a no-op disposer and + // the dedupe is logged at debug level. + expect(trees.providers).toHaveLength(1) + expect(returned()).toEqual(expect.any(Function)) + expect(debug).toHaveBeenCalledWith('advisor tui tree already registered — no tree on this fiber (multi-fiber dedupe)') + }) }) // --------------------------------------------------------------------------- From 7e637933c5afaf602ee9347a8af84d0bfa85c22e Mon Sep 17 00:00:00 2001 From: Tang Bohao Date: Sun, 16 Aug 2026 12:25:27 +0800 Subject: [PATCH 5/7] =?UTF-8?q?chore(iteration):=20close=20iter-20260816-d?= =?UTF-8?q?sh-advisor-n8=20=E2=80=94=20compound=20round,=20roadmap=20updat?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .mstar/knowledge/README.md | 2 + .../dsh-tui-plugin-client-surface.md | 77 +++++++++++++++++++ ...h-tui-verification-and-host-boot-repair.md | 69 +++++++++++++++++ CONCEPTS.md | 4 + 4 files changed, 152 insertions(+) create mode 100644 .mstar/knowledge/architecture-patterns/dsh-tui-plugin-client-surface.md create mode 100644 .mstar/knowledge/developer-experience/dsh-tui-verification-and-host-boot-repair.md diff --git a/.mstar/knowledge/README.md b/.mstar/knowledge/README.md index d92370c..7facdac 100644 --- a/.mstar/knowledge/README.md +++ b/.mstar/knowledge/README.md @@ -4,10 +4,12 @@ |----------|--------|-------------|--------| | `developer-experience/dsh-standalone-plugin-dev.md` | standalone bundle bring-up (peer-stubs → link farm → registry peers) | Standalone dsh plugin bundle development against private @deepseek-ai packages: registry peer resolution via autoInstallPeers (superseding the link farm, peer-stubs, and gitignored shim overlay), prepare-based git-URL installs, plural inject names, install smoke | active | | `developer-experience/pnpm11-workspace-config-and-windows-link-farm.md` | PR #11 (fix/install: Windows + pnpm 11) | pnpm 11 ignores non-auth .npmrc settings (move autoInstallPeers/nodeLinker/allowBuilds to pnpm-workspace.yaml), peer ranges must match prerelease tags (^0.0.1 vs 0.0.1-rc.1), Windows-safe link farm (junction/file per target, USERPROFILE fallback, separator normalization) | superseded (registry peers) | +| `developer-experience/dsh-tui-verification-and-host-boot-repair.md` | iteration:iter-20260816-dsh-advisor-n8 QA | dsh profile boot repair (node-addon-require-builtin platform binding missing → all boots fail with cordis-plugin-loader ERR_MODULE_NOT_FOUND while --dump-config still works; repair = global dsh reinstall + relink binding) + reliable interactive TUI verification (deterministic evidence + direct PTY probe + documented human spot-check residual; subagent PTY loops are unreliable) | active | | `architecture-patterns/omp-advisor-dsh-port.md` | core MVP port | omp advisor → dsh mechanism map (cursor/delta/guard/delivery/failure) + MVP decisions + accepted gaps | active | | `architecture-patterns/dsh-plugin-client-half.md` | client half + settings section work | dsh web client half for a standalone plugin: dsh.client declaration (nested under dsh, post-20da39e), closure-factory CJS bundle contract (frozen externals/purity/automatic JSX), CSS-modules inline injection + style-tag lifecycle + bundle hygiene, settings.section slot registration (legacy — the advisor's configuration surface is now the settings.plugin.item card, see dsh-plugin-config-card-surface.md), settings namespace wiring | active | | `architecture-patterns/dsh-plugin-config-card-surface.md` | iteration:iter-20260811-dsh-advisor-n6/guides/plugin-config-migration.md | dsh web "插件配置" page card surface: the settings.plugin.item card slot (declared by the ui-plugin-config settings.section id 'plugins'), generator + yield registration with locale / business-only inject faces, PropsRuntime + PropsLocale + InjectFace contract, type-only peer dependency, load-on-mount invariant, settings-scope vs GatewayService data-channel routes, CSS-fragment build discipline — the advisor's current configuration surface (supersedes the settings.section recipe) | active | | `architecture-patterns/dsh-auxiliary-model-start-profile.md` | dsh-advisor-minimal-start | dsh auxiliary model calls (advisor KD-6): minimal closed-whitelist GenerateOptions (zero tools, literal-pinned caps) + capability-gated thinking-off with failure-retry caching discipline (never cache a failed capability lookup; throw and deadline abort are one failure class) | active | +| `architecture-patterns/dsh-tui-plugin-client-surface.md` | iteration:iter-20260816-dsh-advisor-n8 | dsh-TUI plugin client surface with zero dsh-TUI changes: bundle composition into the dsh-tui profile, DSH command-registry auto-merge into the TUI / menu, the tuiCommandTrees plugin-facing seam (structural TuiCommandTreeProvider types — no @deepseek-harness-tui peer), no-settings-page constraint (namespace + profile patch + global settings.yaml), and the session-less config-readback parity rule (readback resolves the composed config like the web gateway, never the per-session override) | active | | `architecture-patterns/dsh-settings-exposure-boundary.md` | settings exposure work (patch retirement + gateway channel) | dsh host settings exposure boundary — NO registration-level opt-in exists upstream (verified at pristine 20da39e); the working fix is the official GatewayService RPC channel (`/api//`, typertGateway claims, in-process settings.update un-gated) — web section reads/writes through it, bypassing the allowlist; no host patch; circular-verification trap documented; **SRC `@Remote` claims fail for locally-linked plugins under a dlx host (module-private marker table, physically separate peers) — explicit `ctx.typert.register(contribution)` is the module-identity-proof path (2026-08-13)** | active | | `workflow-patterns/dsh-host-dispatch-concurrency.md` | core MVP port | dsh same-step tool-call scheduling: subagent calls are exclusive (serial) — isConcurrencySafe fail-closed | active | | `workflow-patterns/dsh-upstream-bump-adaptation.md` | upstream bump + patch retirement | Surviving a dsh snapshot upgrade as a plugin bundle: probe discriminators (present/absent), dshClient → dsh.client migration (no fallback, negative-verdict cache), restart + runtime verification sequence; host-patch mechanism retired; host tree must stay pristine (staging worktree edits are the same class as the retired patch) | active | diff --git a/.mstar/knowledge/architecture-patterns/dsh-tui-plugin-client-surface.md b/.mstar/knowledge/architecture-patterns/dsh-tui-plugin-client-surface.md new file mode 100644 index 0000000..4fc4407 --- /dev/null +++ b/.mstar/knowledge/architecture-patterns/dsh-tui-plugin-client-surface.md @@ -0,0 +1,77 @@ +--- +module: dsh plugin TUI client surface (dsh-tui) +date: 2026-08-16 +problem_type: architecture_pattern +category: architecture-patterns +severity: medium +title: Adding a plugin client surface to the dsh-TUI terminal front door (tuiCommandTrees + session-less config readback) +description: Verified pattern for surfacing a standalone dsh plugin inside the dsh-TUI terminal front end with zero dsh-TUI changes — bundle composition into the dsh-tui profile, DSH command-registry auto-merge into the TUI / menu, the plugin-facing tuiCommandTrees seam (register a TuiCommandTreeProvider for localized descriptions + subcommand completion), the no-settings-page constraint (settings = settings namespace + profile patch + global $DSH_HOME/settings.yaml), and the session-less config-readback parity rule (a readback command must resolve the composed config exactly like the web gateway — never the per-session override). +last_updated: 2026-08-16 +tags: + - dsh + - plugin + - dsh-tui + - client +--- + +# Adding a plugin client surface to the dsh-TUI terminal front door + +## Context + +dsh-TUI (`@deepseek-harness-tui/dsh-tui`, profile `dsh-tui`, launcher `bin/dsh-tui.js` self-bootstraps `dsh --profile dsh-tui add @deepseek-harness-tui/dsh-tui@` then spawns `dsh --profile dsh-tui`) is a terminal-only Ink/TUI front door over dsh-base. It renders NO web client bundles, has NO settings page, NO typert gateway, and NO generic plugin settings UI. Verified against source @ 557a27a (2026-08-16). + +The dsh-advisor plugin (a per-session reviewer) needed a first-class TUI surface: commands discoverable in the `/` menu + a settings readback, without modifying the dsh-TUI repo and without adding a dependency on it. + +## Guidance + +### 1. Bundle composition needs zero dsh-TUI changes + +`dsh plugin --profile dsh-tui add ` reads the package's `dsh.bundle.patch` (package.json `dsh` → `cordis.patch.yml`), appends its `- insert:` rows as a composition layer (dsh-base → bundles → bundle patches → user patch layer `~/.dsh/profiles/dsh-tui/cordis.patch.yml`). The advisor's `- insert: id: advisor` row lands in the profile with no host edits. `dsh --profile dsh-tui --dump-config` shows the row (composition-only, works even when the full plugin-tree boot is broken). + +### 2. Commands auto-surface in the TUI `/` menu + +The TUI merges the DSH command registry into its `/` menu (`refreshCommandList` in `src/dsh-adapter/channel.ts`: `commandService.list(target)` → merged rows; dispatch via `commandService.execute`). A plugin's registry commands (`ctx.inject(['commands'], ...)`) appear automatically. The row's `tag` comes from `CommandDefinition.input.hint` — keep it in sync when adding subcommands. + +### 3. The plugin-facing TUI seam is `tuiCommandTrees` + +`ctx.tuiCommandTrees` (cordis Service, row `dsh-tui-command-trees` — shipped in the dsh-tui bundle) lets plugins register: + +```ts +interface TuiCommandTreeProvider { + root: string // '^[a-z][a-z0-9_-]*$'; duplicate root throws + descriptions?: LocalizedDescriptions // Readonly>> + children(canonicalPath: readonly string[]): readonly CommandCompletionNode[] // root at index 0 +} +interface CommandCompletionNode { + name: string; aliases?: readonly string[]; description: string + descriptions?: LocalizedDescriptions; tag?: string; descriptionKey?: string +} +``` + +`descriptions(root)` overrides the root row's description; `children` drives `/` overlay completion (leaves return `[]` — the TUI asks at depth 2). **Do NOT add `@deepseek-harness-tui/dsh-tui` as a dependency** — the shapes are small and structural; replicate them locally (zero new peers). The cordis Context lacks the `tuiCommandTrees` augmentation outside dsh-TUI — use a structural cast with a conditional `ctx.inject(['tuiCommandTrees'], ...)` (absent service → clean no-op, same pattern as settings/typert/commands children). Register behind any single-instance claim (the advisor's `claimReviewer()`) and defensively catch duplicate-root ('already registered' → debug log + no-op disposer) — the multi-fiber composition that affects sibling optional registrations applies here too. + +### 4. The TUI has no settings page — settings surface = namespace + readback + docs + +No plugin settings UI seam exists (filed upstream: ccch1mneyyy/dsh-TUI#165). The working surface is: +- the plugin's settings namespace (registers via the dsh settings service; reads the same live composed config), +- operator edit paths: profile patch layer (`~/.dsh/profiles/dsh-tui/cordis.patch.yml`) + the GLOBAL `$DSH_HOME/settings.yaml` (shared across ALL profiles — the web Settings card writes the same user layer), +- a read-only readback command (`/advisor config`) rendering the composed config + edit hints. + +### 5. Session-less config readback parity (correctness rule) + +A settings-readback command MUST read the composed config exactly like the web gateway (`/api/advisor/get`): resolve the bridge source through the shared resolver (`resolveAdvisorConfig`), with **no session context**. Never route the readback through the per-session effective config (`effectiveConfig`/`safeEffective`) — those bake the `/advisor off` session toggle into `enabled`, so a user who turns the advisor off for the session would see the readback misreport the persisted settings (web-vs-TUI divergence). Runtime state (on/off, pending, last activity) stays in the status command; config state stays in the config command. Containment: when the resolver throws on a rejected settings user layer, seed the readback's scalar latches from the RAW source (`raw?.immuneTurns ?? 3`, ...) — mirroring the gateway's S1 fallback — so both front ends report the same values. + +## Why This Matters + +- A terminal front door and a web front door share one config SSOT; readback parity prevents "the TUI says 3, the web card says 5" confusion for the same settings.yaml. +- The `tuiCommandTrees` seam is the entire plugin-facing UI surface of dsh-TUI today — knowing it means future TUI work (e.g. the post-#165 write surface) starts from the right contract instead of re-deriving it from the host source. +- Zero new peers keeps the plugin's dependency contract intact (mount-only, public-registry peers only). + +## When to Apply + +- Adding or maintaining ANY plugin surface in a dsh-TUI profile (commands, completion, settings readback). +- The upstream settings-seam work (ccch1mneyyy/dsh-TUI#165): when dsh-TUI gains a settings UI, the advisor's TUI write surface should reuse the same composed-config resolver + namespace, adding a write path on top of this readback. + +## Examples + +- dsh-advisor iter-20260816-n8: `src/tui.ts` (structural TuiCommandTreeProvider for `/advisor` with zh/en descriptions + on|off|status|config completion), `src/commands.ts` + `src/index.ts` (`/advisor config` — `AdvisorComposedConfig` built from `safeResolved()`, session-less; `safeFallback` seeds scalars from the raw source; `input.hint` `'[on|off|status|config]'`), README dsh-tui profile section, `dsh --profile dsh-tui` live QA (dump-config + PTY boot + `/advisor status|config` rendering). diff --git a/.mstar/knowledge/developer-experience/dsh-tui-verification-and-host-boot-repair.md b/.mstar/knowledge/developer-experience/dsh-tui-verification-and-host-boot-repair.md new file mode 100644 index 0000000..2f950c7 --- /dev/null +++ b/.mstar/knowledge/developer-experience/dsh-tui-verification-and-host-boot-repair.md @@ -0,0 +1,69 @@ +--- +module: dsh host profile boot repair + interactive TUI verification +date: 2026-08-16 +problem_type: knowledge +category: developer-experience +severity: medium +title: Repairing a broken dsh profile boot (node-addon-require-builtin binding) and verifying interactive TUI surfaces reliably +description: Two verified lessons from dsh-tui QA. (1) Host repair: when ALL dsh profile boots fail with ERR_MODULE_NOT_FOUND for '@deepseek-ai/cordis-plugin-timer' imported from cordis-plugin-loader, the node-addon-require-builtin platform binding is missing/broken — `--dump-config` still works (composition-only) while the full plugin-tree boot does not; repair by reinstalling the global dsh CLI and relinking the platform binding into the loader's node_modules. (2) Verification: subagent-driven interactive TUI PTY automation is unreliable (Ink full-screen ANSI repaint parsing, keystroke-timing-sensitive completion overlays, async LLM turn + reviewer windows) — the dependable evidence path is dump-config + unit/integration pins + a direct PTY command probe (boot, send commands, capture rendered output). +last_updated: 2026-08-16 +tags: + - dsh + - dsh-tui + - qa + - troubleshooting +--- + +# Repairing dsh profile boot + verifying interactive TUI surfaces reliably + +## Context + +QA of the dsh-advisor dsh-tui client surface required a REAL `dsh --profile dsh-tui` session. Four subagent-driven QA attempts failed — two on a host environment issue, two on the flakiness of driving the interactive Ink TUI through a PTY. Both lessons are reusable. + +## Lesson 1 — Host boot repair: `node-addon-require-builtin` platform binding + +### Symptoms + +Every real dsh profile boot (web AND tui) exits 1 immediately: + +``` +Error: dsh: plugin tree failed to load: failed to apply loader entry include (cordis:include): loader entries failed to apply + [cause]: Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@deepseek-ai/cordis-plugin-timer' + imported from .../cordis-plugin-loader/lib/index.js +``` + +`dsh --profile

--dump-config` still exits 0 (composition-only — does not load the full plugin tree). This one symptom hides a HOST issue, not a profile/plugin issue. + +### Root cause + +`@deepseek-ai/cordis-plugin-loader/lib/index.js` resolves bare specifiers through `node-addon-require-builtin` (a native ESM/CJS loader hook, `requireBuiltin(id)`). The wrapper (`node-addon-require-builtin`) requires `node-addon-native-custom-loader`, which probes for the platform binding package (`node-addon-require-builtin-darwin-arm64`, prebuilt `prebuilt/darwin-arm64-napi-v9.node`) via optionalDependencies. When pnpm does not link the platform package into the loader's tree (pnpm 11 blocked builds / stale global lockfile), the probe fails with `No usable native binding found for node-addon-require-builtin-darwin-arm64 (auto)` and bare imports fall through to Node's default resolution from the loader package dir → package not found. + +### Repair (verified) + +1. `pnpm add -g @deepseek-ai/dsh@0.1.0-rc.6` — rebuild the global install (re-points the bin shim; may still reuse the broken store tree). +2. Relink the platform binding so `node-addon-native-custom-loader`'s `require('node-addon-require-builtin-darwin-arm64')` resolves — the binding package lives in the pnpm store (`.../store/v11/links/@/node-addon-require-builtin-darwin-arm64///node_modules/node-addon-require-builtin-darwin-arm64`); symlink it into the native loader's `node_modules/`. Verify with `node -e "require('/node_modules/node-addon-require-builtin')"` → `getBindingInfo()` shows `bindingSource: optional-package`, ABI `napi-v9`. +3. Re-test: `dsh --profile

` in a PTY must reach the app banner. For dsh-tui specifically, a non-TTY stdout is REFUSED by design (`dsh-tui requires an interactive terminal (stdout must be a TTY)`) — that error means the plugin tree loaded and the TUI guard is working. + +## Lesson 2 — Interactive TUI verification: what actually works + +### What is unreliable + +Subagent-driven PTY automation of an Ink TUI through a supervised-process channel: attempts hung (a 3600s `ln` command in one agent's shell), stalled ~60 min mid-session, and required repeated cancellation. Failure modes: full-screen ANSI repaint streams mixed with UI chrome (noisy to parse), completion-overlay/keystroke timing sensitivity, and async LLM turn + reviewer windows with no deterministic completion signal. + +### The dependable evidence path (ranked) + +1. **`--dump-config`** — deterministic; proves bundle composition + row presence (e.g. `# == dsh-advisor` / `- id: advisor`). +2. **Unit/integration pins** — command parse/render, completion children, session-less config readback, inject/steer delivery with the advisor source kind (vitest, fake LLM adapters). +3. **Direct PTY probe (PM/QA seat, not a subagent loop)** — boot via a supervised process with a readiness log pattern (the TUI banner), `send` the commands, capture the log, `stop`; then stop. This captured live `/advisor status` → 'Advisor: enabled' and `/advisor config` → 'Advisor config: enabled' rendering. +4. **Human spot-check** for the remaining interactive UX (menu overlay snapshot, Tab completion, a live turn→note injection) — document as an explicit residual gap with the exact commands, rather than grinding an unreliable automation path. + +## Why This Matters + +- The dump-config-works-but-boot-fails signal is easy to misread as a plugin defect; it is a host binding issue affecting every profile. +- Wasted effort is avoidable: ~4 subagent QA attempts + 3h before switching to the dependable evidence path. +- The repair steps are machine-portable (any developer with a broken pnpm-global dsh install). + +## When to Apply + +- Any dsh profile boot failure with `cordis-plugin-loader`/`cordis:include` ERR_MODULE_NOT_FOUND symptoms. +- Planning QA for any interactive terminal front end (dsh-tui or similar): budget for deterministic evidence + a direct probe + an explicit human-spot-check residual, and time-box subagent PTY attempts. diff --git a/CONCEPTS.md b/CONCEPTS.md index 67ec41a..31bba64 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -62,6 +62,10 @@ T4 提取与 T6 投递之间的守门员:normalize(小写 + NFKC + 非字母 `/advisor on | off | status | usage`(T7):session-scoped 临时 override(不写持久配置),runtime gate 读取;`status` 展示运行态(running / paused / quota_exhausted / halted / disabled)、门禁 disabled 原因、resolved provider/model、pending 数与最近一次接受 note 的活动时间。命令经条件 `ctx.inject(['commands'], ...)` 子 fiber 注册,宿主无 commands 服务时静默不注册。 *Avoid:* 把 `/advisor` 当持久配置写入入口(override 是临时的、会话级) +### dsh-tui client seam(TUI client 面) +dsh-TUI(终端前端,profile `dsh-tui`)的插件扩展面:DSH command registry 自动 merge 进 TUI `/` 菜单(dispatch 走 `commandService.execute`);`ctx.tuiCommandTrees` 是唯一插件 UI seam —— 注册 `TuiCommandTreeProvider { root, descriptions?, children }` 提供 root 行本地化描述 + 子命令补全(结构类型本地声明,不引 `@deepseek-harness-tui/dsh-tui` peer)。TUI **无设置页**(上游 issue ccch1mneyyy/dsh-TUI#165):插件 settings 面 = settings namespace + profile patch layer + 全局 `$DSH_HOME/settings.yaml`(跨 profile 共享,web 卡片写同一 user layer)+ 只读回读命令。**回读 parity 规则**:config 回读必须走与 web gateway 相同的组合配置解析(无 session),绝不读 per-session effective config(否则 `/advisor off` 会让回读误报持久配置)。 +*Avoid:* 用 per-session effective config 渲染配置回读;给 TUI 面引入宿主 peer 依赖;把 TUI 当有设置页的前端设计 + ## 已决歧义 - `nit` / `concern` / `blocker` 三档 severity 是**闭集**:缺失 / 非法值按 `nit`(最小侵入默认),不要在代码里新增第四档。 From a22ff94481fb2ee4db9f13f6eeebe9068f6a5688 Mon Sep 17 00:00:00 2001 From: Tang Bohao Date: Sun, 16 Aug 2026 12:34:12 +0800 Subject: [PATCH 6/7] docs(readme): restructure per dsh-llm-fallbacks pattern (en + zh) --- README.i18n.yaml | 4 +- README.md | 310 ++++++++++------------------------------------- README.zh.md | 179 ++++++++++----------------- 3 files changed, 130 insertions(+), 363 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 518f8a3..5e17318 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -3,5 +3,5 @@ # editing either side, bring the other along and re-record with: # git hash-object README.md # git hash-object README.zh.md -README.md: 26aa5e8b7ce48a9b61b90d0ad7ae3699ade6e103 -README.zh.md: a1a27cfefa7eda4333cbf456d6007e7910545cbd +README.md: 6e2326a2532924b56d1f86f4b2e849ca1a8b2878 +README.zh.md: b8f5c051a2e8d5aad32320302b86705663d60352 diff --git a/README.md b/README.md index 26aa5e8..6e2326a 100644 --- a/README.md +++ b/README.md @@ -1,163 +1,62 @@ # dsh-advisor -English | [中文](README.zh.md) +[English](README.md) | [中文](README.zh.md) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-339933.svg) ![dsh](https://img.shields.io/badge/dsh-DeepSeek%20Harness%20compatible-4B32C3.svg) [![dshfind](https://dshfind.com/api/badge/omdsh-dev/dsh-advisor)](https://dshfind.com/plugins/omdsh-dev/dsh-advisor?ref=badge) -A standalone dsh plugin bundle porting the omp "advisor" -subsystem: a per-session reviewer model that observes the primary transcript, -reviews each stepped turn with an explicitly configured model (provider + -model are required), and injects severity-ranked advice (nit / concern / -blocker) back into the session — without polluting or recursively reviewing -itself. +A standalone dsh (DeepSeek Harness) plugin bundle porting the omp "advisor" subsystem: a per-session independent reviewer model that observes the primary transcript, reviews each stepped turn with an explicitly configured model (provider + model are required), and injects severity-ranked advice (nit / concern / blocker) back into the session — without polluting or recursively reviewing itself. -Install with a single command: +**Advisory only.** The advisor never approves or rejects the primary agent's actions, and never issues commands as if it were the primary agent. Every delivered message is self-described advisory content, and a misbehaving reviewer is bounded end to end (emission guard, immuneTurns cooldown, failure policy) so it can never stall or pollute the primary loop. -```sh -dsh plugin --profile web add dsh-advisor # = your profile name -``` - -**Advisory only.** The advisor never approves or rejects the primary agent's -actions; it never issues commands as if it were the primary agent. Every -delivered message is self-described advisory content, and a misbehaving -reviewer is bounded end to end (emission guard, immuneTurns cooldown, failure -policy) so it can never stall or pollute the primary loop. - -## Install - -### One-line registry install - -```sh -dsh plugin --profile web add dsh-advisor # = your profile name -``` +Works in both dsh front ends: the **web** profile (Settings → 插件配置 → Advisor card) and the **dsh-tui** terminal profile (`/advisor` + `/advisor config`). -A registry install fetches the published tarball, which ships the built -artifacts (`lib/` + `cordis.patch.yml`), so no `prepare` build or build -permission is needed. Runtime dependencies (`@deepseek-ai/cordis`, `@deepseek-ai/schemastery`, -and the `@deepseek-ai/dsh-*` peers) are declared as peerDependencies and resolve -through the dsh installation's flat profile module fallback — no extra install -step. Pin an exact version (`dsh-advisor@0.1.0`) for reproducible installs. +## Quick start -### Local directory install (recommended for development / verification) +### Install ```sh -pnpm install # build the bundle (the prepare self-build) -dsh plugin --profile web add . # = your profile name -``` - -### Verify - -```sh -dsh --profile web --dump-config # shows a "# == dsh-advisor" layer with the advisor row -dsh --profile web +dsh plugin --profile web add dsh-advisor # web profile (Settings → Advisor card) +dsh plugin --profile dsh-tui add dsh-advisor # dsh-tui terminal profile ``` -Tarball install and uninstall are covered in [docs/install.md](docs/install.md). +Same plugin, either front end — the only difference is the `--profile` flag. Pin a version with `@` (e.g. `dsh-advisor@0.1.0`). A registry install fetches the published tarball, which ships the built artifacts (`lib/` + `cordis.patch.yml`) — nothing builds on the target machine, and runtime dependencies (`@deepseek-ai/cordis`, `@deepseek-ai/schemastery`, `@deepseek-ai/dsh-*` peers) resolve through the dsh installation's flat profile module fallback — no extra install step. Registry / git / tarball / local-directory variants (local-dir from a built checkout: `dsh plugin --profile web add .` or `dsh plugin --profile dsh-tui add .`), web Settings exposure, uninstall, and `--dump-config` verification → [docs/install.md](docs/install.md). -### dsh-tui profile +### Configuration -The advisor also runs in the terminal TUI client (`dsh --profile dsh-tui`), -installed the same way as the web profile: +Add an `advisor:` section to the global dsh settings document (default `$DSH_HOME/settings.yaml` — shared across profiles; the web Settings card writes to this same file): -```sh -dsh plugin --profile dsh-tui add dsh-advisor # = your profile name -# Local-dir variant (from a built checkout): -dsh plugin --profile dsh-tui add . +```yaml +advisor: + enabled: true # master switch (default false) — set explicitly to enable + provider: deepseek-official # REQUIRED when enabled + model: deepseek-v4-flash # REQUIRED when enabled + systemPrompt: "" # optional; "" = built-in reviewer prompt + immuneTurns: 3 # int ≥ 0, default 3 — cooldown after a delivered steer + maxDeltaMessages: 60 # int ≥ 0, default 60 — delta window; 0 = unbounded ``` -**Settings** — the TUI has no settings page: the advisor settings compose -from the same two persisted surfaces as the web profile — the dsh-tui profile -patch layer `~/.dsh/profiles/dsh-tui/cordis.patch.yml` (plugin row) and the -global `$DSH_HOME/settings.yaml` `advisor:` section, **shared across -profiles** (the web Settings card writes to this same file). `/advisor -config` prints the composed config as a read-only readback with edit hints. +The advisor is off by default. When enabled, `provider` and `model` are **mandatory**: `enabled: true` without both is a hard gate — the advisor never starts a model call and reports a disabled-with-reason status; unknown config keys are rejected. -**Commands** — open the TUI `/` menu: `/advisor` (bare toggle) and -`/advisor on|off|status|config` are listed with subcommand completion. -Discovery requires a profile with the `dsh-tui-command-trees` row — the -shipped dsh-tui bundle has it. +The same keys compose across **three surfaces** (later layers override earlier ones; every surface shares the same key set and the same hard gate, with the host-side gate as the final line of defense on every path): -**Limitation** — the web Settings card is web-only; there is no TUI settings -page and no write command. Edit settings through the profile patch or -`$DSH_HOME/settings.yaml`; `/advisor config` only reads them back. +1. **Plugin-row config** — the profile patch layer (`$DSH_HOME/profiles//cordis.patch.yml`). This is the composition base. +2. **dsh web Settings page — the "插件配置" (Plugin Configuration) page** — the Advisor **card** (id `advisor`) with the enabled toggle, provider / model selects restricted to system-configured providers and their models, and the optional fields. Saving writes into the `advisor` settings namespace and applies to new sessions immediately — no restart. The card requires a current dsh web build whose shell declares the `settings.plugin.item` card slot and loads packages that declare `dsh.client`; it reads and writes the namespace through the official `GatewayService` RPC channel (`/api/advisor/get` + `/api/advisor/set`), which is not gated by the settings exposure allowlist. It additionally blocks saving while enabled with a required field empty. +3. **`/advisor` command** — per-session and ephemeral: it flips a session override, never the persisted config (see [Verify](#verify)). -## Config +In a **dsh-tui** profile there is no settings page: the same two persisted surfaces (profile patch layer + global `$DSH_HOME/settings.yaml`) compose the config, and `/advisor config` prints the composed config as a read-only readback with edit hints. Full reference → [docs/configuration.md](docs/configuration.md). ![Advisor card on the dsh web Settings (插件配置) page](docs/screenshots/advisor-settings-card.webp) -The advisor is off by default. When enabled, `provider` and `model` are -**mandatory**: `enabled: true` without both is a hard gate — the advisor never -starts a model call and reports a disabled-with-reason status. Unknown config -keys are rejected. - -Configuration composes across **three surfaces** (later layers override earlier -ones; every surface uses the same key set): - -1. **Plugin-row config** — `$DSH_HOME/profiles/web/cordis.patch.yml` - (below). This is the composition base. -2. **dsh web Settings page — the "插件配置" (Plugin Configuration) page** — - the Advisor **card** (id `advisor`, rendered after the upstream bash / - agent-loop / web-search cards) with the enabled toggle, provider / model - selects restricted to system-configured providers and their models, and the - optional fields. Saving writes into the `advisor` settings namespace and - overrides the plugin-row config without editing it. Saving applies to new - sessions immediately — no restart (the runtime reads the composed value - live). Requires a current dsh web build whose shell declares the - `settings.plugin.item` card slot and loads packages that declare - `dsh.client`. The card reads and writes the namespace through the - **official `GatewayService` RPC channel** (`/api/advisor/get` + - `/api/advisor/set`, claimed by the host's typertGateway — the same - mechanism the dsh `goals` service uses), which is **not gated by the - settings exposure allowlist**: the in-process write - (`ctx.settings.update`) carries no exposed-namespace check. No host - patching is applied or required. -3. **`/advisor` command** — per-session and ephemeral: it flips a session - override, never the persisted config (see [Usage](#usage)). - -Both persisted surfaces share the same hard gate: `enabled: true` with empty -`provider`/`model` never starts a model call (disabled-with-reason). The -Settings page additionally blocks saving while enabled with a required field -empty; the host-side gate stays the final line of defense on every path. - -Plugin-row config: +### Verify -```yaml -# profiles/web/cordis.patch.yml — the profile's user patch layer -- id: advisor - config: - enabled: true # master switch (default false) - provider: deepseek-official # REQUIRED when enabled - model: deepseek-v4-flash # REQUIRED when enabled - systemPrompt: "" # optional; "" = built-in reviewer prompt - immuneTurns: 3 # int ≥ 0, default 3 — cooldown after a delivered interrupt - maxDeltaMessages: 60 # int ≥ 0, default 60 — delta window; 0 = unbounded +```sh +dsh --profile web --dump-config # shows a "# == dsh-advisor" layer with the advisor row ``` -| Key | Type / default | Meaning | -|---|---|---| -| `enabled` | bool, `false` | Master switch. | -| `provider` | string, optional | Provider route. Required (non-empty) when `enabled: true`. | -| `model` | string, optional | Model id. Required (non-empty) when `enabled: true`. | -| `systemPrompt` | string, `""` | Overrides the built-in reviewer prompt (severity definitions + JSON-frame output contract). | -| `immuneTurns` | int ≥ 0, `3` | After a concern/blocker is actually steered, the next N stepped primary turns must complete before another interrupting note may steer; notes inside the window downgrade to inject. | -| `maxDeltaMessages` | int ≥ 0, `60` | Bounded advisor input window. Deltas beyond N are truncated with a `… ` marker; `0` = unbounded. | - -**Model capability & budget**: the advisor call runs with `reasoningEffort: -'off'` — sent only when the configured model's adapter declares that effort -(deepseek models do; any other model gets the option omitted automatically, so -non-reasoning providers keep working) — and a **5120-token** output cap (a -user-directed 20× supersession of the original 256). Extracted notes are -bounded (1000 chars) and the notice summary to 120 chars, so the raised budget -cannot translate into an unbounded injection into the primary session. - -## Usage - -Once installed and enabled, the advisor observes every session. Control it per -session with the `/advisor` command (available when a command registry is -composed): +With the advisor installed and enabled, control it in-session with the `/advisor` command (available when a command registry is composed): ``` /advisor toggle the advisor for this session @@ -166,146 +65,65 @@ composed): /advisor status show state, model, runtime status, pending count, last activity ``` -`/advisor on|off|toggle` are session-scoped and ephemeral: they flip a -per-session override, never the persisted config. Enabling a session whose -config lacks `provider`/`model` starts no model call — `/advisor status` (and -the `/advisor on` reply) shows the gate reason. +`/advisor on|off|toggle` are session-scoped and ephemeral: they flip a per-session override, never the persisted config. Enabling a session whose config lacks `provider`/`model` starts no model call — `/advisor status` (and the `/advisor on` reply) shows the gate reason: the advisor runs only when enabled **with** both configured. `/advisor on` is also the manual recovery path: a session advisor paused by a quota/rate-limit (`quota_exhausted` — no auto-resume timer) resumes in place, and a halted advisor (permanent model error, e.g. invalid credentials) is rebuilt fresh for the session. + +In a **dsh-tui** profile, `/advisor config` additionally reads back the composed configuration — read-only, with edit hints: the web Settings card is web-only, and the TUI has no settings page and no write command, so edit through the profile patch layer or `$DSH_HOME/settings.yaml`. The `/advisor` / `on|off|status|config` commands are listed in the TUI `/` menu with subcommand completion (command discovery requires the `dsh-tui-command-trees` row — the shipped dsh-tui bundle has it). -`/advisor on` is also the manual recovery path: a session advisor paused by a -quota/rate-limit (`quota_exhausted` — KD-5 has no auto-resume timer) resumes in -place, and a halted advisor (permanent model error, e.g. invalid credentials) -is rebuilt fresh for the session. +## Features -The advisor reviews on a dual-mode trigger, depending on the session shape: +- **Independent reviewer per session**: a separate model call observes the primary transcript and reviews each stepped primary turn; advisor messages are excluded from later deltas, so the advisor never reads its own advice back. +- **Severity-ranked advice with inject/steer semantics**: at most one note per review — **nit** (a minor style, clarity, or quality suggestion; delivered via non-waking `agent.inject`, consumed at the next pre-step boundary), **concern** (a material risk or clearly better direction to weigh before continuing; delivered via waking `agent.steer`, subject to the `immuneTurns` cooldown), **blocker** (continuing clearly wastes work — contradicts an explicit user instruction, going in circles, fundamentally unsound; delivered via `agent.steer`). Delivered messages carry the `[advisor:{severity}]` prefix and are self-described advisory content: -- **Standard stepped sessions** — after each stepped primary turn that ends - normally (`completed`, `max-tokens`, or `error`), the advisor reviews the - incremental transcript delta. -- **Agentic / harness sessions** (never emit `turn/end`) — after each completed - agent reply round: when a new human input arrives (inbox-spliced input - included) after an unreviewed assistant increment, the advisor reviews that - increment. + ``` + [advisor:concern] extract the helper into a module and unit-test it + ``` -Either way the advisor emits at most one note per review, ranked by severity: +- **Explicit model gate**: `enabled` defaults to off; `enabled: true` without `provider` + `model` never starts a model call — status reports disabled-with-reason. Unknown config keys are rejected. +- **Zero-tool minimal start**: the reviewer is an independent model call only — no advisor tools, nothing it can do to the session besides advisory messages. +- **No-stall failure policy**: a failing or quota-limited advisor only drops its own bounded backlog — it can never park or pollute the primary loop. +- **Session-scoped controls**: `/advisor on|off|status|config` work per session; the toggles are ephemeral overrides, never persisted config. -- **nit** — a minor style, clarity, or quality suggestion; delivered via - `agent.inject` (non-waking, consumed at the next pre-step boundary). -- **concern** — a material risk or clearly better direction to weigh before - continuing; delivered via `agent.steer` (waking), subject to the - `immuneTurns` cooldown. -- **blocker** — continuing clearly wastes work (contradicts an explicit user - instruction, going in circles, fundamentally unsound); delivered via - `agent.steer`. +![Advisor note injected into the session stream](docs/screenshots/advisor-injected-note.webp) -Injected advice appears in the session stream as a user-role message carrying -the advisor source kind and self-describing content, e.g.: +## Mount-only (no dsh modification) -``` -[advisor:concern] extract the helper into a module and unit-test it -``` +The plugin installs as a **pure mount**: bundle insert + client card (web Settings 插件配置) + its own gateway channel (`/api/advisor/get|set`, claimed by the host's typertGateway — the same mechanism the dsh `goals` service uses, not gated by the settings exposure allowlist) + the `/advisor` commands — no dsh patches, no postinstall step, and dsh upgrades never require re-patching. -The `[advisor:{severity}]` prefix is the only cue the primary model gets about -how to treat it — the primary system prompt never mentions advisories. Advisor -messages are excluded from later advisor deltas, so the advisor never reads -its own advice back. +## Development -![Advisor note injected into the session stream](docs/screenshots/advisor-injected-note.webp) +**How it works** — the plugin subscribes to `session/event` and renders an incremental markdown delta of the primary transcript (own advisor messages excluded), queued on a per-session runtime: after each stepped `turn/end` in standard stepped sessions, and — in agentic/harness sessions that never emit `turn/end` — at each completed agent reply round (when a new human input arrives after an unreviewed assistant increment, inbox-spliced input included). The runtime calls the separately configured model via `ctx.llm.stream` — with reasoning off (`reasoningEffort: 'off'`, sent only when the configured model's adapter declares that effort; deepseek models do, other models get the option omitted automatically) and a **5120-token** output cap (a user-directed 20× supersession of the original 256); extracted notes are bounded (1000 chars) and the notice summary to 120 chars, so the raised budget cannot translate into an unbounded injection into the primary session. It extracts one `{note, severity}` from the JSON-framed reply, gates it through an emission guard (normalize / dedupe / content-free suppression / one-note-per-update), and routes it: nit → inject, concern/blocker → steer. The `[advisor:{severity}]` prefix is the only cue the primary model gets about how to treat it — the primary system prompt never mentions advisories. Compaction and surface rewrites reset the observer, the emission guard, and the immuneTurns latch; the drain is fully async with a bounded backlog, so a failing or quota'd advisor can only drop its own backlog — never park the primary loop. -## How it works - -The plugin subscribes to `session/event`. Two triggers render an incremental -markdown delta of the primary transcript (own advisor messages excluded) and -queue it on a per-session runtime: after each stepped `turn/end` in standard -stepped sessions, and — in agentic/harness sessions that never emit `turn/end` -— when a new human input arrives (inbox-spliced input included) after an -unreviewed assistant increment, i.e. at each completed agent reply round. The -runtime calls a separately configured model via `ctx.llm.stream`, extracts one -`{note, severity}` from the JSON-framed reply, gates it through an emission -guard (normalize / dedupe / content-free suppression / one-note-per-update), -and routes it: nit → inject, concern/blocker → steer. The advisor call runs -with reasoning off and a 20x token budget so the JSON note is never starved by -reasoning output. Compaction and surface rewrites reset the observer, the -emission guard, and the immuneTurns latch -(KD-5); the drain is fully async with a bounded backlog, so a failing or -quota'd advisor can only drop its own backlog — never park the primary loop. - -## Limitations & roadmap - -The MVP deliberately drops full omp parity. Accepted gaps (tracked in the -harness iteration roadmap): - -- **Single advisor per session** — no parallel advisor roster or WATCHDOG-style - file discovery (next iteration). -- **No advisor tools** — the reviewer is an independent model call only; it - cannot verify claims itself (next-next iteration). -- **No in-session advisor panel** — advice surfaces only as tagged injected - messages (the Advisor card on the "插件配置" settings page is a config - surface, not a session view; an in-session card is next-next iteration). -- **No transcript persistence or cost stats** — no resumable advisor history or - cost observability (next-next iteration). -- **No secret obfuscation of delta content** — secrets present in the transcript - can reach the advisor model; mitigate by configuring a trusted reviewer model. -- **No quarantine of unsafe advisor output** — a misbehaving note can carry - directive text; the JSON frame + validation + advisory-only framing - (`[advisor:…]`, "weigh, don't blindly obey") are the only mitigation, and the - note is delivered as-is into the primary transcript (roadmap). -- **No `syncBacklog` catch-up wait** — a far-behind advisor does not wait for - the primary loop; its backlog is bounded and dropped (never parks the - primary), so advisor notes may arrive after the next primary turn started - (roadmap: context-maintenance batch). -- **Bounded advisor context** — long-session full replays are truncated - (`maxDeltaMessages`), so the advisor may lose early context after compaction; - advisor context maintenance is roadmap (next-next iteration). +**Limitations & roadmap** — the MVP deliberately drops full omp parity. Accepted gaps (tracked in the harness iteration roadmap): -## Development +- **Single advisor per session** — no parallel advisor roster or WATCHDOG-style file discovery (next iteration). +- **No advisor tools** — the reviewer is an independent model call only; it cannot verify claims itself (next-next iteration). +- **No in-session advisor panel** — advice surfaces only as tagged injected messages; the web Advisor card is a config surface, not a session view (next-next iteration). +- **No transcript persistence or cost stats** — no resumable advisor history or cost observability (next-next iteration). +- **No secret obfuscation of delta content** — secrets present in the transcript can reach the advisor model; mitigate by configuring a trusted reviewer model. +- **No quarantine of unsafe advisor output** — a misbehaving note can carry directive text; the JSON frame + validation + advisory-only framing are the only mitigation, and the note is delivered as-is (roadmap). +- **No `syncBacklog` catch-up wait** — a far-behind advisor does not wait for the primary loop; its backlog is bounded and dropped, so notes may arrive after the next primary turn started (roadmap: context-maintenance batch). +- **Bounded advisor context** — long-session full replays are truncated (`maxDeltaMessages`), so the advisor may lose early context after compaction (roadmap: next-next iteration). -The bundle builds itself on install: `package.json` declares `"prepare": -"pnpm build"` (the same build `prepack` runs), so any clone is immediately -buildable. The private `@deepseek-ai/dsh-*` runtime dependencies are -**peerDependencies only** (never `dependencies` / `devDependencies`); -`pnpm-workspace.yaml` sets `autoInstallPeers: true` + `nodeLinker: hoisted` -(pnpm 11+ ignores non-auth settings in `.npmrc`), so at dev time pnpm -resolves the real `@deepseek-ai/*` packages from the npm registry using the -auth token in your user-level `~/.npmrc`. There is no local link-farm and no -`DSH_HOME` / `DSH_SOURCE_DIR` prerequisite for dependency resolution. +**Build** — the bundle builds itself on install: `package.json` declares `"prepare": "pnpm build"` (the same build `prepack` runs), so any clone is immediately buildable. The private `@deepseek-ai/dsh-*` runtime dependencies are **peerDependencies only** (never `dependencies` / `devDependencies`); at dev time `pnpm-workspace.yaml` sets `autoInstallPeers: true` + `nodeLinker: hoisted` (pnpm 11+ ignores non-auth settings in `.npmrc`), so pnpm resolves the real `@deepseek-ai/*` packages from the npm registry using the auth token in your user-level `~/.npmrc` — there is no local link-farm and no `DSH_HOME` / `DSH_SOURCE_DIR` prerequisite for dependency resolution. The in-box `cordis` framework is declared as the scoped peer `@deepseek-ai/cordis` (never bare `cordis`), and prerelease peer ranges must carry the exact publish tag — the `@deepseek-ai/dsh-*` peers are pinned `^0.1.0-rc.6`, since per the node-semver prerelease-tuple rule a range like `^4.0.0-rc.7` never matches a `4.0.1-rc.1` publish. There is no `postinstall` step: already-built tarball installs skip the build entirely. `prepack` and `prepare` both run `pnpm build`, so `pnpm pack` builds twice — the documented tradeoff that keeps git-install builds working. ```sh -pnpm install # registry deps incl. the @deepseek-ai/* peers (via autoInstallPeers + ~/.npmrc auth) +pnpm install # registry deps incl. the @deepseek-ai/* peers (autoInstallPeers + ~/.npmrc auth) pnpm test # vitest (unit + the composed integration loop) pnpm typecheck # tsc --noEmit (node) + tsc -p tsconfig.client.json --noEmit + tsc -p tsconfig.spec.json --noEmit pnpm build # tsc -p tsconfig.build.json emit to lib/ + node scripts/build-client.mjs (client bundle) pnpm pack # build + produce dsh-advisor-0.0.1.tgz ``` -The in-box `cordis` framework is declared as the scoped peer -`@deepseek-ai/cordis` (never bare `cordis`) — the declared pin is -`"@deepseek-ai/cordis": "^4.0.1"` (`package.json` peerDependencies). Peer -ranges against prerelease publishes must carry the exact publish tag — e.g. -the `@deepseek-ai/dsh-*` peers are pinned `^0.1.0-rc.6`; per the node-semver -prerelease-tuple rule a comparator with a prerelease only matches the same -`[major, minor, patch]` tuple, so a range like `^4.0.0-rc.7` never matches a -`4.0.1-rc.1` publish. -The scoped peer resolves from the npm registry like the other -`@deepseek-ai/*` peers, so dev-time `import '@deepseek-ai/cordis'` and the -host see the same package identity. - -`prepack` runs `pnpm build`; `prepare` runs `pnpm build`, so `pnpm pack` runs -the build twice (once per lifecycle) — the documented tradeoff that keeps -git-install builds working. There is no `postinstall` step: already-built -tarball installs skip the build entirely. A local `dsh plugin add .` mounts -the bundle from the working tree, so run `pnpm build` (or `pnpm install`) -first — pnpm does not run `prepare` for `link:` dependencies. - -The integration test (`tests/integration.test.ts`) composes the plugin into a -real cordis context with a stub LLM adapter and drives the full -turn → delta → advisor call → inject/steer cycle. - ## Documentation | Doc | Content | |---|---| -| [docs/install.md](docs/install.md) | full install guide: git / tarball / local-directory install, web Settings exposure, uninstall, `--dump-config` verification | +| [docs/install.md](docs/install.md) | profile install (web + dsh-tui) / registry / git / tarball / local-directory variants / web Settings exposure / uninstall / `--dump-config` verification | +| [docs/configuration.md](docs/configuration.md) | full `advisor` namespace reference: keys & defaults, explicit model gate (S4), settings surfaces (web card / patch layer / global settings.yaml), example YAML, live re-apply behavior | +| [docs/consumer-api.md](docs/consumer-api.md) | developer consumption contract: package-root library API, `dsh-advisor/client` entry, `/advisor` command surface, export inventory, lifecycle | +| [docs/verification.md](docs/verification.md) | verification records: test matrix (16 files / 319 cases), typecheck/build, CI contract, real-environment steps | +| [docs/release.md](docs/release.md) | release process: PR-driven Release prep + Release workflows, OIDC trusted publishing, version strategy, rollback | ## License -MIT +Released under the **MIT** License — see [LICENSE](LICENSE). The LICENSE file is authoritative for copyright and license terms. diff --git a/README.zh.md b/README.zh.md index a1a27cf..b8f5c05 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,106 +1,62 @@ # dsh-advisor -[English](README.md) | 中文 +[English](README.md) | [中文](README.zh.md) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ![node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-339933.svg) ![dsh](https://img.shields.io/badge/dsh-DeepSeek%20Harness%20compatible-4B32C3.svg) [![dshfind](https://dshfind.com/api/badge/omdsh-dev/dsh-advisor?lang=zh)](https://dshfind.com/zh/plugins/omdsh-dev/dsh-advisor?ref=badge) -一个移植 omp「advisor」子系统的独立 dsh 插件组合包:一个按会话运行的评审模型,观察主会话 transcript,用显式配置的模型(provider 与 model 均为必填)评审每个已完成的 stepped turn,并把按严重度排序的建议(nit / concern / blocker)注入回会话 —— 不污染主循环,也不递归地评审自己。 - -一条命令即可安装: - -```sh -dsh plugin --profile web add dsh-advisor # = 你的 profile 名 -``` +一个移植 omp「advisor」子系统的独立 dsh(DeepSeek Harness)插件组合包:一个按会话运行的独立评审模型,观察主会话 transcript,用显式配置的模型(provider 与 model 均为必填)评审每个已完成的 stepped turn,并把按严重度排序的建议(nit / concern / blocker)注入回会话——不污染主循环,也不递归地评审自己。 **仅作建议。** advisor 从不批准或否决主 agent 的动作,也绝不会像主 agent 那样发出命令。每条送达的消息都是自我描述的 advisory 内容;一个行为异常的评审者会被端到端约束(emission guard、immuneTurns 冷却、failure policy),因此它永远不会卡住或污染主循环。 -## 安装 - -### 一条命令的 registry 安装 +两个 dsh 前端均可用:**web** profile(设置 → 插件配置 → Advisor 卡片)与 **dsh-tui** 终端 profile(`/advisor` + `/advisor config`)。 -```sh -dsh plugin --profile web add dsh-advisor # = 你的 profile 名 -``` - -registry 安装拉取的是已发布的 tarball,其中自带构建产物(`lib/` + `cordis.patch.yml`),因此不会运行 `prepare` 构建,也无需构建放行。运行时依赖(`@deepseek-ai/cordis`、`@deepseek-ai/schemastery` 与 `@deepseek-ai/dsh-*` peers)声明为 peerDependencies,由 dsh 安装的扁平 profile module fallback 解析——无需额外安装步骤。需要可复现安装时用 `dsh-advisor@0.1.0` 钉住精确版本。 +## 快速开始 -### 本地目录安装(推荐用于开发 / 验证) +### 安装 ```sh -pnpm install # 构建组合包(prepare 自建) -dsh plugin --profile web add . # = 你的 profile 名 +dsh plugin --profile web add dsh-advisor # web profile(设置 → Advisor 卡片) +dsh plugin --profile dsh-tui add dsh-advisor # dsh-tui 终端 profile ``` -### 验证 +同一个插件、两个前端——区别只在 `--profile` 参数。钉版本:加 `@`(如 `dsh-advisor@0.1.0`)。registry 安装拉取的是已发布的 tarball,自带构建产物(`lib/` + `cordis.patch.yml`)——目标机无需构建;运行时依赖(`@deepseek-ai/cordis`、`@deepseek-ai/schemastery` 与 `@deepseek-ai/dsh-*` peers)经 dsh 安装的扁平 profile module fallback 解析——无需额外安装步骤。registry / git / tarball / 本地目录变体(本地目录从已构建 checkout 安装:`dsh plugin --profile web add .` 或 `dsh plugin --profile dsh-tui add .`)、web Settings 暴露、卸载与 `--dump-config` 验证 → [docs/install.zh.md](docs/install.zh.md)。 -```sh -dsh --profile web --dump-config # 显示带 advisor 配置行的 "# == dsh-advisor" 层 -dsh --profile web -``` - -tarball 安装与卸载见 [docs/install.zh.md](docs/install.zh.md)。 +### 配置 -### dsh-tui profile +在全局 dsh 设置文档(默认 `$DSH_HOME/settings.yaml`——跨 profile 共享;web Settings 卡片也写入这个文件)中添加 `advisor:` 分节: -advisor 也可以运行在终端 TUI 客户端(`dsh --profile dsh-tui`)中,安装方式与 web profile 相同: - -```sh -dsh plugin --profile dsh-tui add dsh-advisor # = 你的 profile 名 -# 本地目录变体(在已构建的 checkout 中): -dsh plugin --profile dsh-tui add . +```yaml +advisor: + enabled: true # 总开关(默认 false)——需显式打开后生效 + provider: deepseek-official # enabled: true 时必填(非空) + model: deepseek-v4-flash # enabled: true 时必填(非空) + systemPrompt: "" # 可选;"" = 内置评审 prompt + immuneTurns: 3 # 整数 ≥ 0,默认 3 —— 打断性送达后的冷却步数 + maxDeltaMessages: 60 # 整数 ≥ 0,默认 60 —— delta 窗口;0 = 无上限 ``` -**设置** —— TUI 没有设置页:advisor 配置由与 web profile 相同的两个持久化配置面合成——dsh-tui profile 补丁层 `~/.dsh/profiles/dsh-tui/cordis.patch.yml`(插件行)与全局 `$DSH_HOME/settings.yaml` 的 `advisor:` 段,**跨 profile 共享**(web Settings 卡片也写入这个文件)。`/advisor config` 以只读回读方式打印合成后的配置,并附编辑提示。 +advisor 默认关闭。启用后,`provider` 与 `model` 为**必填**:`enabled: true` 而缺少两者之一是一个硬门禁——advisor 不会发起任何模型调用,并报告带原因的禁用状态(disabled-with-reason);未知配置键会被拒绝。 -**指令** —— 打开 TUI 的 `/` 菜单:`/advisor`(裸 toggle)与 `/advisor on|off|status|config` 会列出并带子命令补全。指令发现要求 profile 带有 `dsh-tui-command-trees` 行——随附的 dsh-tui 组合包自带。 +同一组键在**三个配置面**之间合成(后一层覆盖前一层;各处使用同一组键与同一个硬门禁,宿主侧门禁始终是所有路径上的最后防线): -**限制** —— web Settings 卡片仅限 web;TUI 没有设置页,也没有写指令。设置请通过 profile 补丁或 `$DSH_HOME/settings.yaml` 修改;`/advisor config` 只读回读。 +1. **插件行 config** —— profile 补丁层(`$DSH_HOME/profiles//cordis.patch.yml`)。这是合成 base。 +2. **dsh web Settings 页 —— "插件配置"页** —— Advisor **卡片**(id `advisor`),含 enabled 开关、只列出系统内已配置 provider 及其模型的 provider/model 选择框与可选字段。保存写入 `advisor` settings namespace,新会话立即生效,无需重启。卡片要求当前版本的 dsh web 构建(其 web shell 声明了 `settings.plugin.item` 卡片 slot 并能加载 `dsh.client` 声明包);它通过官方 `GatewayService` RPC 通道读写该命名空间(`/api/advisor/get` + `/api/advisor/set`),不受 settings 暴露白名单门控。卡片还会在 enabled 且必填字段为空时阻止保存。 +3. **`/advisor` 指令** —— 按会话且临时:翻转的是会话级 override,从不修改持久化配置(见[验证](#验证))。 -## 配置 +**dsh-tui** profile 没有设置页:同样由两个持久化配置面(profile 补丁层 + 全局 `$DSH_HOME/settings.yaml`)合成配置,`/advisor config` 以只读回读方式打印合成后的配置并附编辑提示。完整参考 → [docs/configuration.md](docs/configuration.md)。 ![dsh web Settings("插件配置")页上的 Advisor 卡片](docs/screenshots/advisor-settings-card.webp) -advisor 默认关闭。启用后,`provider` 与 `model` 为**必填**:`enabled: true` 而缺少两者之一是一个硬门禁 —— advisor 不会发起任何模型调用,并报告带原因的禁用状态(disabled-with-reason)。未知配置键会被拒绝。 - -配置在**三个配置面**之间合成(后一层覆盖前一层;各处使用同一组键): - -1. **插件行 config** —— `$DSH_HOME/profiles/web/cordis.patch.yml`(见下)。这是合成 base。 -2. **dsh web Settings 页 —— "插件配置"页** —— Advisor **卡片**(id `advisor`,渲染在三张上游卡片 bash / agent-loop / web-search 之后),含 enabled 开关、只列出系统内已配置 provider 及其模型的 provider/model 选择框与可选字段。保存写入 `advisor` settings namespace,覆盖插件行 config 而无需改动它。保存后新会话立即生效,无需重启(运行时 live 读取合成值)。需要当前版本的 dsh web 构建(其 web shell 声明了 `settings.plugin.item` 卡片 slot 并能加载 `dsh.client` 声明包)。卡片通过**官方 `GatewayService` RPC 通道**读写该命名空间(`/api/advisor/get` + `/api/advisor/set`,由宿主的 typertGateway claims——与 dsh 内建 `goals` 服务同一机制),该通道**不受 settings 暴露白名单门控**:进程内写入(`ctx.settings.update`)没有 exposed-namespace 检查。无需也不施加任何宿主补丁。 -3. **`/advisor` 指令** —— 按会话且临时:翻转的是会话级 override,从不修改持久化配置(见[用法](#用法))。 - -两个持久化配置面共享同一个硬门禁:`enabled: true` 而 `provider`/`model` 为空时绝不发起模型调用(disabled-with-reason)。Settings 页还会在 enabled 且必填字段为空时阻止保存;宿主侧硬门禁始终是所有路径上的最后防线。 - -插件行配置: +### 验证 -```yaml -# profiles/web/cordis.patch.yml — the profile's user patch layer -- id: advisor - config: - enabled: true # master switch (default false) - provider: deepseek-official # REQUIRED when enabled - model: deepseek-v4-flash # REQUIRED when enabled - systemPrompt: "" # optional; "" = built-in reviewer prompt - immuneTurns: 3 # int ≥ 0, default 3 — cooldown after a delivered interrupt - maxDeltaMessages: 60 # int ≥ 0, default 60 — delta window; 0 = unbounded +```sh +dsh --profile web --dump-config # 显示带 advisor 配置行的 "# == dsh-advisor" 层 ``` -| 键 | 类型 / 默认值 | 含义 | -|---|---|---| -| `enabled` | bool, `false` | 总开关。 | -| `provider` | string, optional | 供应商路由。`enabled: true` 时必须(非空)。 | -| `model` | string, optional | 模型 id。`enabled: true` 时必须(非空)。 | -| `systemPrompt` | string, `""` | 覆盖内置评审 prompt(严重度定义 + JSON-frame 输出契约)。 | -| `immuneTurns` | int ≥ 0, `3` | 实际 steer 过一次 concern/blocker 后,接下来 N 个完成的 stepped 主 turn 必须走完,另一条打断性 note 才可再次 steer;窗口内的 note 降级为 inject。 | -| `maxDeltaMessages` | int ≥ 0, `60` | 有界的 advisor 输入窗口。超过 N 的 delta 以 `… ` 标记截断;`0` = 无上限。 | - -**模型能力与预算**:advisor 调用以 `reasoningEffort: 'off'` 运行 —— 仅当所配置模型的 adapter 声明该档位时才发送(deepseek 模型声明;其他模型会自动省略该选项,因此非推理供应商照常工作)—— 并以 **5120 tokens** 作为输出上限(用户指示的 256 → 5120 的 20 倍超驰)。抽取出的 note 有界(1000 字符),notice summary 有界(120 字符),因此提高的预算不会变成注入主会话的无界内容。 - -## 用法 - -安装并启用后,advisor 观察每个会话。用 `/advisor` 指令按会话控制它(组合了 command registry 时可用): +安装并启用后,在会话内用 `/advisor` 指令控制它(组合了 command registry 时可用): ``` /advisor toggle the advisor for this session @@ -109,72 +65,65 @@ advisor 默认关闭。启用后,`provider` 与 `model` 为**必填**:`enabl /advisor status show state, model, runtime status, pending count, last activity ``` -`/advisor on|off|toggle` 是会话级且临时的:它们翻转的是按会话的 override,从不修改持久化配置。启用一个 config 缺少 `provider`/`model` 的会话不会发起模型调用 —— `/advisor status`(以及 `/advisor on` 的回复)会显示门禁原因。 - -`/advisor on` 也是手动恢复路径:被 quota/rate-limit 暂停的会话 advisor(`quota_exhausted` —— KD-5 没有自动恢复定时器)会在原地恢复;被终止的 advisor(永久性模型错误,如凭据无效)会为该会话全新重建。 +`/advisor on|off|toggle` 是会话级且临时的:它们翻转的是按会话的 override,从不修改持久化配置。启用一个 config 缺少 `provider`/`model` 的会话不会发起模型调用——`/advisor status`(以及 `/advisor on` 的回复)会显示门禁原因:advisor 只有在启用**且**两者均已配置时才运行。`/advisor on` 也是手动恢复路径:被 quota/rate-limit 暂停的会话 advisor(`quota_exhausted`——无自动恢复定时器)会在原地恢复;被终止的 advisor(永久性模型错误,如凭据无效)会为该会话全新重建。 -advisor 采用双模式触发,取决于会话形态: +在 **dsh-tui** profile 中,`/advisor config` 额外回读组合配置——只读,附编辑提示:web Settings 卡片仅限 web,TUI 没有设置页、也没有写指令,请通过 profile 补丁层或 `$DSH_HOME/settings.yaml` 修改。`/advisor` / `on|off|status|config` 指令出现在 TUI 的 `/` 菜单中并带子命令补全(指令发现要求 `dsh-tui-command-trees` 行——随附的 dsh-tui 组合包自带)。 -- **标准 stepped 会话** —— 在每个正常结束(`completed`、`max-tokens` 或 `error`)的 stepped 主 turn 之后,评审增量 transcript delta。 -- **agentic / harness 会话**(从不发出 `turn/end`)—— 在每个完成的 agent 回复轮次之后:当新的用户输入(含 inbox 拼接输入)在未评审的 assistant 增量之后到达时,评审该增量。 +## 能力一览 -无论哪种模式,每次评审至多发出一条 note,按严重度排序: +- **每个会话一个独立评审者**:独立的模型调用观察主 transcript 并评审每个 stepped 主 turn;advisor 消息被排除在此后的 delta 之外,因此 advisor 永远不会读回自己的建议。 +- **按严重度排序的建议 + inject/steer 语义**:每次评审至多发出一条 note——**nit**(轻微的样式、清晰度或质量建议;经非唤醒的 `agent.inject` 送达,在下一个 pre-step 边界消费)、**concern**(继续之前值得权衡的重大风险或明显更优的方向;经唤醒的 `agent.steer` 送达,受 `immuneTurns` 冷却约束)、**blocker**(继续下去明显是在浪费工作——与显式用户指令矛盾、原地打转、根本性不可行;经 `agent.steer` 送达)。送达的消息携带 `[advisor:{severity}]` 前缀且为自我描述的 advisory 内容: -- **nit** —— 轻微的样式、清晰度或质量建议;通过 `agent.inject` 送达(非唤醒,在下一个 pre-step 边界消费)。 -- **concern** —— 在继续之前值得权衡的重大风险或明显更优的方向;通过 `agent.steer` 送达(唤醒),受 `immuneTurns` 冷却约束。 -- **blocker** —— 继续下去明显是在浪费工作(与显式用户指令矛盾、原地打转、根本性不可行);通过 `agent.steer` 送达。 + ``` + [advisor:concern] extract the helper into a module and unit-test it + ``` -注入的建议以 user-role 消息出现在会话流中,携带 advisor source kind 与自我描述的内容,例如: - -``` -[advisor:concern] extract the helper into a module and unit-test it -``` - -`[advisor:{severity}]` 前缀是主模型获得的关于如何对待它的唯一线索 —— 主 system prompt 从不提及 advisory。advisor 消息会被排除在此后的 advisor delta 之外,因此 advisor 永远不会读回自己的建议。 +- **显式模型门禁**:`enabled` 默认关闭;`enabled: true` 而缺少 `provider` + `model` 时绝不发起模型调用——状态报告 disabled-with-reason。未知配置键会被拒绝。 +- **零工具的最小启动**:评审者只是一个独立的模型调用——无 advisor tools,除了 advisory 消息之外它无法对会话做任何事。 +- **不卡主循环的失败策略**:失败或 quota 耗尽的 advisor 只会丢弃自己有界的 backlog——永远不会卡住或污染主循环。 +- **会话级控制**:`/advisor on|off|status|config` 按会话工作;开关是临时的 override,从不修改持久化配置。 ![注入到会话流中的 advisor 建议](docs/screenshots/advisor-injected-note.webp) -## 工作原理 +## 纯挂载(零 dsh 修改) -插件订阅 `session/event`。两种触发方式会把主 transcript 的增量 markdown delta(排除 advisor 自己的消息)渲染出来并放入按会话的 runtime 队列:标准 stepped 会话在每个 stepped `turn/end` 之后;agentic/harness 会话(从不发出 `turn/end`)则在新的用户输入(含 inbox 拼接输入)于未评审的 assistant 增量之后到达时 —— 即每个完成的 agent 回复轮次。runtime 通过 `ctx.llm.stream` 调用一个单独配置的模型,从 JSON-framed 回复中提取一条 `{note, severity}`,经过 emission guard 门禁(normalize / dedupe / content-free 抑制 / 每次更新至多一条 note),然后路由:nit → inject,concern/blocker → steer。advisor 调用以关闭推理(reasoning off)和 20 倍 token 预算运行,因此 JSON note 绝不会被推理输出挤占。compaction 与 surface 重写会重置 observer、emission guard 与 immuneTurns latch(KD-5);drain 完全异步且 backlog 有界,因此失败或 quota 耗尽的 advisor 只能丢弃自己的 backlog —— 永远不会卡住主循环。 +插件以**纯挂载**方式安装:bundle 插入 + 客户端卡片(web Settings "插件配置")+ 自有 gateway 通道(`/api/advisor/get|set`,由宿主 typertGateway 认领——与 dsh 内建 `goals` 服务同一机制,不受 settings 暴露白名单门控)+ `/advisor` 指令——无 dsh 补丁、无 postinstall 步骤,dsh 升级永不需重打。 -## 限制与路线图 +## 开发 -MVP 有意放弃与 omp 的完整对等。已接受的差距(在 harness 迭代路线图中跟踪): +**工作原理**——插件订阅 `session/event`,把主 transcript 的增量 markdown delta(排除 advisor 自己的消息)渲染出来并放入按会话的 runtime 队列:标准 stepped 会话在每个 stepped `turn/end` 之后;agentic/harness 会话(从不发出 `turn/end`)则在每个完成的 agent 回复轮次(未评审的 assistant 增量之后有新的人类输入到达,含 inbox 拼接输入)。runtime 通过 `ctx.llm.stream` 调用单独配置的模型——关闭推理(`reasoningEffort: 'off'`,仅当所配置模型的 adapter 声明该档位时才发送;deepseek 模型声明,其他模型会自动省略该选项)并以 **5120 tokens** 作为输出上限(用户指示的 256 → 5120 的 20 倍超驰);抽取出的 note 有界(1000 字符),notice summary 有界(120 字符),因此提高的预算不会变成注入主会话的无界内容。runtime 从 JSON-framed 回复中提取一条 `{note, severity}`,经过 emission guard 门禁(normalize / dedupe / content-free 抑制 / 每次更新至多一条 note),然后路由:nit → inject,concern/blocker → steer。`[advisor:{severity}]` 前缀是主模型获得的关于如何对待它的唯一线索——主 system prompt 从不提及 advisory。compaction 与 surface 重写会重置 observer、emission guard 与 immuneTurns latch;drain 完全异步且 backlog 有界,因此失败或 quota 耗尽的 advisor 只能丢弃自己的 backlog——永远不会卡住主循环。 -- **每个会话一个 advisor** —— 无并行 advisor roster 或 WATCHDOG 式文件发现(下一迭代)。 -- **无 advisor tools** —— 评审者只是一个独立的模型调用;它无法自行核验主张(下下迭代)。 -- **无会话内 advisor 面板** —— 建议仅以带标签的注入消息呈现("插件配置"设置页上的 Advisor 卡片是配置面,不是会话内视图;会话内卡片为下下迭代)。 -- **无 transcript 持久化或成本统计** —— 无可恢复的 advisor 历史或成本可观测性(下下迭代)。 -- **无 delta 内容密钥混淆** —— transcript 中出现的 secrets 可能到达 advisor 模型;请通过配置可信的评审模型来缓解。 -- **不隔离不安全的 advisor 输出** —— 行为异常的 note 可能携带指令性文本;JSON frame + 校验 + advisory-only 框架(`[advisor:…]`、"weigh, don't blindly obey")是仅有的缓解手段,且 note 会原样送达主 transcript(路线图)。 -- **无 `syncBacklog` 追赶等待** —— 落后很多的 advisor 不会等待主循环;其 backlog 有界且会被丢弃(永远不会卡住主循环),因此 advisor note 可能在下一次主 turn 开始之后才到达(路线图:context-maintenance batch)。 -- **advisor 上下文有界** —— 长会话的完整重放会被截断(`maxDeltaMessages`),因此 compaction 后 advisor 可能丢失早期上下文;advisor 上下文维护在路线图中(下下迭代)。 +**限制与路线图**——MVP 有意放弃与 omp 的完整对等。已接受的差距(在 harness 迭代路线图中跟踪): -## 开发 +- **每个会话一个 advisor**——无并行 advisor roster 或 WATCHDOG 式文件发现(下一迭代)。 +- **无 advisor tools**——评审者只是一个独立的模型调用;它无法自行核验主张(下下迭代)。 +- **无会话内 advisor 面板**——建议仅以带标签的注入消息呈现;web Advisor 卡片是配置面,不是会话内视图(下下迭代)。 +- **无 transcript 持久化或成本统计**——无可恢复的 advisor 历史或成本可观测性(下下迭代)。 +- **无 delta 内容密钥混淆**——transcript 中出现的 secrets 可能到达 advisor 模型;请通过配置可信的评审模型来缓解。 +- **不隔离不安全的 advisor 输出**——行为异常的 note 可能携带指令性文本;JSON frame + 校验 + advisory-only 框架是仅有的缓解手段,且 note 会原样送达主 transcript(路线图)。 +- **无 `syncBacklog` 追赶等待**——落后很多的 advisor 不会等待主循环;其 backlog 有界且会被丢弃,因此 note 可能在下一次主 turn 开始之后才到达(路线图:context-maintenance batch)。 +- **advisor 上下文有界**——长会话的完整重放会被截断(`maxDeltaMessages`),因此 compaction 后 advisor 可能丢失早期上下文(路线图:下下迭代)。 -组合包在安装时自行构建:`package.json` 声明了 `"prepare": "pnpm build"`(与 `prepack` 相同的构建),因此任何克隆都立即可构建。私有的 `@deepseek-ai/dsh-*` 运行时依赖**只声明为 peerDependencies**(绝不进 `dependencies` / `devDependencies`);`pnpm-workspace.yaml` 设了 `autoInstallPeers: true` + `nodeLinker: hoisted`(pnpm 11+ 忽略 `.npmrc` 中的非认证设置),因此开发期 pnpm 用你用户级 `~/.npmrc` 里的认证令牌从 npm registry 解析真实的 `@deepseek-ai/*` 包。没有本地链接农场,依赖解析也不需要 `DSH_HOME` / `DSH_SOURCE_DIR` 前置条件。 +**构建**——组合包在安装时自行构建:`package.json` 声明了 `"prepare": "pnpm build"`(与 `prepack` 相同的构建),因此任何克隆都立即可构建。私有的 `@deepseek-ai/dsh-*` 运行时依赖**只声明为 peerDependencies**(绝不进 `dependencies` / `devDependencies`);开发期 `pnpm-workspace.yaml` 设了 `autoInstallPeers: true` + `nodeLinker: hoisted`(pnpm 11+ 忽略 `.npmrc` 中的非认证设置),pnpm 用你用户级 `~/.npmrc` 里的认证令牌从 npm registry 解析真实的 `@deepseek-ai/*` 包——没有本地链接农场,依赖解析也不需要 `DSH_HOME` / `DSH_SOURCE_DIR` 前置条件。内置 `cordis` 框架声明为 scoped peer `@deepseek-ai/cordis`(绝不用裸名 `cordis`);针对 prerelease 发布的 peer 范围必须带精确的发布 tag——`@deepseek-ai/dsh-*` peers 钉在 `^0.1.0-rc.6`,因为按 node-semver prerelease-tuple 规则,`^4.0.0-rc.7` 这样的范围永远匹配不到 `4.0.1-rc.1` 的发布。没有 `postinstall` 步骤:tarball 安装已带构建产物,完全跳过构建。`prepack` 与 `prepare` 都会运行 `pnpm build`,因此 `pnpm pack` 会构建两次——这是为保持 git 安装可构建而接受的取舍。 ```sh -pnpm install # registry deps,含 @deepseek-ai/* peers(经 autoInstallPeers + ~/.npmrc 认证) -pnpm test # vitest (unit + the composed integration loop) +pnpm install # registry deps,含 @deepseek-ai/* peers(autoInstallPeers + ~/.npmrc 认证) +pnpm test # vitest(unit + 组合集成循环) pnpm typecheck # tsc --noEmit (node) + tsc -p tsconfig.client.json --noEmit + tsc -p tsconfig.spec.json --noEmit -pnpm build # tsc -p tsconfig.build.json emit to lib/ + node scripts/build-client.mjs (client bundle) +pnpm build # tsc -p tsconfig.build.json emit to lib/ + node scripts/build-client.mjs(client bundle) pnpm pack # build + produce dsh-advisor-0.0.1.tgz ``` -内置 `cordis` 框架声明为 scoped peer `@deepseek-ai/cordis`(绝不用裸名 `cordis`)。针对 prerelease 发布的 peer 范围必须带精确的发布 tag —— 例如 `@deepseek-ai/dsh-*` peers 钉在 `^0.1.0-rc.6`;按 node-semver prerelease-tuple 规则,带 prerelease 的 comparator 只匹配同 `[major, minor, patch]` tuple,因此 `^4.0.0-rc.7` 这样的范围永远匹配不到 `4.0.1-rc.1` 的发布。scoped peer 与其他 `@deepseek-ai/*` peers 一样从 npm registry 解析,所以开发期的 `import '@deepseek-ai/cordis'` 与宿主看到的是同一个包身份。 - -`prepack` 运行 `pnpm build`;`prepare` 运行 `pnpm build`,因此 `pnpm pack` 会构建两次(每个生命周期一次)——这是为保持 git 安装可构建而接受的取舍。没有 `postinstall` 步骤:tarball 安装已带构建产物,完全跳过构建。本地 `dsh plugin add .` 从工作树挂载 bundle,因此请先运行 `pnpm build`(或 `pnpm install`)——pnpm 不会为 `link:` 依赖运行 `prepare`。 - -集成测试(`tests/integration.test.ts`)把插件组合进一个带 stub LLM adapter 的真实 cordis 上下文,驱动完整的 turn → delta → advisor call → inject/steer 循环。 - ## 文档 | 文档 | 内容 | |---|---| -| [docs/install.zh.md](docs/install.zh.md) | 完整安装指南:git / tarball / 本地目录安装、web Settings 暴露、卸载、`--dump-config` 验证 | +| [docs/install.zh.md](docs/install.zh.md) | profile 安装(web + dsh-tui)/ registry / git / tarball / 本地目录变体 / web Settings 暴露 / 卸载 / `--dump-config` 验证 | +| [docs/configuration.md](docs/configuration.md) | `advisor` 命名空间全字段:键与默认值、显式模型门禁(S4)、配置面(web 卡片 / 补丁层 / 全局 settings.yaml)、示例 YAML、live 重应用行为 | +| [docs/consumer-api.md](docs/consumer-api.md) | 开发者消费契约:包根库 API、`dsh-advisor/client` 入口、`/advisor` 指令面、导出清单、生命周期 | +| [docs/verification.md](docs/verification.md) | 验证记录:测试矩阵(16 文件 / 319 用例)、typecheck/build、CI 契约、真实环境步骤 | +| [docs/release.md](docs/release.md) | 发布流程:PR 驱动的 Release prep + Release 工作流、OIDC trusted publishing、版本策略、回滚 | -## 许可证 +## 许可 -MIT +本项目以 **MIT** 许可证发布,全文见 [LICENSE](LICENSE)。版权与许可条款以 LICENSE 文件为准。 From 46d35714aeed63ba261912569ccbc7616dcfecb9 Mon Sep 17 00:00:00 2001 From: Tang Bohao Date: Sun, 16 Aug 2026 12:48:00 +0800 Subject: [PATCH 7/7] fix(advisor): 768 output caps (256->5120->768, thinking-off default) + de-codename user-facing log + dev internals out of user README --- .../dsh-auxiliary-model-start-profile.md | 2 +- .../omp-advisor-dsh-port.md | 2 +- .mstar/specs/advisor-plugin.md | 10 +++--- README.i18n.yaml | 4 +-- README.md | 16 ++------- README.zh.md | 16 ++------- docs/configuration.md | 4 +-- docs/verification.md | 2 +- src/advisor-runtime.ts | 36 ++++++++++--------- tests/advisor-runtime.test.ts | 6 ++-- 10 files changed, 38 insertions(+), 60 deletions(-) diff --git a/.mstar/knowledge/architecture-patterns/dsh-auxiliary-model-start-profile.md b/.mstar/knowledge/architecture-patterns/dsh-auxiliary-model-start-profile.md index 74d4292..b826149 100644 --- a/.mstar/knowledge/architecture-patterns/dsh-auxiliary-model-start-profile.md +++ b/.mstar/knowledge/architecture-patterns/dsh-auxiliary-model-start-profile.md @@ -19,7 +19,7 @@ dsh plugins that make **auxiliary** model calls (advisor reviewer, future subage Two invariants, both regression-pinned at the single builder seam: -1. **Minimal request shape — closed whitelist.** Every auxiliary `ctx.llm.stream` call builds `GenerateOptions` in ONE function (`buildOptions`) whose key set is exactly `['maxTokens','messages','model','provider','reasoningEffort','signal','system']` (minus `reasoningEffort` when the model does not advertise it). No `tools` (the wire field is omitted by both dsh adapters when unset — verified deepseek `serialize.ts`, pi-ai `context.ts`; no stock `llm/stream` middleware injects tools). Tests assert `Object.keys(options).sort()` equality with a **hardcoded literal** (not derived from the code under test) plus literal `5120` for the frozen token cap — so any new key or value drift breaks loudly. Pin the plugin control surface (`GenerateOptions`), never adapter wire JSON. +1. **Minimal request shape — closed whitelist.** Every auxiliary `ctx.llm.stream` call builds `GenerateOptions` in ONE function (`buildOptions`) whose key set is exactly `['maxTokens','messages','model','provider','reasoningEffort','signal','system']` (minus `reasoningEffort` when the model does not advertise it). No `tools` (the wire field is omitted by both dsh adapters when unset — verified deepseek `serialize.ts`, pi-ai `context.ts`; no stock `llm/stream` middleware injects tools). Tests assert `Object.keys(options).sort()` equality with a **hardcoded literal** (not derived from the code under test) plus literal `768` for the frozen token cap — so any new key or value drift breaks loudly. Pin the plugin control surface (`GenerateOptions`), never adapter wire JSON. 2. **Thinking-off is capability-gated, never unconditional.** Sending `reasoningEffort` to a model without reasoning metadata throws `UNSUPPORTED_REASONING_EFFORT` from `LlmRuntime.resolveCallFor` and silently kills the auxiliary caller for non-deepseek models. Resolve capabilities via `ctx.llm.resolveModelInfo(provider, model, signal)` and send `'off'` **only** when `reasoning.efforts` advertises it (DeepSeek wire: `'off' → thinking: {type:'disabled'}` — host behavior, not plugin config). diff --git a/.mstar/knowledge/architecture-patterns/omp-advisor-dsh-port.md b/.mstar/knowledge/architecture-patterns/omp-advisor-dsh-port.md index c1a8254..8f2a97f 100644 --- a/.mstar/knowledge/architecture-patterns/omp-advisor-dsh-port.md +++ b/.mstar/knowledge/architecture-patterns/omp-advisor-dsh-port.md @@ -34,7 +34,7 @@ omp's advisor attaches an independent reviewer model to a session: after each pr | turn-end hook (`setOnTurnEnd`) | session/event listener; stepped turn/end detection (`findLastMessageTurnEnd` semantics); filter reason kinds completed / max-tokens / error | | cursor + delivered-prefix fingerprints | per-session cursor + message fingerprints; prefix rewrite (compact events, surface replace ops) → reset + full replay of the post-rewrite transcript; seed-to-length on mid-session enable | | delta renderer (role labels, own-message exclusion) | markdown with `**user:**` / `**agent:**` labels; advisor's own injected messages excluded via a custom merge-extensible source kind (`advisor`) — the self-review guard; bounded delta window (default 60 messages, 0 = unbounded) with a truncation marker | -| advise tool + canned `Recorded.` | MVP: JSON-framed note + severity reply (first balanced `{...}`; empty note dropped; missing severity → nit; no parse retry; output cap `ADVISOR_MAX_TOKENS = 5120` since n4, 2026-08-11 — KD-2's original 256 was superseded 20× and frozen as KD-6) — no tool loop | +| advise tool + canned `Recorded.` | MVP: JSON-framed note + severity reply (first balanced `{...}`; empty note dropped; missing severity → nit; no parse retry; output cap `ADVISOR_MAX_TOKENS = 768` since 2026-08-16 — KD-2's 256 → 5120 supersession chain settled at 768 with thinking-off default, frozen as KD-6) — no tool loop | | emission guard | normalize (NFKC → lowercase → non-alnum runs → single space), content-free phrase suppression, exact-text dedupe (FIFO, 4096), one-note-per-update, severity escalation (nit→concern→blocker allowed, equal/lower suppressed) | | delivery routing | nit → inject (non-waking, next pre-step); concern/blocker → steer (waking); immuneTurns cooldown (default 3) after a delivered interrupt downgrades later interrupting notes to inject; messages carry `[advisor:{severity}]` text + the advisor source kind | | backlog/catch-up/failure | async drain per session; retry once + backoff → drop; 3 drops → flush backlog; quota → paused (batch retained, no auto-resume); permanent errors → halted; **call-level deadline** (dsh-timeout, default 60 s) so a hung stream cannot wedge the drain; never parks the primary | diff --git a/.mstar/specs/advisor-plugin.md b/.mstar/specs/advisor-plugin.md index e31f4f5..28ed920 100644 --- a/.mstar/specs/advisor-plugin.md +++ b/.mstar/specs/advisor-plugin.md @@ -104,7 +104,7 @@ Schema library: schemastery (as used by dsh packages). Unknown keys are rejected - `emission-guard.ts` — normalization (`"Stop."` ≡ `*stop*`), dedupe, content-free suppression, one-note-per-update, escalation (nit→concern allowed, concern→nit suppressed), reset clears history. - `config.ts` — schema defaults; missing provider/model with `enabled: true` → disabled-with-reason; unknown keys rejected; severity enum validation. - `advisor-runtime.ts` — drain with a stub adapter registered via `ctx.llm.registerAdapter`; JSON-frame parse (valid/invalid/missing severity → default nit); adapter throw → note dropped, runtime continues; no model call when config disabled; quota error → pause; permanent error → halt. - - `advisor-runtime.ts` — minimal request contract (KD-6, §8.6): every recorded advisor `GenerateOptions` key set matches the closed AC-1 whitelist (`['maxTokens', 'messages', 'model', 'provider', 'reasoningEffort', 'signal', 'system']` when the model advertises `'off'`, same list without `reasoningEffort` otherwise) with `'tools'`/`'temperature'`/`'stop'`/`'purpose'` absent, one user delta, `maxTokens === ADVISOR_MAX_TOKENS` (5120), and the configured `system`; a `resolveModelInfo` failure (throw or deadline abort) writes no cache entry and a later definitive resolution re-advertises `reasoningEffort: 'off'` (no-latch + recovery); a definitive no-`'off'` logs the `advisor: thinking-off unavailable …` debug line once per runtime (log-once) while a resolution failure never logs it (failures silent); a deadline-aborted resolution is re-resolved by the retry and the drain stays deadline-bounded (n4 QC N-5 rewrite). + - `advisor-runtime.ts` — minimal request contract (KD-6, §8.6): every recorded advisor `GenerateOptions` key set matches the closed AC-1 whitelist (`['maxTokens', 'messages', 'model', 'provider', 'reasoningEffort', 'signal', 'system']` when the model advertises `'off'`, same list without `reasoningEffort` otherwise) with `'tools'`/`'temperature'`/`'stop'`/`'purpose'` absent, one user delta, `maxTokens === ADVISOR_MAX_TOKENS` (768), and the configured `system`; a `resolveModelInfo` failure (throw or deadline abort) writes no cache entry and a later definitive resolution re-advertises `reasoningEffort: 'off'` (no-latch + recovery); a definitive no-`'off'` logs the `advisor: thinking-off unavailable …` debug line once per runtime (log-once) while a resolution failure never logs it (failures silent); a deadline-aborted resolution is re-resolved by the retry and the drain stays deadline-bounded (n4 QC N-5 rewrite). - `delivery.ts` — nit injects without waking; concern/blocker steer; immuneTurns downgrade window; advisor-source messages carry `source.kind === 'advisor'`. - **Integration:** a composed cordis context with a stub LLM adapter + a fake session/agent harness; assert the full `user → primary → turn/end → delta → advisor call → note → inject` cycle, and assert the explicit-gate (no model call when `enabled: true` without provider/model). - **Install smoke (T1):** `pnpm pack` → `dsh plugin --profile add ` → boot or `--dump-config` shows the `advisor` row with no load errors. Dev-side resolution evidence: KD-1 (§8.1). @@ -157,7 +157,7 @@ Environment: Node v24.18.0, pnpm 10.28.1 (assignment note said "pnpm ≥ 11 avai - **Extraction:** locate the first balanced `{…}` object in the reply (tolerant of surrounding prose/markdown fences), `JSON.parse` it. - **Validation:** `note` must be a non-empty string after trim — otherwise **drop + log** (never crash the drain). `severity` must be one of `nit|concern|blocker`; missing or invalid → default `nit` (rationale: the least-invasive default — matches omp's "omit for a plain nit" and a mis-severity defaulting to nit minimizes disruption). - **Invalid-reply fallback:** drop + log a warning; **no retry for parse failures** (the retry budget is reserved for transport errors; a model that cannot emit the frame will not improve on retry). -- **Output-token cap:** the advisor call sets `maxTokens: 256` so a runaway reply cannot blow the budget; the frame must fit within it. **[Superseded (2026-08-11, n4 user direction — qc2 S-2 / qc1 S-2 / qc3 F-2):** the advisor call runs with `ADVISOR_MAX_TOKENS = 5120` (256 → 5120, 20x) so even a reasoning-heavy reply cannot starve the JSON frame — see KD-6 (§8.6). The historical 256 text above is preserved for context; the raised ceiling is re-bounded downstream (`extractAdviceNote` `ADVISOR_NOTE_MAX_CHARS` cap, bounded notice summary).]** +- **Output-token cap:** the advisor call sets `maxTokens: 256` so a runaway reply cannot blow the budget; the frame must fit within it. **[Superseded (2026-08-11, n4 user direction — qc2 S-2 / qc1 S-2 / qc3 F-2):** the advisor call runs with `ADVISOR_MAX_TOKENS = 5120` (256 → 5120, 20x) so even a reasoning-heavy reply cannot starve the JSON frame — see KD-6 (§8.6). The historical 256 text above is preserved for context; the raised ceiling is re-bounded downstream (`extractAdviceNote` `ADVISOR_NOTE_MAX_CHARS` cap, bounded notice summary).] **[Superseded again (2026-08-16, user direction):** with `reasoningEffort: 'off'` the capability-gated default, the 20× reasoning headroom is unnecessary — `ADVISOR_MAX_TOKENS = 768` (5120 → 768), a modest budget that fits one `ADVISOR_NOTE_MAX_CHARS` note (768 chars) plus the JSON frame; see KD-6 (§8.6).]** - **One-per-update enforcement:** prompt rule + the emission guard's one-note-per-update rate limit (guard also drops extras). ### 8.3 KD-3 — delta message window — RESOLVED @@ -184,13 +184,13 @@ Environment: Node v24.18.0, pnpm 10.28.1 (assignment note said "pnpm ≥ 11 avai ### 8.6 KD-6 — minimal advisor request contract (zero tools, capability-gated thinking-off) — RESOLVED -**Decision: every advisor `ctx.llm.stream` call is a minimal start — the closed AC-1 `GenerateOptions` whitelist, zero `tools` key, capability-gated `reasoningEffort: 'off'`, `purpose` unset (KD-5), and `maxTokens` 5120 (KD-2 supersession). The guarantee is a code invariant, not an operator switch.** +**Decision: every advisor `ctx.llm.stream` call is a minimal start — the closed AC-1 `GenerateOptions` whitelist, zero `tools` key, capability-gated `reasoningEffort: 'off'`, `purpose` unset (KD-5), and `maxTokens` 768 (KD-2 supersession chain: 256 → 5120 → 768). The guarantee is a code invariant, not an operator switch.** -- **Closed whitelist (AC-1, regression-pinned):** every recorded advisor `GenerateOptions` key set is exactly one of two variants (`Object.keys(…).sort()` equality): `['maxTokens', 'messages', 'model', 'provider', 'reasoningEffort', 'signal', 'system']` when the resolved model advertises `'off'`, else the same list **without** `reasoningEffort`. `'tools'`, `'temperature'`, `'stop'`, `'purpose'` are never present; `messages` is exactly one user delta; `maxTokens === ADVISOR_MAX_TOKENS` (5120); `system` is the configured prompt. The pin is on the plugin control surface (`GenerateOptions`), not dsh adapter wire JSON — adapters omit the wire `tools` field when unset and stock `llm/stream` middleware injects none (verified against dsh source). +- **Closed whitelist (AC-1, regression-pinned):** every recorded advisor `GenerateOptions` key set is exactly one of two variants (`Object.keys(…).sort()` equality): `['maxTokens', 'messages', 'model', 'provider', 'reasoningEffort', 'signal', 'system']` when the resolved model advertises `'off'`, else the same list **without** `reasoningEffort`. `'tools'`, `'temperature'`, `'stop'`, `'purpose'` are never present; `messages` is exactly one user delta; `maxTokens === ADVISOR_MAX_TOKENS` (768); `system` is the configured prompt. The pin is on the plugin control surface (`GenerateOptions`), not dsh adapter wire JSON — adapters omit the wire `tools` field when unset and stock `llm/stream` middleware injects none (verified against dsh source). - **Thinking-off is capability-gated (n4 QC frozen — qc2 W-1 / qc1 W-1 / qc3 F-3):** `reasoningEffort: 'off'` is sent **only** when the resolved model's `reasoning.efforts` advertises `'off'`; otherwise the option is omitted entirely (an explicit effort for a model without reasoning metadata is rejected with `UNSUPPORTED_REASONING_EFFORT`, silently killing the advisor for non-deepseek models). The DeepSeek wire mapping `off → thinking: {type:'disabled'}` is **host behavior**, not a plugin configuration; unconditional `'off'` is a Non-Goal. - **Resolution failures never latch; definitive no-`'off'` logs once:** a `resolveModelInfo` throw **or** deadline abort is a failure, not a verdict — it writes no `reasoningEffortCache` entry and the next call re-resolves afresh. Only **definitive** outcomes are cached: method absent ("no capability API") or a resolved verdict (`'off'` / no-`'off'`). A definitive no-`'off'` emits the `advisor: thinking-off unavailable …` debug line **once per runtime**; resolution failures never emit it and get no log-once latch of their own. - **No new plugin config keys:** the single §5.1 schema surface is unchanged (`enabled` / `provider` / `model` / `systemPrompt` / `immuneTurns` / `maxDeltaMessages`); no `thinking` / `tools` / `reasoningEffort` / "minimal start" toggle keys exist. -- **`purpose` unset (KD-5); `maxTokens` 5120 (KD-2 supersession):** an advisor call is an ordinary conversation request and leaves `purpose` unset (closed union `'compaction' | 'session-title'`). `maxTokens` is `ADVISOR_MAX_TOKENS` (5120) — the user-directed supersession of KD-2's historical `256` (§8.2 annotation above); the raised ceiling is re-bounded downstream (`extractAdviceNote` `ADVISOR_NOTE_MAX_CHARS` cap, bounded notice summary). +- **`purpose` unset (KD-5); `maxTokens` 768 (KD-2 supersession chain):** an advisor call is an ordinary conversation request and leaves `purpose` unset (closed union `'compaction' | 'session-title'`). `maxTokens` is `ADVISOR_MAX_TOKENS` (768) — the user-directed supersession of the 5120 value (itself the supersession of KD-2's historical `256`, §8.2 annotations above): with thinking-off the default, a modest budget that fits one `ADVISOR_NOTE_MAX_CHARS` note (768 chars) plus the JSON frame; the ceiling is re-bounded downstream (`extractAdviceNote` `ADVISOR_NOTE_MAX_CHARS` cap, bounded notice summary). - **Contract freeze (AC-4):** the §4 isolation row points at this contract; §7 verification entries mirror the T1/T2 regression pins (no-latch + recovery, log-once, failures-silent, N-5 deadline-bounded rewrite, closed-whitelist two variants). ## 9. Risks and rollback diff --git a/README.i18n.yaml b/README.i18n.yaml index 5e17318..f05ce02 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -3,5 +3,5 @@ # editing either side, bring the other along and re-record with: # git hash-object README.md # git hash-object README.zh.md -README.md: 6e2326a2532924b56d1f86f4b2e849ca1a8b2878 -README.zh.md: b8f5c051a2e8d5aad32320302b86705663d60352 +README.md: 6da971fed9908121287601132c6e39ff66dba750 +README.zh.md: 4c770571e72e38e64c9cf48a789cc41aa544192b diff --git a/README.md b/README.md index 6e2326a..6da971f 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,9 @@ In a **dsh-tui** profile, `/advisor config` additionally reads back the composed The plugin installs as a **pure mount**: bundle insert + client card (web Settings 插件配置) + its own gateway channel (`/api/advisor/get|set`, claimed by the host's typertGateway — the same mechanism the dsh `goals` service uses, not gated by the settings exposure allowlist) + the `/advisor` commands — no dsh patches, no postinstall step, and dsh upgrades never require re-patching. -## Development +## Limitations & roadmap -**How it works** — the plugin subscribes to `session/event` and renders an incremental markdown delta of the primary transcript (own advisor messages excluded), queued on a per-session runtime: after each stepped `turn/end` in standard stepped sessions, and — in agentic/harness sessions that never emit `turn/end` — at each completed agent reply round (when a new human input arrives after an unreviewed assistant increment, inbox-spliced input included). The runtime calls the separately configured model via `ctx.llm.stream` — with reasoning off (`reasoningEffort: 'off'`, sent only when the configured model's adapter declares that effort; deepseek models do, other models get the option omitted automatically) and a **5120-token** output cap (a user-directed 20× supersession of the original 256); extracted notes are bounded (1000 chars) and the notice summary to 120 chars, so the raised budget cannot translate into an unbounded injection into the primary session. It extracts one `{note, severity}` from the JSON-framed reply, gates it through an emission guard (normalize / dedupe / content-free suppression / one-note-per-update), and routes it: nit → inject, concern/blocker → steer. The `[advisor:{severity}]` prefix is the only cue the primary model gets about how to treat it — the primary system prompt never mentions advisories. Compaction and surface rewrites reset the observer, the emission guard, and the immuneTurns latch; the drain is fully async with a bounded backlog, so a failing or quota'd advisor can only drop its own backlog — never park the primary loop. - -**Limitations & roadmap** — the MVP deliberately drops full omp parity. Accepted gaps (tracked in the harness iteration roadmap): +The MVP deliberately drops full omp parity. Accepted gaps (tracked in the harness iteration roadmap): - **Single advisor per session** — no parallel advisor roster or WATCHDOG-style file discovery (next iteration). - **No advisor tools** — the reviewer is an independent model call only; it cannot verify claims itself (next-next iteration). @@ -104,16 +102,6 @@ The plugin installs as a **pure mount**: bundle insert + client card (web Settin - **No `syncBacklog` catch-up wait** — a far-behind advisor does not wait for the primary loop; its backlog is bounded and dropped, so notes may arrive after the next primary turn started (roadmap: context-maintenance batch). - **Bounded advisor context** — long-session full replays are truncated (`maxDeltaMessages`), so the advisor may lose early context after compaction (roadmap: next-next iteration). -**Build** — the bundle builds itself on install: `package.json` declares `"prepare": "pnpm build"` (the same build `prepack` runs), so any clone is immediately buildable. The private `@deepseek-ai/dsh-*` runtime dependencies are **peerDependencies only** (never `dependencies` / `devDependencies`); at dev time `pnpm-workspace.yaml` sets `autoInstallPeers: true` + `nodeLinker: hoisted` (pnpm 11+ ignores non-auth settings in `.npmrc`), so pnpm resolves the real `@deepseek-ai/*` packages from the npm registry using the auth token in your user-level `~/.npmrc` — there is no local link-farm and no `DSH_HOME` / `DSH_SOURCE_DIR` prerequisite for dependency resolution. The in-box `cordis` framework is declared as the scoped peer `@deepseek-ai/cordis` (never bare `cordis`), and prerelease peer ranges must carry the exact publish tag — the `@deepseek-ai/dsh-*` peers are pinned `^0.1.0-rc.6`, since per the node-semver prerelease-tuple rule a range like `^4.0.0-rc.7` never matches a `4.0.1-rc.1` publish. There is no `postinstall` step: already-built tarball installs skip the build entirely. `prepack` and `prepare` both run `pnpm build`, so `pnpm pack` builds twice — the documented tradeoff that keeps git-install builds working. - -```sh -pnpm install # registry deps incl. the @deepseek-ai/* peers (autoInstallPeers + ~/.npmrc auth) -pnpm test # vitest (unit + the composed integration loop) -pnpm typecheck # tsc --noEmit (node) + tsc -p tsconfig.client.json --noEmit + tsc -p tsconfig.spec.json --noEmit -pnpm build # tsc -p tsconfig.build.json emit to lib/ + node scripts/build-client.mjs (client bundle) -pnpm pack # build + produce dsh-advisor-0.0.1.tgz -``` - ## Documentation | Doc | Content | diff --git a/README.zh.md b/README.zh.md index b8f5c05..4c77057 100644 --- a/README.zh.md +++ b/README.zh.md @@ -89,11 +89,9 @@ dsh --profile web --dump-config # 显示带 advisor 配置行的 "# == dsh-adv 插件以**纯挂载**方式安装:bundle 插入 + 客户端卡片(web Settings "插件配置")+ 自有 gateway 通道(`/api/advisor/get|set`,由宿主 typertGateway 认领——与 dsh 内建 `goals` 服务同一机制,不受 settings 暴露白名单门控)+ `/advisor` 指令——无 dsh 补丁、无 postinstall 步骤,dsh 升级永不需重打。 -## 开发 +## 限制与路线图 -**工作原理**——插件订阅 `session/event`,把主 transcript 的增量 markdown delta(排除 advisor 自己的消息)渲染出来并放入按会话的 runtime 队列:标准 stepped 会话在每个 stepped `turn/end` 之后;agentic/harness 会话(从不发出 `turn/end`)则在每个完成的 agent 回复轮次(未评审的 assistant 增量之后有新的人类输入到达,含 inbox 拼接输入)。runtime 通过 `ctx.llm.stream` 调用单独配置的模型——关闭推理(`reasoningEffort: 'off'`,仅当所配置模型的 adapter 声明该档位时才发送;deepseek 模型声明,其他模型会自动省略该选项)并以 **5120 tokens** 作为输出上限(用户指示的 256 → 5120 的 20 倍超驰);抽取出的 note 有界(1000 字符),notice summary 有界(120 字符),因此提高的预算不会变成注入主会话的无界内容。runtime 从 JSON-framed 回复中提取一条 `{note, severity}`,经过 emission guard 门禁(normalize / dedupe / content-free 抑制 / 每次更新至多一条 note),然后路由:nit → inject,concern/blocker → steer。`[advisor:{severity}]` 前缀是主模型获得的关于如何对待它的唯一线索——主 system prompt 从不提及 advisory。compaction 与 surface 重写会重置 observer、emission guard 与 immuneTurns latch;drain 完全异步且 backlog 有界,因此失败或 quota 耗尽的 advisor 只能丢弃自己的 backlog——永远不会卡住主循环。 - -**限制与路线图**——MVP 有意放弃与 omp 的完整对等。已接受的差距(在 harness 迭代路线图中跟踪): +MVP 有意放弃与 omp 的完整对等。已接受的差距(在 harness 迭代路线图中跟踪): - **每个会话一个 advisor**——无并行 advisor roster 或 WATCHDOG 式文件发现(下一迭代)。 - **无 advisor tools**——评审者只是一个独立的模型调用;它无法自行核验主张(下下迭代)。 @@ -104,16 +102,6 @@ dsh --profile web --dump-config # 显示带 advisor 配置行的 "# == dsh-adv - **无 `syncBacklog` 追赶等待**——落后很多的 advisor 不会等待主循环;其 backlog 有界且会被丢弃,因此 note 可能在下一次主 turn 开始之后才到达(路线图:context-maintenance batch)。 - **advisor 上下文有界**——长会话的完整重放会被截断(`maxDeltaMessages`),因此 compaction 后 advisor 可能丢失早期上下文(路线图:下下迭代)。 -**构建**——组合包在安装时自行构建:`package.json` 声明了 `"prepare": "pnpm build"`(与 `prepack` 相同的构建),因此任何克隆都立即可构建。私有的 `@deepseek-ai/dsh-*` 运行时依赖**只声明为 peerDependencies**(绝不进 `dependencies` / `devDependencies`);开发期 `pnpm-workspace.yaml` 设了 `autoInstallPeers: true` + `nodeLinker: hoisted`(pnpm 11+ 忽略 `.npmrc` 中的非认证设置),pnpm 用你用户级 `~/.npmrc` 里的认证令牌从 npm registry 解析真实的 `@deepseek-ai/*` 包——没有本地链接农场,依赖解析也不需要 `DSH_HOME` / `DSH_SOURCE_DIR` 前置条件。内置 `cordis` 框架声明为 scoped peer `@deepseek-ai/cordis`(绝不用裸名 `cordis`);针对 prerelease 发布的 peer 范围必须带精确的发布 tag——`@deepseek-ai/dsh-*` peers 钉在 `^0.1.0-rc.6`,因为按 node-semver prerelease-tuple 规则,`^4.0.0-rc.7` 这样的范围永远匹配不到 `4.0.1-rc.1` 的发布。没有 `postinstall` 步骤:tarball 安装已带构建产物,完全跳过构建。`prepack` 与 `prepare` 都会运行 `pnpm build`,因此 `pnpm pack` 会构建两次——这是为保持 git 安装可构建而接受的取舍。 - -```sh -pnpm install # registry deps,含 @deepseek-ai/* peers(autoInstallPeers + ~/.npmrc 认证) -pnpm test # vitest(unit + 组合集成循环) -pnpm typecheck # tsc --noEmit (node) + tsc -p tsconfig.client.json --noEmit + tsc -p tsconfig.spec.json --noEmit -pnpm build # tsc -p tsconfig.build.json emit to lib/ + node scripts/build-client.mjs(client bundle) -pnpm pack # build + produce dsh-advisor-0.0.1.tgz -``` - ## 文档 | 文档 | 内容 | diff --git a/docs/configuration.md b/docs/configuration.md index f252387..9be893d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -78,10 +78,10 @@ schema 默认值 → 插件行 config(base)→ settings user layer(web 卡 ### 评审运行策略(`src/advisor-runtime.ts`) - 每个会话一个 `AdvisorRuntime`;delta 进有界 FIFO 队列(默认 32,满时丢最新并记日志),串行异步 drain —— **主循环永不被 park**; -- 每次 `llm.stream` 调用:`{ provider, model, system, messages: [user delta], maxTokens: 5120 }`(5120 = 用户指示的 256 → 5120 的 20 倍预算;`purpose` 不设置,KD-5)。`reasoningEffort: 'off'` 仅在所配置模型的 adapter 声明该档位时发送(`src/advisor-runtime.ts` `resolveModelInfo` 能力查询); +- 每次 `llm.stream` 调用:`{ provider, model, system, messages: [user delta], maxTokens: 768 }`(768 = 用户指示的 256 → 5120 → 768 超驰链终值:thinking-off 为默认后无需 reasoning 余量;`purpose` 不设置,KD-5)。`reasoningEffort: 'off'` 仅在所配置模型的 adapter 声明该档位时发送(`src/advisor-runtime.ts` `resolveModelInfo` 能力查询); - 每次调用有 60s 整调用 deadline(超时按 transient 处理,KD-5 retry → drop); - **failure policy(KD-5)**:transient → 1 次重试(1s backoff)→ drop;连续 3 次 drop → 冲刷积压 backlog(不 stall);quota/rate-limit → `quota_exhausted` 暂停(批次保留,**无自动恢复定时器** —— `/advisor on` 手动恢复);permanent(`invalid_request_error` / model-not-found / "is not supported when" / does not exist)→ `halted`(原地终止;`/advisor on` 为该会话全新重建); -- **KD-2 抽取**:解析回复中第一个平衡 JSON 帧(容忍 prose/fence)为 `{note, severity}`;`note` 非空否则 drop+log;`severity` 缺失/非法默认 `nit`;不做解析重试;note 文本有界(1000 字符,`ADVISOR_NOTE_MAX_CHARS`); +- **KD-2 抽取**:解析回复中第一个平衡 JSON 帧(容忍 prose/fence)为 `{note, severity}`;`note` 非空否则 drop+log;`severity` 缺失/非法默认 `nit`;不做解析重试;note 文本有界(768 字符,`ADVISOR_NOTE_MAX_CHARS`); - **T5 emission guard**(`src/emission-guard.ts`):normalize(等价拼写归一到同一身份)、content-free 短语抑制(stop / done / complete / no issue continue / lgtm / nothing to add)、跨 update 去重(允许 nit → concern → blocker 升级)、每次 update 至多一条 note、FIFO 有界去重历史(默认 4096);compaction / surface 重写清空历史与 latch。 ### 双模式触发与自审排除(`src/transcript.ts`) diff --git a/docs/verification.md b/docs/verification.md index 1f66614..27b6cf4 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -74,7 +74,7 @@ dsh --profile web # 重启 dsh 会话使宿主半与客户端 ### 4. 运行时行为验证(真实模型调用) 1. 配置 enabled + provider/model,发起一次会话并完成若干 stepped 主 turn。 -2. **预期**:每个可评审 turn/end 之后日志出现一次 advisor 模型调用(`ctx.llm.stream`,`maxTokens 5120`);抽取出的 note 以 `[advisor:{severity}] ` 出现在会话流(nit → 非唤醒注入;concern/blocker → steer 唤醒);advisor 自己的消息不进入后续 advisor delta(自审排除)。 +2. **预期**:每个可评审 turn/end 之后日志出现一次 advisor 模型调用(`ctx.llm.stream`,`maxTokens 768`);抽取出的 note 以 `[advisor:{severity}] ` 出现在会话流(nit → 非唤醒注入;concern/blocker → steer 唤醒);advisor 自己的消息不进入后续 advisor delta(自审排除)。 3. **immuneTurns 冷却**:连续产生 concern/blocker 时,前一条实际 steer 后接下来的 `immuneTurns` 个主 turn 内,打断性 note 降级为 inject。 4. **failure policy 抽查**:配置不可用的模型(如不存在/无效凭据)→ 日志出现 transient drop(或 permanent halt);halted 后该会话 advisor 停止,`/advisor on` 重建。 diff --git a/src/advisor-runtime.ts b/src/advisor-runtime.ts index 7c74ba5..1473196 100644 --- a/src/advisor-runtime.ts +++ b/src/advisor-runtime.ts @@ -10,7 +10,7 @@ * - a FIFO queue of pending transcript deltas (bounded — spec §6 "bounded * backlog"; drop-newest when full); * - a serialized async drain loop: one `llm.stream` call per delta with - * `{ provider, model, system, messages: [user delta], maxTokens: 5120 }` and + * `{ provider, model, system, messages: [user delta], maxTokens: 768 }` and * `purpose` left UNSET (KD-5 — an advisor call is an ordinary conversation * request); * - a call-level deadline on every `llm.stream` call (dsh-timeout `deadline`, @@ -128,28 +128,30 @@ export interface AdvisorRuntimeOptions { } /** - * n4 user direction: the advisor call runs with a 20x token budget - * (256 -> 5120) so even a reasoning-heavy reply cannot starve the JSON frame. + * User-directed token budget for one advisor call (256 → 5120 → 768). * Exported so the test suites assert the pinned value instead of a magic * literal. * - * Supersession note (qc2 S-2 / qc1 S-2 / qc3 F-2): the frozen spec §8.2 - * (KD-2) pins `maxTokens: 256` ("so a runaway reply cannot blow the budget"). - * This 5120 value is the USER-DIRECTED supersession of that pin — a 20x - * worst-case per-call ceiling, adopted together with `reasoningEffort: 'off'` - * (capability-gated, see `resolveReasoningEffort`) so the raised budget goes - * to the JSON frame rather than reasoning output. The looser runaway-reply - * guard is re-bounded downstream: `extractAdviceNote` caps the note at + * Supersession chain: the frozen spec §8.2 (KD-2) pins `maxTokens: 256` ("so + * a runaway reply cannot blow the budget"); the 5120 value (a 20x worst-case + * per-call ceiling) was a USER-DIRECTED supersession adopted together with + * `reasoningEffort: 'off'` when reasoning-heavy replies could starve the JSON + * frame. With thinking-off now the capability-gated default (see + * `resolveReasoningEffort`), the reasoning headroom is unnecessary: this 768 + * value is the latest USER-DIRECTED supersession — a modest budget that fits + * one `ADVISOR_NOTE_MAX_CHARS` note plus the JSON frame. The runaway-reply + * guard stays re-bounded downstream: `extractAdviceNote` caps the note at * `ADVISOR_NOTE_MAX_CHARS` and `buildAdvisorMessage` bounds the notice * summary via `boundContextSummary`. */ -export const ADVISOR_MAX_TOKENS = 5_120 +export const ADVISOR_MAX_TOKENS = 768 /** * One extracted note's length cap (qc3 F-2 / qc2 S-1): a verbose/rogue advisor - * reply with the 20x token budget must not inject an unbounded user-role - * message into the primary session. Truncated with a '…' marker. + * reply must not inject an unbounded user-role message into the primary + * session (the token budget is now matched to this cap — 768). Truncated with + * a '…' marker. */ -export const ADVISOR_NOTE_MAX_CHARS = 1_000 +export const ADVISOR_NOTE_MAX_CHARS = 768 const DEFAULT_RETRY_BACKOFF_MS = 1_000 const DEFAULT_MAX_QUEUED = 32 /** Whole-call deadline for one `llm.stream` (qc2 W-4 / qc3 W-1); see `callTimeoutMs`. */ @@ -627,7 +629,7 @@ export class AdvisorRuntime { // no retry on parse failures — the frame must simply be absent/valid). const note = extractAdviceNote(text) if (note === undefined) { - this.logger.debug('advisor: reply yielded no note — dropped (KD-2)') + this.logger.debug('advisor: reply yielded no note — dropped (no parseable non-empty JSON note in the reply)') return { kind: 'no-note' } } try { @@ -680,8 +682,8 @@ export class AdvisorRuntime { // silently killing the advisor for non-deepseek models — pre-n4 these // worked because no effort was sent). ...(reasoningEffort === undefined ? {} : { reasoningEffort }), - // n4 user direction: amplify the token budget 20x (256 -> 5120) so even a - // reasoning-heavy reply cannot starve the JSON frame. + // user-directed budget (256 -> 5120 -> 768): with thinking-off the + // default, a modest cap that fits one bounded note plus the JSON frame. maxTokens: ADVISOR_MAX_TOKENS, signal, // KD-5: `purpose` is a closed union ('compaction' | 'session-title'); an diff --git a/tests/advisor-runtime.test.ts b/tests/advisor-runtime.test.ts index efa53b2..365c53c 100644 --- a/tests/advisor-runtime.test.ts +++ b/tests/advisor-runtime.test.ts @@ -5,7 +5,7 @@ * Contract under test: * - `AdvisorRuntime` (per-session): `enqueue(delta)` queues a rendered transcript * delta and asynchronously drains it — one `llm.stream` call per delta with - * `{ provider, model, system, messages: [user delta], maxTokens: 5120 }` and + * `{ provider, model, system, messages: [user delta], maxTokens: 768 }` and * `purpose` left UNSET (KD-5). Extracted `{note, severity}` is handed to the * `onNote` hook (the T5 emission guard wraps it). * - JSON-frame extraction (KD-2): first balanced `{…}` parsed, tolerant of @@ -449,7 +449,7 @@ describe('AdvisorRuntime — minimal request shape (AC-1 closed whitelist)', () expect(options.messages[0]!.role).toBe('user') expect(options.system).toBe(TEST_SYSTEM_PROMPT) // KD-6 frozen value (= ADVISOR_MAX_TOKENS) - expect(options.maxTokens).toBe(5120) + expect(options.maxTokens).toBe(768) }) it('sends the same minimal request without reasoningEffort when the model has no reasoning capability', async () => { @@ -472,7 +472,7 @@ describe('AdvisorRuntime — minimal request shape (AC-1 closed whitelist)', () expect(options.messages[0]!.role).toBe('user') expect(options.system).toBe(TEST_SYSTEM_PROMPT) // KD-6 frozen value (= ADVISOR_MAX_TOKENS) - expect(options.maxTokens).toBe(5120) + expect(options.maxTokens).toBe(768) }) })