diff --git a/README.md b/README.md index cb46a24..4c2cc77 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,35 @@ DCP uses its own config file: When enabled, turn protection prevents tool outputs from being pruned for a configurable number of message turns. This gives the AI time to reference recent tool outputs before they become prunable. Applies to both `discard` and `extract` tools, as well as automatic strategies. +### Protected File Patterns + +`protectedFilePatterns` is a list of glob patterns matched against `tool parameters.filePath` (e.g. `read`/`write`/`edit` calls). + +When a tool call matches a protected pattern: + +- It will not appear in the `` list. +- `discard` / `extract` will reject attempts to prune it. +- Automatic strategies (deduplication, supersede-writes, purge-errors) will not prune it. + +Supported glob tokens: + +- `*` matches any characters except `/` +- `?` matches a single character except `/` +- `**` matches across `/` (e.g. `**/*.ts`) + +Examples: + +- `**/*.env` (protect env files) +- `**/.opencode/**` (protect project opencode configs) +- `**/credentials.*` (protect credential files) + +### Nudge Reminders + +When `tools.settings.nudgeEnabled` is true, DCP injects a short “use prune tools” reminder into the `` message: + +- Every `tools.settings.nudgeFrequency` tool results. +- Also after a `todowrite` call transitions an existing todo to `status: "completed"`. + ### Protected Tools By default, these tools are always protected from pruning across all strategies: diff --git a/lib/messages/inject.ts b/lib/messages/inject.ts index 5d9b36f..901d12b 100644 --- a/lib/messages/inject.ts +++ b/lib/messages/inject.ts @@ -117,12 +117,18 @@ export const insertPruneToolContext = ( logger.debug("prunable-tools: \n" + prunableToolsList) let nudgeString = "" - if ( + const shouldNudge = config.tools.settings.nudgeEnabled && - state.nudgeCounter >= config.tools.settings.nudgeFrequency - ) { + (state.nudgeCounter >= config.tools.settings.nudgeFrequency || + state.todoCompletionNudgePending) + + if (shouldNudge) { logger.info("Inserting prune nudge message") nudgeString = "\n" + getNudgeString(config) + + if (state.todoCompletionNudgePending) { + state.todoCompletionNudgePending = false + } } prunableToolsContent = prunableToolsList + nudgeString diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts index dbec5dd..3cb29e3 100644 --- a/lib/prompts/index.ts +++ b/lib/prompts/index.ts @@ -1,8 +1,11 @@ import { readFileSync } from "fs" -import { join } from "path" +import { dirname, join } from "path" +import { fileURLToPath } from "url" + +const PROMPTS_DIR = dirname(fileURLToPath(import.meta.url)) export function loadPrompt(name: string, vars?: Record): string { - const filePath = join(__dirname, `${name}.txt`) + const filePath = join(PROMPTS_DIR, `${name}.txt`) let content = readFileSync(filePath, "utf8").trim() if (vars) { for (const [key, value] of Object.entries(vars)) { diff --git a/lib/state/state.ts b/lib/state/state.ts index e68ecf8..c61bfc4 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -31,6 +31,8 @@ export const checkSession = async ( state.lastCompaction = lastCompactionTimestamp state.toolParameters.clear() state.prune.toolIds = [] + state.todoStatusById.clear() + state.todoCompletionNudgePending = false logger.info("Detected compaction from messages - cleared tool cache", { timestamp: lastCompactionTimestamp, }) @@ -56,6 +58,9 @@ export function createSessionState(): SessionState { lastCompaction: 0, currentTurn: 0, variant: undefined, + + todoStatusById: new Map(), + todoCompletionNudgePending: false, } } @@ -75,6 +80,9 @@ export function resetSessionState(state: SessionState): void { state.lastCompaction = 0 state.currentTurn = 0 state.variant = undefined + + state.todoStatusById.clear() + state.todoCompletionNudgePending = false } export async function ensureSessionInitialized( @@ -101,6 +109,8 @@ export async function ensureSessionInitialized( state.lastCompaction = findLastCompactionTimestamp(messages) state.currentTurn = countTurns(state, messages) + seedTodoStatusFromMessages(state, messages) + const persisted = await loadSessionState(sessionId, logger) if (persisted === null) { return @@ -115,6 +125,28 @@ export async function ensureSessionInitialized( } } +function seedTodoStatusFromMessages(state: SessionState, messages: WithParts[]): void { + for (const msg of messages) { + if (isMessageCompacted(state, msg)) { + continue + } + for (const part of msg.parts) { + if (part.type !== "tool" || part.tool !== "todowrite") { + continue + } + const todos = (part.state?.input as any)?.todos + if (!Array.isArray(todos)) { + continue + } + for (const todo of todos) { + if (todo && typeof todo.id === "string" && typeof todo.status === "string") { + state.todoStatusById.set(todo.id, todo.status) + } + } + } + } +} + function findLastCompactionTimestamp(messages: WithParts[]): number { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] diff --git a/lib/state/tool-cache.ts b/lib/state/tool-cache.ts index f9d3d3c..f606dbb 100644 --- a/lib/state/tool-cache.ts +++ b/lib/state/tool-cache.ts @@ -60,6 +60,30 @@ export async function syncToolCache( continue } + if (part.tool === "todowrite") { + const todos = (part.state?.input as any)?.todos + if (Array.isArray(todos)) { + for (const todo of todos) { + if ( + !todo || + typeof todo.id !== "string" || + typeof todo.status !== "string" + ) { + continue + } + const previousStatus = state.todoStatusById.get(todo.id) + if ( + previousStatus !== undefined && + previousStatus !== "completed" && + todo.status === "completed" + ) { + state.todoCompletionNudgePending = true + } + state.todoStatusById.set(todo.id, todo.status) + } + } + } + state.toolParameters.set(part.callID, { tool: part.tool, parameters: part.state?.input ?? {}, diff --git a/lib/state/types.ts b/lib/state/types.ts index 1e41170..45e86e5 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -35,4 +35,7 @@ export interface SessionState { lastCompaction: number currentTurn: number variant: string | undefined + + todoStatusById: Map + todoCompletionNudgePending: boolean }