From d75b539419e35011bdf28bbfd7e92cba2e2d420d Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Sun, 11 Oct 2026 13:55:14 +0800 Subject: [PATCH] feat: system prompt prefix/suffix settings joined into every session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new per-user preferences, `promptPrefix`/`promptSuffix`, hold free text joined at the very start / end of the harness's assembled system prompt for all agents (subagents included) — a per-deployment persona or behavioral rule riding the harness's own prompt registry instead of session-log surgery: - src/host/promptInjection.ts registers two global prompt sections through a deferred `ctx.inject(['systemPrompt'], …)` (a composition without the registry degrades to a no-op; the registration unwinds with this plugin's fiber). Orders anchor on the harness's own section-order registry, so a central renumbering keeps the prefix first and the suffix last. Text is registered `interpolate: false`, so `{{...}}` in user text stays literal. - The section text providers read the volatile config references the loader rewrites in place on preference edits, so an edit applies from the next turn without remounting the entry; new sessions carry the text from their first turn through the harness's own system-prompt projection. - The settings card grows two free-text rows (local draft, committed on blur and on unmount; a host push never clobbers in-progress typing), zh/en strings, and the served-value sanitize/degrade path for the string fields. Verified against a running harness: the committed text persists in the profile layer, and a new session's system prompt starts with the prefix and ends with the suffix. docs/compatibility.md documents the seams, and the compat matrix probes them on both supported lines. --- README.md | 4 +- docs/compatibility.md | 6 +- src/client/components/settingsCard.tsx | 60 ++++++++- src/client/i18n.ts | 8 ++ src/client/settings.ts | 10 +- src/client/styles/settings.css | 6 + src/host/config.ts | 3 + src/host/index.ts | 2 + src/host/promptInjection.ts | 81 ++++++++++++ src/shared/types.ts | 4 + tests/baselines.ts | 19 +++ tests/client/components/settingsCard.spec.ts | 72 ++++++++++- tests/client/helpers/kit.ts | 11 ++ tests/client/index.spec.ts | 1 + tests/client/settings.spec.ts | 27 +++- tests/compat/matrix.spec.ts | 22 ++++ tests/host/config.spec.ts | 12 +- tests/host/promptInjection.spec.ts | 128 +++++++++++++++++++ 18 files changed, 463 insertions(+), 13 deletions(-) create mode 100644 src/host/promptInjection.ts create mode 100644 tests/host/promptInjection.spec.ts diff --git a/README.md b/README.md index dcf2339..c132cf5 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Five surfaces, one story — what your agent is carrying, how it got there, and | **Context tab** | The full dashboard: stats, composition, per-request trend, events, and file activity — in every session. | | **Fleet tab** | The agent network: the current agent, its parents, and every subagent, live. | | **`/context` command** | A centered modal with the same composition and context browser, without leaving the chat. | -| **Preferences card** | Per-user defaults: view placement, trend granularity & mode, File Activity sort, and more. | +| **Preferences card** | Per-user defaults: view placement, trend granularity & mode, File Activity sort, and the system prompt prefix/suffix joined around every session. | ## 🗂️ The Context Insights page @@ -187,6 +187,8 @@ A centered dialog opens with the **Current Composition** card and the **Context The **Context** preferences card holds this plugin's per-user settings — default placement (the **Context** and **Fleet** conversation tabs, the right Sidebar panel, or both), the **Fleet** tab's own Show/Hide switch, trend granularity (Step/Turn), trend mode (Total/Delta), the trend duration curve (shown by default), tool and File Activity sort, and the sidebar insights entry. It lives on the **Plugins** page: the sidebar **Plugins** entry → the **dsh-context** bundle's page → its **Configuration** section (the card is served by the entry's live Config form). +The **System Prompt · Prefix / Suffix** rows hold free text joined at the very start / end of every session's system prompt, for all agents (subagents included) — a per-deployment persona or behavioral rule that rides the harness's own prompt assembly. Edits apply from the next turn; leave a row empty to disable it. + In-chart and in-card toggles stay per-view and never overwrite the stored preference. ![The Context settings card](https://raw.githubusercontent.com/bowenliang123/dsh-context/main/docs/settings.png) diff --git a/docs/compatibility.md b/docs/compatibility.md index 1d03379..4a09628 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -51,8 +51,12 @@ The fold never branches on a detected harness version. The version probe is best - **Developer messages**: `developer/message` tool-registry changes are nested under `data.message` and fold into injected context, including request composition, live surface, and removed-node archives. They never count as human inputs; empty-content events keep their surface position at zero tokens, matching the harness's message projection (its `deriveEventMessage` projects an empty message to null — the fold's structural read keeps the node). - **Tool-result error mark**: the event-level `data.error` metadata is admitted only alongside the message mark (the generation's own validation refuses a row whose `data.error` outlives `message.isError`), so the fold reads `message.isError` alone; a hostile row carrying only the envelope field degrades to unflagged, never to a throw. - **Image offload accounting (deferred)**: when a route rejects with `IMAGE_OFFLOAD_REQUIRED`, the harness offloads images out of the context through a message projection. The plugin's fold reads raw durable events, so an offloaded message keeps its pre-offload image count and price until compaction removes it — an over-count on a rare recovery path, not a crash. Revisit alongside the harness's own token-meter treatment if it shows up in practice. -- **User preferences**: plugin configuration lives on the Plugins page, derived from the loader entry's own Config schema (`src/host/config.ts`): cordis validates the `config:` block through the schema's Standard Schema face, and the SAME schema is what the settings `describe` projects — the namespace is the entry id (`dsh-context`) and the nine preference fields (`.volatile()`-marked) are the served, live-editable form; the fold bounds stay ordinary entry config (their edit remounts the entry, a preference edit commits volatile-only and never remounts). The browser half registers its card on the Plugins page's keyed `plugins.bundle.config` seat over the `configForms` transport (`ctx.configForms.whileServed` keeps the card alive only while the Host serves the namespace). The `.volatile()` modifier is feature-detected at schema build time (`volatileField`), so the same bundle still loads — preferences inert — on a below-floor harness whose schemastery predates it (the gate's fallback composition). +- **User preferences**: plugin configuration lives on the Plugins page, derived from the loader entry's own Config schema (`src/host/config.ts`): cordis validates the `config:` block through the schema's Standard Schema face, and the SAME schema is what the settings `describe` projects — the namespace is the entry id (`dsh-context`) and the preference fields (`.volatile()`-marked) are the served, live-editable form; the fold bounds stay ordinary entry config (their edit remounts the entry, a preference edit commits volatile-only and never remounts). The browser half registers its card on the Plugins page's keyed `plugins.bundle.config` seat over the `configForms` transport (`ctx.configForms.whileServed` keeps the card alive only while the Host serves the namespace). The `.volatile()` modifier is feature-detected at schema build time (`volatileField`), so the same bundle still loads — preferences inert — on a below-floor harness whose schemastery predates it (the gate's fallback composition). - Preference values persisted on a retired line (the `settings.yaml` section `dsh-context`) are moved into the profile entry of the same name by the harness's own legacy import on first boot, so existing values carry into the current surface. +- **System-prompt prefix/suffix injection**: the `promptPrefix`/`promptSuffix` preferences are joined around the harness's assembled system prompt by two globally registered prompt sections (`src/host/promptInjection.ts`), not by string-editing session events. The seams this rides, probed per supported line in the compat matrix: + - `ctx.systemPrompt.section()` — the ordered-section registry face (`packages/core/system-prompt`). The sections are registered through a deferred `ctx.inject(['systemPrompt'], …)`, so a composition that starts the registry later still gets them, one without it degrades to a no-op, and the registration unwinds with this plugin's fiber. The orders anchor on the harness's own registry (`HARNESS_IDENTITY - 1000`, `DEPLOYMENT_PERSONA_SUFFIX + 1000`), keeping the prefix first and the suffix last even if the central order table is renumbered. + - The volatile-only commit — `vendor/loader/src/config/entry.ts` rewrites the running config references on a preference edit without remounting the entry. The section text providers read those references per assembly, so an edit applies from the next model step; the harness's own system-prompt projection then updates the session log (`system/message` replace or append, per its routing), which is how a new session carries the text from its first turn. + - Scope: global sections participate in every assembly — every session, subagents included (the same semantics as the harness's own deployment `personaPrefix`/`personaSuffix`). A deployment that registers a `complete` prompt section keeps ONLY that section, by the registry's design; the configured text is dropped there. The text is registered `interpolate: false`, so `{{...}}` in user text stays literal and can never trip the strict variable interpolation. ## Parsing resilience [IMPORTANT!] diff --git a/src/client/components/settingsCard.tsx b/src/client/components/settingsCard.tsx index ccfc588..8a193ef 100644 --- a/src/client/components/settingsCard.tsx +++ b/src/client/components/settingsCard.tsx @@ -1,7 +1,7 @@ /** The dsh-context preference card, keyed on the Host-served `dsh-context` namespace: while that namespace is * unavailable (a deployment without the Host half, or a remote browser) the card renders nothing. */ -import { useState, type ReactElement } from 'react' +import { useEffect, useRef, useState, type ReactElement } from 'react' import { Menu } from '@deepseek-ai/dsh-client-ui-primitives' import { IconChevronDown } from '../primitives' import type { SettingsField, SettingsState } from '../settings' @@ -54,6 +54,49 @@ function PrefRow(props: PrefRowProps): ReactElement { type Translate = ViewKit['t'] +interface PromptTextRowProps { + label: string + placeholder: string + value: string + disabled: boolean + onCommit: (text: string) => void +} + +/** One free-text preference row: a local draft committed on blur (and on an + * unmount without a prior blur), so typing never writes the wire per keystroke. */ +function PromptTextRow(props: PromptTextRowProps): ReactElement { + const [draft, setDraft] = useState(props.value) + const [focused, setFocused] = useState(false) + const latest = useRef({ draft: props.value, value: props.value, commit: props.onCommit }) + latest.current = { draft, value: props.value, commit: props.onCommit } + // Follow the served value only while the row is idle: a host push (or the + // optimistic echo) must never clobber in-progress typing. + if (!focused && draft !== props.value) setDraft(props.value) + useEffect(() => () => { + const { draft: text, value, commit } = latest.current + if (text !== value) commit(text) + }, []) + const rows = Math.min(8, Math.max(2, draft.split('\n').length)) + return ( +
+ {props.label} +