Merge commit '70396085b141370ce32de1be4e225b4384eaf46d' into HEAD

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md
#	.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md
#	.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml
#	docs/config-catalog.i18n.yaml
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	docs/tool-catalog.i18n.yaml
#	docs/tool-catalog.md
#	docs/tool-catalog.zh.md
#	examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json
#	examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json
#	examples/acp-agent/tests/snapshots/code-mode-turn/tool-schemas.expected.json
#	packages/core/tools/README.i18n.yaml
#	packages/core/tools/README.zh.md
#	packages/core/tools/src/code-mode.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/host/plugin-inventory/tests/inventory.spec.ts
#	packages/mcp/mcp-client/tests/mcp-client.e2e.ts
#	packages/mcp/mcp-client/tests/mcp-client.spec.ts
#	packages/self-modification/tool-cordis/src/api-catalog.ts
#	packages/test-support/acp-snapshot/README.i18n.yaml
#	pnpm-lock.yaml
This commit is contained in:
Tianyi Cui
2026-08-17 11:31:59 +08:00
3845 changed files with 62208 additions and 100402 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/README.md
README.md: 843f19b04c8dd63924a20d3ed01a5b179a731484
README.zh.md: e4f5153d1b101773871df835ec6bc0d748d83406
README.md: fa28751e548dc4aec8e2e2711508816729f5d407
README.zh.md: 9d12c7a854c6cb008c3806ea246ec5b5667be941

View File

@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context`, `tmux-context`, and `session-reference` are opt-in.
Product plugins that add model-visible request context without defining a tool. `agent-instructions` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context`, `tmux-context`, and `session-reference` are opt-in.
| Package | Role | ctx key |
|---|---|---|
| [`session-reference/`](session-reference/README.md) | Bounded snapshots of other sessions | `ctx.sessionReferences` |
| [`session-reference/`](session-reference/README.md) | Bounded snapshots of other sessions | `ctx.sessionReferenceResolver` |
| [`time-context/`](time-context/README.md) | Current-time and elapsed-time context | — |
| [`tmux-context/`](tmux-context/README.md) | tmux location context | — |
| [`workspace-context/`](workspace-context/README.md) | Workspace-instruction context | — |
| [`agent-instructions/`](agent-instructions/README.md) | Workspace-instruction context | — |
Session references are documented in [docs/subsystems/session-reference.md](../../docs/subsystems/session-reference.md); the [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) owns its per-agent/session isolation and lifecycle split.
Session references are documented in [docs/subsystems/session-reference.md](../../docs/subsystems/session-reference.md); the [`agent-instructions` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) owns its per-agent/session isolation and lifecycle split.

View File

@@ -2,13 +2,13 @@
[English](README.md) | 中文
在不定义工具的情况下添加模型可见的请求上下文的产品插件。`workspace-context` 包含在默认 `dsh-agent-spine-demo` 组合包中,可通过组合包配置禁用;`time-context``tmux-context``session-reference` 需主动启用。
在不定义工具的情况下添加模型可见的请求上下文的产品插件。`agent-instructions` 包含在默认 `dsh-agent-spine-demo` 组合包中,可通过组合包配置禁用;`time-context``tmux-context``session-reference` 需主动启用。
| 包 | 职责 | ctx key |
|---|---|---|
| [`session-reference/`](session-reference/README.md) | 其他会话的有界快照 | `ctx.sessionReferences` |
| [`session-reference/`](session-reference/README.md) | 其他会话的有界快照 | `ctx.sessionReferenceResolver` |
| [`time-context/`](time-context/README.md) | 当前时间与耗时上下文 | — |
| [`tmux-context/`](tmux-context/README.md) | tmux 位置上下文 | — |
| [`workspace-context/`](workspace-context/README.md) | 工作区指令上下文 | — |
| [`agent-instructions/`](agent-instructions/README.md) | 工作区指令上下文 | — |
会话引用见 [docs/subsystems/session-reference.md](../../docs/subsystems/session-reference.md)[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)规定了其按 agent智能体/会话隔离与生命周期拆分。
会话引用见 [docs/subsystems/session-reference.md](../../docs/subsystems/session-reference.md)[`agent-instructions` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)规定了其按 agent智能体/会话隔离与生命周期拆分。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/workspace-context/README.md
README.md: 1ae47bef728a81c61f0db637872c93321a7bc687
README.zh.md: 8087412057b3e5924764df179ae293db5699f9ae
# pnpm run verify-translation-pairing --write packages/context/agent-instructions/README.md
README.md: 6f9d3f4e6b93609d44be0000c75e1a257eeb29d4
README.zh.md: e999af402ebc4e8460216c83230623c611140ae2

View File

@@ -1,4 +1,4 @@
# @deepseek-ai/dsh-workspace-context
# @deepseek-ai/dsh-agent-instructions
English | [中文](README.zh.md)
@@ -48,7 +48,7 @@ The plugin owns the complete `<system-reminder>` framing, and every injected `us
## State And Refresh
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `workspace-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step waits for every queued projection, folds newly composed context into its final batch immediately after the claimed messages, and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. Nested results aggregate successful file touches under their parent execution token, including when a later composite result is blocked; the top-level result transfers those touches either to the currently open session step or directly to the per-agent projection queue. A `step/end` releases its staged touches only after that boundary is in durable history, and serialized projections reconcile against visible session events plus the current inbox before replacing the single pending workspace context.
Model-visible text contains no hidden state markers. Each baseline or dynamic context event instead carries a typed `agent-instructions` source with a list of `{ action, scope, path, digest? }` changes; a complete baseline also carries `baseline: true` and a `baselineIdentity` derived from normalized discovery, precedence, project-root, and budget configuration. A matching durable `user/message` confirms a queued baseline and its candidate versions. An entering pre-step waits for every queued projection, folds newly composed context into its final batch immediately after the claimed messages, and removes the pending inbox copy; rejection keeps the current context queued. If a listener rewrites away a claimed workspace message without entering its replacement, a later boundary recomposes the current context. Nested results aggregate successful file touches under their parent execution token, including when a later composite result is blocked; the top-level result transfers those touches either to the currently open session step or directly to the per-agent projection queue. A `step/end` releases its staged touches only after that boundary is in durable history, and serialized projections reconcile against visible session events plus the current inbox before replacing the single pending workspace context.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope provider cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the typed source, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. A model-visible change enters the source, pending state, and version cache only when its file-specific section retains at least one content byte, or when its original content is genuinely empty. Partial truncation records the complete-content digest once any content byte survives; truncation to zero remains eligible for a later touch, while a same-digest version refresh updates only the provider cache. A baseline may still publish its budget diagnostic with an empty change list. A dynamic batch with no committed change is not injected at all, and a later touch retries it.

View File

