Browse Source

refactor(session): keep instruction and skill scans on event log

_Kerman 1 month ago
parent
commit
3d05fdfbfb

+ 2 - 4
packages/context/agent-instructions/package.json

@@ -39,12 +39,10 @@
     "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-session-projection": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "dependencies": {
-    "@deepseek-ai/schemastery": "workspace:^",
-    "zod": "^4.4.3"
+    "@deepseek-ai/schemastery": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",

+ 0 - 7
packages/context/agent-instructions/src/index.ts

@@ -15,13 +15,11 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
 import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
-import type {} from '@deepseek-ai/dsh-session-projection'
 import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts'
 import { findProjectRoot, loadBaselineInstructionSet } from './files.ts'
 import {
   applyInstructionVersionUpdates,
   baselineInstructionState,
-  createWorkspaceInstructionsProjection,
   name,
   reconcileInstructionContext,
   workspaceContextMessage,
@@ -79,12 +77,7 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
   return filePath.length > 0 ? filePath : undefined
 }
 
-/** Required services (the projection registry drives the instruction fold). */
-export const inject = ['sessionProjections']
-
 export function apply(ctx: Context, config: Config): void {
-  ctx.sessionProjections.register(createWorkspaceInstructionsProjection())
-
   const resolved: ResolvedConfig = resolveConfig(config)
   const instructionVersions: InstructionVersionCache = new WeakMap()
   const baselinePreparations = new WeakMap<Session, {

+ 1 - 1
packages/context/agent-instructions/src/render.ts

@@ -48,7 +48,7 @@ export interface AgentInstructionChange {
   action: 'set' | 'replace' | 'remove'
   scope: string
   path: string
-  digest?: string | undefined
+  digest?: string
 }
 
 /** One state transition paired with the content used to render it. */

+ 6 - 56
packages/context/agent-instructions/src/state.ts

@@ -4,10 +4,7 @@
  * @module @deepseek-ai/dsh-agent-instructions/state
  */
 
-import { z as zod } from 'zod'
 import type { Agent } from '@deepseek-ai/dsh-agent'
-import type {} from '@deepseek-ai/dsh-session-projection'
-import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { Message } from '@deepseek-ai/dsh-llm'
 import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
@@ -136,65 +133,18 @@ function sameInstructionChange(a: AgentInstructionChange, b: AgentInstructionCha
     && a.digest === b.digest
 }
 
-const workspaceInstructionsStateSchema = zod.record(zod.string(), zod.array(zod.object({
-  change: zod.object({
-    action: zod.enum(['set', 'replace', 'remove']),
-    scope: zod.string(),
-    path: zod.string(),
-    digest: zod.string().optional(),
-  }),
-  seq: zod.number().int().nonnegative(),
-})))
-
-/** Newest-first workspace-instruction change history by scope. */
-export type WorkspaceInstructionsState = zod.infer<typeof workspaceInstructionsStateSchema>
-
-declare module '@deepseek-ai/dsh-session-projection/types' {
-  interface SessionProjectionStateMap {
-    /** Newest-first workspace-instruction change history by scope. */
-    workspaceInstructions: WorkspaceInstructionsState
-  }
-}
-
-/**
- * Create the workspace-instruction projection.
- * @returns Workspace-instruction projection definition.
- */
-export function createWorkspaceInstructionsProjection(): ProjectionDefinition<'workspaceInstructions', WorkspaceInstructionsState> {
-  return {
-    key: 'workspaceInstructions',
-    stateSchema: workspaceInstructionsStateSchema,
-    init: () => ({}),
-    apply: (state, event) => {
-      if (event.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) return state
-      const changes = workspaceInstructionChanges(event.data.source)
-      if (changes.length === 0) return state
-      let next = state
-      for (const change of changes) {
-        const record = { change, seq: event.seq }
-        const history = next[change.scope]
-        next = { ...next, [change.scope]: history === undefined ? [record] : [record, ...history] }
-      }
-      return next
-    },
-    stateVersion: 2,
-  }
-}
-
 function visibleInstructionChanges(
   agent: Agent,
   authorityMessages: readonly UserMessage[],
 ): Map<string, AgentInstructionChange> {
   const visibleSeqs = new Set(agent.session.surface.nodes)
   const visible = new Map<string, AgentInstructionChange>()
-  const folded = agent.ctx.get('sessionProjections')?.stateOf(agent.session, 'workspaceInstructions')
-  if (folded === undefined) throw new Error('workspaceInstructions projection is not registered')
-  for (const [scope, history] of Object.entries(folded)) {
-    // History is newest-first; the latest visible record restores the previous
-    // scan-visible semantics when a surface replacement shadows the newest
-    // change but an older one stays visible.
-    const latestVisible = history.find(record => visibleSeqs.has(record.seq))
-    if (latestVisible !== undefined) visible.set(scope, latestVisible.change)
+  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)
+    for (const change of changes) {
+      if (visibleSeqs.has(seq)) visible.set(change.scope, change)
+    }
   }
   for (const message of authorityMessages) {
     if (!isWorkspaceContextSource(message.source)) continue

+ 48 - 125
packages/context/agent-instructions/tests/agent-instructions.spec.ts

@@ -37,7 +37,6 @@ import {
 import {
   applyInstructionVersionUpdates,
   baselineInstructionState,
-  createWorkspaceInstructionsProjection,
   reconcileInstructionContext,
   type InstructionVersionCache,
 } from '../src/state.ts'
@@ -169,16 +168,9 @@ class BlockingReadFileSystem extends RecordingFileSystem {
   }
 }
 
-async function pluginWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise<Awaited<ReturnType<Context['plugin']>>> {
-  if (ctx.get('sessionProjections') === undefined) {
-    await ctx.plugin(SessionProjectionRegistry)
-  }
-  return ctx.plugin(workspaceContext, config)
-}
-
 async function mountWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise<Awaited<ReturnType<Context['plugin']>>> {
   await ctx.plugin(LocalFileSystem, { cwd: '/' })
-  return pluginWorkspaceContext(ctx, config)
+  return ctx.plugin(workspaceContext, config)
 }
 
 async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspaceContext.Config): Promise<Awaited<ReturnType<Context['plugin']>>> {
@@ -186,17 +178,14 @@ async function mountFileToolsAndWorkspaceContext(ctx: Context, config: workspace
   await ctx.plugin(ToolRuntime)
   await ctx.plugin(LocalFileSystem, { cwd: '/' })
   await ctx.plugin(ToolFs)
-  return pluginWorkspaceContext(ctx, config)
+  return ctx.plugin(workspaceContext, config)
 }
 
 function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
   const id = SessionId('s1')
   const session = Session.create(id, seed, cwd === undefined ? undefined : { version: SESSION_FORMAT_VERSION, id, createdAt: 0, cwd })
-  const ctx = new Context()
-  new SessionProjectionRegistry(ctx)
-  ctx.sessionProjections.register(createWorkspaceInstructionsProjection())
   return {
-    ctx,
+    ctx: new Context(),
     id: SessionId('a1'),
     options: {},
     session,
@@ -205,7 +194,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(),
@@ -634,10 +623,10 @@ describe('workspace context instruction discovery', () => {
   it('labels the default DSH home as ~/.dsh when HOME points at the configured default', async () => {
     const root = await tempRepo()
     const home = await tempRepo()
-    vi.stubEnv('DSH_HOME', '')
     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 }))
@@ -997,27 +986,26 @@ describe('workspace context request injection', () => {
   it('requires an explicit maxBytes configuration', async () => {
     const ctx = new Context()
 
-    await expect(pluginWorkspaceContext(ctx, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/)
+    await expect(ctx.plugin(workspaceContext, {} as workspaceContext.Config)).rejects.toThrow(/maxBytes/)
   })
 
   it('mounts without requiring a filesystem provider', async () => {
     const ctx = new Context()
     try {
-      await pluginWorkspaceContext(ctx, { maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { maxBytes: 65536 })
     } finally {
       await ctx.fiber.dispose()
     }
   })
 
   it('does not declare fs as a static inject dependency', () => {
-    expect(workspaceContext.inject).toEqual(['sessionProjections'])
-    expect(workspaceContext.inject).not.toContain('fs')
+    expect('inject' in workspaceContext).toBe(false)
   })
 
   it('does not inject baseline context when no filesystem provider is present', async () => {
     const ctx = new Context()
     try {
-      await pluginWorkspaceContext(ctx, { maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { maxBytes: 65536 })
       const agent = stubAgent('/virtual/repo')
 
       await composeBaselinePrefix(ctx, agent)
@@ -1124,7 +1112,7 @@ describe('workspace context request injection', () => {
       const fs = ctx.fs as RecordingFileSystem
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const original = stubAgent(root)
       await composeBaselinePrefix(ctx, original)
 
@@ -1368,7 +1356,7 @@ describe('workspace context request injection', () => {
       expect(inserted?.source).toMatchObject({ kind: 'agent-instructions', baseline: true })
 
       await fiber.dispose()
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const resumed = stubAgent(root, [...original.session.events])
       agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
       const claimed = resumed.inbox.claim('next-step', 1)
@@ -1414,7 +1402,7 @@ describe('workspace context request injection', () => {
 
       await write(join(root, 'AGENTS.md'), 'new repo rule')
       await fiber.dispose()
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const resumed = stubAgent(root, [...original.session.events])
       agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
       const staleClaim = resumed.inbox.claim('next-step', 1)
@@ -1467,7 +1455,7 @@ describe('workspace context request injection', () => {
 
       await originalCtx.fiber.dispose()
       if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' })
-      await pluginWorkspaceContext(resumedCtx, { dshHome: home, maxBytes })
+      await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes })
       const resumed = stubAgent(root, [...original.session.events])
       agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' })
       const claimed = resumed.inbox.claim('next-step', 1)
@@ -1577,7 +1565,7 @@ describe('workspace context request injection', () => {
 
       const decision = await agentEvents(ctx, agent).waterfall(
         'agent/pre-step',
-        { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(10_000) },
+        { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
         () => Promise.resolve(downstream),
       )
 
@@ -1662,7 +1650,7 @@ describe('workspace context request injection', () => {
       // Hot remount over the live session: the durable baseline remains
       // visible, so the fresh mount does not append a duplicate.
       await fiber.dispose()
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       await composeBaselinePrefix(ctx, agent)
 
       expect(baselineEvents(agent)).toHaveLength(1)
@@ -1692,7 +1680,7 @@ describe('workspace context request injection', () => {
       await write(join(root, 'AGENTS.md'), 'repo rule')
       const ctx = new Context()
       await ctx.plugin(LocalFileSystem, { cwd: '/' })
-      const fiber = await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      const fiber = await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
       await composeBaselinePrefix(ctx, agent)
       const baseline = baselineEvents(agent)[0]
@@ -1707,7 +1695,7 @@ describe('workspace context request injection', () => {
       })
 
       await fiber.dispose()
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       await composeBaselinePrefix(ctx, agent)
 
       expect(baselineEvents(agent)).toHaveLength(2)
@@ -1994,7 +1982,7 @@ describe('workspace context request injection', () => {
       const fs = ctx.fs as RecordingFileSystem
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ctx.fs rule' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       await composeBaselinePrefix(ctx, agent)
@@ -2017,7 +2005,7 @@ describe('workspace context request injection', () => {
       const fs = ctx.fs as RecordingFileSystem
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider-only rule' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       await composeBaselinePrefix(ctx, agent)
@@ -2059,7 +2047,7 @@ describe('workspace context request injection', () => {
       const fs = ctx.fs as RecordingFileSystem
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'far too large' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 })
 
       const prefix = await composeBaselinePrefix(ctx, stubAgent(root))
 
@@ -2084,7 +2072,7 @@ describe('workspace context request injection', () => {
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(instructionPath, { type: 'file', content: 'far too large' })
       fs.omitSizes.add(instructionPath)
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536, maxSourceBytes: 4 })
 
       const prefix = await composeBaselinePrefix(ctx, stubAgent(root))
 
@@ -2107,7 +2095,7 @@ describe('workspace context request injection', () => {
       const fs = ctx.fs as BlockingReadFileSystem
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'blocked' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const controller = new AbortController()
       const reason = new Error('cancel prefix')
       const pending = agentEvents(ctx, stubAgent(root)).waterfall(
@@ -2141,7 +2129,7 @@ describe('workspace context request injection', () => {
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(home, 'AGENTS.md'), { type: 'file', content: 'ctx global rule' })
       fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'ctx claude rule' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       await composeBaselinePrefix(ctx, agent)
@@ -2167,7 +2155,7 @@ describe('workspace context request injection', () => {
       const fs = ctx.fs as RecordingFileSystem
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(root, 'AGENTS.md'), { type: 'directory' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       await composeBaselinePrefix(ctx, agent)
@@ -2190,7 +2178,7 @@ describe('workspace context request injection', () => {
       const fs = ctx.fs as RecordingFileSystem
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(root, 'AGENTS.md'), { type: 'file' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       await composeBaselinePrefix(ctx, agent)
@@ -2213,7 +2201,7 @@ describe('workspace context request injection', () => {
       const fs = ctx.fs as RecordingFileSystem
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.throwOnStat.add(join(root, 'AGENTS.md'))
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       await composeBaselinePrefix(ctx, agent)
@@ -2235,7 +2223,7 @@ describe('workspace context request injection', () => {
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.throwOnStat.add(join(root, 'AGENTS.md'))
       fs.entries.set(join(root, 'CLAUDE.md'), { type: 'file', content: 'claude sibling rule' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       await composeBaselinePrefix(ctx, agent)
@@ -2260,7 +2248,7 @@ describe('workspace context request injection', () => {
       const fs = ctx.fs as RecordingFileSystem
       fs.throwOnStat.add(join(root, '.git'))
       fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       await composeBaselinePrefix(ctx, agent)
@@ -2310,7 +2298,7 @@ describe('workspace context request injection', () => {
       await write(join(cwd, 'AGENTS.md'), 'child schema default rule')
       const ctx = new Context()
       await ctx.plugin(LocalFileSystem, { cwd: '/' })
-      await pluginWorkspaceContext(ctx, { maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { maxBytes: 65536 })
       const agent = stubAgent(cwd)
 
       await composeBaselinePrefix(ctx, agent)
@@ -2331,7 +2319,7 @@ describe('workspace context request injection', () => {
       await write(join(root, 'AGENTS.local.md'), 'local rule')
       const ctx = new Context()
       await ctx.plugin(LocalFileSystem, { cwd: '/' })
-      await pluginWorkspaceContext(ctx, { maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { maxBytes: 65536 })
       const agent = stubAgent(root)
 
       await composeBaselinePrefix(ctx, agent)
@@ -2527,7 +2515,8 @@ describe('dynamic nested workspace context injection', () => {
       await ctx.plugin(AgentRegistry)
       await ctx.plugin(LocalFileSystem, { cwd: '/' })
       await ctx.plugin(ToolFs)
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(SessionProjectionRegistry)
       await ctx.plugin(AgentLoop, { agents: [] })
       ctx.llm.registerAdapter(['mock'], adapter)
       const agent = ctx.agentLoop.create(SessionId('workspace-context-abort'), { provider: 'mock', model: 'mock' }, { cwd: root })
@@ -2603,7 +2592,7 @@ describe('dynamic nested workspace context injection', () => {
       const fs = ctx.fs as RecordingFileSystem
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const controller = new AbortController()
       const reason = new Error('cancel dynamic reconciliation')
       controller.abort(reason)
@@ -2866,7 +2855,7 @@ describe('dynamic nested workspace context injection', () => {
       fs.omitSizes.add(instructionPath)
       fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
       await ctx.plugin(ToolFs)
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       const first = await ctx.tools.execute({
@@ -2903,7 +2892,7 @@ describe('dynamic nested workspace context injection', () => {
       fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-1') })
       fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
       await ctx.plugin(ToolFs)
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       await ctx.tools.execute({
@@ -2948,7 +2937,7 @@ describe('dynamic nested workspace context injection', () => {
       fs.entries.set(instructionPath, { type: 'file', content: 'shared path, separate sessions' })
       fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
       await ctx.plugin(ToolFs)
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
 
       const firstAgent = stubAgent(root)
       const secondAgent = stubAgent(root)
@@ -3484,7 +3473,7 @@ describe('dynamic nested workspace context injection', () => {
       fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'provider package rule' })
       fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
       await ctx.plugin(ToolFs)
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       const first = await ctx.tools.execute({
@@ -3626,72 +3615,6 @@ describe('dynamic nested workspace context injection', () => {
     }
   })
 
-  it('still removes a deleted instruction whose latest update is shadowed while an older one stays visible', async () => {
-    const root = await tempRepo()
-    const home = await tempRepo()
-    try {
-      await mkdir(join(root, '.git'), { recursive: true })
-      await write(join(root, 'pkg/AGENTS.md'), 'first nested rule')
-      await write(join(root, 'pkg/deep/file.txt'), 'hello')
-      const ctx = new Context()
-      await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
-      const agent = stubAgent(root)
-
-      const first = await ctx.tools.execute({
-        signal: testToolSignal,
-        callId: CallId('read-before-update'),
-        name: 'read',
-        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
-        agent,
-      })
-      const firstSeq = (await appendAdditionalContexts(ctx, agent))!
-
-      await write(join(root, 'pkg/AGENTS.md'), 'second nested rule')
-      const second = await ctx.tools.execute({
-        signal: testToolSignal,
-        callId: CallId('read-after-update'),
-        name: 'read',
-        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
-        agent,
-      })
-      const secondSeq = (await appendAdditionalContexts(ctx, agent))!
-      expect(agent.session.surface.nodes).toContain(firstSeq)
-      expect(agent.session.surface.nodes).toContain(secondSeq)
-
-      // A surface replacement shadows only the latest update; the older one
-      // stays visible, so the scope still has visible state to lose.
-      agent.session.append('user/message', createUserMessage({
-        content: [{ type: 'text', text: 'compacted summary' }],
-        source: { kind: 'plugin', plugin: 'compact' },
-      }), {
-        surfaceOp: { op: 'replace', start: secondSeq, end: secondSeq },
-        sourceEventSeqs: [secondSeq],
-      })
-
-      await rm(join(root, 'pkg/AGENTS.md'))
-      await ctx.tools.execute({
-        signal: testToolSignal,
-        callId: CallId('read-after-delete'),
-        name: 'read',
-        arguments: { file_path: join('pkg', 'deep', 'file.txt') },
-        agent,
-      })
-      await appendAdditionalContexts(ctx, agent)
-
-      expect(first.additionalContexts).toBeUndefined()
-      expect(second.additionalContexts).toBeUndefined()
-      const removal = agent.session.events.find(event => event.type === 'user/message'
-        && 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') }],
-      })
-    } finally {
-      await rm(root, { recursive: true, force: true })
-      await rm(home, { recursive: true, force: true })
-    }
-  })
-
   it('re-arms an unchanged baseline after compaction removes it from the surface', async () => {
     const root = await tempRepo()
     const home = await tempRepo()
@@ -3929,7 +3852,7 @@ describe('dynamic nested workspace context injection', () => {
       fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' })
       fs.throwOnRead.add(nested)
       await ctx.plugin(ToolFs)
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       const result = await ctx.tools.execute({
@@ -4070,7 +3993,7 @@ describe('dynamic nested workspace context injection', () => {
           ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer policy block' }] }
           : downstream
       })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       const blocked = await ctx.tools.execute({
@@ -4139,7 +4062,7 @@ describe('dynamic nested workspace context injection', () => {
           ? { kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'outer composite block' }] }
           : downstream
       })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
 
       const blocked = await ctx.tools.execute({
@@ -4163,7 +4086,7 @@ describe('dynamic nested workspace context injection', () => {
     const ctx = new Context()
     try {
       await ctx.plugin(RecordingFileSystem)
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const fs = ctx.fs as RecordingFileSystem
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'nested package rule' })
@@ -4238,7 +4161,7 @@ describe('dynamic nested workspace context injection', () => {
       agent.session.append('step/start', { turn: 1, step: 1 })
       agent.session.append('step/end', { turn: 1, step: 1 })
       agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
 
       ctx.emit('tools/result', stubToolExecution({
         signal: testToolSignal,
@@ -4261,7 +4184,7 @@ describe('dynamic nested workspace context injection', () => {
     const ctx = new Context()
     try {
       await ctx.plugin(RecordingFileSystem)
-      await pluginWorkspaceContext(ctx, { maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { maxBytes: 65536 })
       const fs = ctx.fs as RecordingFileSystem
       const agent = stubAgent('/')
       const plainResult = { callId: CallId('plain'), content: [], isError: false as const, value: null }
@@ -4310,7 +4233,7 @@ describe('dynamic nested workspace context injection', () => {
     const ctx = new Context()
     try {
       await ctx.plugin(RecordingFileSystem)
-      await pluginWorkspaceContext(ctx, { maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { maxBytes: 65536 })
       const fs = ctx.fs as RecordingFileSystem
       const root = resolve('/')
       const agent = stubAgent(root)
@@ -4376,7 +4299,7 @@ describe('dynamic nested workspace context injection', () => {
       fs.entries.set(instructionPath, { type: 'file', content: 'x'.repeat(1000) })
       fs.entries.set(join(root, 'pkg/file.txt'), { type: 'file', content: 'hello' })
       await ctx.plugin(ToolFs)
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 20 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 20 })
       const agent = stubAgent(root)
 
       const first = await ctx.tools.execute({
@@ -4498,7 +4421,7 @@ describe('workspace context inbox synchronization', () => {
       const fs = ctx.fs as RecordingFileSystem
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(root, 'pkg/AGENTS.md'), { type: 'file', content: 'tiny-budget rule' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 1 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 1 })
       const agent = stubAgent(root)
       ctx.emit('tools/result', stubToolExecution({
         signal: testToolSignal,
@@ -4574,7 +4497,7 @@ describe('workspace context inbox synchronization', () => {
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'restored A' })
       fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'restored B' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
       const first = stubToolExecution({
         signal: testToolSignal,
@@ -4614,7 +4537,7 @@ describe('workspace context inbox synchronization', () => {
       fs.entries.set(join(root, '.git'), { type: 'directory' })
       fs.entries.set(join(root, 'a/AGENTS.md'), { type: 'file', content: 'scope A' })
       fs.entries.set(join(root, 'b/AGENTS.md'), { type: 'file', content: 'scope B' })
-      await pluginWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
+      await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
       const agent = stubAgent(root)
       const first = stubToolExecution({
         signal: testToolSignal,

+ 3 - 6
packages/skill/tool-skill/package.json

@@ -37,12 +37,10 @@
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-skill": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-session-projection": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^"
   },
   "dependencies": {
-    "@deepseek-ai/schemastery": "workspace:^",
-    "zod": "^4.4.3"
+    "@deepseek-ai/schemastery": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-agent": "workspace:^",
@@ -53,7 +51,6 @@
     "@deepseek-ai/dsh-skill": "workspace:^",
     "@deepseek-ai/dsh-skill-filesystem": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-session-projection": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^"
   }
 }

+ 17 - 46
packages/skill/tool-skill/src/index.ts

@@ -7,12 +7,10 @@
 import { createHash } from 'node:crypto'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import { z as zod } from 'zod'
 import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
 import { defineTool } from '@deepseek-ai/dsh-tools'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { UserMessage } from '@deepseek-ai/dsh-session'
-import type {} from '@deepseek-ai/dsh-session-projection'
 import {
   escapeText,
   isModelInvocable,
@@ -24,7 +22,7 @@ import {
 } from '@deepseek-ai/dsh-skill'
 
 export const name = 'tool-skill'
-export const inject = ['agents', 'tools', 'skills', 'sessionProjections']
+export const inject = ['agents', 'tools', 'skills']
 
 const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
 /**
@@ -70,30 +68,12 @@ export const Config: z<Config> = z.object({
   catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH),
 })
 
-/**
- * The skill-catalog projection's state schema — the one definition of the
- * state shape; the type is inferred from it (state equals the public shape).
- */
-const skillCatalogStateSchema = zod.array(zod.object({
-  digest: zod.string().min(1),
-  seq: zod.number().int().nonnegative(),
-})).nullable()
-
-type SkillCatalogState = zod.infer<typeof skillCatalogStateSchema>
-
 /**
  * Register the model-facing skill loader and its visibility-matched
  * durable session catalog. The catalog is emitted only when the calling agent
  * resolves this plugin's exact tool registration; a restriction or scoped
  * same-name shadow therefore removes both the schema and its call guidance.
  */
-declare module '@deepseek-ai/dsh-session-projection/types' {
-  interface SessionProjectionStateMap {
-    /** Newest-first published skill-catalog messages (digest + seq); null before the first publication. */
-    skillCatalog: SkillCatalogState
-  }
-}
-
 export function apply(ctx: Context, config: Config = {}): void {
   const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH
   assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3)
@@ -223,20 +203,6 @@ export function apply(ctx: Context, config: Config = {}): void {
     return { ...decision, messages: [...decision.messages, ...injections] }
   })
 
-  ctx.sessionProjections.register({
-    key: 'skillCatalog',
-    stateVersion: 2,
-    stateSchema: skillCatalogStateSchema,
-    init: () => null,
-    apply: (state, event) => {
-      if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') return state
-      const entries = readCatalogEntries(event.data.source)
-      if (entries === undefined) return state
-      const record = { digest: digestCatalogEntries(entries), seq: event.seq }
-      return state === null ? [record] : [record, ...state]
-    },
-  })
-
   // Register after the tool so reverse teardown removes guidance first. Exact definition
   // identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
   //
@@ -260,7 +226,7 @@ export function apply(ctx: Context, config: Config = {}): void {
     const skills = snapshot.skills.filter(isModelInvocable)
     const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength)
     const digest = digestCatalogEntries(entries)
-    const history = catalogHistory(ctx, agent)
+    const history = catalogHistory(agent)
     const existing = catalogMessage(decision.messages)
     if (history.visibleDigest === digest) {
       return existing === undefined
@@ -392,17 +358,22 @@ function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | un
   return readable
 }
 
-function catalogHistory(ctx: Context, agent: Agent): { visibleDigest?: string; published: boolean } {
+function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } {
   const visible = new Set(agent.session.surface.nodes)
-  const state = ctx.sessionProjections.stateOf(agent.session, 'skillCatalog') ?? null
-  if (state === null) return { published: false }
-  // History is newest-first; the latest visible record restores the previous
-  // scan-visible semantics when a surface replacement shadows the newest
-  // catalog message but an older one stays visible.
-  const latestVisible = state.find(record => visible.has(record.seq))
-  return latestVisible !== undefined
-    ? { visibleDigest: latestVisible.digest, published: true }
-    : { published: true }
+  const events = agent.session.events
+  let published = false
+  for (let index = events.length - 1; index >= 0; index -= 1) {
+    // The loop bounds prove the read-only event view contains this index.
+    // oxlint-disable-next-line typescript/no-non-null-assertion
+    const event = events[index]!
+    if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue
+    const entries = readCatalogEntries(event.data.source)
+    if (entries === undefined) continue
+    const digest = digestCatalogEntries(entries)
+    published = true
+    if (visible.has(event.seq)) return { visibleDigest: digest, published }
+  }
+  return { published }
 }
 
 function catalogMessage(

+ 0 - 4
packages/skill/tool-skill/tests/tool-skill.spec.ts

@@ -9,7 +9,6 @@ import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepse
 import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
 import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
-import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import SkillRegistry from '@deepseek-ai/dsh-skill'
 import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem'
 import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
@@ -31,7 +30,6 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise<Conte
   await ctx.plugin(SystemPrompt)
   await ctx.plugin(ToolRuntime)
   await ctx.plugin(AgentRegistry)
-  await ctx.plugin(SessionProjectionRegistry)
   await ctx.plugin(SkillRegistry)
   await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
   await ctx.plugin(toolSkill, config)
@@ -164,7 +162,6 @@ describe('dsh-tool-skill', () => {
     await ctx.plugin(SystemPrompt)
     await ctx.plugin(ToolRuntime)
     await ctx.plugin(AgentRegistry)
-    await ctx.plugin(SessionProjectionRegistry)
     const home = await tempDir('tool-schema')
     await ctx.plugin(SkillRegistry)
     await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
@@ -754,7 +751,6 @@ describe('dsh-tool-skill', () => {
     await ctx.plugin(SystemPrompt)
     await ctx.plugin(ToolRuntime)
     await ctx.plugin(AgentRegistry)
-    await ctx.plugin(SessionProjectionRegistry)
     await ctx.plugin(SkillRegistry)
     await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
 

+ 0 - 9
pnpm-lock.yaml

@@ -3879,9 +3879,6 @@ importers:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
-      zod:
-        specifier: ^4.4.3
-        version: 4.4.3
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -8039,9 +8036,6 @@ importers:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
-      zod:
-        specifier: ^4.4.3
-        version: 4.4.3
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -8061,9 +8055,6 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
-      '@deepseek-ai/dsh-session-projection':
-        specifier: workspace:^
-        version: link:../../session/session-projection
       '@deepseek-ai/dsh-skill':
         specifier: workspace:^
         version: link:../skill