feat(workspace-context): load all instruction candidates, dedup by trimmed content, follow symlinks
Squashes feat/instruction-load-all-dedup and feat/allow-instruction-symlink.
This commit is contained in:
@@ -10,7 +10,7 @@ import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
import { instructionContentSha1 } from './digest.ts'
|
||||
import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts'
|
||||
import {
|
||||
ancestorChain,
|
||||
descendantDirsBetween,
|
||||
@@ -21,9 +21,12 @@ import {
|
||||
type LoadedInstructionFile,
|
||||
} from './files.ts'
|
||||
import {
|
||||
candidateScopeKey,
|
||||
decodeScopeKey,
|
||||
instructionScopeKey,
|
||||
renderInstructionChanges,
|
||||
scopeForDisplayPath,
|
||||
scopeKey,
|
||||
USER_GLOBAL_DIRECTORY,
|
||||
USER_GLOBAL_FILE,
|
||||
type ChangeRenderItem,
|
||||
type WorkspaceInstructionChange,
|
||||
} from './render.ts'
|
||||
@@ -45,6 +48,11 @@ export interface InstructionVersionState {
|
||||
path: string
|
||||
version: FsVersion
|
||||
digest: string
|
||||
/**
|
||||
* Trimmed-content identity ({@link trimmedInstructionDigest}) used to suppress
|
||||
* per-directory duplicates on the metadata fast path without re-reading a sibling.
|
||||
*/
|
||||
trimmedDigest: string
|
||||
}
|
||||
|
||||
/** Session-isolated fast-path state keyed by logical instruction scope. */
|
||||
@@ -72,7 +80,6 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[
|
||||
action: change.action,
|
||||
scope: change.scope,
|
||||
path: change.path,
|
||||
...change.previousPath !== undefined ? { previousPath: change.previousPath } : {},
|
||||
...change.digest !== undefined ? { digest: change.digest } : {},
|
||||
}))
|
||||
const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges }
|
||||
@@ -113,13 +120,11 @@ function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInst
|
||||
if (!isRecord(value)) continue
|
||||
if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
|
||||
if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
|
||||
if (value.previousPath !== undefined && typeof value.previousPath !== 'string') continue
|
||||
if (value.digest !== undefined && typeof value.digest !== 'string') continue
|
||||
changes.push({
|
||||
action: value.action,
|
||||
scope: value.scope,
|
||||
path: value.path,
|
||||
...value.previousPath !== undefined ? { previousPath: value.previousPath } : {},
|
||||
...value.digest !== undefined ? { digest: value.digest } : {},
|
||||
})
|
||||
}
|
||||
@@ -130,7 +135,6 @@ function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstru
|
||||
return a.action === b.action
|
||||
&& a.scope === b.scope
|
||||
&& a.path === b.path
|
||||
&& a.previousPath === b.previousPath
|
||||
&& a.digest === b.digest
|
||||
}
|
||||
|
||||
@@ -170,13 +174,18 @@ export function baselineInstructionState(files: LoadedInstructionFile[]): {
|
||||
const digest = instructionContentSha1(file.content)
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action: 'set',
|
||||
scope: scopeKey(scopeForDisplayPath(file.displayPath), file.tier ?? 'base'),
|
||||
scope: instructionScopeKey(file.displayPath),
|
||||
path: file.displayPath,
|
||||
digest,
|
||||
}
|
||||
changes.set(change.scope, change)
|
||||
if (file.version !== undefined) {
|
||||
versions.set(change.scope, { path: file.displayPath, version: file.version, digest })
|
||||
versions.set(change.scope, {
|
||||
path: file.displayPath,
|
||||
version: file.version,
|
||||
digest,
|
||||
trimmedDigest: trimmedInstructionDigest(file.content),
|
||||
})
|
||||
}
|
||||
}
|
||||
return { changes, versions }
|
||||
@@ -392,40 +401,57 @@ export async function reconcileInstructionContext(
|
||||
// recomputing it after marker edits reinterprets the existing relative scope keys.
|
||||
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
|
||||
const scopes = new Set<string>()
|
||||
const localEnabled = resolved.localInstructionFileCandidates.length > 0
|
||||
const addDirScopes = (directory: string): void => {
|
||||
for (const candidate of resolved.instructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
|
||||
for (const candidate of resolved.localInstructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
|
||||
}
|
||||
const addProjectScopes = (dir: string): void => {
|
||||
const scope = relativeScope(projectRoot, dir)
|
||||
scopes.add(scope)
|
||||
if (localEnabled) scopes.add(scopeKey(scope, 'local'))
|
||||
addDirScopes(relativeScope(projectRoot, dir))
|
||||
}
|
||||
if (options.includeBaselineScopes) {
|
||||
scopes.add('user-global')
|
||||
scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(dir)
|
||||
}
|
||||
for (const scope of effective.keys()) scopes.add(scope)
|
||||
for (const scope of effective.keys()) {
|
||||
const { directory } = decodeScopeKey(scope)
|
||||
if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
|
||||
else addDirScopes(directory)
|
||||
}
|
||||
if (options.touchedPath !== undefined) {
|
||||
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(dir)
|
||||
}
|
||||
|
||||
const versions = versionStatesFor(session, versionCache)
|
||||
const seenAbsolutePaths = new Set<string>()
|
||||
// Per-directory trimmed-content identities kept so far this pass, iterated in
|
||||
// candidate order (base before local); a later sibling matching an earlier one
|
||||
// is a duplicate and is dropped or removed rather than rendered twice.
|
||||
const keptTrimmedByDir = new Map<string, Set<string>>()
|
||||
const registerKeptTrimmed = (directory: string, digest: string): boolean => {
|
||||
let digests = keptTrimmedByDir.get(directory)
|
||||
if (digests === undefined) {
|
||||
digests = new Set()
|
||||
keptTrimmedByDir.set(directory, digests)
|
||||
}
|
||||
if (digests.has(digest)) return true
|
||||
digests.add(digest)
|
||||
return false
|
||||
}
|
||||
const items: ChangeRenderItem[] = []
|
||||
const versionUpdates: InstructionVersionUpdate[] = []
|
||||
const pushRemoval = (scope: string, path: string): void => {
|
||||
const change: WorkspaceInstructionChange = { action: 'remove', scope, path }
|
||||
items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
|
||||
versionUpdates.push({ change })
|
||||
}
|
||||
for (const scope of scopes) {
|
||||
const { directory } = decodeScopeKey(scope)
|
||||
const previous = effective.get(scope)
|
||||
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
|
||||
if (probe.kind === 'unavailable') continue
|
||||
if (probe.kind === 'absent') {
|
||||
if (previous === undefined || previous.action === 'remove') {
|
||||
versions.delete(scope)
|
||||
continue
|
||||
}
|
||||
const change: WorkspaceInstructionChange = { action: 'remove', scope, path: previous.path }
|
||||
items.push({
|
||||
change,
|
||||
file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' },
|
||||
})
|
||||
versionUpdates.push({ change })
|
||||
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
|
||||
else pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
const { file: probedFile } = probe
|
||||
@@ -440,29 +466,39 @@ export async function reconcileInstructionContext(
|
||||
&& previous.action !== 'remove'
|
||||
&& previous.path === cached.path
|
||||
&& previous.digest === cached.digest
|
||||
) continue
|
||||
) {
|
||||
// Unchanged and previously rendered: keep it, but an earlier sibling that
|
||||
// now matches its trimmed content makes this the duplicate to remove.
|
||||
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
|
||||
continue
|
||||
}
|
||||
|
||||
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
|
||||
if (file === undefined) continue
|
||||
const currentDigest = instructionContentSha1(file.content)
|
||||
const trimmedDigest = trimmedInstructionDigest(file.content)
|
||||
if (registerKeptTrimmed(directory, trimmedDigest)) {
|
||||
// A distinct file whose trimmed content already appeared earlier in this
|
||||
// directory: drop it, removing any copy that was previously rendered.
|
||||
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
|
||||
else versions.delete(scope)
|
||||
continue
|
||||
}
|
||||
const nextVersion: InstructionVersionState = {
|
||||
path: file.displayPath,
|
||||
version: probedFile.version,
|
||||
digest: currentDigest,
|
||||
trimmedDigest,
|
||||
}
|
||||
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
|
||||
versions.set(scope, nextVersion)
|
||||
continue
|
||||
}
|
||||
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
|
||||
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
|
||||
? previous.path
|
||||
: undefined
|
||||
const change: WorkspaceInstructionChange = {
|
||||
action,
|
||||
scope,
|
||||
path: file.displayPath,
|
||||
...previousPath === undefined ? {} : { previousPath },
|
||||
digest: currentDigest,
|
||||
}
|
||||
items.push({ change, file })
|
||||
|
||||
Reference in New Issue
Block a user