@@ -1,4 +1,4 @@
# @deepseek-ai/dsh-workspace-context
# @deepseek-ai/dsh-agent-instructions
[English](README.md) | 中文
@@ -48,7 +48,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when
## 状态与刷新
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `workspace-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会等待所有已排队投影完成,再把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;若被拒绝,当前上下文则继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。即使后续复合结果被拦截,成功的嵌套文件 touch 也会聚合到父级执行 token 下;顶层结果会将这些 touch 交给当前打开的会话步骤,或直接交给逐 agent 投影队列。`step/end` 只会在自身边界进入持久历史后释放其暂存的 touch串行投影会根据可见会话事件和当前 inbox 协调状态,再替换唯一一条待处理工作区上下文。
模型可见文本不含隐藏状态标记。每个基线或动态上下文事件改为携带带类型的 `agent-instructions` 来源,其中包含 `{ action, scope, path, digest? }` 变更列表;完整基线还会携带 `baseline: true`,以及从规范化的发现、优先级、项目根目录和预算配置派生的 `baselineIdentity`。匹配的持久 `user/message` 会确认已排队基线及其候选版本。进入步骤的 pre-step 会等待所有已排队投影完成,再把新组合的上下文折入最终批次,位置紧随已领取的消息,并移除 inbox 中仍待处理的副本;若被拒绝,当前上下文则继续排队。若监听器改写掉已领取的 workspace 消息,又没有让替代消息进入,后续边界会重新组合当前上下文。即使后续复合结果被拦截,成功的嵌套文件 touch 也会聚合到父级执行 token 下;顶层结果会将这些 touch 交给当前打开的会话步骤,或直接交给逐 agent 投影队列。`step/end` 只会在自身边界进入持久历史后释放其暂存的 touch串行投影会根据可见会话事件和当前 inbox 协调状态,再替换唯一一条待处理工作区上下文。
路径与 SHA-1 内容 digest 都未变时,不会重复注入。每会话、每 scope 提供方 cache 只存储 `{ path, version, digest, trimmedDigest }`:当提供方的不透明 `FsVersion` 与有效可见状态都匹配时,对账会跳过内容读取;版本改变会在任何模型可见更新之前触发有界读取与 SHA-1 确认。`trimmedDigest` 是针对去除空白后内容的 SHA-1也是每目录重复 key因此较早候选文件与某个未更改文件的内容收敛后后者仍可被移除。恢复可行因为 SHA-1 状态持久化在带类型的来源中,而空的内存版本 cache 只会导致一次确认读取。压缩compaction会在 scope 的上下文事件离开可见表层后重新启用它,即使缓存版本未变。移除是 tombstone因此候选文件之后重新出现时会重新加载。模型可见变更只有在对应文件专属段落保留至少一个内容字节或原始内容确实为空时才会进入来源、pending 状态和版本 cache。只要任一内容字节保留下来部分截断就会记录完整内容的 digest截断到零字节则仍可在后续 touch 处理,而相同 digest 的版本刷新只更新提供方 cache。基线即使带空变更列表仍可发布字节预算诊断。动态批次若没有可提交变更则完全不注入并在后续 touch 时重试。

View File

@@ -1,14 +1,14 @@
{
"name": "@deepseek-ai/dsh-workspace-context",
"name": "@deepseek-ai/dsh-agent-instructions",
"description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files",
"version": "0.0.1-rc.2",
"version": "0.1.0-rc.6",
"publishConfig": {
"access": "restricted"
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/context/workspace-context"
"directory": "packages/context/agent-instructions"
},
"type": "module",
"main": "lib/index.js",
@@ -30,13 +30,13 @@
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
@@ -53,7 +53,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",

View File

@@ -1,12 +1,12 @@
/**
* Configuration normalization for workspace instruction discovery and rendering.
*
* @module @deepseek-ai/dsh-workspace-context/config
* @module @deepseek-ai/dsh-agent-instructions/config
*/
import { relative } from 'node:path'
import z from '@deepseek-ai/schemastery'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const

View File

@@ -1,7 +1,7 @@
/**
* Content identity for workspace instruction duplicate suppression.
*
* @module @deepseek-ai/dsh-workspace-context/digest
* @module @deepseek-ai/dsh-agent-instructions/digest
*/
import { createHash } from 'node:crypto'

View File

@@ -1,7 +1,7 @@
/**
* Instruction-file discovery and bounded, abort-aware provider reads.
*
* @module @deepseek-ai/dsh-workspace-context/files
* @module @deepseek-ai/dsh-agent-instructions/files
*/
import { createReadStream } from 'node:fs'
@@ -9,7 +9,7 @@ import { stat } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import type { FileSystem, FsInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
import { dshHomeDisplay } from '@deepseek-ai/dsh-home-paths'
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
import { trimmedInstructionDigest } from './digest.ts'
import {

View File

@@ -6,7 +6,7 @@
* Plugin lifecycle reads use the optional `ctx.fs` provider, so providerless products
* mount it as a no-op.
*
* @module @deepseek-ai/dsh-workspace-context
* @module @deepseek-ai/dsh-agent-instructions
*/
import type { Context } from '@deepseek-ai/cordis'
@@ -24,9 +24,9 @@ import {
reconcileInstructionContext,
workspaceContextMessage,
type InstructionVersionCache,
type WorkspaceInstructionSource,
type AgentInstructionSource,
} from './state.ts'
import type { WorkspaceInstructionChange } from './render.ts'
import type { AgentInstructionChange } from './render.ts'
export { Config, name }
export {
@@ -43,23 +43,23 @@ export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts
function visibleBaselineSource(
agent: Agent,
authorityMessages: readonly UserMessage[],
): WorkspaceInstructionSource | undefined {
): AgentInstructionSource | undefined {
for (const message of authorityMessages.toReversed()) {
if (message.source.kind === 'workspace-instructions' && message.source.baseline === true) {
if (message.source.kind === 'agent-instructions' && message.source.baseline === true) {
return message.source
}
}
for (const seq of agent.session.surface.nodes.toReversed()) {
const event = agent.session.events[seq]
if (event?.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.kind === 'agent-instructions'
&& event.data.source.baseline === true) return event.data.source
}
return undefined
}
function isWorkspaceContext(message: UserMessage): boolean {
return message.source.kind === 'workspace-instructions'
return message.source.kind === 'agent-instructions'
}
function sameContextPayload(left: UserMessage, right: UserMessage): boolean {
@@ -89,10 +89,10 @@ export function apply(ctx: Context, config: Config): void {
const executionTouches = new Map<ToolExecutionToken, ProjectionTouch[]>()
ctx.effect(
() => () => {
projectionLifecycle.abort(new Error('workspace-context disposed'))
projectionLifecycle.abort(new Error('agent-instructions disposed'))
executionTouches.clear()
},
'workspace-context.projectionLifecycle',
'agent-instructions.projectionLifecycle',
)
// Emit listeners are not awaited, so each projection must compose against the
// inbox produced by earlier file results for the same agent.
@@ -117,7 +117,7 @@ export function apply(ctx: Context, config: Config): void {
if (fileSystem === undefined) return undefined
if (touchedPaths.length === 0 && pending.length > 0) return pending[0]
const content: UserMessage['content'][number][] = []
const changes: WorkspaceInstructionChange[] = []
const changes: AgentInstructionChange[] = []
let desiredBaseline = false
const authorityMessages = [...claimed]
/* v8 ignore next -- normal agents carry an absolute session cwd. */
@@ -174,7 +174,7 @@ export function apply(ctx: Context, config: Config): void {
authorityMessages.push(createUserMessage({
content: baselineContent,
source: {
kind: 'workspace-instructions',
kind: 'agent-instructions',
form: 'instructions',
baseline: true,
baselineIdentity: identity,
@@ -201,8 +201,8 @@ export function apply(ctx: Context, config: Config): void {
)
if (update !== undefined) {
content.push(...update.context.content)
/* v8 ignore next -- reconciliation constructs only workspace-instructions contexts. */
if (update.context.source.kind === 'workspace-instructions') {
/* v8 ignore next -- reconciliation constructs only agent-instructions contexts. */
if (update.context.source.kind === 'agent-instructions') {
changes.push(...update.context.source.changes)
}
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
@@ -212,7 +212,7 @@ export function apply(ctx: Context, config: Config): void {
return createUserMessage({
content,
source: {
kind: 'workspace-instructions',
kind: 'agent-instructions',
form: 'instructions',
...desiredBaseline ? { baseline: true } : {},
...desiredBaseline ? { baselineIdentity: identity } : {},

View File

@@ -1,13 +1,13 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-workspace-context`.
* @module @deepseek-ai/dsh-workspace-context/invariant
* Package-owned invariant companion for `@deepseek-ai/dsh-agent-instructions`.
* @module @deepseek-ai/dsh-agent-instructions/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-workspace-context'
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-instructions'
/** Cordis companion plugin name. */
export const name = 'workspace-context-invariant'

View File

