Merge remote-tracking branch 'upstream/master' into fix/workspace-context-rendered-change-proof
# Conflicts: # .agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml # .agents/notes/implemented/feature/2026-06-24-workspace-context.md # .agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md # packages/context/workspace-context/README.i18n.yaml # packages/context/workspace-context/src/files.ts # packages/context/workspace-context/src/render.ts
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
* @module @deepseek-ai/dsh-workspace-context/config
|
||||
*/
|
||||
|
||||
import { relative } from 'node:path'
|
||||
import z from 'schemastery'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
@@ -58,6 +59,28 @@ export interface ResolvedConfig extends ResolvedDiscoveryConfig {
|
||||
maxSourceBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the discovery, precedence, and budget semantics of one baseline.
|
||||
* @param config - normalized plugin configuration.
|
||||
* @param cwd - absolute session working directory.
|
||||
* @param projectRoot - project root selected for the current baseline.
|
||||
* @returns stable serialized identity for compatibility checks on resume.
|
||||
*/
|
||||
export function workspaceBaselineIdentity(
|
||||
config: ResolvedConfig,
|
||||
cwd: string,
|
||||
projectRoot: string,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
projectRoot: relative(cwd, projectRoot),
|
||||
projectRootMarkers: config.projectRootMarkers,
|
||||
maxBytes: config.maxBytes,
|
||||
maxSourceBytes: config.maxSourceBytes,
|
||||
instructionFileCandidates: config.instructionFileCandidates,
|
||||
localInstructionFileCandidates: config.localInstructionFileCandidates,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve defaults, the harness home, and valid same-directory candidates.
|
||||
* @param config - user-facing plugin configuration.
|
||||
|
||||
@@ -15,10 +15,9 @@ import { trimmedInstructionDigest } from './digest.ts'
|
||||
import {
|
||||
decodeScopeKey,
|
||||
renderWorkspaceInstructionSet,
|
||||
type RenderedWorkspaceContext,
|
||||
USER_GLOBAL_DIRECTORY,
|
||||
USER_GLOBAL_FILE,
|
||||
type RenderedInstructionSet,
|
||||
type RenderedWorkspaceContext,
|
||||
} from './render.ts'
|
||||
|
||||
/** An instruction candidate identified by absolute and model-facing paths. */
|
||||
@@ -53,14 +52,24 @@ interface DiscoverOptions {
|
||||
projectRootMarkers?: string[]
|
||||
instructionFileCandidates?: string[]
|
||||
localInstructionFileCandidates?: string[]
|
||||
projectRoot?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface LoadOptions extends DiscoverOptions {
|
||||
maxBytes: number
|
||||
maxSourceBytes?: number
|
||||
replacePreviousBaseline?: boolean
|
||||
}
|
||||
|
||||
/** Rendered baseline plus the successfully read and byte-budget-retained files. */
|
||||
export interface RenderedInstructionSet {
|
||||
rendered: RenderedWorkspaceContext
|
||||
/** Successfully read candidates before content deduplication and byte budgeting. */
|
||||
observed: LoadedInstructionFile[]
|
||||
/** Candidates retained by content deduplication and byte budgeting. */
|
||||
included: LoadedInstructionFile[]
|
||||
}
|
||||
/** Tri-state scope probe that distinguishes confirmed absence from provider failure. */
|
||||
export type ScopeInstructionProbe =
|
||||
| { kind: 'present'; file: ProbedInstructionFile }
|
||||
@@ -287,7 +296,8 @@ async function discoverInstructionFiles(
|
||||
}
|
||||
|
||||
const cwd = resolve(options.cwd)
|
||||
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
|
||||
const projectRoot = options.projectRoot
|
||||
?? await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) {
|
||||
for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) {
|
||||
for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) {
|
||||
@@ -390,7 +400,7 @@ export async function loadBaselineInstructions(
|
||||
* Load a baseline together with the files retained after rendering.
|
||||
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
|
||||
* @param fileSystem - optional provider used instead of host filesystem reads.
|
||||
* @returns rendered context and retained files, or undefined when empty or disabled.
|
||||
* @returns rendered context and retained files, an explicit empty replacement set, or undefined when empty or disabled.
|
||||
*/
|
||||
export async function loadBaselineInstructionSet(
|
||||
options: LoadOptions,
|
||||
@@ -413,8 +423,29 @@ export async function loadBaselineInstructionSet(
|
||||
}
|
||||
}
|
||||
const deduped = dedupInstructionFilesByDirectory(loaded)
|
||||
if (deduped.length === 0) return undefined
|
||||
return renderWorkspaceInstructionSet(deduped, { maxBytes: config.maxBytes })
|
||||
if (deduped.length === 0) {
|
||||
if (options.replacePreviousBaseline !== true) return undefined
|
||||
const { rendered, included } = renderWorkspaceInstructionSet([], {
|
||||
maxBytes: config.maxBytes,
|
||||
replacePreviousBaseline: true,
|
||||
})
|
||||
return {
|
||||
rendered,
|
||||
observed: [],
|
||||
included,
|
||||
}
|
||||
}
|
||||
const { rendered, included } = renderWorkspaceInstructionSet(deduped, {
|
||||
maxBytes: config.maxBytes,
|
||||
...options.replacePreviousBaseline === undefined
|
||||
? {}
|
||||
: { replacePreviousBaseline: options.replacePreviousBaseline },
|
||||
})
|
||||
return {
|
||||
rendered,
|
||||
observed: loaded,
|
||||
included,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,10 +13,10 @@ import type { Context } from 'cordis'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import { Config, resolveConfig, type ResolvedConfig } from './config.ts'
|
||||
import { loadBaselineInstructionSet } from './files.ts'
|
||||
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import { Config, resolveConfig, workspaceBaselineIdentity, type ResolvedConfig } from './config.ts'
|
||||
import { findProjectRoot, loadBaselineInstructionSet } from './files.ts'
|
||||
import {
|
||||
applyInstructionVersionUpdates,
|
||||
baselineInstructionState,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
reconcileInstructionContext,
|
||||
workspaceContextMessage,
|
||||
type InstructionVersionCache,
|
||||
type WorkspaceInstructionSource,
|
||||
} from './state.ts'
|
||||
import type { WorkspaceInstructionChange } from './render.ts'
|
||||
|
||||
@@ -39,13 +40,22 @@ export type {
|
||||
export { renderWorkspaceContext } from './render.ts'
|
||||
export type { RenderedWorkspaceContext, TruncatedInstruction } from './render.ts'
|
||||
|
||||
function hasVisibleBaseline(agent: Agent): boolean {
|
||||
return agent.session.surface.nodes.some((seq) => {
|
||||
function visibleBaselineSource(
|
||||
agent: Agent,
|
||||
authorityMessages: readonly UserMessage[],
|
||||
): WorkspaceInstructionSource | undefined {
|
||||
for (const message of authorityMessages.toReversed()) {
|
||||
if (message.source.kind === 'workspace-instructions' && message.source.baseline === true) {
|
||||
return message.source
|
||||
}
|
||||
}
|
||||
for (const seq of agent.session.surface.nodes.toReversed()) {
|
||||
const event = agent.session.events[seq]
|
||||
return event?.type === 'user/message'
|
||||
if (event?.type === 'user/message'
|
||||
&& event.data.source.kind === 'workspace-instructions'
|
||||
&& event.data.source.baseline === true
|
||||
})
|
||||
&& event.data.source.baseline === true) return event.data.source
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isWorkspaceContext(message: UserMessage): boolean {
|
||||
@@ -70,16 +80,27 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved: ResolvedConfig = resolveConfig(config)
|
||||
const instructionVersions: InstructionVersionCache = new WeakMap()
|
||||
const baselinePreparations = new WeakMap<Session, {
|
||||
identity: string
|
||||
excludedScopes: ReadonlySet<string>
|
||||
}>()
|
||||
const projectionLifecycle = new AbortController()
|
||||
type ProjectionTouch = { agent: Agent; path: string }
|
||||
const executionTouches = new Map<ToolExecutionToken, ProjectionTouch[]>()
|
||||
ctx.effect(
|
||||
() => () => {
|
||||
projectionLifecycle.abort(new Error('workspace-context disposed'))
|
||||
executionTouches.clear()
|
||||
},
|
||||
'workspace-context.projectionLifecycle',
|
||||
)
|
||||
// Emit listeners are not awaited, so each projection must compose against the
|
||||
// inbox produced by earlier file results for the same agent.
|
||||
const projectionTails = new WeakMap<Agent, Promise<void>>()
|
||||
// Execution ancestry and the enclosing durable step are the two commit
|
||||
// boundaries before an asynchronous projection may mutate the agent inbox.
|
||||
const openSteps = new WeakMap<Session, boolean>()
|
||||
const stepTouches = new WeakMap<Session, ProjectionTouch[]>()
|
||||
|
||||
const compose = async (
|
||||
agent: Agent,
|
||||
@@ -99,11 +120,20 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const changes: WorkspaceInstructionChange[] = []
|
||||
let desiredBaseline = false
|
||||
const authorityMessages = [...claimed]
|
||||
const baselinePresent = hasVisibleBaseline(agent) || claimed.some(message =>
|
||||
message.source.kind === 'workspace-instructions' && message.source.baseline === true)
|
||||
if (!baselinePresent) {
|
||||
/* v8 ignore next -- normal agents carry an absolute session cwd. */
|
||||
const cwd = agent.session.header.cwd ?? process.cwd()
|
||||
/* v8 ignore next -- normal agents carry an absolute session cwd. */
|
||||
const cwd = agent.session.header.cwd ?? process.cwd()
|
||||
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, signal)
|
||||
const identity = workspaceBaselineIdentity(resolved, cwd, projectRoot)
|
||||
const visibleBaseline = visibleBaselineSource(agent, authorityMessages)
|
||||
const baselinePresent = visibleBaseline !== undefined
|
||||
const keepVisibleBaseline = visibleBaseline?.baselineIdentity === identity
|
||||
const prepared = baselinePreparations.get(agent.session)
|
||||
let excludedBaselineScopes = keepVisibleBaseline && prepared?.identity === identity
|
||||
? prepared.excludedScopes
|
||||
: undefined
|
||||
let nextPreparation: { identity: string; excludedScopes: ReadonlySet<string> } | undefined
|
||||
if (!baselinePresent || !keepVisibleBaseline || excludedBaselineScopes === undefined) {
|
||||
const replacePreviousBaseline = baselinePresent && !keepVisibleBaseline
|
||||
const instructions = await loadBaselineInstructionSet({
|
||||
cwd,
|
||||
dshHome: resolved.dshHome,
|
||||
@@ -112,18 +142,45 @@ export function apply(ctx: Context, config: Config): void {
|
||||
maxSourceBytes: resolved.maxSourceBytes,
|
||||
instructionFileCandidates: resolved.instructionFileCandidates,
|
||||
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
|
||||
projectRoot,
|
||||
replacePreviousBaseline,
|
||||
signal,
|
||||
}, fileSystem)
|
||||
const baseline = baselineInstructionState(instructions?.included ?? [])
|
||||
const observedBaseline = baselineInstructionState(instructions?.observed ?? [])
|
||||
const excludedScopes = new Set(observedBaseline.changes.keys())
|
||||
for (const scope of baseline.changes.keys()) excludedScopes.delete(scope)
|
||||
excludedBaselineScopes = excludedScopes
|
||||
nextPreparation = { identity, excludedScopes }
|
||||
let versionStates = instructionVersions.get(agent.session)
|
||||
if (versionStates === undefined && baseline.versions.size > 0) {
|
||||
versionStates = new Map()
|
||||
instructionVersions.set(agent.session, versionStates)
|
||||
}
|
||||
for (const [scope, state] of baseline.versions) versionStates?.set(scope, state)
|
||||
if (instructions !== undefined && instructions.rendered.text.length > 0) {
|
||||
content.push(...workspaceContextMessage(instructions.rendered.text).content)
|
||||
changes.push(...baseline.changes.values())
|
||||
if (!keepVisibleBaseline && instructions !== undefined && instructions.rendered.text.length > 0) {
|
||||
const baselineContent = workspaceContextMessage(instructions.rendered.text).content
|
||||
content.push(...baselineContent)
|
||||
const replacementScopes = new Set(baseline.changes.keys())
|
||||
const replacementRemovals = replacePreviousBaseline
|
||||
? visibleBaseline.changes.flatMap(change => (
|
||||
change.action === 'remove' || replacementScopes.has(change.scope)
|
||||
? []
|
||||
: [{ action: 'remove' as const, scope: change.scope, path: change.path }]
|
||||
))
|
||||
: []
|
||||
const baselineChanges = [...replacementRemovals, ...baseline.changes.values()]
|
||||
changes.push(...baselineChanges)
|
||||
authorityMessages.push(createUserMessage({
|
||||
content: baselineContent,
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
form: 'instructions',
|
||||
baseline: true,
|
||||
baselineIdentity: identity,
|
||||
changes: baselineChanges,
|
||||
},
|
||||
}))
|
||||
desiredBaseline = true
|
||||
}
|
||||
}
|
||||
@@ -132,7 +189,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
resolved,
|
||||
instructionVersions,
|
||||
fileSystem,
|
||||
{ authorityMessages, scopeMessages: pending, includeBaselineScopes: baselinePresent, touchedPaths, signal },
|
||||
{
|
||||
authorityMessages,
|
||||
scopeMessages: pending,
|
||||
includeBaselineScopes: keepVisibleBaseline,
|
||||
...keepVisibleBaseline ? { excludedBaselineScopes } : {},
|
||||
touchedPaths,
|
||||
projectRoot,
|
||||
signal,
|
||||
},
|
||||
)
|
||||
if (update !== undefined) {
|
||||
content.push(...update.context.content)
|
||||
@@ -142,6 +207,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
applyInstructionVersionUpdates(agent.session, update.versionUpdates, instructionVersions)
|
||||
}
|
||||
if (nextPreparation !== undefined) baselinePreparations.set(agent.session, nextPreparation)
|
||||
if (content.length === 0) return undefined
|
||||
return createUserMessage({
|
||||
content,
|
||||
@@ -149,6 +215,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
kind: 'workspace-instructions',
|
||||
form: 'instructions',
|
||||
...desiredBaseline ? { baseline: true } : {},
|
||||
...desiredBaseline ? { baselineIdentity: identity } : {},
|
||||
changes,
|
||||
},
|
||||
})
|
||||
@@ -212,10 +279,48 @@ export function apply(ctx: Context, config: Config): void {
|
||||
while ((projection = projectionTails.get(agent)) !== undefined) await projection
|
||||
}
|
||||
|
||||
const stepIsOpen = (session: Session): boolean => {
|
||||
const known = openSteps.get(session)
|
||||
if (known !== undefined) return known
|
||||
let open = false
|
||||
for (const event of session.events) {
|
||||
if (event.type === 'step/start') open = true
|
||||
else if (event.type === 'step/end' || event.type === 'turn/end') open = false
|
||||
}
|
||||
openSteps.set(session, open)
|
||||
return open
|
||||
}
|
||||
|
||||
const projectTouch = (touch: ProjectionTouch): void => {
|
||||
const session = touch.agent.session
|
||||
if (!stepIsOpen(session)) {
|
||||
queueProjection(touch.agent, touch.path)
|
||||
return
|
||||
}
|
||||
const pending = stepTouches.get(session)
|
||||
if (pending === undefined) stepTouches.set(session, [touch])
|
||||
else pending.push(touch)
|
||||
}
|
||||
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type === 'step/start') {
|
||||
openSteps.set(session, true)
|
||||
return
|
||||
}
|
||||
if (event.type === 'turn/end') {
|
||||
openSteps.set(session, false)
|
||||
return
|
||||
}
|
||||
if (event.type !== 'step/end') return
|
||||
openSteps.set(session, false)
|
||||
const pending = stepTouches.get(session)
|
||||
if (pending === undefined) return
|
||||
stepTouches.delete(session)
|
||||
for (const touch of pending) queueProjection(touch.agent, touch.path)
|
||||
})
|
||||
|
||||
ctx.on('agent/pre-step', async (
|
||||
agent: Agent,
|
||||
messages,
|
||||
{ step, signal },
|
||||
{ agent, messages, step, signal },
|
||||
next,
|
||||
): Promise<PreStepDecision> => {
|
||||
const decision = await next()
|
||||
@@ -243,9 +348,20 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
|
||||
ctx.on('tools/result', (exec: ToolExecution, result: ToolExecutionResult) => {
|
||||
if (result.isError || exec.agent === undefined || exec.signal.aborted) return
|
||||
const ownPath = filePathFromExecution(exec)
|
||||
if (ownPath === undefined) return
|
||||
queueProjection(exec.agent, ownPath)
|
||||
const touches = executionTouches.get(exec.token) ?? []
|
||||
executionTouches.delete(exec.token)
|
||||
if (!result.isError && exec.agent !== undefined && !exec.signal.aborted) {
|
||||
const ownPath = filePathFromExecution(exec)
|
||||
if (ownPath !== undefined) touches.push({ agent: exec.agent, path: ownPath })
|
||||
}
|
||||
if (exec.parent !== undefined) {
|
||||
if (touches.length > 0) {
|
||||
const parentTouches = executionTouches.get(exec.parent)
|
||||
if (parentTouches === undefined) executionTouches.set(exec.parent, touches)
|
||||
else parentTouches.push(...touches)
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const touch of touches) projectTouch(touch)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ const SYSTEM_REMINDER_CLOSE = '</system-reminder>'
|
||||
const WORKSPACE_CONTEXT_INTRO = 'The following workspace instructions may be relevant to your work. '
|
||||
+ 'Use them as guidance when applicable. More specific instructions take precedence over broader ones. '
|
||||
+ 'They do not override system, developer, or direct user instructions.'
|
||||
const REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
|
||||
+ WORKSPACE_CONTEXT_INTRO
|
||||
const EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO = 'This complete workspace instruction baseline replaces all earlier workspace instruction baselines. '
|
||||
+ 'No workspace instructions are currently active.'
|
||||
const COMPACT_WORKSPACE_CONTEXT_INTRO = 'Workspace instructions were omitted or truncated to fit the configured byte budget.'
|
||||
|
||||
/** Byte-accounting record for one truncated instruction file. */
|
||||
@@ -39,15 +43,6 @@ interface RenderedInstructionContext extends RenderedWorkspaceContext {
|
||||
represented: LoadedInstructionFile[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered baseline plus files whose section retained content, or whose original content was empty.
|
||||
* A partially rendered file keeps the digest of its complete original content.
|
||||
*/
|
||||
export interface RenderedInstructionSet {
|
||||
rendered: RenderedWorkspaceContext
|
||||
included: LoadedInstructionFile[]
|
||||
}
|
||||
|
||||
/** Structured dynamic state persisted outside model-visible prompt prose. */
|
||||
export interface WorkspaceInstructionChange {
|
||||
action: 'set' | 'replace' | 'remove'
|
||||
@@ -163,6 +158,16 @@ function additionalSectionText(file: LoadedInstructionFile): string {
|
||||
|
||||
const BASELINE_RENDER_STYLE: RenderStyle = { intro: WORKSPACE_CONTEXT_INTRO, section: sectionText }
|
||||
|
||||
function baselineRenderStyle(files: LoadedInstructionFile[], replacePreviousBaseline: boolean | undefined): RenderStyle {
|
||||
if (replacePreviousBaseline !== true) return BASELINE_RENDER_STYLE
|
||||
return {
|
||||
...BASELINE_RENDER_STYLE,
|
||||
intro: files.length === 0
|
||||
? EMPTY_REPLACEMENT_WORKSPACE_CONTEXT_INTRO
|
||||
: REPLACEMENT_WORKSPACE_CONTEXT_INTRO,
|
||||
}
|
||||
}
|
||||
|
||||
function changedSectionText(item: ChangeRenderItem): string {
|
||||
const { change, file } = item
|
||||
if (change.action === 'set') return additionalSectionText(file)
|
||||
@@ -329,27 +334,28 @@ function renderInstructionContext(
|
||||
/**
|
||||
* Render a baseline together with the exact source files semantically represented in it.
|
||||
* @param files - loaded files ordered from broadest to most specific.
|
||||
* @param options - required rendering byte budget.
|
||||
* @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
|
||||
* @returns bounded public rendering plus files with surviving content, including genuinely empty files.
|
||||
* @internal
|
||||
*/
|
||||
export function renderWorkspaceInstructionSet(
|
||||
files: LoadedInstructionFile[],
|
||||
options: { maxBytes: number },
|
||||
): RenderedInstructionSet {
|
||||
const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, BASELINE_RENDER_STYLE)
|
||||
options: { maxBytes: number; replacePreviousBaseline?: boolean },
|
||||
): { rendered: RenderedWorkspaceContext; included: LoadedInstructionFile[] } {
|
||||
const style = baselineRenderStyle(files, options.replacePreviousBaseline)
|
||||
const { represented, ...rendered } = renderInstructionContext(files, options.maxBytes, style)
|
||||
return { rendered, included: represented }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the baseline instruction chain with deterministic precedence budgeting.
|
||||
* @param files - loaded files ordered from broadest to most specific.
|
||||
* @param options - required rendering byte budget.
|
||||
* @param options - rendering byte budget and whether this baseline supersedes a visible predecessor.
|
||||
* @returns bounded baseline prompt text and budget diagnostics.
|
||||
*/
|
||||
export function renderWorkspaceContext(
|
||||
files: LoadedInstructionFile[],
|
||||
options: { maxBytes: number },
|
||||
options: { maxBytes: number; replacePreviousBaseline?: boolean },
|
||||
): RenderedWorkspaceContext {
|
||||
return renderWorkspaceInstructionSet(files, options).rendered
|
||||
}
|
||||
|
||||
@@ -33,13 +33,15 @@ import {
|
||||
|
||||
export const name = 'workspace-context'
|
||||
|
||||
/** Durable provenance and reconciliation facts for one workspace context. */
|
||||
/** Durable producer, file, and reconciliation facts for one workspace context. */
|
||||
export interface WorkspaceInstructionSource {
|
||||
kind: 'workspace-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[]
|
||||
}
|
||||
|
||||
@@ -251,6 +253,8 @@ export async function reconcileInstructionContext(
|
||||
scopeMessages: readonly UserMessage[]
|
||||
touchedPaths: readonly string[]
|
||||
includeBaselineScopes: boolean
|
||||
excludedBaselineScopes?: ReadonlySet<string>
|
||||
projectRoot?: string
|
||||
signal?: AbortSignal
|
||||
},
|
||||
): Promise<ReconciledInstructionContext | undefined> {
|
||||
@@ -260,7 +264,8 @@ export async function reconcileInstructionContext(
|
||||
const cwd = session.header.cwd ?? process.cwd()
|
||||
// TODO(frozen-project-root): retain the baseline root for the loop instance;
|
||||
// recomputing it after marker edits reinterprets the existing relative scope keys.
|
||||
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
|
||||
const projectRoot = options.projectRoot
|
||||
?? await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
|
||||
const scopes = new Set<string>()
|
||||
const baselineScopes = new Set<string>()
|
||||
const addDirScopes = (target: Set<string>, directory: string): void => {
|
||||
@@ -324,11 +329,23 @@ export async function reconcileInstructionContext(
|
||||
else directoryScopes.push(scope)
|
||||
}
|
||||
for (const [directory, directoryScopes] of scopesByDirectory) {
|
||||
const probedScopes: string[] = []
|
||||
for (const scope of directoryScopes) {
|
||||
if (options.excludedBaselineScopes !== undefined
|
||||
&& baselineScopes.has(scope)
|
||||
&& options.excludedBaselineScopes.has(scope)) {
|
||||
const previous = effective.get(scope)
|
||||
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
|
||||
else pushRemoval(scope, previous.path)
|
||||
} else {
|
||||
probedScopes.push(scope)
|
||||
}
|
||||
}
|
||||
const itemStart = items.length
|
||||
const versionUpdateStart = versionUpdates.length
|
||||
const addedAbsolutePaths: string[] = []
|
||||
const priorVersions = new Map(directoryScopes.map(scope => [scope, versions.get(scope)]))
|
||||
for (const scope of directoryScopes) {
|
||||
const priorVersions = new Map(probedScopes.map(scope => [scope, versions.get(scope)]))
|
||||
for (const scope of probedScopes) {
|
||||
const previous = effective.get(scope)
|
||||
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
|
||||
if (probe.kind === 'unavailable') {
|
||||
|
||||
Reference in New Issue
Block a user