@@ -1,7 +1,7 @@
/**
* Model-facing workspace instruction rendering within an explicit byte budget.
*
* @module @deepseek-ai/dsh-workspace-context/render
* @module @deepseek-ai/dsh-agent-instructions/render
*/
import { basename, dirname } from 'node:path'
@@ -44,7 +44,7 @@ interface RenderedInstructionContext extends RenderedWorkspaceContext {
}
/** Structured dynamic state persisted outside model-visible prompt prose. */
export interface WorkspaceInstructionChange {
export interface AgentInstructionChange {
action: 'set' | 'replace' | 'remove'
scope: string
path: string
@@ -53,7 +53,7 @@ export interface WorkspaceInstructionChange {
/** One state transition paired with the content used to render it. */
export interface ChangeRenderItem {
change: WorkspaceInstructionChange
change: AgentInstructionChange
file: LoadedInstructionFile
}
@@ -192,7 +192,7 @@ function changedSectionText(item: ChangeRenderItem): string {
export function renderInstructionChanges(
items: ChangeRenderItem[],
maxBytes: number,
): { text: string; changes: WorkspaceInstructionChange[] } {
): { text: string; changes: AgentInstructionChange[] } {
const byAbsolutePath = new Map(items.map(item => [item.file.absolutePath, item]))
const style: RenderStyle = {
intro: '',

View File

@@ -1,7 +1,7 @@
/**
* Session-visible workspace instruction state and dynamic reconciliation.
*
* @module @deepseek-ai/dsh-workspace-context/state
* @module @deepseek-ai/dsh-agent-instructions/state
*/
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -28,26 +28,26 @@ import {
USER_GLOBAL_DIRECTORY,
USER_GLOBAL_FILE,
type ChangeRenderItem,
type WorkspaceInstructionChange,
type AgentInstructionChange,
} from './render.ts'
export const name = 'workspace-context'
export const name = 'agent-instructions'
/** Durable producer, file, and reconciliation facts for one workspace context. */
export interface WorkspaceInstructionSource {
kind: 'workspace-instructions'
export interface AgentInstructionSource {
kind: 'agent-instructions'
/** Every workspace context carries instructions read out of a file (the `instructions` context form). */
form: 'instructions'
/** Marks the complete startup/resume baseline rather than a later delta. */
baseline?: true
/** Discovery, precedence, and budget identity used to validate a resumed baseline. */
baselineIdentity?: string
changes: WorkspaceInstructionChange[]
changes: AgentInstructionChange[]
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
'workspace-instructions': WorkspaceInstructionSource
'agent-instructions': AgentInstructionSource
}
}
@@ -68,7 +68,7 @@ export type InstructionVersionCache = WeakMap<Session, Map<string, InstructionVe
/** A metadata-cache transition associated with one rendered instruction change. */
export interface InstructionVersionUpdate {
change: WorkspaceInstructionChange
change: AgentInstructionChange
state?: InstructionVersionState
}
@@ -78,10 +78,10 @@ export interface ReconciledInstructionContext {
versionUpdates: InstructionVersionUpdate[]
}
function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[]): UserMessage {
function workspaceContextHook(text: string, changes: AgentInstructionChange[]): UserMessage {
return createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'workspace-instructions', form: 'instructions', changes },
source: { kind: 'agent-instructions', form: 'instructions', changes },
})
}
@@ -99,9 +99,9 @@ export function workspaceContextMessage(text: string): Message {
function isWorkspaceContextSource(
source: unknown,
): source is { kind: 'workspace-instructions'; changes: unknown[] } {
): source is { kind: 'agent-instructions'; changes: unknown[] } {
return typeof source === 'object' && source !== null
&& 'kind' in source && source.kind === 'workspace-instructions'
&& 'kind' in source && source.kind === 'agent-instructions'
&& 'changes' in source && Array.isArray(source.changes)
}
@@ -109,8 +109,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function workspaceInstructionChanges(source: { changes: unknown[] }): WorkspaceInstructionChange[] {
const changes: WorkspaceInstructionChange[] = []
function workspaceInstructionChanges(source: { changes: unknown[] }): AgentInstructionChange[] {
const changes: AgentInstructionChange[] = []
for (const value of source.changes) {
if (!isRecord(value)) continue
if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
@@ -126,7 +126,7 @@ function workspaceInstructionChanges(source: { changes: unknown[] }): WorkspaceI
return changes
}
function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstructionChange): boolean {
function sameInstructionChange(a: AgentInstructionChange, b: AgentInstructionChange): boolean {
return a.action === b.action
&& a.scope === b.scope
&& a.path === b.path
@@ -136,9 +136,9 @@ function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstru
function visibleInstructionChanges(
agent: Agent,
authorityMessages: readonly UserMessage[],
): Map<string, WorkspaceInstructionChange> {
): Map<string, AgentInstructionChange> {
const visibleSeqs = new Set(agent.session.surface.nodes)
const visible = new Map<string, WorkspaceInstructionChange>()
const visible = new Map<string, AgentInstructionChange>()
for (const [seq, event] of agent.session.events.entries()) {
if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue
const changes = workspaceInstructionChanges(event.data.source)
@@ -161,14 +161,14 @@ function visibleInstructionChanges(
* @returns latest baseline changes and provider versions keyed by logical scope.
*/
export function baselineInstructionState(files: LoadedInstructionFile[]): {
changes: Map<string, WorkspaceInstructionChange>
changes: Map<string, AgentInstructionChange>
versions: Map<string, InstructionVersionState>
} {
const changes = new Map<string, WorkspaceInstructionChange>()
const changes = new Map<string, AgentInstructionChange>()
const versions = new Map<string, InstructionVersionState>()
for (const file of files) {
const digest = instructionContentSha1(file.content)
const change: WorkspaceInstructionChange = {
const change: AgentInstructionChange = {
action: 'set',
scope: instructionScopeKey(file.displayPath),
path: file.displayPath,
@@ -204,7 +204,7 @@ function versionStatesFor(session: Session, cache: InstructionVersionCache): Map
*/
export function retainedInstructionVersionUpdates(
updates: readonly InstructionVersionUpdate[],
renderedChanges: readonly WorkspaceInstructionChange[],
renderedChanges: readonly AgentInstructionChange[],
): InstructionVersionUpdate[] {
return updates.filter(update => renderedChanges.some(change => sameInstructionChange(update.change, change)))
}
@@ -317,7 +317,7 @@ export async function reconcileInstructionContext(
const items: ChangeRenderItem[] = []
const versionUpdates: InstructionVersionUpdate[] = []
const pushRemoval = (scope: string, path: string): void => {
const change: WorkspaceInstructionChange = { action: 'remove', scope, path }
const change: AgentInstructionChange = { action: 'remove', scope, path }
items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
versionUpdates.push({ change })
}
@@ -410,7 +410,7 @@ export async function reconcileInstructionContext(
continue
}
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
const change: WorkspaceInstructionChange = {
const change: AgentInstructionChange = {
action,
scope,
path: file.displayPath,

View File

@@ -4,15 +4,15 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context'
import * as WorkspaceContext from '@deepseek-ai/dsh-agent-instructions'
import { candidateScopeKey } from '../src/render.ts'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
@@ -37,10 +37,10 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> {
await mkdir(join(workdir, '.git'), { recursive: true })
await writeFile(join(workdir, 'AGENTS.md'), `If the user asks for the workspace context handshake, reply with exactly this string and nothing else: ${PROBE}.\n`)
ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: 'Answer the user exactly and concisely.' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs)
@@ -109,7 +109,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
const events = [...live.agent.session.events]
const update = events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.kind === 'agent-instructions'
&& event.data.source.baseline !== true)
expect(update?.type === 'user/message' && update.data.source).toMatchObject({
changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }],

View File

@@ -4,8 +4,8 @@ import { tmpdir } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import LlmService, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
import * as workspaceContext from '@deepseek-ai/dsh-agent-instructions'
import LlmRuntime, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -22,7 +22,7 @@ import type {
} from '@deepseek-ai/dsh-fs'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type {
ToolExecution,
ToolExecutionToken,
@@ -32,7 +32,7 @@ import {
discoverBaselineInstructionFiles,
loadBaselineInstructions,
renderWorkspaceContext,
} from '@deepseek-ai/dsh-workspace-context'
} from '@deepseek-ai/dsh-agent-instructions'
import {
applyInstructionVersionUpdates,
baselineInstructionState,
@@ -118,7 +118,7 @@ class RecordingFileSystem extends FileSystem {
}
override async readBytes(_target: FsTarget, _signal: AbortSignal | undefined, _maxBytes: number): Promise<Uint8Array> {
throw new Error('not needed in workspace-context tests')
throw new Error('not needed in agent-instructions tests')
}
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
@@ -173,7 +173,7 @@ async function mountWorkspaceContext(ctx: Context, config: workspaceContext.Conf
async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise<Awaited<ReturnType<Context['plugin']>>> {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs)
return ctx.plugin(workspaceContext, config)
@@ -192,7 +192,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
send: () => {},
followup: () => {},
steer: () => {},
inject: () => { throw new Error('workspace-context must append directly to the open step') },
inject: () => { throw new Error('agent-instructions must append directly to the open step') },
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
@@ -219,7 +219,7 @@ function blocksText(blocks: { type: string; text?: string }[] | undefined): stri
async function workspaceContextOf(agent: Agent): Promise<UserMessage> {
return vi.waitFor(() => {
const context = agent.inbox.nextStep.find(message =>
message.source.kind === 'workspace-instructions')
message.source.kind === 'agent-instructions')
expect(context).toBeDefined()
return context!
})
@@ -240,7 +240,7 @@ async function syncedWorkspaceContext(ctx: Context, agent: Agent): Promise<UserM
function baselineEvents(agent: Agent): SessionEvent[] {
return agent.session.events.filter(event =>
event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.kind === 'agent-instructions'
&& event.data.source.baseline === true)
}
@@ -248,7 +248,7 @@ async function appendAdditionalContexts(ctx: Context, agent: Agent): Promise<num
await syncedWorkspaceContext(ctx, agent)
let lastSeq: number | undefined
for (const claimed of agent.inbox.claim('next-step', 1)) {
if (claimed.source.kind !== 'workspace-instructions') continue
if (claimed.source.kind !== 'agent-instructions') continue
const event = agent.session.append('user/message', claimed, { surfaceOp: 'append' })
ctx.emit('session/event', agent.session, event)
lastSeq = event.seq
@@ -578,10 +578,12 @@ describe('workspace context instruction discovery', () => {
const root = await tempRepo()
const emptyHome = await tempRepo()
// Isolate the default-home fallback: blank DSH_HOME is treated as unset, and
// HOME points at an empty dir so the default ~/.dsh holds no global scope.
// Symlinks are followed, so a real ~/.dsh/AGENTS.md would otherwise leak in.
// the home dirs point at an empty dir so the default ~/.dsh holds no global
// scope. Windows homedir() reads USERPROFILE (not HOME), so both must be
// stubbed or a real ~/.dsh/AGENTS.md would otherwise leak in.
vi.stubEnv('DSH_HOME', '')
vi.stubEnv('HOME', emptyHome)
if (process.platform === 'win32') vi.stubEnv('USERPROFILE', emptyHome)
try {
const cwd = join(root, 'child')
await mkdir(cwd, { recursive: true })
@@ -622,13 +624,16 @@ describe('workspace context instruction discovery', () => {
try {
await write(join(home, '.dsh/AGENTS.md'), 'global default rule')
// A set DSH_HOME would override the homedir default and relabel the home.
vi.stubEnv('DSH_HOME', '')
vi.resetModules()
vi.doMock('node:os', () => ({ homedir: () => home }))
const isolated = await import('@deepseek-ai/dsh-workspace-context')
const isolated = await import('@deepseek-ai/dsh-agent-instructions')
const files = await isolated.discoverBaselineInstructionFiles({ cwd: root })
expect(files.map(file => file.displayPath)).toEqual(['~/.dsh/AGENTS.md'])
} finally {
vi.unstubAllEnvs()
vi.doUnmock('node:os')
vi.resetModules()
await rm(root, { recursive: true, force: true })
@@ -644,7 +649,7 @@ describe('workspace context instruction discovery', () => {
vi.resetModules()
vi.doMock('node:os', () => ({ homedir: () => home }))
const isolated = await import('@deepseek-ai/dsh-workspace-context')
const isolated = await import('@deepseek-ai/dsh-agent-instructions')
const files = await isolated.discoverBaselineInstructionFiles({ cwd: root, dshHome: '~/.dsh' })
expect(files).toEqual([{ absolutePath: join(home, '.dsh/AGENTS.md'), displayPath: '~/.dsh/AGENTS.md' }])
@@ -707,8 +712,8 @@ describe('workspace context rendering', () => {
'package rules',
'</system-reminder>',
].join('\n'))
expect(rendered.text).not.toContain('<workspace-context')
expect(rendered.text).not.toContain('workspace-context:')
expect(rendered.text).not.toContain('<agent-instructions')
expect(rendered.text).not.toContain('agent-instructions:')
expect(rendered.text).not.toContain('/repo/')
expect(rendered.omitted).toEqual([])
expect(rendered.truncated).toEqual([])
@@ -1027,7 +1032,7 @@ describe('workspace context request injection', () => {
data: {
role: 'user',
source: {
kind: 'workspace-instructions',
kind: 'agent-instructions',
form: 'instructions',
baseline: true,
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
@@ -1041,7 +1046,7 @@ describe('workspace context request injection', () => {
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md')
expect(derivedText(agent)).toContain('repo rule')
expect(derivedText(agent)).not.toContain('<context source=')
expect(derivedText(agent)).not.toContain('<workspace-context')
expect(derivedText(agent)).not.toContain('<agent-instructions')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1089,7 +1094,7 @@ describe('workspace context request injection', () => {
expect(baselineEvents(firstResume)).toHaveLength(1)
expect(baselineEvents(secondResume)).toHaveLength(1)
expect(secondResume.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions')).toHaveLength(1)
&& event.data.source.kind === 'agent-instructions')).toHaveLength(1)
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1115,7 +1120,7 @@ describe('workspace context request injection', () => {
expect(baselineEvents(resumed)).toHaveLength(1)
expect(resumed.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions')).toHaveLength(1)
&& event.data.source.kind === 'agent-instructions')).toHaveLength(1)
} finally {
await ctx.fiber.dispose()
await rm(dirname(root), { recursive: true, force: true })
@@ -1143,7 +1148,7 @@ describe('workspace context request injection', () => {
expect(baselineEvents(secondResume)).toHaveLength(1)
expect(secondResume.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions')).toHaveLength(1)
&& event.data.source.kind === 'agent-instructions')).toHaveLength(1)
expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).toContain('omitted AGENTS.md')
expect(blocksText(secondResume.session.deriveMessages()[0]?.content)).not.toContain('root root')
} finally {
@@ -1171,9 +1176,9 @@ describe('workspace context request injection', () => {
expect(baselineEvents(resumed)).toHaveLength(1)
const update = resumed.session.events.findLast(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.kind === 'agent-instructions'
&& event.data.source.baseline !== true)
expect(update?.type === 'user/message' && update.data.source.kind === 'workspace-instructions'
expect(update?.type === 'user/message' && update.data.source.kind === 'agent-instructions'
? update.data.source.changes
: undefined).toMatchObject([
{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
@@ -1216,7 +1221,7 @@ describe('workspace context request injection', () => {
expect(replacementText.indexOf('Instructions from: CLAUDE.md'))
.toBeLessThan(replacementText.indexOf('Instructions from: AGENTS.md'))
const baselineIdentities = baselines.flatMap(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.kind === 'agent-instructions'
&& typeof event.data.source.baselineIdentity === 'string'
? [event.data.source.baselineIdentity]
: [])
@@ -1259,7 +1264,7 @@ describe('workspace context request injection', () => {
const claudeResume = stubAgent(root, [...original.session.events])
await composeBaselinePrefix(claudeCtx, claudeResume)
const claudeBaseline = baselineEvents(claudeResume).at(-1)
expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'workspace-instructions'
expect(claudeBaseline?.type === 'user/message' && claudeBaseline.data.source.kind === 'agent-instructions'
? claudeBaseline.data.source.changes
: undefined).toMatchObject([
{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
@@ -1274,7 +1279,7 @@ describe('workspace context request injection', () => {
const restored = stubAgent(root, [...claudeResume.session.events])
await composeBaselinePrefix(restoredCtx, restored)
const restoredBaseline = baselineEvents(restored).at(-1)
expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'workspace-instructions'
expect(restoredBaseline?.type === 'user/message' && restoredBaseline.data.source.kind === 'agent-instructions'
? restoredBaseline.data.source.changes
: undefined).toMatchObject([
{ action: 'remove', scope: sk('.', 'CLAUDE.md'), path: 'CLAUDE.md' },
@@ -1314,7 +1319,7 @@ describe('workspace context request injection', () => {
const replacement = baselines.at(-1)
expect(replacement?.type === 'user/message' ? blocksText(replacement.data.content) : '')
.toContain('No workspace instructions are currently active.')
expect(replacement?.type === 'user/message' && replacement.data.source.kind === 'workspace-instructions'
expect(replacement?.type === 'user/message' && replacement.data.source.kind === 'agent-instructions'
? replacement.data.source.changes
: undefined).toMatchObject([
{ action: 'remove', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' },
@@ -1346,7 +1351,7 @@ describe('workspace context request injection', () => {
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const inserted = original.inbox.nextStep[0]
expect(inserted?.source).toMatchObject({ kind: 'workspace-instructions', baseline: true })
expect(inserted?.source).toMatchObject({ kind: 'agent-instructions', baseline: true })
await fiber.dispose()
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
@@ -1367,7 +1372,7 @@ describe('workspace context request injection', () => {
expect(decision.messages.map(message => message.id)).toEqual([inserted?.id])
expect(resumed.inbox.nextStep).toEqual([])
expect(resumed.session.events.filter(event => event.type === 'agent/inbox/spliced'
&& event.data.inserted.some(message => message.source.kind === 'workspace-instructions'
&& event.data.inserted.some(message => message.source.kind === 'agent-instructions'
&& message.source.baseline === true))).toHaveLength(1)
expect(baselineEvents(resumed)).toHaveLength(1)
} finally {
@@ -1444,7 +1449,7 @@ describe('workspace context request injection', () => {
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
)
const stale = original.inbox.nextStep[0]
expect(stale?.source).toMatchObject({ kind: 'workspace-instructions', baseline: true })
expect(stale?.source).toMatchObject({ kind: 'agent-instructions', baseline: true })
await originalCtx.fiber.dispose()
if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' })
@@ -1480,7 +1485,7 @@ describe('workspace context request injection', () => {
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'stale nested instructions' }],
source: {
kind: 'workspace-instructions',
kind: 'agent-instructions',
form: 'instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), digest: 'stale' }],
},
@@ -1491,7 +1496,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
const removal = agent.session.events.find(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.kind === 'agent-instructions'
&& event.data.source.changes.some(change => change.action === 'remove'))
expect(removal?.type === 'user/message' ? removal.data.source : undefined).toMatchObject({
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
@@ -1514,7 +1519,7 @@ describe('workspace context request injection', () => {
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'stale nested instructions' }],
source: {
kind: 'workspace-instructions',
kind: 'agent-instructions',
form: 'instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md'), digest: 'stale' }],
},
@@ -1525,16 +1530,16 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
const workspaceEvents = agent.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions')
&& event.data.source.kind === 'agent-instructions')
expect(workspaceEvents).toHaveLength(2)
expect(workspaceEvents.some(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions'
&& event.data.source.kind === 'agent-instructions'
&& event.data.source.changes.some(change => change.action === 'remove'))).toBe(true)
expect(baselineEvents(agent)).toHaveLength(1)
await composeBaselinePrefix(ctx, agent)
expect(agent.session.events.filter(event => event.type === 'user/message'
&& event.data.source.kind === 'workspace-instructions')).toHaveLength(2)
&& event.data.source.kind === 'agent-instructions')).toHaveLength(2)
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1566,7 +1571,7 @@ describe('workspace context request injection', () => {
if (decision.kind !== 'enter') throw new Error('workspace baseline was rejected')
expect(decision.messages).toHaveLength(2)
expect(decision.messages[0]).toBe(prompt)
expect(decision.messages[1]?.source).toMatchObject({ kind: 'workspace-instructions', baseline: true })
expect(decision.messages[1]?.source).toMatchObject({ kind: 'agent-instructions', baseline: true })
expect(blocksText(decision.messages[1]?.content)).toContain('Instructions from: AGENTS.md')
expect(agent.inbox.nextStep).toHaveLength(0)
} finally {
@@ -1592,7 +1597,7 @@ describe('workspace context request injection', () => {
const pending = await workspaceContextOf(agent)
expect(pending?.source).toMatchObject({
kind: 'workspace-instructions',
kind: 'agent-instructions',
changes: [{ action: 'replace', scope: sk(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE) }],
})
expect(blocksText(pending?.content)).toContain('updated global rule')
@@ -1733,7 +1738,7 @@ describe('workspace context request injection', () => {
if (decision.kind !== 'enter') throw new Error('post-compaction request was rejected')
expect(decision.messages).toHaveLength(2)
expect(decision.messages[0]).toBe(prompt)
expect(decision.messages[1]?.source).toMatchObject({ kind: 'workspace-instructions', baseline: true })
expect(decision.messages[1]?.source).toMatchObject({ kind: 'agent-instructions', baseline: true })
expect(blocksText(decision.messages[1]?.content)).toContain('first post-compaction request rule')
expect(agent.inbox.nextStep).toEqual([])
} finally {
@@ -1765,7 +1770,7 @@ describe('workspace context request injection', () => {
const baselines = baselineEvents(resumed)
expect(baselines).toHaveLength(1)
const latest = resumed.session.events.findLast(event =>
event.type === 'user/message' && event.data.source.kind === 'workspace-instructions')
event.type === 'user/message' && event.data.source.kind === 'agent-instructions')
expect(latest?.type === 'user/message' ? latest.data.source : undefined).toMatchObject({
changes: [{ action: 'replace', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
@@ -1950,14 +1955,14 @@ describe('workspace context request injection', () => {
)
expect(contexts).toHaveLength(1)
const source = contexts[0]?.type === 'user/message' ? contexts[0].data.source : undefined
expect(source?.kind === 'workspace-instructions' ? source.changes : undefined).toEqual([])
expect(source?.kind === 'agent-instructions' ? source.changes : undefined).toEqual([])
if (maxBytes === 120) {
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md')
expect(derivedText(agent)).toContain('from 1000 to 0 bytes')
} else {
expect(derivedText(agent)).not.toContain('Instructions from: AGENTS.md')
}
expect(derivedText(agent)).not.toContain('workspace-context:')
expect(derivedText(agent)).not.toContain('agent-instructions:')
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -2364,7 +2369,7 @@ describe('workspace context request injection', () => {
}
})
it('does not inject an empty workspace-context message when maxBytes is negative', async () => {
it('does not inject an empty agent-instructions message when maxBytes is negative', async () => {
const root = await tempRepo()
const home = await tempRepo()
try {
@@ -2434,7 +2439,7 @@ describe('workspace context request injection', () => {
},
}
})
const isolated = await import('@deepseek-ai/dsh-workspace-context')
const isolated = await import('@deepseek-ai/dsh-agent-instructions')
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
observedStats.clear()
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
@@ -2467,7 +2472,7 @@ describe('workspace context request injection', () => {
},
}
})
const isolated = await import('@deepseek-ai/dsh-workspace-context')
const isolated = await import('@deepseek-ai/dsh-agent-instructions')
const rendered = await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, maxBytes: 65536 })
@@ -2501,10 +2506,10 @@ describe('dynamic nested workspace context injection', () => {
toolCallResponse('read-after-abort', 'read', { file_path: join('pkg', 'deep', 'file.txt') }),
textResponse('done'),
])
await ctx.plugin(LlmService)
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs)
@@ -2633,15 +2638,15 @@ describe('dynamic nested workspace context injection', () => {
})
expect(result.isError).toBe(false)
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions' })
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'agent-instructions' })
const queuedSource = ((await syncedWorkspaceContext(ctx, agent))).source
expect(queuedSource).toMatchObject({ kind: 'workspace-instructions', form: 'instructions' })
expect(queuedSource.kind === 'workspace-instructions' && queuedSource.changes.some(change =>
expect(queuedSource).toMatchObject({ kind: 'agent-instructions', form: 'instructions' })
expect(queuedSource.kind === 'agent-instructions' && queuedSource.changes.some(change =>
change.action === 'set'
&& change.scope === sk('pkg', 'AGENTS.md')
&& change.path === join('pkg', 'AGENTS.md'))).toBe(true)
const source = ((await syncedWorkspaceContext(ctx, agent))).source
const firstChange = source?.kind === 'workspace-instructions'
const firstChange = source?.kind === 'agent-instructions'
? source.changes.find(change => change.scope === sk('pkg', 'AGENTS.md'))
: undefined
const changeDigest = typeof firstChange === 'object' && firstChange !== null && !Array.isArray(firstChange)
@@ -2651,7 +2656,7 @@ describe('dynamic nested workspace context injection', () => {
const text = blocksText(((await syncedWorkspaceContext(ctx, agent))).content)
expect(text).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
expect(text).toContain('nested package rule')
expect(text).not.toContain('<workspace-context')
expect(text).not.toContain('<agent-instructions')
expect(text).toContain('baseline root rule')
} finally {
await rm(root, { recursive: true, force: true })
@@ -2745,7 +2750,7 @@ describe('dynamic nested workspace context injection', () => {
})
const source = ((await syncedWorkspaceContext(ctx, agent))).source
const changes = source?.kind === 'workspace-instructions'
const changes = source?.kind === 'agent-instructions'
? source.changes
: []
expect(changes).toEqual(expect.arrayContaining([
@@ -2838,7 +2843,7 @@ describe('dynamic nested workspace context injection', () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
const instructionPath = join(root, 'pkg/AGENTS.md')
@@ -2876,7 +2881,7 @@ describe('dynamic nested workspace context injection', () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
const instructionPath = join(root, 'pkg/AGENTS.md')
@@ -2921,7 +2926,7 @@ describe('dynamic nested workspace context injection', () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
const instructionPath = join(root, 'pkg/AGENTS.md')
@@ -2944,8 +2949,8 @@ describe('dynamic nested workspace context injection', () => {
expect(first.additionalContexts).toBeUndefined()
expect(second.additionalContexts).toBeUndefined()
expect(((await syncedWorkspaceContext(ctx, firstAgent))).source.kind).toBe('workspace-instructions')
expect(((await syncedWorkspaceContext(ctx, secondAgent))).source.kind).toBe('workspace-instructions')
expect(((await syncedWorkspaceContext(ctx, firstAgent))).source.kind).toBe('agent-instructions')
expect(((await syncedWorkspaceContext(ctx, secondAgent))).source.kind).toBe('agent-instructions')
expect(fs.readTargets.filter(path => path === instructionPath)).toHaveLength(2)
} finally {
await ctx.fiber.dispose()
@@ -2977,7 +2982,7 @@ describe('dynamic nested workspace context injection', () => {
})
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
kind: 'workspace-instructions',
kind: 'agent-instructions',
form: 'instructions',
changes: [{ action: 'replace', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
@@ -3088,7 +3093,7 @@ describe('dynamic nested workspace context injection', () => {
if (previous === undefined) throw new Error('missing AGENTS.md baseline state')
const authoritative = createUserMessage({
content: [{ type: 'text', text: 'nested rule' }],
source: { kind: 'workspace-instructions', form: 'instructions', changes: [previous] },
source: { kind: 'agent-instructions', form: 'instructions', changes: [previous] },
})
if (authority === 'visible') {
agent.session.append('user/message', authoritative, { surfaceOp: 'append' })
@@ -3113,7 +3118,7 @@ describe('dynamic nested workspace context injection', () => {
scopeMessages: [createUserMessage({
content: [{ type: 'text', text: 'pending baseline duplicate' }],
source: {
kind: 'workspace-instructions',
kind: 'agent-instructions',
form: 'instructions',
changes: [{ action: 'set', scope: sk('.', 'AGENTS.md'), path: 'AGENTS.md' }],
},
@@ -3158,7 +3163,7 @@ describe('dynamic nested workspace context injection', () => {
if (previous === undefined) throw new Error('missing AGENTS.md baseline state')
const authoritative = createUserMessage({
content: [{ type: 'text', text: 'repo rule' }],
source: { kind: 'workspace-instructions', form: 'instructions', changes: [previous] },
source: { kind: 'agent-instructions', form: 'instructions', changes: [previous] },
})
agent.session.append('user/message', authoritative, { surfaceOp: 'append' })
const resolved = resolveConfig({ dshHome: home, maxBytes: 65536, localInstructionFileCandidates: [] })
@@ -3195,7 +3200,7 @@ describe('dynamic nested workspace context injection', () => {
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'removed nested instructions' }],
source: {
kind: 'workspace-instructions',
kind: 'agent-instructions',
form: 'instructions',
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
},
@@ -3355,7 +3360,7 @@ describe('dynamic nested workspace context injection', () => {
})
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
kind: 'workspace-instructions',
kind: 'agent-instructions',
form: 'instructions',
changes: [{ action: 'remove', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
@@ -3458,7 +3463,7 @@ describe('dynamic nested workspace context injection', () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
@@ -3758,7 +3763,7 @@ describe('dynamic nested workspace context injection', () => {
{ type: 'text', text: 'Updated instructions from: pkg/AGENTS.md' },
],
source: {
kind: 'workspace-instructions',
kind: 'agent-instructions',
form: 'instructions',
changes: [
null,
@@ -3770,7 +3775,7 @@ describe('dynamic nested workspace context injection', () => {
}), { surfaceOp: 'append' })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'stale metadata version' }],
source: { kind: 'workspace-instructions', changes: 'invalid' } as never,
source: { kind: 'agent-instructions', changes: 'invalid' } as never,
}), { surfaceOp: 'append' })
agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'foreign plugin context' }],
@@ -3836,7 +3841,7 @@ describe('dynamic nested workspace context injection', () => {
try {
const nested = join(root, 'pkg/AGENTS.md')
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
@@ -3910,9 +3915,9 @@ describe('dynamic nested workspace context injection', () => {
})
expect(blocksText(result.content)).toContain('downstream replacement')
expect(result.additionalContexts).toHaveLength(1)
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'workspace-instructions' })
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({ kind: 'agent-instructions' })
expect(((await syncedWorkspaceContext(ctx, agent))).source).toMatchObject({
kind: 'workspace-instructions',
kind: 'agent-instructions',
form: 'instructions',
changes: [{ action: 'set', scope: sk('pkg', 'AGENTS.md'), path: join('pkg', 'AGENTS.md') }],
})
@@ -3975,7 +3980,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs)
let shouldBlock = true
@@ -4027,7 +4032,7 @@ describe('dynamic nested workspace context injection', () => {
await write(join(root, 'pkg/AGENTS.md'), 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(ToolFs)
ctx.tools.register(defineContentToolFixture({
@@ -4283,7 +4288,7 @@ describe('dynamic nested workspace context injection', () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
const instructionPath = join(root, 'pkg/AGENTS.md')
@@ -4636,7 +4641,7 @@ describe('workspace context plugin export shape', () => {
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(workspaceContext) as Record<string, unknown>
expect(unwrapped).toBe(workspaceContext)
expect(unwrapped.name).toBe('workspace-context')
expect(unwrapped.name).toBe('agent-instructions')
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})

View File

@@ -30,10 +30,10 @@
"path": "../../fs/fs"
},
{
"path": "../../util/paths"
"path": "../../util/home-paths"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/session-reference/README.md
README.md: 145ec112d6b9cce9e9eb1567c60cde41c6d6eb3c
README.zh.md: 5d528a296ee77dc64b584d0683395cb4df67936a
README.md: 1f7df17f512b109e0e29bebf3f7931516914c368
README.zh.md: 380f1c613621a8e1fd5b4ee27b70f40f4072197b

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as sourced model-facing context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. Hosts that support cross-session mentions may opt into the service.
`ctx.sessionReferenceResolver` prepares bounded, read-only snapshots of other sessions as sourced model-facing context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. Hosts that support cross-session mentions may opt into the service.
## Public API
@@ -12,7 +12,7 @@ English | [中文](README.zh.md)
## Snapshot semantics
Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text.
Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compaction` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text.
The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. When the agent is idle, the standard TUI installs a one-shot `agent/pre-step` wrapper that adds the snapshot only to an `enter` decision containing the claimed direct prompt. While the agent is running, it calls `inject()` immediately before `steer()`, placing both messages in the next-step inbox for the same later claim. The target log therefore records a sourced context `user/message` followed by the readable direct `user/message`. Later source mutation, compaction, or deletion cannot change target replay.
@@ -24,7 +24,7 @@ The context source is `{ kind: 'session-reference', version: 1, references }`; e
| `candidateLimit` | `50` | Default candidate count returned to a host. |
| `maxReferenceBytes` | `65536` | Maximum serialized JSON bytes for one reference object. |
Retention applies `maxReferenceBytes` independently to each source, keeps compact checkpoints and the newest message before dropping older non-checkpoint units, and uses `dsh-retention` head/tail truncation with an exact UTF-8 omission notice. If one source's fixed serialized fields cannot fit, preparation fails with `SESSION_REFERENCE_BUDGET_EXCEEDED` instead of returning a partial context.
Retention applies `maxReferenceBytes` independently to each source, keeps compact checkpoints and the newest message before dropping older non-checkpoint units, and uses `dsh-output-retention` head/tail truncation with an exact UTF-8 omission notice. If one source's fixed serialized fields cannot fit, preparation fails with `SESSION_REFERENCE_BUDGET_EXCEEDED` instead of returning a partial context.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
`ctx.sessionReferences` 会把其他会话准备为有界、只读快照,作为带来源信息、面向模型的上下文。它消费 `ctx.sessionQuery` 与后端无关的 compact 检查点标记;不需要 SQLite FTS。支持跨会话 mention 的宿主可以主动启用该服务。
`ctx.sessionReferenceResolver` 会把其他会话准备为有界、只读快照,作为带来源信息、面向模型的上下文。它消费 `ctx.sessionQuery` 与后端无关的 compact 检查点标记;不需要 SQLite FTS。支持跨会话 mention 的宿主可以主动启用该服务。
## 公开 API
@@ -12,7 +12,7 @@
## 快照语义
准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`,入队后绝不重读。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compact` 源标记。对于已经包含固化前缀上下文的源提示词投影只读取其对模型隐藏的显示内容以防止快照递归传播。已遮蔽的压缩compaction前事件、工具、推理reasoning、上下文、除已标记 compact 检查点外的插件生成 user 消息,以及未完成的 assistant 分片均会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。
准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`,入队后绝不重读。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compaction` 源标记。对于已经包含固化前缀上下文的源提示词投影只读取其对模型隐藏的显示内容以防止快照递归传播。已遮蔽的压缩compaction前事件、工具、推理reasoning、上下文、除已标记 compact 检查点外的插件生成 user 消息,以及未完成的 assistant 分片均会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。
上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留已省略消息数、已省略 UTF-8 字节数与截断状态。agent 空闲时,标准 TUI 会安装一次性的 `agent/pre-step` 包装层,只把快照添加到包含已领取直接提示词的 `enter` 决策。agent 运行时,它会紧接着调用 `inject()``steer()`,把两条消息放入 next-step inbox等待后续同一次领取。目标日志因此会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message`。后续源变更、压缩或删除都无法改变目标回放。
@@ -24,7 +24,7 @@
| `candidateLimit` | `50` | 返回给宿主的默认候选数量。 |
| `maxReferenceBytes` | `65536` | 一个引用对象的最大序列化 JSON 字节数。 |
保留会对每个源独立应用 `maxReferenceBytes`,保留 compact 检查点与最新消息,再丢弃较旧的非检查点单元,并使用 `dsh-retention` 头部/尾部截断和精确 UTF-8 省略通知。如果某个源的固定序列化字段本身就超出限额,准备会以 `SESSION_REFERENCE_BUDGET_EXCEEDED` 失败,而不返回部分上下文。
保留会对每个源独立应用 `maxReferenceBytes`,保留 compact 检查点与最新消息,再丢弃较旧的非检查点单元,并使用 `dsh-output-retention` 头部/尾部截断和精确 UTF-8 省略通知。如果某个源的固定序列化字段本身就超出限额,准备会以 `SESSION_REFERENCE_BUDGET_EXCEEDED` 失败,而不返回部分上下文。
## 模型体验

View File

@@ -1,9 +1,9 @@
{
"name": "@deepseek-ai/dsh-session-reference",
"description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferences)",
"version": "0.0.1-rc.2",
"description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)",
"version": "0.1.0-rc.6",
"publishConfig": {
"access": "restricted"
"access": "public"
},
"repository": {
"type": "git",
@@ -30,26 +30,26 @@
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-compaction": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-retention": "workspace:^",
"@deepseek-ai/dsh-output-retention": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-compaction": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-retention": "workspace:^",
"@deepseek-ai/dsh-output-retention": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"

View File

@@ -52,7 +52,7 @@ const PROMPT_SUFFIX = '\n</referenced-sessions>'
declare module '@deepseek-ai/cordis' {
interface Context {
sessionReferences: SessionReferenceService
sessionReferenceResolver: SessionReferenceResolver
}
}
@@ -67,7 +67,7 @@ interface RenderedSource {
}
/** Exact-read consumer that prepares immutable cross-session message context. */
export class SessionReferenceService extends Service {
export class SessionReferenceResolver extends Service {
static inject = ['sessionQuery']
static Config: z<Config> = z.object({
maxReferences: z.number().step(1).min(1).max(MAX_REFERENCES).default(MAX_REFERENCES),
@@ -78,7 +78,7 @@ export class SessionReferenceService extends Service {
private readonly config: Required<Config>
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'sessionReferences')
super(ctx, 'sessionReferenceResolver')
this.config = {
maxReferences: config.maxReferences ?? MAX_REFERENCES,
candidateLimit: config.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT,
@@ -300,4 +300,4 @@ function cancelled(signal: AbortSignal): SessionReferenceError {
return new SessionReferenceError('session reference preparation was cancelled', 'SESSION_REFERENCE_CANCELLED', { cause: signal.reason })
}
export default SessionReferenceService
export default SessionReferenceResolver

View File

@@ -1,9 +1,9 @@
/** Current-surface projection and byte-bounded rendering. */
import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact'
import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compaction'
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { TextRetainer } from '@deepseek-ai/dsh-retention'
import { TextRetainer } from '@deepseek-ai/dsh-output-retention'
import { stringifyTagSafeJson } from './serialization.ts'
import type { ReferencedConversationItem } from './types.ts'

View File

@@ -1,11 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact'
import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction'
import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import SessionReferenceService, {
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
import SessionReferenceResolver, {
decodeSessionReferenceUri,
encodeSessionReferenceUri,
formatSessionReferenceMention,
@@ -15,16 +15,16 @@ import SessionReferenceService, {
} from '@deepseek-ai/dsh-session-reference'
import { stringifyTagSafeJson } from '../src/serialization.ts'
class TestSessionQueryService extends SessionQueryService {
class TestSessionQueryEngine extends SessionQueryEngine {
override searchSessions(
..._args: Parameters<SessionQueryService['searchSessions']>
): ReturnType<SessionQueryService['searchSessions']> {
..._args: Parameters<SessionQueryEngine['searchSessions']>
): ReturnType<SessionQueryEngine['searchSessions']> {
return Promise.resolve({ items: [] })
}
override searchEvents(
...args: Parameters<SessionQueryService['searchEvents']>
): ReturnType<SessionQueryService['searchEvents']> {
...args: Parameters<SessionQueryEngine['searchEvents']>
): ReturnType<SessionQueryEngine['searchEvents']> {
return this.readSurface(args[0].sessionId).then(surface => ({
session: surface.session,
items: [],
@@ -35,8 +35,8 @@ class TestSessionQueryService extends SessionQueryService {
async function harness(config: Config = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(TestSessionQueryService)
await ctx.plugin(SessionReferenceService, config)
await ctx.plugin(TestSessionQueryEngine)
await ctx.plugin(SessionReferenceResolver, config)
return ctx
}
@@ -252,19 +252,19 @@ describe('session reference discovery and preparation', () => {
source: { kind: 'fallback' },
})
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target))).resolves.toEqual([
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([
{ sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', createdAt: 25 },
{ sessionId: SessionId('same'), label: 'same', cwd: '/same', createdAt: 20 },
{ sessionId: SessionId('none'), label: 'none', createdAt: 30 },
{ sessionId: SessionId('other'), label: 'other', cwd: '/else', createdAt: 40 },
])
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'els', 1)).resolves.toEqual([
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'els', 1)).resolves.toEqual([
{ sessionId: SessionId('other'), label: 'other', cwd: '/else', createdAt: 40 },
])
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'LATEST', 1)).resolves.toEqual([
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'LATEST', 1)).resolves.toEqual([
{ sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', createdAt: 25 },
])
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), '', 0))
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), '', 0))
.rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
let releaseList: (() => void) | undefined
@@ -273,7 +273,7 @@ describe('session reference discovery and preparation', () => {
return []
})
const controller = new AbortController()
const pending = ctx.sessionReferences.listCandidates(fakeAgent(target), '', undefined, controller.signal)
const pending = ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), '', undefined, controller.signal)
await vi.waitFor(() => { expect(releaseList).toBeTypeOf('function') })
const cancelledList = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
controller.abort('autocomplete superseded')
@@ -294,7 +294,7 @@ describe('session reference discovery and preparation', () => {
reason: new Error('broken title log'),
}])
await expect(ctx.sessionReferences.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([
{ sessionId: source.id, label: source.id, createdAt: source.header.createdAt },
])
@@ -306,7 +306,7 @@ describe('session reference discovery and preparation', () => {
return []
})
const controller = new AbortController()
const pending = ctx.sessionReferences.listCandidates(fakeAgent(target), 'source', undefined, controller.signal)
const pending = ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'source', undefined, controller.signal)
await vi.waitFor(() => { expect(releaseTitles).toBeTypeOf('function') })
expect(titleSignal).toBe(controller.signal)
const cancelledTitles = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
@@ -323,7 +323,7 @@ describe('session reference discovery and preparation', () => {
const source = ctx.sessions.create(SessionId('source'), { meta: { cwd: '/source' } })
appendConversation(source)
const prepared = await ctx.sessionReferences.prepare(
const prepared = await ctx.sessionReferenceResolver.prepare(
fakeAgent(target),
[{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id, label: 'source' }],
@@ -380,7 +380,7 @@ describe('session reference discovery and preparation', () => {
source: { kind: 'user' },
}), { surfaceOp: 'append' })
const prepared = await ctx.sessionReferences.prepare(
const prepared = await ctx.sessionReferenceResolver.prepare(
fakeAgent(target),
[{ type: 'text', text: 'inspect source' }],
[{ sessionId: source.id }],
@@ -406,7 +406,7 @@ describe('session reference discovery and preparation', () => {
{ surfaceOp: 'append' },
)
const prepared = await ctx.sessionReferences.prepare(
const prepared = await ctx.sessionReferenceResolver.prepare(
fakeAgent(target),
[{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id }],
@@ -435,36 +435,36 @@ describe('session reference discovery and preparation', () => {
const agent = fakeAgent(target)
const content = [{ type: 'text' as const, text: 'go' }]
const withoutReferences = await ctx.sessionReferences.prepare(agent, content, [])
const withoutReferences = await ctx.sessionReferenceResolver.prepare(agent, content, [])
expect(withoutReferences).toEqual({ content })
expect(withoutReferences.content).not.toBe(content)
await expect(ctx.sessionReferences.prepare(agent, content, [
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [
{ sessionId: one.id, label: 'first' },
{ sessionId: one.id, label: 'ignored duplicate' },
{ sessionId: two.id },
])).resolves.toMatchObject({ additionalContext: { source: { references: [{ label: 'first' }, { label: 'two' }] } } })
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: target.id }]))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: target.id }]))
.rejects.toThrow(expectCode('SESSION_REFERENCE_SELF_REFERENCE'))
await expect(ctx.sessionReferences.prepare(agent, content, [null as never]))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [null as never]))
.rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
await expect(ctx.sessionReferences.prepare(agent, content, [1 as never]))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [1 as never]))
.rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: 1 } as never]))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: 1 } as never]))
.rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE'))
await expect(ctx.sessionReferences.prepare(agent, content, [
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [
{ sessionId: one.id }, { sessionId: two.id }, { sessionId: SessionId('three') },
])).rejects.toThrow(expectCode('SESSION_REFERENCE_TOO_MANY'))
await expect(ctx.sessionReferences.prepare(agent, content, [
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [
{ sessionId: one.id }, { sessionId: SessionId('missing') },
])).rejects.toThrow(expectCode('SESSION_REFERENCE_READ_FAILED'))
const readSurface = vi.spyOn(ctx.sessionQuery, 'readSurface')
readSurface.mockRejectedValueOnce('non-error read failure')
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: one.id }]))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }]))
.rejects.toThrow(/non-error read failure/)
readSurface.mockRejectedValueOnce('non-error signalled read failure')
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: one.id }], new AbortController().signal))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], new AbortController().signal))
.rejects.toThrow(/non-error signalled read failure/)
const duringRead = new AbortController()
@@ -472,7 +472,7 @@ describe('session reference discovery and preparation', () => {
duringRead.abort('cancelled during read')
throw new Error('read interrupted')
})
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: one.id }], duringRead.signal))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], duringRead.signal))
.rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
const snapshot = await ctx.sessionQuery.readSurface(one.id)
@@ -482,7 +482,7 @@ describe('session reference discovery and preparation', () => {
return snapshot
})
const hangingRead = new AbortController()
const pending = ctx.sessionReferences.prepare(agent, content, [{ sessionId: one.id }], hangingRead.signal)
const pending = ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], hangingRead.signal)
await vi.waitFor(() => { expect(releaseRead).toBeTypeOf('function') })
const cancelledRead = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
hangingRead.abort('cancelled while storage remained pending')
@@ -493,7 +493,7 @@ describe('session reference discovery and preparation', () => {
const abort = new AbortController()
abort.abort('host cancelled')
await expect(ctx.sessionReferences.prepare(agent, content, [{ sessionId: one.id }], abort.signal))
await expect(ctx.sessionReferenceResolver.prepare(agent, content, [{ sessionId: one.id }], abort.signal))
.rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
})
@@ -519,7 +519,7 @@ describe('session reference discovery and preparation', () => {
{ surfaceOp: 'append' },
)
const prepared = await ctx.sessionReferences.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }])
const prepared = await ctx.sessionReferenceResolver.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }])
const context = prepared.additionalContext
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
const data = promptData(context.content[0].text) as unknown[]
@@ -554,7 +554,7 @@ describe('session reference discovery and preparation', () => {
return source
})
const prepared = await ctx.sessionReferences.prepare(
const prepared = await ctx.sessionReferenceResolver.prepare(
fakeAgent(target),
[{ type: 'text', text: 'go' }],
sources.map(source => ({ sessionId: source.id })),
@@ -572,7 +572,7 @@ describe('session reference discovery and preparation', () => {
const ctx = await harness({ maxReferenceBytes: 16 })
const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source'))
await expect(ctx.sessionReferences.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }]))
await expect(ctx.sessionReferenceResolver.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }]))
.rejects.toThrow(expectCode('SESSION_REFERENCE_BUDGET_EXCEEDED'))
})
@@ -589,7 +589,7 @@ describe('session reference discovery and preparation', () => {
}),
{ surfaceOp: 'append' },
)
const prepared = await ctx.sessionReferences.prepare(
const prepared = await ctx.sessionReferenceResolver.prepare(
fakeAgent(target),
[{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id }],
@@ -643,19 +643,19 @@ describe('session reference discovery and preparation', () => {
it('rejects direct invalid configuration before service publication', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(TestSessionQueryService)
expect(() => new SessionReferenceService(ctx, { maxReferences: 0 }))
await ctx.plugin(TestSessionQueryEngine)
expect(() => new SessionReferenceResolver(ctx, { maxReferences: 0 }))
.toThrow(expectCode('SESSION_REFERENCE_INVALID_CONFIG'))
const oversizedCtx = new Context()
await oversizedCtx.plugin(SessionStore)
await oversizedCtx.plugin(TestSessionQueryService)
expect(() => new SessionReferenceService(oversizedCtx, { maxReferences: 4 }))
await oversizedCtx.plugin(TestSessionQueryEngine)
expect(() => new SessionReferenceResolver(oversizedCtx, { maxReferences: 4 }))
.toThrow(expectCode('SESSION_REFERENCE_INVALID_CONFIG'))
const defaultCtx = new Context()
await defaultCtx.plugin(SessionStore)
await defaultCtx.plugin(TestSessionQueryService)
expect(() => new SessionReferenceService(defaultCtx)).not.toThrow()
await defaultCtx.plugin(TestSessionQueryEngine)
expect(() => new SessionReferenceResolver(defaultCtx)).not.toThrow()
})
})

View File

@@ -9,12 +9,12 @@
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../util/retention" },
{ "path": "../../util/output-retention" },
{ "path": "../../llm/llm" },
{ "path": "../../core/session" },
{ "path": "../../core/agent" },
{ "path": "../../compact/compact" },
{ "path": "../../support/invariants" },
{ "path": "../../compaction/compaction" },
{ "path": "../../runtime-diagnostics/invariants" },
{ "path": "../../session-query/session-query" }
]
}

View File

@@ -1,9 +1,9 @@
{
"name": "@deepseek-ai/dsh-time-context",
"description": "Opt-in durable per-step context with the current time and elapsed time",
"version": "0.0.1-rc.2",
"version": "0.1.0-rc.6",
"publishConfig": {
"access": "restricted"
"access": "public"
},
"repository": {
"type": "git",
@@ -30,7 +30,7 @@
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},

View File

@@ -4,14 +4,14 @@ import { Context } from '@deepseek-ai/cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
const SECOND = Date.parse('2026-07-14T00:00:00Z')
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(InvariantRegistry, { enabled: true })
await ctx.plugin(TimeInvariant)
return ctx
}
@@ -200,7 +200,7 @@ describe('time-context invariants', () => {
}), { surfaceOp: 'append' })
appendReading(session, reading())
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(InvariantRegistry, { enabled: true })
await expect(ctx.plugin(TimeInvariant)).resolves.toBeDefined()
})
@@ -216,7 +216,7 @@ describe('time-context invariants', () => {
}), { surfaceOp: 'append' })
appendReading(session, reading('1', '2', 'step context'))
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(InvariantRegistry, { enabled: true })
await expect(ctx.plugin(TimeInvariant).then(() => undefined)).rejects.toThrow(/expected turn 1\/step 1/)
})

View File

@@ -293,7 +293,7 @@ describe('durable step context', () => {
if (user === undefined || reading === undefined) throw new Error('missing source surface events')
original.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'compacted history' }],
source: { kind: 'plugin', plugin: 'compact-basic' },
source: { kind: 'plugin', plugin: 'compaction-basic' },
}), {
surfaceOp: { op: 'replace', start: user.seq, end: reading.seq },
sourceEventSeqs: [user.seq, reading.seq],

View File

@@ -28,10 +28,10 @@
"path": "../../core/agent"
},
{
"path": "../../support/loader-smoke"
"path": "../../test-support/loader-smoke"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../core/session"

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md
README.md: 59c452be53b8bb7cb7244a9bb2017749750ce4bf
README.zh.md: 792000403f7757b8d5e35eb6fb6f543ad044b1eb
README.md: 006d4b1e6d824fd255d745cba2ae78ae86eed751
README.zh.md: e3e89f62fce3371e8f53f164f89ac5d08feb172c

View File

@@ -17,7 +17,7 @@ Opt-in durable context naming the tmux session, window, and pane this agent proc
## How it reads tmux
The plugin prepends an `agent/pre-step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor service:
The plugin prepends an `agent/pre-step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.shell` executor service:
```sh
[ -n "$TMUX_PANE" ] || exit 1
@@ -27,7 +27,7 @@ pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1
exec tmux display-message -t "$TMUX_PANE" -p '<format>'
```
`$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) **inherits** `$TMUX` and `$TMUX_PANE` from that ancestor, so the variables are present even though the process does not live in that pane. The command therefore also compares the pane's `#{pane_tty}` against this process's own controlling terminal (`ps -o tty=` for its pid): a genuine pane owns this process's tty, while an inherited environment names some other pane's tty. Running through `ctx.bash` applies the deployment's sandbox and policy; the plugin owns no subprocess code. When `ctx.bash` is absent, the process is not in a real tmux pane (`$TMUX_PANE` unset, or the tty does not match ⇒ nonzero exit), or the reading is malformed, the attempt is a no-op, never an error. The location is optional, so an executor rejection — a policy refusal from `resolve()` or an infrastructure failure from `run()` — is contained and logged as a warning rather than failing the turn.
`$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) **inherits** `$TMUX` and `$TMUX_PANE` from that ancestor, so the variables are present even though the process does not live in that pane. The command therefore also compares the pane's `#{pane_tty}` against this process's own controlling terminal (`ps -o tty=` for its pid): a genuine pane owns this process's tty, while an inherited environment names some other pane's tty. Running through `ctx.shell` applies the deployment's sandbox and policy; the plugin owns no subprocess code. When `ctx.shell` is absent, the process is not in a real tmux pane (`$TMUX_PANE` unset, or the tty does not match ⇒ nonzero exit), or the reading is malformed, the attempt is a no-op, never an error. The location is optional, so an executor rejection — a policy refusal from `resolve()` or an infrastructure failure from `run()` — is contained and logged as a warning rather than failing the turn.
State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane is picked up without any tmux hook or background process. The plugin re-injects only when the rendered tmux state differs from its last injection, so an unchanged location adds nothing.

View File

@@ -17,7 +17,7 @@
## 如何读取 tmux
插件前置注册一个 `agent/pre-step` 监听器,仅在每轮的第一个步骤运行。当需要注入时,它通过 `ctx.bash` 执行器服务运行一条只读命令:
插件前置注册一个 `agent/pre-step` 监听器,仅在每轮的第一个步骤运行。当需要注入时,它通过 `ctx.shell` 执行器服务运行一条只读命令:
```sh
[ -n "$TMUX_PANE" ] || exit 1
@@ -27,7 +27,7 @@ pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1
exec tmux display-message -t "$TMUX_PANE" -p '<format>'
```
仅凭 `$TMUX_PANE` 并不足够:从 tmux shell 启动的终端VS Code 集成终端、桌面启动器)会从该祖先进程**继承** `$TMUX``$TMUX_PANE`,因此即使进程并不位于那个 pane 中,这些变量依然存在。为此该命令还会把 pane 的 `#{pane_tty}` 与本进程自己的控制终端(对其 pid 执行 `ps -o tty=`)作比较:真正的 pane 拥有本进程的 tty而继承而来的环境指向的是另一个 pane 的 tty。通过 `ctx.bash` 运行会应用部署方的沙箱与策略;插件不拥有任何子进程代码。当 `ctx.bash` 缺失、进程不在真实的 tmux pane 内(`$TMUX_PANE` 未设置,或 tty 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。由于位置信息是可选的,执行器的拒绝——`resolve()` 的策略拒绝或 `run()` 的基础设施故障——会被兜住并记录为警告,而不会使该轮失败。
仅凭 `$TMUX_PANE` 并不足够:从 tmux shell 启动的终端VS Code 集成终端、桌面启动器)会从该祖先进程**继承** `$TMUX``$TMUX_PANE`,因此即使进程并不位于那个 pane 中,这些变量依然存在。为此该命令还会把 pane 的 `#{pane_tty}` 与本进程自己的控制终端(对其 pid 执行 `ps -o tty=`)作比较:真正的 pane 拥有本进程的 tty而继承而来的环境指向的是另一个 pane 的 tty。通过 `ctx.shell` 运行会应用部署方的沙箱与策略;插件不拥有任何子进程代码。当 `ctx.shell` 缺失、进程不在真实的 tmux pane 内(`$TMUX_PANE` 未设置,或 tty 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。由于位置信息是可选的,执行器的拒绝——`resolve()` 的策略拒绝或 `run()` 的基础设施故障——会被兜住并记录为警告,而不会使该轮失败。
状态在每个符合条件的轮次拉取——pane 被移动、改名或重新布局都会被感知,无需任何 tmux hook 或后台进程。插件仅在渲染出的 tmux 状态与上次注入不同时才重新注入,因此位置不变时不会新增任何内容。

View File

@@ -1,9 +1,9 @@
{
"name": "@deepseek-ai/dsh-tmux-context",
"description": "Opt-in durable per-step context with this agent's tmux pane and window location",
"version": "0.0.1-rc.2",
"version": "0.1.0-rc.6",
"publishConfig": {
"access": "restricted"
"access": "public"
},
"repository": {
"type": "git",
@@ -30,20 +30,20 @@
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -4,7 +4,7 @@
* and pane this agent process runs in, plus the window's pane-tree layout.
*
* The plugin pulls state once per turn, for the first request (`step === 1`), by
* running one `tmux display-message` through the `ctx.bash` executor service. It
* running one `tmux display-message` through the `ctx.shell` executor service. It
* confirms this process genuinely runs inside the pane `$TMUX_PANE` names by
* matching the pane's `#{pane_tty}` against this process's controlling terminal,
* so a terminal that merely inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor
@@ -12,7 +12,7 @@
* only when the rendered tmux state changes since the last injection (a moved,
* renamed, or re-laid-out pane), with an optional `refreshIntervalMs` floor
* between injections. Absent tmux environment, an inherited-only environment,
* absent `ctx.bash`, or a failed query is a no-op, never an error: an executor
* absent `ctx.shell`, or a failed query is a no-op, never an error: an executor
* rejection is contained and logged as a warning so the turn continues.
*
* @module @deepseek-ai/dsh-tmux-context
@@ -21,7 +21,7 @@
import type { Context, LoggerService } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import type { BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash'
import type { ShellExecutor, ShellRunResult } from '@deepseek-ai/dsh-shell'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
/** Cordis plugin name used by loader diagnostics. */
@@ -105,7 +105,7 @@ const FIELD_SEP = '\\t'
* @returns the parsed location, or `undefined` when not in a real pane or on any failure.
*/
async function queryTmuxLocation(
bash: BashExecutor,
bash: ShellExecutor,
logger: LoggerService,
processId: number,
signal: AbortSignal,
@@ -119,7 +119,7 @@ async function queryTmuxLocation(
'[ "$pane_tty" = "/dev/$self_tty" ] || exit 1',
`exec tmux display-message -t "$TMUX_PANE" -p '${format}'`,
].join('\n')
let result: BashRunResult
let result: ShellRunResult
try {
result = await bash.run(bash.resolve({ command, signal }))
} catch (error: unknown) {
@@ -221,7 +221,7 @@ export function apply(ctx: Context, config: Config): void {
): Promise<PreStepDecision> => {
const decision = await next()
if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision
const bash = ctx.get('bash')
const bash = ctx.get('shell')
if (bash === undefined) return decision
const previous = latestInjectedState(agent)
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) {

View File

@@ -3,8 +3,8 @@ import { Context } from '@deepseek-ai/cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
import * as tmuxContext from '@deepseek-ai/dsh-tmux-context'
import type { Config } from '@deepseek-ai/dsh-tmux-context'
@@ -33,7 +33,7 @@ function tmuxLine(fields: {
].join('\\t')
}
function runResult(stdout: string, overrides: Partial<BashRunResult> = {}): BashRunResult {
function runResult(stdout: string, overrides: Partial<ShellRunResult> = {}): ShellRunResult {
return {
exitCode: 0,
signal: null,
@@ -46,14 +46,14 @@ function runResult(stdout: string, overrides: Partial<BashRunResult> = {}): Bash
}
}
/** A scriptable fake `ctx.bash` recording the command it was asked to run. */
class FakeBash extends BashExecutor {
/** A scriptable fake `ctx.shell` recording the command it was asked to run. */
class FakeBash extends ShellExecutor {
commands: string[] = []
result: BashRunResult = runResult(`${tmuxLine()}\n`)
result: ShellRunResult = runResult(`${tmuxLine()}\n`)
runError?: Error
resolveError?: Error
override resolve(request: BashExecRequest): BashExecSpec {
override resolve(request: ShellExecRequest): ShellExecSpec {
if (this.resolveError) throw this.resolveError
return {
command: request.command,
@@ -64,13 +64,13 @@ class FakeBash extends BashExecutor {
sandboxPolicy: request.sandboxPolicy,
}
}
override async run(spec: BashExecSpec): Promise<BashRunResult> {
override async run(spec: ShellExecSpec): Promise<ShellRunResult> {
this.commands.push(spec.command)
if (this.runError) throw this.runError
return this.result
}
override start(): BashProcess {
throw new Error('tmux-context must never start a background task')
override start(): ShellProcess {
throw new Error('tmux-context must never start a background job')
}
}
@@ -85,7 +85,7 @@ async function mount(
let bash: FakeBash | undefined
if (withBash) {
await ctx.plugin(FakeBash)
bash = ctx.bash as FakeBash
bash = ctx.shell as FakeBash
}
await ctx.plugin(tmuxContext, config)
return { ctx, bash }

View File

@@ -19,7 +19,7 @@
"path": "../../llm/llm"
},
{
"path": "../../bash/bash"
"path": "../../shell/shell"
},
{
"path": "../../core/agent"
@@ -28,7 +28,7 @@
"path": "../../core/system-prompt"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../core/session"