diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 1e0ecaa..4af88e5 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + id-token: write jobs: release-beta: @@ -45,10 +46,11 @@ jobs: - name: Verify package contents run: bun pm pack --dry-run + - name: Install npm with trusted publishing support + run: npm install -g npm@latest + - name: Publish to npm (beta) - run: bun publish --access public --tag beta - env: - NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public --tag beta - name: Create GitHub Release uses: softprops/action-gh-release@26e8ad27a09a225049a7075d7ec1caa2df6ff332 # v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fec4904..7898fe5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + id-token: write steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -34,10 +35,11 @@ jobs: - name: Verify package contents run: bun pm pack --dry-run + - name: Install npm with trusted publishing support + run: npm install -g npm@latest + - name: Publish to npm - run: bun publish --access public - env: - NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public - name: Create GitHub Release uses: softprops/action-gh-release@26e8ad27a09a225049a7075d7ec1caa2df6ff332 # v2 diff --git a/.opencode/rules/10-runtime-consistency.md b/.opencode/rules/10-runtime-consistency.md index e5fc9ce..1180d4f 100644 --- a/.opencode/rules/10-runtime-consistency.md +++ b/.opencode/rules/10-runtime-consistency.md @@ -1,13 +1,14 @@ --- globs: - 'src/runtime.ts' - - 'src/utils.ts' - 'src/message-context.ts' - 'src/mcp-tools.ts' + - 'src/runtime-context.ts' + - 'src/runtime-chat.ts' --- # Runtime Consistency - Use shared message-context helpers for prompt and part extraction. Do not duplicate extraction loops in runtime hooks. -- Keep CI/env boolean detection on one parser path (`parseEnvBoolean` / `isTruthyEnvValue`) across all provider checks. +- Keep CI/env boolean detection on `parseEnvBoolean` in `src/runtime-context.ts`; do not inline truthiness checks per provider. - Route all plugin console output through the gated helpers in `src/debug.ts`; use UI state or intentional thrown errors for user-visible behavior that must remain available when debug logging is disabled. diff --git a/.opencode/rules/11-readme-and-doc-sync.md b/.opencode/rules/11-readme-and-doc-sync.md index 19d2386..9b51467 100644 --- a/.opencode/rules/11-readme-and-doc-sync.md +++ b/.opencode/rules/11-readme-and-doc-sync.md @@ -1,6 +1,8 @@ --- globs: - 'README.md' + - 'AGENTS.md' + - 'CONTEXT.md' - 'docs/**/*.md' keywords: - 'readme' @@ -13,5 +15,6 @@ match: any # README and Documentation Sync - When adding, removing, or renaming production modules, update the README Project Structure section in the same change. +- Keep `AGENTS.md` and `CONTEXT.md` aligned with the module layout and runtime behavior they describe. - Keep architecture docs aligned with current hook/runtime behavior and supported rule filters. -- Remove stale references to deprecated behavior as part of the same PR that changes behavior. +- Remove stale references to deprecated behavior (including dead directory listings like `openspec/`) as part of the same PR that changes behavior. diff --git a/.opencode/rules/12-hotspot-guardrails.md b/.opencode/rules/12-hotspot-guardrails.md index e7440c7..3970d74 100644 --- a/.opencode/rules/12-hotspot-guardrails.md +++ b/.opencode/rules/12-hotspot-guardrails.md @@ -7,6 +7,6 @@ globs: # Hotspot Guardrails -- In `src/utils.ts`, do not add new unrelated responsibilities. Prefer splitting by domain (discovery, metadata, matching, message paths). +- Keep `src/utils.ts` a compatibility re-export facade; add new logic to domain modules instead. `src/api-surface.typecheck.ts` enforces intentional exports. - In `src/runtime.ts`, extract shared helpers before adding additional inline transformation logic. - In `src/index.test.ts`, prefer creating or expanding module-focused test files instead of growing the monolithic suite. diff --git a/AGENTS.md b/AGENTS.md index bcd8051..9caa2e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ - Use Aube and install with `aube install --frozen-lockfile`. Run checks in this order: `aube run lint` -> `aubx tsc --noEmit` -> `aube run test:run`. - There is no `typecheck` script; typecheck with `aubx tsc --noEmit`. -- Run one colocated Vitest file with `aube run test:run src/.test.ts` (or a path under `tui/`). `tsconfig.json` excludes test/spec files, so `tsc` does not typecheck them. +- Run one colocated Vitest file with `aube run test:run src//.test.ts` (or a path under `tui/`). `tsconfig.json` excludes test/spec files, so `tsc` does not typecheck them. - `docs/silent-message-implementation.md` describes a superseded design; current delivery is synthetic parts via `chat.message`. ## Architecture @@ -21,7 +21,7 @@ - The `"./tui"` package export must point to `./dist/tui/index.js`, not raw `./tui/index.tsx`: OpenCode/Bun does not reliably remap `.js` relative imports when loading raw TSX, while those targets exist only after the TypeScript build. - OpenCode caches npm plugin specs by their literal specifier; an existing `~/.cache/opencode/packages/opencode-rules@latest` wrapper pins the version resolved when it was created and does not refresh when `latest` changes. Clear that cache or use an explicit new version when validating a release. - tsconfig is strict-plus (`exactOptionalPropertyTypes`, `noUnusedLocals`/`noUnusedParameters`, `verbatimModuleSyntax`), so type-only imports and unused symbols will fail typecheck even though lint passes. -- `src/utils.ts` is the compatibility re-export facade; add logic to domain modules instead. `src/api-surface.typecheck.ts` enforces intentionally private exports during `tsc`. +- Server source is grouped by domain: `src/rules/` (discovery, metadata, filter, hooks), `src/delivery/` (delivery engine composed behind `createRuleDelivery` plus codec and history port), `src/session/` (session/matched-rule state, file observations, message extraction), `src/runtime/` (orchestrator, client adapter, tool-hook flow, match context, chat capture), `src/detection/` (git-branch, project-fingerprint, mcp-tools), `src/shared/` (debug, bounded-session-map). `src/api-surface.typecheck.ts` enforces intentionally private exports during `tsc`. - Do not edit generated `dist/`; `tsc` builds it from `src/` and `tui/`. - This repo dogfoods its own plugin: `.opencode/rules/*.md` are injected into sessions and contain additional scoped guardrails. - When adding/removing/renaming production modules, update the README "Project Structure" section in the same change (`.opencode/rules/11-readme-and-doc-sync.md`). diff --git a/README.md b/README.md index 0106f5d..3cb78d1 100644 --- a/README.md +++ b/README.md @@ -492,29 +492,40 @@ The following shows the key source modules. Additional test files (`*.test.ts`) opencode-rules/ ├── src/ │ ├── index.ts # Main plugin entry point and exports -│ ├── runtime.ts # OpenCodeRulesRuntime class (hook orchestration) -│ ├── file-observation-context.ts # Runtime-owned per-session File-observation store (bounded LRU; feeds globs/fileContains matching and earliest-dispatch admission; live events only) -│ ├── session-working-context.ts # Runtime-owned Working context (path-only compaction projection, history prefetch, never a matching source) -│ ├── file-observation.ts # File-observation normalization for read/write/edit/apply_patch/lsp (live events; history parts feed path-only Working context) -│ ├── rule-delivery.ts # Durable/transient delivery, Hook queues, and identity ledger -│ ├── rule-delivery-codec.ts # Delivery identifiers, formats, history decoding, and transient presence facts -│ ├── rule-delivery-history.ts # Raw history port for delivery decoding -│ ├── runtime-context.ts # Context-building helpers (match context, project detection) -│ ├── runtime-chat.ts # Chat message handling and text extraction -│ ├── rule-discovery.ts # Rule file scanning, discovery, and per-session snapshots -│ ├── rule-metadata.ts # YAML frontmatter parsing -│ ├── rule-filter.ts # Rule matching against context, lifetime classification (globs, fileContains, keywords, tools, runtime) -│ ├── message-paths.ts # Legacy path-extraction compatibility facade -│ ├── message-context.ts # User prompt extraction from message parts -│ ├── session-store.ts # Per-session state management -│ ├── project-fingerprint.ts # Project type detection (Node.js, Python, etc.) -│ ├── mcp-tools.ts # MCP tool ID extraction -│ ├── git-branch.ts # Git branch detection -│ ├── matched-rules-state.ts # Persists Matched-rule state for TUI -│ ├── debug.ts # Debug logging utilities -│ ├── utils.ts # Re-export facade for backwards compatibility │ ├── test-fixtures.ts # Shared test fixtures and builders -│ └── *.test.ts # Unit/integration tests in src +│ ├── api-surface.typecheck.ts # Type-level privacy contract (checked by tsc) +│ ├── rules/ +│ │ ├── rule-discovery.ts # Rule file scanning, discovery, and per-session snapshots +│ │ ├── rule-metadata.ts # YAML frontmatter parsing +│ │ ├── rule-filter.ts # Rule matching against context, lifetime classification (globs, fileContains, keywords, tools, runtime) +│ │ └── rule-hooks.ts # Hook evaluation against serialized tool args +│ ├── delivery/ +│ │ ├── rule-delivery.ts # Delivery engine composing the seams below (durable/transient delivery, Hook queues, identity ledger) +│ │ ├── delivery-state.ts # Per-session delivery state and operation serialization +│ │ ├── delivery-ledger.ts # History seeding and rule-admission persistence +│ │ ├── delivery-transient.ts # Transient dispatch presence and turn tracking +│ │ ├── rule-delivery-codec.ts # Delivery identifiers, formats, history decoding, and transient presence facts +│ │ └── rule-delivery-history.ts # Raw history port for delivery decoding +│ ├── session/ +│ │ ├── session-store.ts # Per-session state management +│ │ ├── matched-rules-state.ts # Persists Matched-rule state for TUI +│ │ ├── file-observation.ts # File-observation normalization for read/write/edit/apply_patch/lsp (live events; history parts feed path-only Working context) +│ │ ├── file-observation-context.ts # Runtime-owned per-session File-observation store (bounded LRU; feeds globs/fileContains matching and earliest-dispatch admission; live events only) +│ │ ├── session-working-context.ts # Runtime-owned Working context (path-only compaction projection, history prefetch, never a matching source) +│ │ └── message-extraction.ts # File-path, prompt, and session-ID extraction from message parts +│ ├── runtime/ +│ │ ├── orchestrator.ts # OpenCodeRulesRuntime class (hook orchestration) +│ │ ├── client-adapter.ts # OpenCode client port (history reads, no-reply admission, tool-ID/MCP queries) +│ │ ├── tool-hook-flow.ts # PreToolUse/PostToolUse evaluation, blockers, side-effects, Hook queuing +│ │ ├── match-context.ts # Context-building helpers (match context, project detection) +│ │ └── chat-capture.ts # Chat message handling and text extraction +│ ├── detection/ +│ │ ├── project-fingerprint.ts # Project type detection (Node.js, Python, etc.) +│ │ ├── mcp-tools.ts # MCP tool ID extraction +│ │ └── git-branch.ts # Git branch detection +│ └── shared/ +│ ├── bounded-session-map.ts # Shared internal LRU-bounded per-session map (sole value owner; unstamped reads; optional eviction protection) +│ └── debug.ts # Debug logging utilities ├── tui/ │ ├── index.tsx # TUI entrypoint, exports { id, tui } │ ├── slots/ @@ -526,7 +537,6 @@ opencode-rules/ │ └── opencode-plugin-tui.d.ts # Vendored type shim ├── docs/ │ └── rules.md # Detailed usage documentation -├── openspec/ # Project specifications and proposals └── dist/ # Compiled JavaScript output ``` @@ -534,23 +544,31 @@ opencode-rules/ The following highlights the primary runtime modules: -- **runtime.ts** - Orchestrates hooks (`tool.execute.before`, `chat.message`, `experimental.chat.*`) -- **rule-delivery.ts** - Owns durable/transient delivery, matched Hook queues, history reconstruction, and the identity ledger -- **rule-delivery-codec.ts** - Encodes durable/transient delivery and decodes durable history facts plus transient presence facts -- **rule-delivery-history.ts** - Defines the raw host-history port used by delivery decoding -- **runtime-context.ts** - Builds `RuleMatchContext` from session state and environment -- **runtime-chat.ts** - Extracts text from chat message parts for keyword matching -- **rule-discovery.ts** - Recursively scans directories for `.md`/`.mdc` rule files -- **rule-metadata.ts** - Parses YAML frontmatter into typed `RuleMetadata` -- **rule-filter.ts** - Matches rules against context (file-observation family: globs + fileContains, keywords, tools, runtime filters) and classifies each match as session-durable or ephemeral -- **message-paths.ts** - Compatibility facade for the legacy path-extraction API; runtime matching uses normalized File observations -- **message-context.ts** - Extracts user prompt text, slash commands, and session IDs from message parts -- **session-store.ts** - Manages per-session state with LRU eviction -- **project-fingerprint.ts** - Detects project type from marker files (e.g., `package.json`) -- **mcp-tools.ts** - Maps connected MCP clients to tool IDs for `tools` condition matching -- **git-branch.ts** - Resolves current git branch for `branch` condition matching -- **matched-rules-state.ts** - Persists Matched-rule state to `~/.opencode/state/opencode-rules/{sessionId}.json` for TUI consumption (atomic writes, per-session queuing) -- **utils.ts** - Thin facade re-exporting from decomposed modules +- **runtime/orchestrator.ts** - Orchestrates hooks (`tool.execute.before`, `chat.message`, `experimental.chat.*`) +- **runtime/client-adapter.ts** - Isolates the OpenCode client port: history reads, no-reply admission via `session.prompt`, tool-ID/MCP queries +- **runtime/tool-hook-flow.ts** - Evaluates PreToolUse/PostToolUse hooks, throws on blockers, runs side-effects, queues matched Hook content +- **delivery/rule-delivery.ts** - Owns durable/transient delivery composed over per-session state, ledger, and transient seams +- **delivery/delivery-state.ts** - Per-session delivery state with operation serialization +- **delivery/delivery-ledger.ts** - History seeding and rule-admission persistence +- **delivery/delivery-transient.ts** - Transient dispatch presence facts and per-turn tracking +- **delivery/rule-delivery-codec.ts** - Encodes durable/transient delivery and decodes durable history facts plus transient presence facts +- **delivery/rule-delivery-history.ts** - Defines the raw host-history port used by delivery decoding +- **runtime/match-context.ts** - Builds `RuleMatchContext` from session state and environment +- **runtime/chat-capture.ts** - Extracts text from chat message parts for keyword matching +- **rules/rule-discovery.ts** - Recursively scans directories for `.md`/`.mdc` rule files +- **rules/rule-metadata.ts** - Parses YAML frontmatter into typed `RuleMetadata` +- **rules/rule-filter.ts** - Matches rules against context (file-observation family: globs + fileContains, keywords, tools, runtime filters) and classifies each match as session-durable or ephemeral +- **rules/rule-hooks.ts** - Evaluates rule hooks against serialized tool arguments +- **session/message-extraction.ts** - Extracts file paths, user prompt text, slash commands, and session IDs from message parts +- **session/session-store.ts** - Manages per-session state with LRU eviction +- **session/file-observation-context.ts** - Bounded per-session File-observation store feeding globs/fileContains matching +- **session/session-working-context.ts** - Path-only Working context with history prefetch and compaction projection +- **detection/project-fingerprint.ts** - Detects project type from marker files (e.g., `package.json`) +- **detection/mcp-tools.ts** - Maps connected MCP clients to tool IDs for `tools` condition matching +- **detection/git-branch.ts** - Resolves current git branch for `branch` condition matching +- **session/matched-rules-state.ts** - Persists Matched-rule state to `~/.opencode/state/opencode-rules/{sessionId}.json` for TUI consumption (atomic writes, per-session queuing) +- **shared/bounded-session-map.ts** - LRU-bounded per-session map shared across stores +- **shared/debug.ts** - Gated debug logging utilities ### TUI Sidebar diff --git a/docs/compaction-handling.md b/docs/compaction-handling.md index 80cb149..b7193b8 100644 --- a/docs/compaction-handling.md +++ b/docs/compaction-handling.md @@ -73,7 +73,6 @@ Per-session state is stored in `sessionStateMap` with the following structure: interface SessionState { workingContextPaths: Set; // Current working set of file paths lastUserPrompt?: string; // Latest user message text - lastUpdated: number; // Timestamp for LRU cache pruning workingContextSeeded: boolean; // Flag: first successful seeding source completed lastModelID?: string; // Latest model ID lastAgentType?: string; // Latest agent type @@ -85,8 +84,9 @@ Delivery bookkeeping (dedup ledger, pending Hook queues, rescan flag) lives in the runtime-owned `RuleDelivery` instance, not in SessionState. - Maximum of 100 concurrent sessions in memory (LRU eviction) -- Each entry is tagged with `lastUpdated` for age tracking -- Sessions are automatically pruned when limit is exceeded +- Eviction is owned by the internal `BoundedSessionMap` each store composes; + entries are stamped on write/read access and the least-recently-stamped + session is pruned when the limit is exceeded - Compaction invalidates durable delivery identities; the next transformed request rebuilds them from surviving synthetic delivery metadata, missing durable rules are re-appended, and ephemeral rules are recomputed per request ## Data Flow diff --git a/eslint.config.js b/eslint.config.js index 1a977e2..419c46e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -45,7 +45,6 @@ export default [ '@typescript-eslint/no-explicit-any': 'off', }, }, - // TUI production files { files: ['tui/**/*.ts', 'tui/**/*.tsx'], ignores: [ @@ -73,7 +72,6 @@ export default [ ], }, }, - // TUI test files { files: [ 'tui/**/*.test.ts', diff --git a/package.json b/package.json index 956872e..bc9a2a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-rules", - "version": "1.0.0-beta01", + "version": "1.0.0", "description": "OpenCode plugin that discovers and injects markdown rules into system prompts", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -81,10 +81,10 @@ } }, "devDependencies": { - "@opencode-ai/plugin": "^1.18.23", - "@opencode-ai/sdk": "^1.18.23", - "@opentui/core": "^0.5.8", - "@opentui/solid": "^0.5.8", + "@opencode-ai/plugin": "^1.18.27", + "@opencode-ai/sdk": "^1.18.27", + "@opentui/core": "^0.5.10", + "@opentui/solid": "^0.5.10", "@types/node": "^20.19.43", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", diff --git a/src/api-surface.typecheck.ts b/src/api-surface.typecheck.ts index ee3b56a..7350bdc 100644 --- a/src/api-surface.typecheck.ts +++ b/src/api-surface.typecheck.ts @@ -1,34 +1,25 @@ /** - * Type-level API surface contract tests. - * - * This file uses @ts-expect-error to assert that certain types are NOT exported. - * If a forbidden type is accidentally re-exported, the @ts-expect-error will - * become invalid and TypeScript compilation will fail. - * - * This file is checked by `npm run build` / `tsc` but produces no runtime output. + * Type-level API surface contract tests. Each import must fail because of + * its @ts-expect-error; accidentally exporting the type makes the error + * directive invalid and fails compilation. Checked by tsc; no runtime + * output. */ -// --- mcp-tools.ts: McpStatusMap should NOT be exported --- // @ts-expect-error McpStatusMap is internal and should not be exported -import type { McpStatusMap } from './mcp-tools.js'; +import type { McpStatusMap } from './detection/mcp-tools.js'; -// --- runtime.ts: OpenCodeRulesRuntimeOptions should NOT be exported --- // @ts-expect-error OpenCodeRulesRuntimeOptions is internal and should not be exported -import type { OpenCodeRulesRuntimeOptions } from './runtime.js'; +import type { OpenCodeRulesRuntimeOptions } from './runtime/orchestrator.js'; -// --- session-store.ts: SessionStoreOptions should NOT be exported --- // @ts-expect-error SessionStoreOptions is internal and should not be exported -import type { SessionStoreOptions } from './session-store.js'; +import type { SessionStoreOptions } from './session/session-store.js'; -// --- matched-rules-state.ts: MatchedRulesStateStoreOptions should NOT be exported --- // @ts-expect-error MatchedRulesStateStoreOptions is internal and should not be exported -import type { MatchedRulesStateStoreOptions } from './matched-rules-state.js'; +import type { MatchedRulesStateStoreOptions } from './session/matched-rules-state.js'; -// --- rule-delivery.ts: delivery implementation types should NOT be exported --- // @ts-expect-error RuleDeliveryOptions is internal and should not be exported -import type { RuleDeliveryOptions } from './rule-delivery.js'; +import type { RuleDeliveryOptions } from './delivery/rule-delivery.js'; -// Suppress unused variable warnings for the type imports above void (0 as unknown as McpStatusMap); void (0 as unknown as OpenCodeRulesRuntimeOptions); void (0 as unknown as SessionStoreOptions); diff --git a/src/delivery/delivery-ledger.ts b/src/delivery/delivery-ledger.ts new file mode 100644 index 0000000..3f31038 --- /dev/null +++ b/src/delivery/delivery-ledger.ts @@ -0,0 +1,114 @@ +import { + buildRuleAdmissionPart, + decodeRawHistory, + type DeliveryLedgerFacts, + type DeliveryPart, +} from './rule-delivery-codec.js'; +import { + type RawHistoryAdapter, + type RawHistoryResult, +} from './rule-delivery-history.js'; +import { createDebugLog, formatError, type DebugLog } from '../shared/debug.js'; +import type { MatchedRuleContent } from './rule-delivery.js'; +import { + deliveryKey, + hasDeliveryKey, + type DeliveryState, +} from './delivery-state.js'; + +type PersistAdmission = ( + sessionID: string, + part: DeliveryPart +) => Promise; + +export class DeliveryLedger { + private readonly rawHistory: RawHistoryAdapter; + private readonly debugLog: DebugLog; + private readonly persistAdmission: PersistAdmission | undefined; + + constructor(options: { + rawHistory: RawHistoryAdapter; + persistAdmission?: PersistAdmission | undefined; + debugLog?: DebugLog | undefined; + }) { + this.rawHistory = options.rawHistory; + this.debugLog = options.debugLog ?? createDebugLog(); + this.persistAdmission = options.persistAdmission; + } + + async decodeHistory( + sessionID: string + ): Promise { + let result: RawHistoryResult; + try { + result = await this.rawHistory.readHistory(sessionID); + } catch (error) { + this.debugLog( + `History read failed for ${sessionID}: ${formatError(error)}` + ); + return undefined; + } + + if (!result.ok) return undefined; + return decodeRawHistory(result.messages); + } + + replaceLedger(state: DeliveryState, facts: DeliveryLedgerFacts): void { + state.ruleKeys = new Set(facts.ruleKeys); + state.hookKeys = new Set(facts.hookKeys); + state.ledgerRevision++; + } + + queuePendingRules( + state: DeliveryState, + rules: readonly MatchedRuleContent[] + ): boolean { + let added = false; + for (const rule of rules) { + if ( + hasDeliveryKey(state.ruleKeys, rule) || + state.pendingRuleQueue.some( + pending => deliveryKey(pending) === deliveryKey(rule) + ) + ) { + continue; + } + state.pendingRuleQueue.push(rule); + added = true; + } + return added; + } + + async persistPendingRules( + sessionID: string, + state: DeliveryState + ): Promise { + if (!this.persistAdmission || state.pendingRuleQueue.length === 0) { + return state.pendingRuleQueue.length === 0; + } + const pending = state.pendingRuleQueue.filter( + rule => !hasDeliveryKey(state.ruleKeys, rule) + ); + if (pending.length === 0) { + state.pendingRuleQueue = []; + return true; + } + try { + await this.persistAdmission( + sessionID, + buildRuleAdmissionPart(pending, sessionID) + ); + } catch (error) { + this.debugLog( + `Rule admission persistence failed for ${sessionID}: ${formatError(error)}` + ); + return false; + } + for (const rule of pending) state.ruleKeys.add(deliveryKey(rule)); + const accepted = new Set(pending.map(deliveryKey)); + state.pendingRuleQueue = state.pendingRuleQueue.filter( + rule => !accepted.has(deliveryKey(rule)) + ); + return true; + } +} diff --git a/src/delivery/delivery-state.ts b/src/delivery/delivery-state.ts new file mode 100644 index 0000000..73ebc70 --- /dev/null +++ b/src/delivery/delivery-state.ts @@ -0,0 +1,95 @@ +import { BoundedSessionMap } from '../shared/bounded-session-map.js'; +import { ruleKeyFor } from './rule-delivery-codec.js'; +import type { + MatchedHookContent, + MatchedRuleContent, +} from './rule-delivery.js'; + +export interface DeliveryState { + ruleKeys: Set; + hookKeys: Set; + ledgerRevision: number; + seededFromHistory: boolean; + needsRescan: boolean; + pendingHookQueue: MatchedHookContent[]; + pendingRuleQueue: MatchedRuleContent[]; + durableHookQueue: MatchedHookContent[]; + transientHookQueue: MatchedHookContent[]; + transientTurn: + | { + id: string; + ruleKeys: Set; + hookKeys: Set; + } + | undefined; +} + +export function createDeliveryState(): DeliveryState { + return { + ruleKeys: new Set(), + hookKeys: new Set(), + ledgerRevision: 0, + seededFromHistory: false, + needsRescan: false, + pendingHookQueue: [], + pendingRuleQueue: [], + durableHookQueue: [], + transientHookQueue: [], + transientTurn: undefined, + }; +} + +export function deliveryKey(rule: MatchedRuleContent): string { + return ruleKeyFor(rule.identity ?? rule.relativePath); +} + +export function hasDeliveryKey( + keys: ReadonlySet, + rule: MatchedRuleContent +): boolean { + return ( + keys.has(deliveryKey(rule)) || + (rule.identity !== undefined && keys.has(ruleKeyFor(rule.relativePath))) + ); +} + +export class DeliveryStateStore { + private readonly states: BoundedSessionMap; + private readonly operationTails = new Map>(); + + constructor(maxSessions: number | undefined) { + this.states = new BoundedSessionMap({ + minBound: 1, + max: maxSessions ?? 100, + isEvictable: sessionID => !this.operationTails.has(sessionID), + }); + } + + getState(sessionID: string): DeliveryState { + return this.states.ensure(sessionID, createDeliveryState); + } + + async serialize( + sessionID: string, + operation: () => Promise + ): Promise { + const previous = this.operationTails.get(sessionID) ?? Promise.resolve(); + const result = previous.then(operation); + const tail = result.then( + () => undefined, + () => undefined + ); + this.operationTails.set(sessionID, tail); + + try { + return await result; + } finally { + // Deleting the tail before evicting unprotects this session in the + // same settle path. + if (this.operationTails.get(sessionID) === tail) { + this.operationTails.delete(sessionID); + } + this.states.evict(); + } + } +} diff --git a/src/delivery/delivery-transient.ts b/src/delivery/delivery-transient.ts new file mode 100644 index 0000000..88e5fc7 --- /dev/null +++ b/src/delivery/delivery-transient.ts @@ -0,0 +1,126 @@ +import { + buildTransientDeliveryMessage, + decodeTransientPresence, + isTransientMessageId, +} from './rule-delivery-codec.js'; +import type { + MatchedHookContent, + MatchedRuleContent, + TransientDispatchInput, + TransientDispatchMessage, +} from './rule-delivery.js'; +import { + deliveryKey, + hasDeliveryKey, + type DeliveryState, +} from './delivery-state.js'; + +export class TransientDispatcher { + append(input: TransientDispatchInput, state: DeliveryState): void { + const { + ids: presentIDs, + ruleKeys: presentRuleKeys, + hookKeys: presentHookKeys, + } = decodeTransientPresence(input.messages); + const realUserInfo = latestRealUserInfo(input.messages); + const turnID = + typeof realUserInfo?.id === 'string' ? realUserInfo.id : undefined; + if (turnID && state.transientTurn?.id !== turnID) { + state.transientTurn = { + id: turnID, + ruleKeys: new Set(), + hookKeys: new Set(), + }; + } + const transientTurn = turnID ? state.transientTurn : undefined; + if (transientTurn) { + for (const key of presentRuleKeys) transientTurn.ruleKeys.add(key); + for (const key of presentHookKeys) transientTurn.hookKeys.add(key); + } + + const baseInfo = + realUserInfo ?? input.messages[input.messages.length - 1]?.info ?? {}; + const transientRules: MatchedRuleContent[] = []; + for (const rule of [...input.matchedRules, ...state.pendingRuleQueue]) { + const key = deliveryKey(rule); + if ( + hasDeliveryKey(state.ruleKeys, rule) || + hasDeliveryKey(presentRuleKeys, rule) || + (transientTurn !== undefined && + hasDeliveryKey(transientTurn.ruleKeys, rule)) + ) { + continue; + } + presentRuleKeys.add(key); + transientRules.push(rule); + } + + const transientHooks: MatchedHookContent[] = []; + for (const hook of [ + ...state.durableHookQueue, + ...state.transientHookQueue, + ]) { + const key = deliveryKey(hook); + if ( + hasDeliveryKey(presentHookKeys, hook) || + (transientTurn !== undefined && + hasDeliveryKey(transientTurn.hookKeys, hook)) + ) { + continue; + } + presentHookKeys.add(key); + transientHooks.push(hook); + } + + if (transientRules.length > 0 || transientHooks.length > 0) { + const transientMessage = buildTransientDeliveryMessage( + transientRules, + transientHooks, + baseInfo + ); + const part = transientMessage.parts[0]; + if ( + part && + !presentIDs.has(transientMessage.info.id) && + !presentIDs.has(part.id) + ) { + input.messages.push({ + info: transientMessage.info, + parts: [ + { + ...part, + sessionID: input.sessionID, + messageID: transientMessage.info.id, + }, + ], + }); + if (transientTurn) { + for (const rule of transientRules) { + transientTurn.ruleKeys.add(deliveryKey(rule)); + } + for (const hook of transientHooks) { + transientTurn.hookKeys.add(deliveryKey(hook)); + } + } + } + } + state.transientHookQueue = []; + } +} + +function latestRealUserInfo( + messages: readonly TransientDispatchMessage[] +): Record | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const info: unknown = messages[index]?.info; + if ( + !info || + (info as { role?: unknown }).role !== 'user' || + isTransientMessageId((info as { id?: unknown }).id) + ) { + continue; + } + return info as Record; + } + return undefined; +} diff --git a/src/rule-delivery-admission.test.ts b/src/delivery/rule-delivery-admission.test.ts similarity index 100% rename from src/rule-delivery-admission.test.ts rename to src/delivery/rule-delivery-admission.test.ts diff --git a/src/rule-delivery-codec.test.ts b/src/delivery/rule-delivery-codec.test.ts similarity index 100% rename from src/rule-delivery-codec.test.ts rename to src/delivery/rule-delivery-codec.test.ts diff --git a/src/rule-delivery-codec.ts b/src/delivery/rule-delivery-codec.ts similarity index 91% rename from src/rule-delivery-codec.ts rename to src/delivery/rule-delivery-codec.ts index 1a45098..fe15f4f 100644 --- a/src/rule-delivery-codec.ts +++ b/src/delivery/rule-delivery-codec.ts @@ -227,7 +227,7 @@ export function decodeRawHistory( const metadata = asRecord(part.metadata); recordKeys(facts.ruleKeys, metadata?.ruleKeys); recordKeys(facts.hookKeys, metadata?.hookKeys); - // Read pre-release top-level keys for histories produced by development builds. + // Top-level keys are a pre-release persisted form; read them too. recordKeys(facts.ruleKeys, part.ruleKeys); recordKeys(facts.hookKeys, part.hookKeys); @@ -245,16 +245,9 @@ export function decodeRawHistory( return facts; } -/** - * Reads Transient delivery presence facts: identifiers and canonical metadata - * keys already present in a dispatch's message array. Deliberately separate - * from decodeRawHistory: presence counts Transient delivery parts, which the - * durable ledger must exclude, and ignores the legacy persisted forms the - * ledger must accept. Neither function calls the other. - * - * Malformed messages abort with the offending property-access TypeError, - * matching the inline scan this replaced; hardening is deferred. - */ +// Presence counts transient delivery parts, which the durable ledger must +// exclude, and ignores the legacy persisted forms the ledger must accept — +// deliberately not shared with decodeRawHistory. export function decodeTransientPresence( messages: readonly TransientPresenceMessage[] ): TransientPresenceFacts { @@ -277,9 +270,8 @@ export function decodeTransientPresence( const metadata = asRecord(part.metadata); recordKeys(facts.ruleKeys, metadata?.ruleKeys); recordKeys(facts.hookKeys, metadata?.hookKeys); - // Legacy top-level key arrays and legacy `## path` header text are - // persisted durable forms only; Transient presence is canonical-shape - // only, so they are deliberately not read here. + // Legacy top-level key arrays and `## path` header text are persisted + // durable forms only; transient presence reads canonical shape only. } } diff --git a/src/rule-delivery-history.ts b/src/delivery/rule-delivery-history.ts similarity index 100% rename from src/rule-delivery-history.ts rename to src/delivery/rule-delivery-history.ts diff --git a/src/rule-delivery.test.ts b/src/delivery/rule-delivery.test.ts similarity index 100% rename from src/rule-delivery.test.ts rename to src/delivery/rule-delivery.test.ts diff --git a/src/delivery/rule-delivery.ts b/src/delivery/rule-delivery.ts new file mode 100644 index 0000000..dfe21fb --- /dev/null +++ b/src/delivery/rule-delivery.ts @@ -0,0 +1,295 @@ +import { + buildDurableDeliveryPart, + decodeRawHistory, + type DeliveryPart, +} from './rule-delivery-codec.js'; +import { type RawHistoryAdapter } from './rule-delivery-history.js'; +import { createDebugLog, formatError, type DebugLog } from '../shared/debug.js'; +import type { RuleLifetime } from '../rules/rule-filter.js'; +import { + deliveryKey, + hasDeliveryKey, + type DeliveryState, + DeliveryStateStore, +} from './delivery-state.js'; +import { DeliveryLedger } from './delivery-ledger.js'; +import { TransientDispatcher } from './delivery-transient.js'; + +export interface RuleDelivery { + deliverDurableTurn(input: DurableTurnInput): Promise; + deliverTransientDispatch(input: TransientDispatchInput): void; + retryPendingAdmissions(sessionID: string): Promise; + admitDurableMatches( + input: DurableAdmissionInput + ): Promise; + markCompacted(sessionID: string): void; + markHistoryChanged(sessionID: string): void; + queueMatchedHooks(input: MatchedHooksInput): void; +} + +export type DurableTurnResult = 'accepted' | 'deferred'; +export type DurableAdmissionResult = 'accepted' | 'pending' | 'duplicate'; + +export interface MatchedRuleContent { + identity?: string; + relativePath: string; + name?: string; + content: string; +} + +export interface DurableTurnOutput { + parts?: DeliveryPart[]; +} + +export interface DurableTurnInput { + sessionID: string; + messageID?: string; + matchedRules: readonly MatchedRuleContent[]; + output: DurableTurnOutput; +} + +export interface DurableAdmissionInput { + sessionID: string; + rules: readonly MatchedRuleContent[]; +} + +export interface MatchedHookContent extends MatchedRuleContent { + lifetime: RuleLifetime; +} + +export interface MatchedHooksInput { + sessionID: string; + hooks: readonly MatchedHookContent[]; +} + +export interface TransientDispatchMessage { + info?: Record; + parts?: unknown[]; +} + +export interface TransientDispatchInput { + sessionID: string; + matchedRules: readonly MatchedRuleContent[]; + messages: TransientDispatchMessage[]; +} + +type RuleDeliveryOptions = { + rawHistory: RawHistoryAdapter; + persistAdmission?: (sessionID: string, part: DeliveryPart) => Promise; + debugLog?: DebugLog; + maxSessions?: number; +}; + +class DefaultRuleDelivery implements RuleDelivery { + private readonly debugLog: DebugLog; + private readonly states: DeliveryStateStore; + private readonly ledger: DeliveryLedger; + private readonly transientDispatcher: TransientDispatcher; + + constructor(options: RuleDeliveryOptions) { + this.debugLog = options.debugLog ?? createDebugLog(); + this.states = new DeliveryStateStore(options.maxSessions); + this.ledger = new DeliveryLedger({ + rawHistory: options.rawHistory, + persistAdmission: options.persistAdmission, + debugLog: this.debugLog, + }); + this.transientDispatcher = new TransientDispatcher(); + } + + async admitDurableMatches( + input: DurableAdmissionInput + ): Promise { + return this.states.serialize(input.sessionID, async () => { + const state = this.states.getState(input.sessionID); + if ( + state.needsRescan || + !(await this.seedFromHistory(input.sessionID, state)) + ) { + this.ledger.queuePendingRules(state, input.rules); + return 'pending'; + } + + const added = this.ledger.queuePendingRules(state, input.rules); + if (!added && state.pendingRuleQueue.length === 0) return 'duplicate'; + return (await this.ledger.persistPendingRules(input.sessionID, state)) + ? 'accepted' + : 'pending'; + }); + } + + /** + * Seeds the ledger from live history when stale. Returns false when + * history is unreadable, which callers treat as "still pending". + * Compaction leaves needsRescan set: the caller defers, because a + * mid-read revision bump cannot be retried from here. + */ + private async seedFromHistory( + sessionID: string, + state: DeliveryState, + source?: TransientDispatchMessage[] + ): Promise { + if (state.seededFromHistory && !state.needsRescan) return true; + const facts = source + ? decodeRawHistory(source) + : await this.ledger.decodeHistory(sessionID); + if (!facts) { + state.needsRescan = true; + return false; + } + this.ledger.replaceLedger(state, facts); + state.seededFromHistory = true; + state.needsRescan = false; + return true; + } + + async deliverDurableTurn( + input: DurableTurnInput + ): Promise { + return this.states.serialize(input.sessionID, async () => { + try { + return await this.deliverDurable(input); + } catch (error) { + this.debugLog( + `Durable delivery failed for ${input.sessionID}: ${formatError(error)}` + ); + return 'deferred'; + } + }); + } + + private async deliverDurable( + input: DurableTurnInput + ): Promise { + const state = this.states.getState(input.sessionID); + if (state.needsRescan) return 'deferred'; + + if (!state.seededFromHistory) { + // Compaction may bump the revision while history reads; a stale + // decode must not clobber the newer ledger. + const ledgerRevision = state.ledgerRevision; + const facts = await this.ledger.decodeHistory(input.sessionID); + if (!facts) { + state.needsRescan = true; + return 'deferred'; + } + if (state.ledgerRevision !== ledgerRevision) return 'deferred'; + this.ledger.replaceLedger(state, facts); + state.seededFromHistory = true; + } + this.routePendingHooks(state); + + if (!input.messageID) return 'deferred'; + + const newRuleKeys = new Set(); + const newHookKeys = new Set(); + const newRules: MatchedRuleContent[] = []; + const newHooks: MatchedHookContent[] = []; + for (const rule of input.matchedRules) { + const key = deliveryKey(rule); + if (hasDeliveryKey(state.ruleKeys, rule) || newRuleKeys.has(key)) + continue; + newRuleKeys.add(key); + newRules.push(rule); + } + + for (const hook of state.durableHookQueue) { + const key = deliveryKey(hook); + if (hasDeliveryKey(state.hookKeys, hook) || newHookKeys.has(key)) + continue; + newHookKeys.add(key); + newHooks.push(hook); + } + + if (newRules.length > 0 || newHooks.length > 0) { + input.output.parts ??= []; + input.output.parts.push( + buildDurableDeliveryPart(newRules, newHooks, { + sessionID: input.sessionID, + messageID: input.messageID, + }) + ); + } + for (const key of newRuleKeys) state.ruleKeys.add(key); + for (const key of newHookKeys) state.hookKeys.add(key); + state.durableHookQueue = []; + return 'accepted'; + } + + queueMatchedHooks(input: MatchedHooksInput): void { + const state = this.states.getState(input.sessionID); + const queueIdentity = (hook: MatchedRuleContent): string => + hook.identity ?? hook.relativePath; + for (const hook of input.hooks) { + const identity = queueIdentity(hook); + if ( + [ + ...state.pendingHookQueue, + ...state.durableHookQueue, + ...state.transientHookQueue, + ].some(pending => queueIdentity(pending) === identity) + ) { + continue; + } + state.pendingHookQueue.push(hook); + } + if (state.seededFromHistory && !state.needsRescan) { + this.routePendingHooks(state); + } + } + + deliverTransientDispatch(input: TransientDispatchInput): void { + try { + const state = this.states.getState(input.sessionID); + this.seedFromHistory(input.sessionID, state, input.messages); + this.routePendingHooks(state); + + const target = input.messages[input.messages.length - 1]; + if (!target || !Array.isArray(target.parts)) return; + + this.transientDispatcher.append(input, state); + } catch (error) { + this.debugLog( + `Transient delivery failed for ${input.sessionID}: ${formatError(error)}` + ); + } + } + + async retryPendingAdmissions(sessionID: string): Promise { + await this.states.serialize(sessionID, async () => { + const state = this.states.getState(sessionID); + await this.ledger.persistPendingRules(sessionID, state); + }); + } + + markCompacted(sessionID: string): void { + const state = this.states.getState(sessionID); + state.ledgerRevision++; + state.needsRescan = true; + state.transientTurn = undefined; + } + + markHistoryChanged(sessionID: string): void { + const state = this.states.getState(sessionID); + state.ledgerRevision++; + state.seededFromHistory = false; + state.needsRescan = false; + state.transientTurn = undefined; + } + + private routePendingHooks(state: DeliveryState): void { + for (const hook of state.pendingHookQueue) { + const ownerIsDurable = hasDeliveryKey(state.ruleKeys, hook); + const queue = + ownerIsDurable || hook.lifetime === 'durable' + ? state.durableHookQueue + : state.transientHookQueue; + queue.push(hook); + } + state.pendingHookQueue = []; + } +} + +export function createRuleDelivery(options: RuleDeliveryOptions): RuleDelivery { + return new DefaultRuleDelivery(options); +} diff --git a/src/git-branch.test.ts b/src/detection/git-branch.test.ts similarity index 100% rename from src/git-branch.test.ts rename to src/detection/git-branch.test.ts diff --git a/src/git-branch.ts b/src/detection/git-branch.ts similarity index 94% rename from src/git-branch.ts rename to src/detection/git-branch.ts index 5e9bfaf..de7b7eb 100644 --- a/src/git-branch.ts +++ b/src/detection/git-branch.ts @@ -1,5 +1,5 @@ import { execFile, type ExecFileOptions } from 'node:child_process'; -import { createDebugLog } from './debug.js'; +import { createDebugLog } from '../shared/debug.js'; const debugLog = createDebugLog(); const GIT_TIMEOUT_MS = 5000; diff --git a/src/mcp-tools.test.ts b/src/detection/mcp-tools.test.ts similarity index 100% rename from src/mcp-tools.test.ts rename to src/detection/mcp-tools.test.ts diff --git a/src/mcp-tools.ts b/src/detection/mcp-tools.ts similarity index 100% rename from src/mcp-tools.ts rename to src/detection/mcp-tools.ts diff --git a/src/project-fingerprint.test.ts b/src/detection/project-fingerprint.test.ts similarity index 100% rename from src/project-fingerprint.test.ts rename to src/detection/project-fingerprint.test.ts diff --git a/src/project-fingerprint.ts b/src/detection/project-fingerprint.ts similarity index 100% rename from src/project-fingerprint.ts rename to src/detection/project-fingerprint.ts diff --git a/src/index.integration.test.ts b/src/index.integration.test.ts index f6987a1..083a633 100644 --- a/src/index.integration.test.ts +++ b/src/index.integration.test.ts @@ -7,15 +7,15 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'node:path'; import { writeFileSync, mkdirSync } from 'node:fs'; -import { clearRuleCache } from './utils.js'; +import { clearRuleCache } from './rules/rule-discovery.js'; import { setupTestDirs, teardownTestDirs, getTestDirs, createMockPluginInput, } from './test-fixtures.js'; -import { buildDurableDeliveryPart } from './rule-delivery-codec.js'; -import { MatchedRulesStateStore } from './matched-rules-state.js'; +import { buildDurableDeliveryPart } from './delivery/rule-delivery-codec.js'; +import { MatchedRulesStateStore } from './session/matched-rules-state.js'; import { __testOnly } from './index.js'; function createHooksWithMatchedRulesStateStore( @@ -975,7 +975,6 @@ describe('Synthetic-part delivery lifecycle', () => { output: { messages: unknown[] } ) => Promise<{ messages: unknown[] }>; - // Turn 1: user message — rule part persisted const turn1: ChatMessageOutputLike = { message: { role: 'user' }, parts: [{ type: 'text', text: 'run the linter' }], @@ -993,13 +992,11 @@ describe('Synthetic-part delivery lifecycle', () => { '\nMind the linter.\n' ); - // Mid-turn: hook fires on a tool call await before( { tool: 'bash', sessionID: 'ses_life', callID: 'call_1' }, { args: { command: 'npx eslint src/' } } ); - // Next dispatch within the turn: transient delivery at the tail const dispatch: Array> = [ { info: { id: 'msg_u1', role: 'user', sessionID: 'ses_life' }, @@ -1019,7 +1016,6 @@ describe('Synthetic-part delivery lifecycle', () => { '\nMind the linter.\n' ); - // Turn 2: user message — hook text lands durably, rule not duplicated const turn2: ChatMessageOutputLike = { message: { role: 'user' }, parts: [{ type: 'text', text: 'thanks' }], @@ -1061,7 +1057,6 @@ describe('Synthetic-part delivery lifecycle', () => { ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - // Simulated persisted history from before the restart const history = [ { info: { id: 'msg_u0', role: 'user', sessionID: 'ses_restart' }, @@ -1133,7 +1128,6 @@ describe('Synthetic-part delivery lifecycle', () => { output: { messages: unknown[] } ) => Promise<{ messages: unknown[] }>; - // Turn 1: rule part injected const turn1: ChatMessageOutputLike = { message: { role: 'user' }, parts: [{ type: 'text', text: 'first' }], @@ -1162,7 +1156,6 @@ describe('Synthetic-part delivery lifecycle', () => { } ); - // Turn 2: missing durable rule is re-appended. const turn2: ChatMessageOutputLike = { message: { role: 'user' }, parts: [{ type: 'text', text: 'second' }], diff --git a/src/index.rules.test.ts b/src/index.rules.test.ts index 8d825a5..8bc3419 100644 --- a/src/index.rules.test.ts +++ b/src/index.rules.test.ts @@ -5,16 +5,17 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'node:path'; import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { discoverRuleFiles, clearRuleCache } from './rules/rule-discovery.js'; +import { parseRuleMetadata } from './rules/rule-metadata.js'; import { - discoverRuleFiles, - parseRuleMetadata, - extractFilePathsFromMessages, promptMatchesKeywords, toolsMatchAvailable, - clearRuleCache, +} from './rules/rule-filter.js'; +import { + extractFilePathsFromMessages, type Message, -} from './utils.js'; -import { extractToolCallPaths } from './message-paths.js'; +} from './session/message-extraction.js'; +import { extractToolCallPaths } from './session/message-extraction.js'; import { setupTestDirs, teardownTestDirs, diff --git a/src/index.runtime.test.ts b/src/index.runtime.test.ts index 9533072..c52c3c3 100644 --- a/src/index.runtime.test.ts +++ b/src/index.runtime.test.ts @@ -17,115 +17,95 @@ import { type CiEnvSnapshot, } from './test-fixtures.js'; -// Import modules for boundary tests -import * as ruleDiscoveryModule from './rule-discovery.js'; -import * as ruleMetadataModule from './rule-metadata.js'; -import * as ruleFilterModule from './rule-filter.js'; -import * as messagePathsModule from './message-paths.js'; -import * as utilsModule from './utils.js'; -import * as sessionStoreModule from './session-store.js'; -import * as matchedRulesStateModule from './matched-rules-state.js'; -import * as runtimeContextModule from './runtime-context.js'; -import * as runtimeChatModule from './runtime-chat.js'; -import * as ruleHooksModule from './rule-hooks.js'; +import * as ruleDiscoveryModule from './rules/rule-discovery.js'; +import * as ruleMetadataModule from './rules/rule-metadata.js'; +import * as ruleFilterModule from './rules/rule-filter.js'; +import * as messagePathsModule from './session/message-extraction.js'; +import * as ruleHooksModule from './rules/rule-hooks.js'; +import * as sessionStoreModule from './session/session-store.js'; +import * as matchedRulesStateModule from './session/matched-rules-state.js'; +import * as runtimeContextModule from './runtime/match-context.js'; +import * as runtimeChatModule from './runtime/chat-capture.js'; import { __testOnly } from './index.js'; import { MatchedRulesStateStore, readMatchedRulesState, -} from './matched-rules-state.js'; -import { clearRuleCache } from './utils.js'; -import { buildDurableDeliveryPart } from './rule-delivery-codec.js'; +} from './session/matched-rules-state.js'; +import { clearRuleCache } from './rules/rule-discovery.js'; +import { buildDurableDeliveryPart } from './delivery/rule-delivery-codec.js'; describe('module boundary tests', () => { - it('should re-export discoverRuleFiles from rule-discovery module', () => { + it('should export discoverRuleFiles from rule-discovery module', () => { expect(ruleDiscoveryModule.discoverRuleFiles).toBeDefined(); expect(typeof ruleDiscoveryModule.discoverRuleFiles).toBe('function'); - expect(utilsModule.discoverRuleFiles).toBe( - ruleDiscoveryModule.discoverRuleFiles - ); }); - it('should re-export parseRuleMetadata from rule-metadata module', () => { + it('should export parseRuleMetadata from rule-metadata module', () => { expect(ruleMetadataModule.parseRuleMetadata).toBeDefined(); expect(typeof ruleMetadataModule.parseRuleMetadata).toBe('function'); - expect(utilsModule.parseRuleMetadata).toBe( - ruleMetadataModule.parseRuleMetadata - ); }); - it('should re-export promptMatchesKeywords and toolsMatchAvailable from rule-filter module', () => { + it('should export promptMatchesKeywords and toolsMatchAvailable from rule-filter module', () => { expect(ruleFilterModule.promptMatchesKeywords).toBeDefined(); expect(ruleFilterModule.toolsMatchAvailable).toBeDefined(); expect(typeof ruleFilterModule.promptMatchesKeywords).toBe('function'); expect(typeof ruleFilterModule.toolsMatchAvailable).toBe('function'); - expect(utilsModule.promptMatchesKeywords).toBe( - ruleFilterModule.promptMatchesKeywords - ); - expect(utilsModule.toolsMatchAvailable).toBe( - ruleFilterModule.toolsMatchAvailable - ); }); - it('should re-export extractFilePathsFromMessages from message-paths module', () => { + it('should export extractFilePathsFromMessages from message-extraction module', () => { expect(messagePathsModule.extractFilePathsFromMessages).toBeDefined(); expect(typeof messagePathsModule.extractFilePathsFromMessages).toBe( 'function' ); - expect(utilsModule.extractFilePathsFromMessages).toBe( - messagePathsModule.extractFilePathsFromMessages - ); }); - it('should re-export clearRuleCache from rule-discovery module', () => { + it('should export clearRuleCache from rule-discovery module', () => { expect(ruleDiscoveryModule.clearRuleCache).toBeDefined(); expect(typeof ruleDiscoveryModule.clearRuleCache).toBe('function'); - expect(utilsModule.clearRuleCache).toBe(ruleDiscoveryModule.clearRuleCache); }); - it('should re-export DiscoveredRule type via utils facade', () => { - const rule: utilsModule.DiscoveredRule = { + it('should export DiscoveredRule type from rule-discovery module', () => { + const rule: ruleDiscoveryModule.DiscoveredRule = { filePath: '/test/rule.md', relativePath: 'rule.md', }; - const ruleFromDiscovery: ruleDiscoveryModule.DiscoveredRule = rule; - expect(ruleFromDiscovery.filePath).toBe('/test/rule.md'); + expect(rule.filePath).toBe('/test/rule.md'); }); - it('should re-export RuleMatchContext type via utils facade', () => { - const context: utilsModule.RuleMatchContext = { + it('should export RuleMatchContext type from rule-filter module', () => { + const context: ruleFilterModule.RuleMatchContext = { userPrompt: 'test', fileObservations: [{ path: 'src/test.ts', tool: 'read', content: '' }], }; expect(context.userPrompt).toBe('test'); }); - it('should re-export Message and MessagePart types via utils facade', () => { - const msg: utilsModule.Message = { + it('should export Message and MessagePart types from message-extraction module', () => { + const msg: messagePathsModule.Message = { role: 'user', parts: [{ type: 'text', text: 'hello' }], }; expect(msg.role).toBe('user'); }); - // Runtime decomposition module boundary tests - it('should export buildRuleMatchContext from runtime-context module', () => { + it('should export buildRuleMatchContext from match-context module', () => { expect(runtimeContextModule.buildRuleMatchContext).toBeDefined(); expect(typeof runtimeContextModule.buildRuleMatchContext).toBe('function'); }); - it('should export detectCiEnvironment from runtime-context module', () => { + it('should export detectCiEnvironment from match-context module', () => { expect(runtimeContextModule.detectCiEnvironment).toBeDefined(); expect(typeof runtimeContextModule.detectCiEnvironment).toBe('function'); }); - it('should export updateSessionFromChatMessage from runtime-chat module', () => { + it('should export updateSessionFromChatMessage from chat-capture module', () => { expect(runtimeChatModule.updateSessionFromChatMessage).toBeDefined(); expect(typeof runtimeChatModule.updateSessionFromChatMessage).toBe( 'function' ); }); - it('should detect CI environment correctly via runtime-context module', () => { + it('should detect CI environment correctly via match-context module', () => { const originalCI = process.env.CI; process.env.CI = 'true'; @@ -141,15 +121,11 @@ describe('module boundary tests', () => { } }); - it('should re-export evaluateHooks and serializeToolArgs from rule-hooks module', () => { + it('should export evaluateHooks and serializeToolArgs from rule-hooks module', () => { expect(ruleHooksModule.evaluateHooks).toBeDefined(); expect(ruleHooksModule.serializeToolArgs).toBeDefined(); expect(typeof ruleHooksModule.evaluateHooks).toBe('function'); expect(typeof ruleHooksModule.serializeToolArgs).toBe('function'); - expect(utilsModule.evaluateHooks).toBe(ruleHooksModule.evaluateHooks); - expect(utilsModule.serializeToolArgs).toBe( - ruleHooksModule.serializeToolArgs - ); }); }); @@ -296,7 +272,6 @@ describe('OpenCodeRulesPlugin', () => { ) => Promise<{ messages: unknown[] }>; const result = await messagesTransform({}, { messages: originalMessages }); - // No pending hook injections: nothing appended, nothing mutated. expect(result.messages).toEqual(originalMessages); }); @@ -456,7 +431,6 @@ describe('OpenCodeRulesPlugin', () => { { title: '', output: '', metadata: {} } ); - // Allow async side-effect to complete await new Promise(resolve => setTimeout(resolve, 100)); const { readFileSync } = await import('fs'); @@ -496,9 +470,9 @@ describe('SessionState', () => { const { __testOnly } = await import('./index.js'); __testOnly.setSessionStateLimit(2); - __testOnly.upsertSessionState('ses_1', s => void (s.lastUpdated = 1)); - __testOnly.upsertSessionState('ses_2', s => void (s.lastUpdated = 2)); - __testOnly.upsertSessionState('ses_3', s => void (s.lastUpdated = 3)); + __testOnly.upsertSessionState('ses_1', () => {}); + __testOnly.upsertSessionState('ses_2', () => {}); + __testOnly.upsertSessionState('ses_3', () => {}); const ids = __testOnly.getSessionStateIDs(); expect(ids).toHaveLength(2); @@ -834,7 +808,6 @@ describe('SessionState', () => { { args: { pattern: 'src/legacy/**/*.ts' } } ); const snapshot = __testOnly.getSessionStateSnapshot('ses_glob_live'); - // Glob is an excluded tool: no observation, no path. expect(snapshot?.workingContextPaths.size ?? 0).toBe(0); const afterOutput: { title: string; output: string; metadata: unknown } = { @@ -1078,7 +1051,6 @@ describe('history scan and rescan', () => { first ); - // One history read seeded Working context and fed delivery's ledger. expect(historyReads).toBe(1); expect(first.parts.filter(p => p.synthetic)[0]?.text).toContain( 'Seeded rule.' @@ -1137,7 +1109,6 @@ describe('history scan and rescan', () => { }, }); - // Path observed before removal still drives matching after it. const output: HookChatOutput = { message: { role: 'user' }, parts: [{ type: 'text', text: 'check kept files' }], @@ -1331,7 +1302,6 @@ Conditional rule for gpt-5 only.` }; await chatMessage({ sessionID, messageID: 'msg_state_nomatch_1' }, output); - // No rules should match (model is not gpt-5) expect(output.parts.filter(p => p.synthetic)).toHaveLength(0); // Wait for fire-and-forget write to complete @@ -1358,38 +1328,28 @@ Conditional rule for gpt-5 only.` output: HookChatOutput ) => Promise; - // Call without sessionID const output: HookChatOutput = { message: { role: 'user' }, parts: [{ type: 'text', text: 'hello' }], }; await chatMessage({}, output); - // Wait briefly await new Promise(resolve => setTimeout(resolve, 50)); - // Verify no state files were created in the state directory const files = readdirSync(stateDir); const jsonFiles = files.filter(f => f.endsWith('.json')); expect(jsonFiles).toHaveLength(0); }); }); -describe('utils runtime exports', () => { +describe('rule-discovery runtime exports', () => { it('exports only expected functions at runtime', () => { - const exportedKeys = Object.keys(utilsModule).sort(); + const exportedKeys = Object.keys(ruleDiscoveryModule).sort(); expect(exportedKeys).toEqual([ 'clearRuleCache', 'discoverRuleFiles', - 'evaluateHooks', - 'extractFilePathsFromMessages', 'getCachedRule', - 'hasConditions', - 'parseRuleMetadata', - 'promptMatchesKeywords', - 'readMatchedRulesState', - 'serializeToolArgs', - 'toolsMatchAvailable', + 'loadRuleSnapshots', ]); }); }); diff --git a/src/index.test.ts b/src/index.test.ts index 071da4d..9d45d00 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -23,7 +23,7 @@ import { } from 'vitest'; import path from 'node:path'; import { writeFileSync, mkdirSync } from 'node:fs'; -import { clearRuleCache } from './utils.js'; +import { clearRuleCache } from './rules/rule-discovery.js'; import { __testOnly } from './index.js'; import { setupTestDirs, @@ -59,7 +59,6 @@ type ChatMessageOutputLike = { }>; }; -// Retained plugin-level tests with complex runtime match context describe('Runtime match context integration (plugin-level)', () => { let savedEnvXDG: string | undefined; let savedEnvConfigDir: string | undefined; @@ -657,7 +656,7 @@ Feature branch guidelines.` ); process.env.XDG_CONFIG_HOME = path.join(testDir, '.config'); - const gitBranchModule = await import('./git-branch.js'); + const gitBranchModule = await import('./detection/git-branch.js'); const getGitBranchSpy = vi .spyOn(gitBranchModule, 'getGitBranch') .mockResolvedValue('feature/add-login'); diff --git a/src/index.ts b/src/index.ts index 829134c..554be7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,12 @@ -/** - * OpenCode Rules Plugin - * - * Discovers markdown rule files and delivers them into sessions as - * synthetic rule parts. - */ - import type { Plugin, PluginInput } from '@opencode-ai/plugin'; -import { discoverRuleFiles } from './utils.js'; -import { OpenCodeRulesRuntime } from './runtime.js'; -import { SessionStore, type SessionState } from './session-store.js'; -import { MatchedRulesStateStore } from './matched-rules-state.js'; +import { discoverRuleFiles } from './rules/rule-discovery.js'; +import { OpenCodeRulesRuntime } from './runtime/orchestrator.js'; +import { SessionStore, type SessionState } from './session/session-store.js'; +import { MatchedRulesStateStore } from './session/matched-rules-state.js'; const sessionStore = new SessionStore(); const matchedRulesStateStore = new MatchedRulesStateStore(); -import { createDebugLog } from './debug.js'; +import { createDebugLog } from './shared/debug.js'; const debugLog = createDebugLog(); @@ -42,12 +35,8 @@ const openCodeRulesPlugin = async (pluginInput: PluginInput) => { return createRuntimeHooks(pluginInput, sessionStore, matchedRulesStateStore); }; -/** - * Test-only exports for accessing internal state and functions. - * @internal - Test utilities only. Not part of public API. - */ -// NOTE: OpenCode's plugin loader calls every named export as a plugin initializer. -// To avoid runtime crashes, __testOnly must be callable. +// NOTE: OpenCode's plugin loader calls every named export as a plugin +// initializer, so __testOnly must be callable. const __testOnly = Object.freeze( Object.assign(async () => ({}), { setSessionStateLimit: (limit: number): void => { diff --git a/src/message-context.ts b/src/message-context.ts deleted file mode 100644 index 0d2fdb4..0000000 --- a/src/message-context.ts +++ /dev/null @@ -1,149 +0,0 @@ -import path from 'node:path'; -import type { Message, MessagePart } from './message-paths.js'; - -export interface MessagePartWithSession { - type?: string; - text?: string; - sessionID?: string; - synthetic?: boolean; - id?: string; - callID?: string; - tool?: string; - state?: { - input?: unknown; - }; -} - -export interface MessageWithInfo { - info?: { - id?: string; - role?: string; - sessionID?: string; - }; - parts?: MessagePartWithSession[]; -} - -/** - * Extract and join text content from message parts. - * Skips synthetic parts and parts without text content. - * Returns an empty string if no text is extracted. - */ -export function extractTextFromParts( - parts: Array<{ type?: string; text?: string; synthetic?: boolean }> -): string { - const textParts: string[] = []; - for (const part of parts) { - if (part.synthetic) continue; - - if (part.type === 'text' && part.text) { - textParts.push(part.text); - } else if (typeof part.text === 'string' && !part.type) { - textParts.push(part.text); - } - } - - return textParts - .map(t => t.trim()) - .filter(Boolean) - .join(' ') - .trim(); -} - -/** - * Normalize paths to repo-relative POSIX format. - * If path is absolute and under baseDir, convert to relative POSIX path. - * Otherwise return path as-is. - */ -export function normalizeContextPath( - filePath: string, - baseDir: string -): string { - if (!path.isAbsolute(filePath)) return filePath; - const rel = path.relative(baseDir, filePath); - return rel.split(path.sep).join('/'); -} - -/** - * Strip control characters and limit length for safe inclusion in context strings. - */ -export function sanitizePathForContext(filePath: string): string { - return filePath.replace(/[\r\n\t]/g, ' ').slice(0, 300); -} - -/** - * Extract sessionID from messages array. - */ -export function extractSessionID( - messages: MessageWithInfo[] -): string | undefined { - for (const message of messages) { - if (message.info?.sessionID) { - return message.info.sessionID; - } - if (message.parts) { - for (const part of message.parts) { - if (part.sessionID) { - return part.sessionID; - } - } - } - } - return undefined; -} - -/** - * Extract the latest user message text from messages array. - */ -export function extractLatestUserPrompt( - messages: MessageWithInfo[] -): string | undefined { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (message.info?.role !== 'user') continue; - const parts = message.parts || []; - - const userPrompt = extractTextFromParts(parts); - if (userPrompt) { - return userPrompt; - } - } - - return undefined; -} - -/** - * Convert MessageWithInfo[] to Message[] by filtering out messages - * that lack required fields (role, non-empty parts array). - */ -export function filterValidMessages(messages: MessageWithInfo[]): Message[] { - const result: Message[] = []; - for (const msg of messages) { - const role = msg.info?.role; - if ( - typeof role === 'string' && - Array.isArray(msg.parts) && - msg.parts.length > 0 - ) { - result.push({ - role, - parts: msg.parts as MessagePart[], - }); - } - } - return result; -} - -/** - * Extract the leading slash command from a user prompt. - * Returns the first whitespace-delimited token if it starts with '/' - * and contains at least one non-slash character after the leading slash. - */ -export function extractSlashCommand(prompt?: string): string | undefined { - if (!prompt) return undefined; - const first = prompt.trim().split(/\s+/, 1)[0]; - // Must start with '/' and have at least one additional character - if (first.length > 1 && first.startsWith('/')) { - return first; - } - return undefined; -} diff --git a/src/rule-delivery.ts b/src/rule-delivery.ts deleted file mode 100644 index e223ef1..0000000 --- a/src/rule-delivery.ts +++ /dev/null @@ -1,562 +0,0 @@ -import { - buildDurableDeliveryPart, - buildRuleAdmissionPart, - buildTransientDeliveryMessage, - decodeRawHistory, - decodeTransientPresence, - type DeliveryLedgerFacts, - type DeliveryPart, - isTransientMessageId, - ruleKeyFor, -} from './rule-delivery-codec.js'; -import { - type RawHistoryAdapter, - type RawHistoryResult, -} from './rule-delivery-history.js'; -import { createDebugLog, formatError, type DebugLog } from './debug.js'; -import type { RuleLifetime } from './rule-filter.js'; - -export interface RuleDelivery { - deliverDurableTurn(input: DurableTurnInput): Promise; - deliverTransientDispatch(input: TransientDispatchInput): void; - retryPendingAdmissions(sessionID: string): Promise; - admitDurableMatches( - input: DurableAdmissionInput - ): Promise; - markCompacted(sessionID: string): void; - markHistoryChanged(sessionID: string): void; - queueMatchedHooks(input: MatchedHooksInput): void; -} - -export type DurableTurnResult = 'accepted' | 'deferred'; -export type DurableAdmissionResult = 'accepted' | 'pending' | 'duplicate'; - -export interface MatchedRuleContent { - identity?: string; - relativePath: string; - name?: string; - content: string; -} - -export interface DurableTurnOutput { - parts?: DeliveryPart[]; -} - -export interface DurableTurnInput { - sessionID: string; - messageID?: string; - matchedRules: readonly MatchedRuleContent[]; - output: DurableTurnOutput; -} - -export interface DurableAdmissionInput { - sessionID: string; - rules: readonly MatchedRuleContent[]; -} - -export interface MatchedHookContent extends MatchedRuleContent { - lifetime: RuleLifetime; -} - -export interface MatchedHooksInput { - sessionID: string; - hooks: readonly MatchedHookContent[]; -} - -export interface TransientDispatchMessage { - info?: Record; - parts?: unknown[]; -} - -export interface TransientDispatchInput { - sessionID: string; - matchedRules: readonly MatchedRuleContent[]; - messages: TransientDispatchMessage[]; -} - -type RuleDeliveryOptions = { - rawHistory: RawHistoryAdapter; - persistAdmission?: (sessionID: string, part: DeliveryPart) => Promise; - debugLog?: DebugLog; - maxSessions?: number; -}; - -interface DeliveryState { - ruleKeys: Set; - hookKeys: Set; - ledgerRevision: number; - seededFromHistory: boolean; - needsRescan: boolean; - pendingHookQueue: MatchedHookContent[]; - pendingRuleQueue: MatchedRuleContent[]; - durableHookQueue: MatchedHookContent[]; - transientHookQueue: MatchedHookContent[]; - transientTurn: - | { - id: string; - ruleKeys: Set; - hookKeys: Set; - } - | undefined; - lastUpdated: number; -} - -function deliveryKey(rule: MatchedRuleContent): string { - return ruleKeyFor(rule.identity ?? rule.relativePath); -} - -function hasDeliveryKey( - keys: ReadonlySet, - rule: MatchedRuleContent -): boolean { - return ( - keys.has(deliveryKey(rule)) || - (rule.identity !== undefined && keys.has(ruleKeyFor(rule.relativePath))) - ); -} - -class DefaultRuleDelivery implements RuleDelivery { - private readonly rawHistory: RawHistoryAdapter; - private readonly debugLog: DebugLog; - private readonly maxSessions: number; - private readonly persistAdmission: - ((sessionID: string, part: DeliveryPart) => Promise) | undefined; - private readonly states = new Map(); - private readonly operationTails = new Map>(); - private tick = 0; - - constructor(options: RuleDeliveryOptions) { - this.rawHistory = options.rawHistory; - this.debugLog = options.debugLog ?? createDebugLog(); - this.maxSessions = Math.max(1, options.maxSessions ?? 100); - this.persistAdmission = options.persistAdmission; - } - - async admitDurableMatches( - input: DurableAdmissionInput - ): Promise { - return this.serialize(input.sessionID, async () => { - const state = this.getState(input.sessionID); - if (!state.seededFromHistory || state.needsRescan) { - const facts = await this.decodeHistory(input.sessionID); - if (!facts) { - this.queuePendingRules(state, input.rules); - return 'pending'; - } - this.replaceLedger(state, facts); - state.seededFromHistory = true; - state.needsRescan = false; - } - - const added = this.queuePendingRules(state, input.rules); - if (!added && state.pendingRuleQueue.length === 0) return 'duplicate'; - return (await this.persistPendingRules(input.sessionID, state)) - ? 'accepted' - : 'pending'; - }); - } - - private async decodeHistory( - sessionID: string - ): Promise { - let result: RawHistoryResult; - try { - result = await this.rawHistory.readHistory(sessionID); - } catch (error) { - this.debugLog( - `History read failed for ${sessionID}: ${formatError(error)}` - ); - return undefined; - } - - if (!result.ok) return undefined; - return decodeRawHistory(result.messages); - } - - async deliverDurableTurn( - input: DurableTurnInput - ): Promise { - return this.serialize(input.sessionID, async () => { - try { - return await this.deliverDurable(input); - } catch (error) { - this.debugLog( - `Durable delivery failed for ${input.sessionID}: ${formatError(error)}` - ); - return 'deferred'; - } - }); - } - - private async deliverDurable( - input: DurableTurnInput - ): Promise { - const state = this.getState(input.sessionID); - if (state.needsRescan) return 'deferred'; - - if (!state.seededFromHistory) { - const ledgerRevision = state.ledgerRevision; - const facts = await this.decodeHistory(input.sessionID); - if (!facts) { - state.needsRescan = true; - return 'deferred'; - } - if (state.ledgerRevision !== ledgerRevision) return 'deferred'; - this.replaceLedger(state, facts); - state.seededFromHistory = true; - } - this.routePendingHooks(state); - - if (!input.messageID) return 'deferred'; - - const newRuleKeys = new Set(); - const newHookKeys = new Set(); - const newRules: MatchedRuleContent[] = []; - const newHooks: MatchedHookContent[] = []; - for (const rule of input.matchedRules) { - const key = deliveryKey(rule); - if (hasDeliveryKey(state.ruleKeys, rule) || newRuleKeys.has(key)) - continue; - newRuleKeys.add(key); - newRules.push(rule); - } - - for (const hook of state.durableHookQueue) { - const key = deliveryKey(hook); - if (hasDeliveryKey(state.hookKeys, hook) || newHookKeys.has(key)) - continue; - newHookKeys.add(key); - newHooks.push(hook); - } - - if (newRules.length > 0 || newHooks.length > 0) { - input.output.parts ??= []; - input.output.parts.push( - buildDurableDeliveryPart(newRules, newHooks, { - sessionID: input.sessionID, - messageID: input.messageID, - }) - ); - } - for (const key of newRuleKeys) state.ruleKeys.add(key); - for (const key of newHookKeys) state.hookKeys.add(key); - state.durableHookQueue = []; - return 'accepted'; - } - - queueMatchedHooks(input: MatchedHooksInput): void { - const state = this.getState(input.sessionID); - for (const hook of input.hooks) { - if ( - state.pendingHookQueue.some( - pending => - (pending.identity ?? pending.relativePath) === - (hook.identity ?? hook.relativePath) - ) || - state.durableHookQueue.some( - pending => - (pending.identity ?? pending.relativePath) === - (hook.identity ?? hook.relativePath) - ) || - state.transientHookQueue.some( - pending => - (pending.identity ?? pending.relativePath) === - (hook.identity ?? hook.relativePath) - ) - ) { - continue; - } - state.pendingHookQueue.push(hook); - } - if (state.seededFromHistory && !state.needsRescan) { - this.routePendingHooks(state); - } - } - - deliverTransientDispatch(input: TransientDispatchInput): void { - try { - const state = this.getState(input.sessionID); - if (!state.seededFromHistory || state.needsRescan) { - const facts = decodeRawHistory(input.messages); - this.replaceLedger(state, facts); - state.seededFromHistory = true; - state.needsRescan = false; - } - this.routePendingHooks(state); - - const target = input.messages[input.messages.length - 1]; - if (!target || !Array.isArray(target.parts)) return; - - const { - ids: presentIDs, - ruleKeys: presentRuleKeys, - hookKeys: presentHookKeys, - } = decodeTransientPresence(input.messages); - const realUserInfo = this.latestRealUserInfo(input.messages); - const turnID = - typeof realUserInfo?.id === 'string' ? realUserInfo.id : undefined; - if (turnID && state.transientTurn?.id !== turnID) { - state.transientTurn = { - id: turnID, - ruleKeys: new Set(), - hookKeys: new Set(), - }; - } - const transientTurn = turnID ? state.transientTurn : undefined; - if (transientTurn) { - for (const key of presentRuleKeys) transientTurn.ruleKeys.add(key); - for (const key of presentHookKeys) transientTurn.hookKeys.add(key); - } - - const baseInfo = - realUserInfo ?? input.messages[input.messages.length - 1]?.info ?? {}; - const transientRules: MatchedRuleContent[] = []; - for (const rule of [...input.matchedRules, ...state.pendingRuleQueue]) { - const key = deliveryKey(rule); - if ( - hasDeliveryKey(state.ruleKeys, rule) || - hasDeliveryKey(presentRuleKeys, rule) || - (transientTurn !== undefined && - hasDeliveryKey(transientTurn.ruleKeys, rule)) - ) { - continue; - } - presentRuleKeys.add(key); - transientRules.push(rule); - } - - const transientHooks: MatchedHookContent[] = []; - for (const hook of [ - ...state.durableHookQueue, - ...state.transientHookQueue, - ]) { - const key = deliveryKey(hook); - if ( - hasDeliveryKey(presentHookKeys, hook) || - (transientTurn !== undefined && - hasDeliveryKey(transientTurn.hookKeys, hook)) - ) { - continue; - } - presentHookKeys.add(key); - transientHooks.push(hook); - } - - if (transientRules.length > 0 || transientHooks.length > 0) { - const transientMessage = buildTransientDeliveryMessage( - transientRules, - transientHooks, - baseInfo - ); - const part = transientMessage.parts[0]; - if ( - part && - !presentIDs.has(transientMessage.info.id) && - !presentIDs.has(part.id) - ) { - input.messages.push({ - info: transientMessage.info, - parts: [ - { - ...part, - sessionID: input.sessionID, - messageID: transientMessage.info.id, - }, - ], - }); - if (transientTurn) { - for (const rule of transientRules) { - transientTurn.ruleKeys.add(deliveryKey(rule)); - } - for (const hook of transientHooks) { - transientTurn.hookKeys.add(deliveryKey(hook)); - } - } - } - } - state.transientHookQueue = []; - } catch (error) { - this.debugLog( - `Transient delivery failed for ${input.sessionID}: ${formatError(error)}` - ); - } - } - - async retryPendingAdmissions(sessionID: string): Promise { - await this.serialize(sessionID, async () => { - const state = this.getState(sessionID); - await this.persistPendingRules(sessionID, state); - }); - } - - private queuePendingRules( - state: DeliveryState, - rules: readonly MatchedRuleContent[] - ): boolean { - let added = false; - for (const rule of rules) { - if ( - hasDeliveryKey(state.ruleKeys, rule) || - state.pendingRuleQueue.some( - pending => deliveryKey(pending) === deliveryKey(rule) - ) - ) { - continue; - } - state.pendingRuleQueue.push(rule); - added = true; - } - return added; - } - - private async persistPendingRules( - sessionID: string, - state: DeliveryState - ): Promise { - if (!this.persistAdmission || state.pendingRuleQueue.length === 0) { - return state.pendingRuleQueue.length === 0; - } - const pending = state.pendingRuleQueue.filter( - rule => !hasDeliveryKey(state.ruleKeys, rule) - ); - if (pending.length === 0) { - state.pendingRuleQueue = []; - return true; - } - try { - await this.persistAdmission( - sessionID, - buildRuleAdmissionPart(pending, sessionID) - ); - } catch (error) { - this.debugLog( - `Rule admission persistence failed for ${sessionID}: ${formatError(error)}` - ); - return false; - } - for (const rule of pending) state.ruleKeys.add(deliveryKey(rule)); - const accepted = new Set(pending.map(deliveryKey)); - state.pendingRuleQueue = state.pendingRuleQueue.filter( - rule => !accepted.has(deliveryKey(rule)) - ); - return true; - } - - markCompacted(sessionID: string): void { - const state = this.getState(sessionID); - state.ledgerRevision++; - state.needsRescan = true; - this.resetTransientTurn(state); - } - - markHistoryChanged(sessionID: string): void { - const state = this.getState(sessionID); - state.ledgerRevision++; - state.seededFromHistory = false; - state.needsRescan = false; - this.resetTransientTurn(state); - } - - private replaceLedger( - state: DeliveryState, - facts: DeliveryLedgerFacts - ): void { - state.ruleKeys = new Set(facts.ruleKeys); - state.hookKeys = new Set(facts.hookKeys); - state.ledgerRevision++; - } - - private routePendingHooks(state: DeliveryState): void { - for (const hook of state.pendingHookQueue) { - const ownerIsDurable = hasDeliveryKey(state.ruleKeys, hook); - const queue = - ownerIsDurable || hook.lifetime === 'durable' - ? state.durableHookQueue - : state.transientHookQueue; - queue.push(hook); - } - state.pendingHookQueue = []; - } - - private latestRealUserInfo( - messages: readonly TransientDispatchMessage[] - ): Record | undefined { - for (let index = messages.length - 1; index >= 0; index--) { - const info = messages[index]?.info; - if (!info || info.role !== 'user' || isTransientMessageId(info.id)) { - continue; - } - return info; - } - return undefined; - } - - private resetTransientTurn(state: DeliveryState): void { - state.transientTurn = undefined; - } - - private getState(sessionID: string): DeliveryState { - let state = this.states.get(sessionID); - if (!state) { - state = { - ruleKeys: new Set(), - hookKeys: new Set(), - ledgerRevision: 0, - seededFromHistory: false, - needsRescan: false, - pendingHookQueue: [], - pendingRuleQueue: [], - durableHookQueue: [], - transientHookQueue: [], - transientTurn: undefined, - lastUpdated: 0, - }; - this.states.set(sessionID, state); - } - state.lastUpdated = ++this.tick; - this.evictOldestSessions(); - return state; - } - - private evictOldestSessions(): void { - while (this.states.size > this.maxSessions) { - let oldestID: string | undefined; - let oldestUpdate = Infinity; - for (const [sessionID, state] of this.states) { - if (this.operationTails.has(sessionID)) continue; - if (state.lastUpdated < oldestUpdate) { - oldestID = sessionID; - oldestUpdate = state.lastUpdated; - } - } - if (!oldestID) return; - this.states.delete(oldestID); - } - } - - private async serialize( - sessionID: string, - operation: () => Promise - ): Promise { - const previous = this.operationTails.get(sessionID) ?? Promise.resolve(); - const result = previous.then(operation); - const tail = result.then( - () => undefined, - () => undefined - ); - this.operationTails.set(sessionID, tail); - - try { - return await result; - } finally { - if (this.operationTails.get(sessionID) === tail) { - this.operationTails.delete(sessionID); - } - this.evictOldestSessions(); - } - } -} - -export function createRuleDelivery(options: RuleDeliveryOptions): RuleDelivery { - return new DefaultRuleDelivery(options); -} diff --git a/src/file-contains-warning.test.ts b/src/rules/file-contains-warning.test.ts similarity index 87% rename from src/file-contains-warning.test.ts rename to src/rules/file-contains-warning.test.ts index 2b25e3e..1c5945d 100644 --- a/src/file-contains-warning.test.ts +++ b/src/rules/file-contains-warning.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { parseRuleMetadata } from './rule-metadata.js'; const warnings: string[] = []; -vi.mock('./debug.js', async importOriginal => { - const actual = await importOriginal(); +vi.mock('../shared/debug.js', async importOriginal => { + const actual = await importOriginal(); return { ...actual, logWarning: (context: string, error: unknown) => { diff --git a/src/rule-discovery.test.ts b/src/rules/rule-discovery.test.ts similarity index 97% rename from src/rule-discovery.test.ts rename to src/rules/rule-discovery.test.ts index 91cb8f1..e9c9521 100644 --- a/src/rule-discovery.test.ts +++ b/src/rules/rule-discovery.test.ts @@ -6,7 +6,7 @@ import { getCachedRule, clearRuleCache, } from './rule-discovery.js'; -import { setupTestDirs, teardownTestDirs } from './test-fixtures.js'; +import { setupTestDirs, teardownTestDirs } from '../test-fixtures.js'; describe('loadRuleSnapshots', () => { afterEach(teardownTestDirs); diff --git a/src/rule-discovery.ts b/src/rules/rule-discovery.ts similarity index 65% rename from src/rule-discovery.ts rename to src/rules/rule-discovery.ts index be7c186..31b35a6 100644 --- a/src/rule-discovery.ts +++ b/src/rules/rule-discovery.ts @@ -1,11 +1,7 @@ -/** - * Rule file discovery utilities - */ - import { stat, readFile, readdir } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; -import { createDebugLog, logWarning } from './debug.js'; +import { createDebugLog, logWarning } from '../shared/debug.js'; import { parseRuleMetadata, stripFrontmatter, @@ -14,39 +10,19 @@ import { const debugLog = createDebugLog(); -/** - * Cached rule data for performance optimization - */ interface CachedRule { - /** Raw file content */ content: string; - /** Parsed metadata from frontmatter */ metadata: RuleMetadata | null; - /** Content with frontmatter stripped */ strippedContent: string; - /** File modification time for cache invalidation */ mtime: number; } -/** - * Rule cache keyed by absolute file path - */ const ruleCache = new Map(); -/** - * Clear the rule cache (useful for testing or manual invalidation) - */ export function clearRuleCache(): void { ruleCache.clear(); } -/** - * Get cached rule data, refreshing from disk if file has changed. - * Uses mtime-based invalidation to detect file changes. - * - * @param filePath - Absolute path to the rule file - * @returns Cached rule data or null if file cannot be read - */ export async function getCachedRule( filePath: string ): Promise { @@ -75,16 +51,12 @@ export async function getCachedRule( ruleCache.set(filePath, entry); return entry; } catch (error) { - // Remove stale cache entry if file no longer exists ruleCache.delete(filePath); logWarning(`Failed to read rule file ${filePath}`, error); return null; } } -/** - * Get the global rules directory path - */ function getGlobalRulesDir(): string | null { const opencodeConfigDir = process.env.OPENCODE_CONFIG_DIR; if (opencodeConfigDir) { @@ -100,13 +72,6 @@ function getGlobalRulesDir(): string | null { return path.join(homeDir, '.config', 'opencode', 'rules'); } -/** - * Recursively scan a directory for markdown rule files - * Skips hidden files and directories (starting with .) - * @param dir - Directory to scan - * @param baseDir - Base directory for relative path calculation - * @returns Array of discovered file paths with their relative paths from baseDir - */ async function scanDirectoryRecursively( dir: string, baseDir: string @@ -130,45 +95,28 @@ async function scanDirectoryRecursively( } } } catch (error) { - // Treat ENOENT as benign (directory doesn't exist or was deleted) if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return results; } - // Log non-ENOENT directory read errors logWarning(`Failed to read directory ${dir}`, error); } return results; } -/** - * Discovered rule file with both absolute and relative paths - */ export interface DiscoveredRule { - /** Absolute path to the rule file */ filePath: string; - /** Relative path from the rules directory root */ relativePath: string; } -/** - * Immutable per-session snapshot of a discovered rule's parsed data. - * Captured once per process/session; file edits do not affect an - * existing session's snapshot. - */ +// One snapshot per process/session; file edits never affect an existing +// session's snapshot. export interface RuleSnapshot extends DiscoveredRule { - /** Short display name from frontmatter or the file name without extension */ name: string; - /** Parsed frontmatter metadata (null when the file has none) */ metadata: RuleMetadata | null; - /** Content with frontmatter stripped */ strippedContent: string; } -/** - * Load rule snapshots for the given discovered files, preserving discovery - * order and skipping unreadable rules (warnings are logged by getCachedRule). - */ export async function loadRuleSnapshots( files: readonly DiscoveredRule[] ): Promise { @@ -192,20 +140,12 @@ export async function loadRuleSnapshots( return snapshots; } -/** - * Discover markdown rule files from standard directories - * Searches recursively in: - * - $OPENCODE_CONFIG_DIR/rules/ (highest priority) - * - $XDG_CONFIG_HOME/opencode/rules/ (or ~/.config/opencode/rules as fallback) - * - .opencode/rules/ (in project directory if provided) - * Finds all .md and .mdc files including nested subdirectories. - */ +// Priority: OPENCODE_CONFIG_DIR > XDG_CONFIG_HOME/opencode > ~/.config/opencode export async function discoverRuleFiles( projectDir?: string ): Promise { const files: DiscoveredRule[] = []; - // Discover global rules (recursively) const globalRulesDir = getGlobalRulesDir(); if (globalRulesDir) { const globalRules = await scanDirectoryRecursively( @@ -218,7 +158,6 @@ export async function discoverRuleFiles( } } - // Discover project-local rules (recursively) if project directory is provided if (projectDir) { const projectRulesDir = path.join(projectDir, '.opencode', 'rules'); const projectRules = await scanDirectoryRecursively( diff --git a/src/rule-filter.test.ts b/src/rules/rule-filter.test.ts similarity index 99% rename from src/rule-filter.test.ts rename to src/rules/rule-filter.test.ts index ea5e4bc..24928a8 100644 --- a/src/rule-filter.test.ts +++ b/src/rules/rule-filter.test.ts @@ -10,7 +10,7 @@ import { import { loadRuleSnapshots } from './rule-discovery.js'; import type { RuleSnapshot } from './rule-discovery.js'; import type { RuleMetadata } from './rule-metadata.js'; -import { setupTestDirs, teardownTestDirs } from './test-fixtures.js'; +import { setupTestDirs, teardownTestDirs } from '../test-fixtures.js'; const snapshot = ( metadata: RuleMetadata | null, diff --git a/src/rule-filter.ts b/src/rules/rule-filter.ts similarity index 68% rename from src/rule-filter.ts rename to src/rules/rule-filter.ts index ca75f7a..e991f48 100644 --- a/src/rule-filter.ts +++ b/src/rules/rule-filter.ts @@ -1,24 +1,16 @@ -/** - * Rule matching and lifetime classification utilities - */ - import { minimatch } from 'minimatch'; -import { createDebugLog } from './debug.js'; +import { createDebugLog } from '../shared/debug.js'; import type { RuleSnapshot } from './rule-discovery.js'; import { hasConditions } from './rule-metadata.js'; import type { RuleMetadata } from './rule-metadata.js'; -import type { FileObservation } from './file-observation.js'; +import type { FileObservation } from '../session/file-observation.js'; const debugLog = createDebugLog(); -/** - * Delivery lifetime of a matched rule. Durable rules are persisted as - * synthetic parts in session history; ephemeral rules are delivered only - * as request-scoped transient messages. - */ +// Durable rules persist as synthetic parts in session history; ephemeral +// rules ride only request-scoped transient messages. export type RuleLifetime = 'durable' | 'ephemeral'; -/** The condition dimensions a rule can declare. */ export type RuleConditionKind = | 'globs' | 'fileContains' @@ -32,14 +24,12 @@ export type RuleConditionKind = | 'os' | 'ci'; -/** Result of evaluating a single declared condition. */ export interface ConditionEvaluation { kind: RuleConditionKind; matched: boolean; lifetime: RuleLifetime; } -/** Session-durable condition kinds (everything except agent/model/branch/tools). */ const DURABLE_KINDS: ReadonlySet = new Set([ 'globs', 'fileContains', @@ -54,12 +44,8 @@ function lifetimeForKind(kind: RuleConditionKind): RuleLifetime { return DURABLE_KINDS.has(kind) ? 'durable' : 'ephemeral'; } -/** - * Classify the delivery lifetime of a matched rule from its condition - * results. Unconditional rules are durable. `match: all` is ephemeral when - * any required condition is ephemeral; `match: any` is durable when at - * least one satisfied condition is durable. - */ +// `match: all` is ephemeral when any required condition is ephemeral; +// `match: any` is durable when at least one satisfied condition is durable. export function classifyRuleLifetime( mode: 'any' | 'all', results: readonly ConditionEvaluation[] @@ -75,29 +61,14 @@ export function classifyRuleLifetime( : 'ephemeral'; } -/** - * Check if a file path matches any of the given glob patterns - */ function fileMatchesGlobs(filePath: string, globs: string[]): boolean { return globs.some(glob => minimatch(filePath, glob, { matchBase: true })); } -/** - * Check if observation content contains any of the case-sensitive literal - * substrings. - */ function contentMatchesLiterals(content: string, literals: string[]): boolean { return literals.some(literal => content.includes(literal)); } -/** - * Check if a user prompt matches any of the given keywords. - * Uses case-insensitive word-boundary matching. - * - * @param prompt - The user's prompt text - * @param keywords - Array of keywords to match - * @returns true if any keyword matches the prompt - */ export function promptMatchesKeywords( prompt: string, keywords: string[] @@ -106,15 +77,13 @@ export function promptMatchesKeywords( return keywords.some(keyword => { const lowerKeyword = keyword.toLowerCase(); - // Escape special regex characters in the keyword const escaped = lowerKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // Word boundary at start, but allow continuation at end (e.g., "test" matches "testing") + // Leading word boundary only: "test" matches "testing". const regex = new RegExp(`\\b${escaped}`, 'i'); return regex.test(lowerPrompt); }); } -/** Check if any required tool is in the available set. */ export function toolsMatchAvailable( availableToolIDs: string[], requiredTools: string[] @@ -123,23 +92,15 @@ export function toolsMatchAvailable( return requiredTools.some(tool => availableSet.has(tool)); } -/** True when the rule declares any file-observation-family condition - * (`globs`, `fileContains`, or both). Shared by live matching and the - * runtime's observation-time admission filter. */ export function hasFileObservationFamily( metadata: RuleMetadata | null | undefined ): boolean { return metadata?.globs !== undefined || metadata?.fileContains !== undefined; } -/** - * Evaluate the file-observation family: `globs` and `fileContains` over one - * observation. With both declared, one observation must satisfy its path - * pattern AND contain a literal. `globs` alone keeps legacy behavior across - * the observation set. `fileContains` without `globs` matches content alone. - * A declared but empty `fileContains` fails closed: the rule never matches - * and one warning is logged. - */ +// With both globs and fileContains declared, one observation must satisfy +// its path pattern AND contain a literal; globs alone keeps legacy +// behavior across the observation set. function evaluateFileObservationFamily( metadata: RuleMetadata, context: RuleMatchContext @@ -148,8 +109,8 @@ function evaluateFileObservationFamily( const { globs, fileContains } = metadata; const failClosed = fileContains !== undefined && fileContains.length === 0; - // The parse-time warning in rule-metadata covers the failure; here it only - // fails closed, silently. + // The parse-time warning in rule-metadata covers the failure; here it + // only fails closed, silently. const matchable = failClosed ? undefined @@ -174,10 +135,6 @@ function evaluateFileObservationFamily( }; } -/** - * Evaluate all declared condition checks for a rule against runtime context. - * Returns one evaluation per declared condition with its kind and lifetime. - */ function evaluateConditionChecks( metadata: RuleMetadata, context: RuleMatchContext, @@ -293,60 +250,28 @@ function evaluateConditionChecks( return checks; } -/** - * Runtime match context for conditional rule matching - */ export interface RuleMatchContext { - /** Normalized file observations (for glob and fileContains matching) */ fileObservations?: FileObservation[]; - /** User's prompt text (for keyword matching) */ userPrompt?: string; - /** Available tool IDs (for tool-based matching) */ availableToolIDs?: string[]; - /** Current model ID */ modelID?: string; - /** Current agent type */ agentType?: string; - /** Current slash command (e.g., /plan, /review) */ command?: string; - /** Detected project tags (e.g., node, python, monorepo) */ projectTags?: string[]; - /** Current git branch name */ gitBranch?: string; - /** Current operating system (e.g., linux, darwin, win32) */ os?: string; - /** Whether running in CI environment */ ci?: boolean; } -/** - * A single rule file that matched the runtime context - */ export interface MatchedRuleEntry { - /** Absolute path to the rule file */ filePath: string; - /** Relative path from the rules directory root */ relativePath: string; - /** Short display name from frontmatter or the file name without extension */ name: string; - /** Rule content with frontmatter stripped */ strippedContent: string; - /** Per-condition evaluation results with delivery-lifetime provenance */ conditionResults: ConditionEvaluation[]; - /** Delivery lifetime classification for this evaluation */ lifetime: RuleLifetime; } -/** - * Match already-loaded rule snapshots against the runtime context. - * Performs no filesystem I/O: callers load snapshots first (live delivery - * uses loadRuleSnapshots, which is mtime-cached per session). Unconditional - * rules are always included; conditional rules are included when their - * declared checks pass (match: any|all). Entry order follows snapshot order. - * - * @param snapshots - Rule snapshots loaded by the caller - * @param context - Optional RuleMatchContext for conditional rule matching - */ export function matchRuleSnapshots( snapshots: readonly RuleSnapshot[], context: RuleMatchContext = {} diff --git a/src/rule-hooks.test.ts b/src/rules/rule-hooks.test.ts similarity index 100% rename from src/rule-hooks.test.ts rename to src/rules/rule-hooks.test.ts diff --git a/src/rule-hooks.ts b/src/rules/rule-hooks.ts similarity index 100% rename from src/rule-hooks.ts rename to src/rules/rule-hooks.ts diff --git a/src/rule-metadata.test.ts b/src/rules/rule-metadata.test.ts similarity index 100% rename from src/rule-metadata.test.ts rename to src/rules/rule-metadata.test.ts diff --git a/src/rule-metadata.ts b/src/rules/rule-metadata.ts similarity index 80% rename from src/rule-metadata.ts rename to src/rules/rule-metadata.ts index 866fb27..e6c82ee 100644 --- a/src/rule-metadata.ts +++ b/src/rules/rule-metadata.ts @@ -1,13 +1,6 @@ -/** - * Rule metadata parsing and frontmatter extraction - */ - const { parse: parseYaml } = await import('yaml'); -import { logWarning } from './debug.js'; +import { logWarning } from '../shared/debug.js'; -/** - * Metadata extracted from .mdc file frontmatter - */ export interface RuleMetadata { name?: string; globs?: string[]; @@ -34,9 +27,6 @@ export interface RuleHook { run?: string; } -/** - * Raw parsed YAML frontmatter structure - */ interface ParsedFrontmatter { name?: unknown; globs?: unknown; @@ -54,7 +44,6 @@ interface ParsedFrontmatter { hooks?: unknown; } -/** Field names in ParsedFrontmatter that are string arrays */ type StringArrayField = | 'globs' | 'keywords' @@ -66,13 +55,9 @@ type StringArrayField = | 'branch' | 'os'; -/** - * Normalize a declared `fileContains` field. Accepts a scalar string - * (shorthand for a one-element array) or an array; trims entries, drops - * non-strings and empties, and deduplicates exact case-sensitive strings. - * A declared field that yields no valid literal returns an empty array so - * the rule fails closed instead of degrading to unconditional. - */ +// Unlike the other condition fields, a declared fileContains that yields no +// valid literal resolves to [] (not undefined) so the rule fails closed +// instead of degrading to unconditional. function extractFileContains(value: unknown): string[] { const entries = typeof value === 'string' ? [value] : Array.isArray(value) ? value : []; @@ -86,13 +71,6 @@ function extractFileContains(value: unknown): string[] { return result; } -/** - * Extract and normalize a string array from parsed frontmatter. - * Filters non-strings, trims whitespace, and removes empty values. - * - * @param value - Raw value from parsed YAML (may be array or undefined) - * @returns Normalized string array, or undefined if empty after filtering - */ function extractStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -104,10 +82,6 @@ function extractStringArray(value: unknown): string[] | undefined { return result.length > 0 ? result : undefined; } -/** - * Parse YAML metadata from rule file content using the yaml package. - * Extracts frontmatter (---) and returns metadata object. - */ export function parseRuleMetadata(content: string): RuleMetadata | null { if (!content.startsWith('---')) { return null; @@ -173,7 +147,6 @@ export function parseRuleMetadata(content: string): RuleMetadata | null { metadata.match = parsed.match; } - // Extract hooks if (Array.isArray(parsed.hooks)) { const hooks: RuleHook[] = []; for (const h of parsed.hooks) { @@ -208,9 +181,6 @@ export function parseRuleMetadata(content: string): RuleMetadata | null { } } -/** - * Strip YAML frontmatter from rule content - */ export function stripFrontmatter(content: string): string { if (!content.startsWith('---')) { return content; @@ -224,9 +194,6 @@ export function stripFrontmatter(content: string): string { return content.substring(endIndex + 3).trimStart(); } -/** - * Check if metadata has any conditional fields set. - */ export function hasConditions(meta: RuleMetadata | null | undefined): boolean { if (!meta) return false; return !!( diff --git a/src/runtime-chat.test.ts b/src/runtime/chat-capture.test.ts similarity index 96% rename from src/runtime-chat.test.ts rename to src/runtime/chat-capture.test.ts index 45f54b4..b8406a8 100644 --- a/src/runtime-chat.test.ts +++ b/src/runtime/chat-capture.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { updateSessionFromChatMessage } from './runtime-chat.js'; -import { SessionStore } from './session-store.js'; +import { updateSessionFromChatMessage } from './chat-capture.js'; +import { SessionStore } from '../session/session-store.js'; describe('updateSessionFromChatMessage', () => { it('captures canonical model and agent from output.message', () => { diff --git a/src/runtime-chat.ts b/src/runtime/chat-capture.ts similarity index 86% rename from src/runtime-chat.ts rename to src/runtime/chat-capture.ts index 04943a4..41ba413 100644 --- a/src/runtime-chat.ts +++ b/src/runtime/chat-capture.ts @@ -1,6 +1,6 @@ -import { extractTextFromParts } from './message-context.js'; -import type { SessionStore } from './session-store.js'; -import type { DebugLog } from './debug.js'; +import { extractTextFromParts } from '../session/message-extraction.js'; +import type { SessionStore } from '../session/session-store.js'; +import type { DebugLog } from '../shared/debug.js'; export interface ChatMessageInput { sessionID?: string; @@ -32,10 +32,6 @@ export interface CapturedChatContext { agentType?: string; } -/** - * Update session state from incoming chat message data. - * Captures user prompts, model IDs, and agent types. - */ export function updateSessionFromChatMessage( input: ChatMessageInput, output: ChatMessageOutput, diff --git a/src/chat-message-persistence.test.ts b/src/runtime/chat-message-persistence.test.ts similarity index 97% rename from src/chat-message-persistence.test.ts rename to src/runtime/chat-message-persistence.test.ts index 7bfd7fd..3955f01 100644 --- a/src/chat-message-persistence.test.ts +++ b/src/runtime/chat-message-persistence.test.ts @@ -13,14 +13,14 @@ import { teardownTestDirs, type HookChatMessage, type HookChatOutput, -} from './test-fixtures.js'; +} from '../test-fixtures.js'; import { MatchedRulesStateStore, readMatchedRulesState, -} from './matched-rules-state.js'; -import { buildDurableDeliveryPart } from './rule-delivery-codec.js'; -import { clearRuleCache } from './utils.js'; -import { __testOnly } from './index.js'; +} from '../session/matched-rules-state.js'; +import { buildDurableDeliveryPart } from '../delivery/rule-delivery-codec.js'; +import { clearRuleCache } from '../rules/rule-discovery.js'; +import { __testOnly } from '../index.js'; describe('chat.message rule persistence', () => { let savedEnvXDG: string | undefined; @@ -42,7 +42,7 @@ describe('chat.message rule persistence', () => { afterEach(async () => { teardownTestDirs(); vi.resetAllMocks(); - const { __testOnly } = await import('./index.js'); + const { __testOnly } = await import('../index.js'); __testOnly.resetSessionState(); if (savedEnvXDG === undefined) { delete process.env.XDG_CONFIG_HOME; @@ -187,7 +187,7 @@ describe('chat.message rule persistence', () => { // them without a second history fetch. const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const mockInput = createMockPluginInput({ testDir }); const persisted: HookChatOutput['parts'] = []; mockInput.client.session.messages = async () => ({ @@ -229,7 +229,7 @@ describe('chat.message rule persistence', () => { const { testDir } = getTestDirs(); const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const mockInput = createMockPluginInput({ testDir, history: [ @@ -282,7 +282,7 @@ describe('chat.message rule persistence', () => { const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const mockInput = createMockPluginInput({ testDir, history: [ @@ -381,7 +381,7 @@ describe('chat.message rule persistence', () => { const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] @@ -459,7 +459,7 @@ describe('chat.message rule persistence', () => { const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const mockInput = createMockPluginInput({ testDir }); const persisted: HookChatOutput['parts'] = []; mockInput.client.session.messages = async () => ({ @@ -506,7 +506,7 @@ describe('chat.message rule persistence', () => { const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const mockInput = createMockPluginInput({ testDir }); const hooks = await plugin( mockInput as unknown as Parameters[0] @@ -563,7 +563,7 @@ describe('chat.message rule persistence', () => { const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const mockInput = createMockPluginInput({ testDir }); let failToolIds = false; mockInput.client.tool.ids = () => { @@ -648,7 +648,7 @@ describe('chat.message rule persistence', () => { const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const mockInput = createMockPluginInput({ testDir, history: [ @@ -720,7 +720,7 @@ describe('chat.message rule persistence', () => { const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const mockInput = createMockPluginInput({ testDir }); // Simulate the real SDK: a prototype-style method that reads instance // state via `this` (arrow functions would mask the detachment bug). diff --git a/src/runtime/client-adapter.ts b/src/runtime/client-adapter.ts new file mode 100644 index 0000000..096c683 --- /dev/null +++ b/src/runtime/client-adapter.ts @@ -0,0 +1,160 @@ +import { extractConnectedMcpCapabilityIDs } from '../detection/mcp-tools.js'; +import { logWarning } from '../shared/debug.js'; +import type { DebugLog } from '../shared/debug.js'; +import type { RawHistoryResult } from '../delivery/rule-delivery-history.js'; +import type { DeliveryPart } from '../delivery/rule-delivery-codec.js'; + +export interface OpenCodeClient { + tool?: { + ids?: (args: { + query: { directory: string }; + }) => Promise<{ data: string[] }>; + }; + mcp?: { + status?: (args: { + query: { directory: string }; + }) => Promise<{ connected?: Array<{ id: string }> }>; + }; + session?: { + messages?: (args: { + path: { id: string }; + query?: { directory?: string }; + }) => Promise<{ data?: Array<{ info?: unknown; parts?: unknown[] }> }>; + prompt?: (args: { + path: { id: string }; + query?: { directory?: string }; + body: { + messageID?: string; + noReply: boolean; + parts: Array<{ + id?: string; + type: 'text'; + text: string; + synthetic?: boolean; + metadata?: Record; + }>; + }; + }) => Promise; + }; +} + +export class OpenCodeClientAdapter { + private readonly client: OpenCodeClient; + private readonly directory: string; + private readonly projectDirectory: string; + private readonly debugLog: DebugLog; + + constructor(options: { + client: OpenCodeClient; + directory: string; + projectDirectory: string; + debugLog: DebugLog; + }) { + this.client = options.client; + this.directory = options.directory; + this.projectDirectory = options.projectDirectory; + this.debugLog = options.debugLog; + } + + async persistRuleAdmission( + sessionID: string, + part: DeliveryPart + ): Promise { + const prompt = this.client.session?.prompt; + if (!prompt || part.type !== 'text' || typeof part.text !== 'string') { + throw new Error('OpenCode session.prompt is unavailable'); + } + await prompt({ + path: { id: sessionID }, + query: { directory: this.projectDirectory }, + body: { + ...(typeof part.messageID === 'string' + ? { messageID: part.messageID } + : {}), + noReply: true, + parts: [ + { + ...(typeof part.id === 'string' ? { id: part.id } : {}), + type: 'text', + text: part.text, + synthetic: true, + ...(part.metadata ? { metadata: part.metadata } : {}), + }, + ], + }, + }); + } + + async readClientHistory(sessionID: string): Promise { + const session = this.client.session; + if (!session?.messages) return { ok: true, messages: [] }; + try { + const result = await session.messages({ + path: { id: sessionID }, + query: { directory: this.directory }, + }); + return { ok: true, messages: result?.data ?? [] }; + } catch (error) { + logWarning('Failed to fetch session history', error); + return { ok: false }; + } + } + + async queryAvailableToolIDs(): Promise { + const ids = new Set(); + const query = { directory: this.directory }; + + const toolPromise = this.client.tool?.ids?.({ query }); + const mcpPromise = this.client.mcp?.status?.({ query }); + + const [toolResult, mcpResult] = await Promise.allSettled([ + toolPromise, + mcpPromise, + ] as const); + + const logSettledError = ( + label: string, + result: PromiseRejectedResult + ): void => { + const message = + result.reason instanceof Error + ? result.reason.message + : String(result.reason); + logWarning(`Failed to query ${label}`, message); + }; + + if ( + toolResult.status === 'fulfilled' && + Array.isArray(toolResult.value?.data) + ) { + for (const id of toolResult.value.data) { + ids.add(id); + } + this.debugLog( + `Built-in tools: ${toolResult.value.data.slice(0, 10).join(', ')}${toolResult.value.data.length > 10 ? '...' : ''} (${toolResult.value.data.length} total)` + ); + } else if (toolResult.status === 'rejected') { + logSettledError('tool IDs', toolResult); + } + + if ( + mcpResult.status === 'fulfilled' && + mcpResult.value && + 'data' in mcpResult.value + ) { + const mcpIds = extractConnectedMcpCapabilityIDs( + mcpResult.value.data as Record + ); + for (const id of mcpIds) { + ids.add(id); + } + if (mcpIds.length > 0) { + this.debugLog(`MCP capability IDs: ${mcpIds.join(', ')}`); + } + } else if (mcpResult.status === 'rejected') { + logSettledError('MCP status', mcpResult); + } + + return Array.from(ids); + } +} diff --git a/src/runtime-context.ts b/src/runtime/match-context.ts similarity index 81% rename from src/runtime-context.ts rename to src/runtime/match-context.ts index ae913a4..8c00694 100644 --- a/src/runtime-context.ts +++ b/src/runtime/match-context.ts @@ -1,9 +1,9 @@ -import { extractSlashCommand } from './message-context.js'; -import { detectProjectTags } from './project-fingerprint.js'; -import { getGitBranch } from './git-branch.js'; -import type { RuleMatchContext } from './rule-filter.js'; -import type { FileObservation } from './file-observation.js'; -import type { DebugLog } from './debug.js'; +import { extractSlashCommand } from '../session/message-extraction.js'; +import { detectProjectTags } from '../detection/project-fingerprint.js'; +import { getGitBranch } from '../detection/git-branch.js'; +import type { RuleMatchContext } from '../rules/rule-filter.js'; +import type { FileObservation } from '../session/file-observation.js'; +import type { DebugLog } from '../shared/debug.js'; export interface BuildRuleMatchContextOptions { fileObservations: FileObservation[]; @@ -15,10 +15,7 @@ export interface BuildRuleMatchContextOptions { debugLog: DebugLog; } -/** - * Parse an env variable value semantically: 'false', '0', '' => false; other non-empty => true. - * Returns undefined if the variable is not set. - */ +// 'false', '0', and '' count as false; any other non-empty value is true. function parseEnvBoolean(value: string | undefined): boolean | undefined { if (value === undefined) return undefined; if (value === '') return false; @@ -27,7 +24,6 @@ function parseEnvBoolean(value: string | undefined): boolean | undefined { return true; } -/** Detect if running in a CI environment by checking common CI environment variables. */ export function detectCiEnvironment(): boolean { const env = process.env; @@ -49,10 +45,6 @@ export function detectCiEnvironment(): boolean { ); } -/** - * Build the match context object used for rule matching. - * Assembles runtime information from various sources. - */ export async function buildRuleMatchContext( opts: BuildRuleMatchContextOptions ): Promise { diff --git a/src/observation-admission.test.ts b/src/runtime/orchestration.test.ts similarity index 98% rename from src/observation-admission.test.ts rename to src/runtime/orchestration.test.ts index 1324e4f..7806c90 100644 --- a/src/observation-admission.test.ts +++ b/src/runtime/orchestration.test.ts @@ -13,11 +13,11 @@ import { teardownTestDirs, type HookChatMessage, type HookChatOutput, -} from './test-fixtures.js'; +} from '../test-fixtures.js'; import { MatchedRulesStateStore, readMatchedRulesState, -} from './matched-rules-state.js'; +} from '../session/matched-rules-state.js'; describe('observation admission and noReply persistence', () => { let savedXDG: string | undefined; @@ -45,7 +45,7 @@ describe('observation admission and noReply persistence', () => { const sessionID = 'ses_no_history_replay'; const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const hooks = await plugin( createMockPluginInput({ testDir, @@ -133,7 +133,7 @@ describe('observation admission and noReply persistence', () => { }> = []; const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const mockInput = createMockPluginInput({ testDir, sessionPrompt: async args => { @@ -187,7 +187,7 @@ describe('observation admission and noReply persistence', () => { let promptCount = 0; const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const mockInput = createMockPluginInput({ testDir, sessionPrompt: async () => { @@ -232,7 +232,7 @@ describe('observation admission and noReply persistence', () => { }> = []; const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const mockInput = createMockPluginInput({ testDir, sessionPrompt: async args => { @@ -349,7 +349,7 @@ describe('observation admission and noReply persistence', () => { }> = []; const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const hooks = await plugin( createMockPluginInput({ testDir, diff --git a/src/runtime.ts b/src/runtime/orchestrator.ts similarity index 55% rename from src/runtime.ts rename to src/runtime/orchestrator.ts index 93bb801..9244800 100644 --- a/src/runtime.ts +++ b/src/runtime/orchestrator.ts @@ -3,95 +3,50 @@ import { matchRuleSnapshots, type RuleMatchContext, type MatchedRuleEntry, -} from './rule-filter.js'; +} from '../rules/rule-filter.js'; import { loadRuleSnapshots, type DiscoveredRule, type RuleSnapshot, -} from './rule-discovery.js'; +} from '../rules/rule-discovery.js'; import { extractLatestUserPrompt, extractSessionID, type MessageWithInfo, -} from './message-context.js'; -import { extractConnectedMcpCapabilityIDs } from './mcp-tools.js'; -import { - createDebugLog, - logWarning, - formatError, - type DebugLog, -} from './debug.js'; -import type { SessionStore } from './session-store.js'; -import type { MatchedRulesStateStore } from './matched-rules-state.js'; -import { buildRuleMatchContext } from './runtime-context.js'; +} from '../session/message-extraction.js'; +import { createDebugLog, formatError, type DebugLog } from '../shared/debug.js'; +import type { SessionStore } from '../session/session-store.js'; +import type { MatchedRulesStateStore } from '../session/matched-rules-state.js'; +import { buildRuleMatchContext } from './match-context.js'; import { updateSessionFromChatMessage, type ChatMessageInput, type ChatMessageOutput, -} from './runtime-chat.js'; -import { evaluateHooks, serializeToolArgs } from './rule-hooks.js'; +} from './chat-capture.js'; import { createRuleDelivery, - type MatchedHookContent, type MatchedRuleContent, type RuleDelivery, -} from './rule-delivery.js'; -import type { RawHistoryResult } from './rule-delivery-history.js'; +} from '../delivery/rule-delivery.js'; import { createSessionWorkingContext, type SessionWorkingContext, -} from './session-working-context.js'; +} from '../session/session-working-context.js'; import { createFileObservationContext, type FileObservationContext, -} from './file-observation-context.js'; +} from '../session/file-observation-context.js'; +import { isRuleAdmissionPart } from '../delivery/rule-delivery-codec.js'; import { - isRuleAdmissionPart, - type DeliveryPart, -} from './rule-delivery-codec.js'; -import { exec } from 'node:child_process'; -import { promisify } from 'node:util'; - -const execAsync = promisify(exec); + OpenCodeClientAdapter, + type OpenCodeClient, +} from './client-adapter.js'; +import { ToolHookFlow } from './tool-hook-flow.js'; interface MessagesTransformOutput { messages: MessageWithInfo[]; } -interface OpenCodeClient { - tool?: { - ids?: (args: { - query: { directory: string }; - }) => Promise<{ data: string[] }>; - }; - mcp?: { - status?: (args: { - query: { directory: string }; - }) => Promise<{ connected?: Array<{ id: string }> }>; - }; - session?: { - messages?: (args: { - path: { id: string }; - query?: { directory?: string }; - }) => Promise<{ data?: Array<{ info?: unknown; parts?: unknown[] }> }>; - prompt?: (args: { - path: { id: string }; - query?: { directory?: string }; - body: { - messageID?: string; - noReply: boolean; - parts: Array<{ - id?: string; - type: 'text'; - text: string; - synthetic?: boolean; - metadata?: Record; - }>; - }; - }) => Promise; - }; -} - interface OpenCodeRulesRuntimeOptions { client: unknown; directory: string; @@ -102,7 +57,6 @@ interface OpenCodeRulesRuntimeOptions { debugLog?: DebugLog; } -/** One object-shaped input for the single session-rule evaluation path. */ interface SessionRuleEvaluationInput { sessionID: string; userPrompt: string | undefined; @@ -112,89 +66,56 @@ interface SessionRuleEvaluationInput { } export class OpenCodeRulesRuntime { - private client: OpenCodeClient; private directory: string; - private projectDirectory: string; private ruleFiles: DiscoveredRule[]; private sessionStore: SessionStore; private matchedRulesStateStore: MatchedRulesStateStore; private debugLog: DebugLog; + private clientAdapter: OpenCodeClientAdapter; + private toolHookFlow: ToolHookFlow; private ruleDelivery: RuleDelivery; private sessionWorkingContext: SessionWorkingContext; private fileObservationContext: FileObservationContext; private snapshotPromises = new Map>(); constructor(opts: OpenCodeRulesRuntimeOptions) { - this.client = opts.client as OpenCodeClient; this.directory = opts.directory; - this.projectDirectory = opts.projectDirectory; this.ruleFiles = opts.ruleFiles; this.sessionStore = opts.sessionStore; this.matchedRulesStateStore = opts.matchedRulesStateStore; this.debugLog = opts.debugLog ?? createDebugLog(); + this.clientAdapter = new OpenCodeClientAdapter({ + client: opts.client as OpenCodeClient, + directory: opts.directory, + projectDirectory: opts.projectDirectory, + debugLog: this.debugLog, + }); this.fileObservationContext = createFileObservationContext({ projectDirectory: opts.projectDirectory, }); this.sessionWorkingContext = createSessionWorkingContext({ sessionStore: opts.sessionStore, projectDirectory: opts.projectDirectory, - readHistory: sessionID => this.readClientHistory(sessionID), + readHistory: sessionID => this.clientAdapter.readClientHistory(sessionID), debugLog: this.debugLog, }); this.ruleDelivery = createRuleDelivery({ rawHistory: this.sessionWorkingContext.rawHistory, debugLog: this.debugLog, persistAdmission: (sessionID, part) => - this.persistRuleAdmission(sessionID, part), + this.clientAdapter.persistRuleAdmission(sessionID, part), }); - } - - private async persistRuleAdmission( - sessionID: string, - part: DeliveryPart - ): Promise { - const prompt = this.client.session?.prompt; - if (!prompt || part.type !== 'text' || typeof part.text !== 'string') { - throw new Error('OpenCode session.prompt is unavailable'); - } - await prompt({ - path: { id: sessionID }, - query: { directory: this.projectDirectory }, - body: { - ...(typeof part.messageID === 'string' - ? { messageID: part.messageID } - : {}), - noReply: true, - parts: [ - { - ...(typeof part.id === 'string' ? { id: part.id } : {}), - type: 'text', - text: part.text, - synthetic: true, - ...(part.metadata ? { metadata: part.metadata } : {}), - }, - ], - }, + this.toolHookFlow = new ToolHookFlow({ + debugLog: this.debugLog, + projectDirectory: opts.projectDirectory, + ensureSessionRuleSnapshot: sessionID => + this.ensureSessionRuleSnapshot(sessionID), + buildMatchContext: sessionID => + this.buildSessionRuleMatchContext(sessionID), + queueMatchedHooks: input => this.ruleDelivery.queueMatchedHooks(input), }); } - private async readClientHistory( - sessionID: string - ): Promise { - const session = this.client.session; - if (!session?.messages) return { ok: true, messages: [] }; - try { - const result = await session.messages({ - path: { id: sessionID }, - query: { directory: this.directory }, - }); - return { ok: true, messages: result?.data ?? [] }; - } catch (error) { - logWarning('Failed to fetch session history', error); - return { ok: false }; - } - } - createHooks(): Record { return { 'tool.execute.before': this.onToolExecuteBefore.bind(this), @@ -240,9 +161,8 @@ export class OpenCodeRulesRuntime { return; } - // Pre-success: no File observation is recorded here. A failed or blocked - // execution must never activate globs/fileContains rules; only the - // after-hook (successful events) feeds the observation store. + // A failed or blocked execution must never activate globs/fileContains + // rules; only successful after-hook events feed the observation store. await this.evaluateAndQueueHooks('PreToolUse', sessionID, toolName, args); } @@ -263,8 +183,8 @@ export class OpenCodeRulesRuntime { return; } - // Successful tool events produce File observations; failed executions - // never reach this hook. Output text supports fileContains matching. + // Output text supports fileContains matching; failed executions never + // reach this hook. const observations = this.fileObservationContext.recordToolEvent( sessionID, { @@ -306,8 +226,8 @@ export class OpenCodeRulesRuntime { rules: this.toDeliveryRules(matches), }); if (result === 'accepted') { - // Union with existing sidebar state; never clobber previously - // matched rules from durable turns. + // Union, never replace: an admission must not clobber sidebar state + // written by durable turns. await this.matchedRulesStateStore.merge( sessionID, matches.map(rule => rule.filePath) @@ -376,8 +296,6 @@ export class OpenCodeRulesRuntime { return output; } - /** Load the per-session rule snapshot exactly once per process/session, - * deduplicating concurrent loads via a promise map. */ private async ensureSessionRuleSnapshot( sessionID: string ): Promise { @@ -403,28 +321,26 @@ export class OpenCodeRulesRuntime { } } - /** Assemble the shared match context from session state and live queries. */ private async buildSessionRuleMatchContext( sessionID: string, - userPrompt: string | undefined, - modelID: string | undefined, - agentType: string | undefined + userPrompt?: string, + modelID?: string, + agentType?: string ): Promise { const fileObservations = this.fileObservationContext.getForMatching(sessionID); - const availableToolIDs = await this.queryAvailableToolIDs(); + const availableToolIDs = await this.clientAdapter.queryAvailableToolIDs(); return buildRuleMatchContext({ fileObservations, userPrompt, availableToolIDs, modelID, agentType, - projectDirectory: this.projectDirectory, + projectDirectory: this.directory, debugLog: this.debugLog, }); } - /** Evaluate the session snapshot against the current request context. */ private async evaluateSessionRules( input: SessionRuleEvaluationInput ): Promise { @@ -469,8 +385,8 @@ export class OpenCodeRulesRuntime { return; } - // 1. Accumulate file paths mentioned in this message before durable-turn - // preparation so matching sees current and restored paths. + // Accumulate paths from this message before durable-turn preparation + // so matching sees current and restored paths together. if (output.parts && output.parts.length > 0) { this.sessionWorkingContext.workingContext.recordMessageParts( sessionID, @@ -514,64 +430,6 @@ export class OpenCodeRulesRuntime { } } - private async queryAvailableToolIDs(): Promise { - const ids = new Set(); - const query = { directory: this.directory }; - - const toolPromise = this.client.tool?.ids?.({ query }); - const mcpPromise = this.client.mcp?.status?.({ query }); - - const [toolResult, mcpResult] = await Promise.allSettled([ - toolPromise, - mcpPromise, - ] as const); - - const logSettledError = ( - label: string, - result: PromiseRejectedResult - ): void => { - const message = - result.reason instanceof Error - ? result.reason.message - : String(result.reason); - logWarning(`Failed to query ${label}`, message); - }; - - if ( - toolResult.status === 'fulfilled' && - Array.isArray(toolResult.value?.data) - ) { - for (const id of toolResult.value.data) { - ids.add(id); - } - this.debugLog( - `Built-in tools: ${toolResult.value.data.slice(0, 10).join(', ')}${toolResult.value.data.length > 10 ? '...' : ''} (${toolResult.value.data.length} total)` - ); - } else if (toolResult.status === 'rejected') { - logSettledError('tool IDs', toolResult); - } - - if ( - mcpResult.status === 'fulfilled' && - mcpResult.value && - 'data' in mcpResult.value - ) { - const mcpIds = extractConnectedMcpCapabilityIDs( - mcpResult.value.data as Record - ); - for (const id of mcpIds) { - ids.add(id); - } - if (mcpIds.length > 0) { - this.debugLog(`MCP capability IDs: ${mcpIds.join(', ')}`); - } - } else if (mcpResult.status === 'rejected') { - logSettledError('MCP status', mcpResult); - } - - return Array.from(ids); - } - private async onSessionCompacting( input: { sessionID?: string }, output: { context?: string[] } @@ -604,119 +462,18 @@ export class OpenCodeRulesRuntime { ); } - private async executeHookSideEffect( - command: string, - sessionID: string - ): Promise { - try { - this.debugLog( - `Executing hook side-effect for session ${sessionID}: ${command}` - ); - await execAsync(command, { cwd: this.projectDirectory }); - this.debugLog( - `Hook side-effect completed for session ${sessionID}: ${command}` - ); - } catch (error) { - logWarning('Hook side-effect failed', error); - } - } - - /** Evaluate hooks for a tool invocation and queue matches. - * @throws {Error} When a PreToolUse hook with block:true matches the tool and arguments. */ + /** @throws when a blocking PreToolUse hook matches. */ private async evaluateAndQueueHooks( hookType: 'PreToolUse' | 'PostToolUse', sessionID: string, toolName: string, args: Record ): Promise { - const serializedArgs = serializeToolArgs(args); - - const snapshots = await this.ensureSessionRuleSnapshot(sessionID); - - // First pass: collect all matched hooks across all rules - const allMatches: Array<{ - hook: { type: string; run?: string }; - rule: RuleSnapshot; - }> = []; - - for (const rule of snapshots) { - if (!rule.metadata?.hooks) continue; - - const typeFiltered = rule.metadata.hooks.filter(h => h.type === hookType); - if (typeFiltered.length === 0) continue; - - const matched = evaluateHooks(typeFiltered, { - toolName, - serializedArgs, - hookType, - }); - - for (const hook of matched) { - allMatches.push({ hook, rule }); - } - } - - if (allMatches.length === 0) return; - - // Build the shared classification context only when hooks actually - // matched: the context query (tool RPCs, project tags, git branch) is - // the expensive part of the tool-event path. - const state = this.sessionStore.get(sessionID); - const matchContext = await this.buildSessionRuleMatchContext( + await this.toolHookFlow.evaluateAndQueueHooks( + hookType, sessionID, - state?.lastUserPrompt, - state?.lastModelID, - state?.lastAgentType + toolName, + args ); - - // Check for blockers globally before any queuing or side-effects - if (hookType === 'PreToolUse') { - const blocker = allMatches.find( - m => - m.hook.type === 'PreToolUse' && (m.hook as { block?: boolean }).block - ); - if (blocker) { - this.debugLog( - `PreToolUse block fired for rule ${blocker.rule.relativePath}, tool ${toolName}` - ); - throw new Error( - `[opencode-rules] Blocked by rule "${blocker.rule.relativePath}": ` + - `tool "${toolName}" matched blocked pattern` - ); - } - } - - // No blockers: queue content and run side-effects - // Queue each matched rule once, regardless of how many hooks matched. - const seenRules = new Set(); - const matchedHooks: MatchedHookContent[] = []; - for (const { hook, rule } of allMatches) { - if (!seenRules.has(rule.filePath)) { - seenRules.add(rule.filePath); - const lifetime = - matchRuleSnapshots([rule], matchContext)[0]?.lifetime ?? 'ephemeral'; - matchedHooks.push({ - identity: rule.filePath, - relativePath: rule.relativePath, - name: rule.name, - content: rule.strippedContent, - lifetime, - }); - - this.debugLog( - `${hookType} hook fired for rule ${rule.relativePath}, tool ${toolName} (${lifetime})` - ); - } - - if (hook.run) { - await this.executeHookSideEffect(hook.run, sessionID); - } - } - if (matchedHooks.length > 0) { - this.ruleDelivery.queueMatchedHooks({ - sessionID, - hooks: matchedHooks, - }); - } } } diff --git a/src/runtime/tool-hook-flow.ts b/src/runtime/tool-hook-flow.ts new file mode 100644 index 0000000..718e87d --- /dev/null +++ b/src/runtime/tool-hook-flow.ts @@ -0,0 +1,147 @@ +import { evaluateHooks, serializeToolArgs } from '../rules/rule-hooks.js'; +import { + matchRuleSnapshots, + type RuleMatchContext, +} from '../rules/rule-filter.js'; +import type { RuleSnapshot } from '../rules/rule-discovery.js'; +import { logWarning, type DebugLog } from '../shared/debug.js'; +import type { + MatchedHookContent, + MatchedHooksInput, +} from '../delivery/rule-delivery.js'; +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execAsync = promisify(exec); + +export class ToolHookFlow { + private readonly debugLog: DebugLog; + private readonly projectDirectory: string; + private readonly ensureSessionRuleSnapshot: ( + sessionID: string + ) => Promise; + private readonly buildMatchContext: ( + sessionID: string + ) => Promise; + private readonly queueMatchedHooks: (input: MatchedHooksInput) => void; + + constructor(options: { + debugLog: DebugLog; + projectDirectory: string; + ensureSessionRuleSnapshot: (sessionID: string) => Promise; + buildMatchContext: (sessionID: string) => Promise; + queueMatchedHooks: (input: MatchedHooksInput) => void; + }) { + this.debugLog = options.debugLog; + this.projectDirectory = options.projectDirectory; + this.ensureSessionRuleSnapshot = options.ensureSessionRuleSnapshot; + this.buildMatchContext = options.buildMatchContext; + this.queueMatchedHooks = options.queueMatchedHooks; + } + + private async executeHookSideEffect( + command: string, + sessionID: string + ): Promise { + try { + this.debugLog( + `Executing hook side-effect for session ${sessionID}: ${command}` + ); + await execAsync(command, { cwd: this.projectDirectory }); + this.debugLog( + `Hook side-effect completed for session ${sessionID}: ${command}` + ); + } catch (error) { + logWarning('Hook side-effect failed', error); + } + } + + /** @throws when a blocking PreToolUse hook matches. */ + async evaluateAndQueueHooks( + hookType: 'PreToolUse' | 'PostToolUse', + sessionID: string, + toolName: string, + args: Record + ): Promise { + const serializedArgs = serializeToolArgs(args); + + const snapshots = await this.ensureSessionRuleSnapshot(sessionID); + + const allMatches: Array<{ + hook: { type: string; run?: string }; + rule: RuleSnapshot; + }> = []; + + for (const rule of snapshots) { + if (!rule.metadata?.hooks) continue; + + const typeFiltered = rule.metadata.hooks.filter(h => h.type === hookType); + if (typeFiltered.length === 0) continue; + + const matched = evaluateHooks(typeFiltered, { + toolName, + serializedArgs, + hookType, + }); + + for (const hook of matched) { + allMatches.push({ hook, rule }); + } + } + + if (allMatches.length === 0) return; + + // The context queries (tool RPCs, project tags, git branch) are the + // expensive part of this path; skip them when nothing matched. + const matchContext = await this.buildMatchContext(sessionID); + + // A blocker must fire before any side-effect runs. + if (hookType === 'PreToolUse') { + const blocker = allMatches.find( + m => + m.hook.type === 'PreToolUse' && (m.hook as { block?: boolean }).block + ); + if (blocker) { + this.debugLog( + `PreToolUse block fired for rule ${blocker.rule.relativePath}, tool ${toolName}` + ); + throw new Error( + `[opencode-rules] Blocked by rule "${blocker.rule.relativePath}": ` + + `tool "${toolName}" matched blocked pattern` + ); + } + } + + // Queue each rule once no matter how many of its hooks matched. + const seenRules = new Set(); + const matchedHooks: MatchedHookContent[] = []; + for (const { hook, rule } of allMatches) { + if (!seenRules.has(rule.filePath)) { + seenRules.add(rule.filePath); + const lifetime = + matchRuleSnapshots([rule], matchContext)[0]?.lifetime ?? 'ephemeral'; + matchedHooks.push({ + identity: rule.filePath, + relativePath: rule.relativePath, + name: rule.name, + content: rule.strippedContent, + lifetime, + }); + + this.debugLog( + `${hookType} hook fired for rule ${rule.relativePath}, tool ${toolName} (${lifetime})` + ); + } + + if (hook.run) { + await this.executeHookSideEffect(hook.run, sessionID); + } + } + if (matchedHooks.length > 0) { + this.queueMatchedHooks({ + sessionID, + hooks: matchedHooks, + }); + } + } +} diff --git a/src/runtime.tool-ids.test.ts b/src/runtime/tool-ids.test.ts similarity index 61% rename from src/runtime.tool-ids.test.ts rename to src/runtime/tool-ids.test.ts index f9532e2..7e1aa68 100644 --- a/src/runtime.tool-ids.test.ts +++ b/src/runtime/tool-ids.test.ts @@ -1,9 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { OpenCodeRulesRuntime } from './runtime.js'; -import { SessionStore } from './session-store.js'; -import * as runtimeModule from './runtime.js'; -import * as runtimeContextModule from './runtime-context.js'; -import * as runtimeChatModule from './runtime-chat.js'; +import { OpenCodeClientAdapter } from './client-adapter.js'; +import * as runtimeModule from './orchestrator.js'; +import * as runtimeContextModule from './match-context.js'; +import * as runtimeChatModule from './chat-capture.js'; describe('runtime module runtime exports', () => { it('exports only OpenCodeRulesRuntime class at runtime', () => { @@ -13,17 +12,17 @@ describe('runtime module runtime exports', () => { }); describe('runtime module boundaries', () => { - it('exports buildRuleMatchContext from runtime-context module', () => { + it('exports buildRuleMatchContext from match-context module', () => { expect(runtimeContextModule.buildRuleMatchContext).toBeDefined(); expect(typeof runtimeContextModule.buildRuleMatchContext).toBe('function'); }); - it('exports detectCiEnvironment from runtime-context module', () => { + it('exports detectCiEnvironment from match-context module', () => { expect(runtimeContextModule.detectCiEnvironment).toBeDefined(); expect(typeof runtimeContextModule.detectCiEnvironment).toBe('function'); }); - it('exports updateSessionFromChatMessage from runtime-chat module', () => { + it('exports updateSessionFromChatMessage from chat-capture module', () => { expect(runtimeChatModule.updateSessionFromChatMessage).toBeDefined(); expect(typeof runtimeChatModule.updateSessionFromChatMessage).toBe( 'function' @@ -31,9 +30,9 @@ describe('runtime module boundaries', () => { }); }); -describe('OpenCodeRulesRuntime.queryAvailableToolIDs', () => { +describe('OpenCodeClientAdapter.queryAvailableToolIDs', () => { it('augments tool ids with connected mcp capability ids', async () => { - const runtime = new OpenCodeRulesRuntime({ + const adapter = new OpenCodeClientAdapter({ client: { tool: { ids: async () => ({ data: ['bash'] }) }, mcp: { @@ -44,31 +43,25 @@ describe('OpenCodeRulesRuntime.queryAvailableToolIDs', () => { } as any, directory: '/tmp', projectDirectory: '/tmp', - ruleFiles: [], - sessionStore: new SessionStore({ max: 10 }), debugLog: () => {}, }); - const ids: string[] = await (runtime as any).queryAvailableToolIDs(); + const ids: string[] = await adapter.queryAvailableToolIDs(); expect(ids).toContain('bash'); expect(ids).toContain('mcp_context7'); }); it('handles missing mcp.status gracefully', async () => { - const runtime = new OpenCodeRulesRuntime({ + const adapter = new OpenCodeClientAdapter({ client: { tool: { ids: async () => ({ data: ['bash'] }) }, - // no mcp property } as any, directory: '/tmp', projectDirectory: '/tmp', - ruleFiles: [], - sessionStore: new SessionStore({ max: 10 }), debugLog: () => {}, }); - const ids: string[] = await (runtime as any).queryAvailableToolIDs(); + const ids: string[] = await adapter.queryAvailableToolIDs(); expect(ids).toContain('bash'); - // Should not throw, just not include mcp_ ids }); }); diff --git a/src/session-store.ts b/src/session-store.ts deleted file mode 100644 index 0883dc5..0000000 --- a/src/session-store.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { RuleSnapshot } from './rule-discovery.js'; -export interface SessionState { - /** Working context: monotonic set of observed file paths. Only - * SessionWorkingContext production code reads or mutates these fields. */ - workingContextPaths: Set; - lastUserPrompt?: string; - lastUpdated: number; - /** True when the first successful seeding source has completed. */ - workingContextSeeded: boolean; - lastModelID?: string; - lastAgentType?: string; - ruleSnapshots?: RuleSnapshot[]; -} - -interface SessionStoreOptions { - max?: number; -} - -export class SessionStore { - private stateMap = new Map(); - private max: number; - private tick = 0; - - constructor(opts: SessionStoreOptions = {}) { - this.max = opts.max ?? 100; - } - - setMax(limit: number): void { - this.max = limit; - } - - ids(): string[] { - return Array.from(this.stateMap.keys()); - } - - get(sessionID: string): SessionState | undefined { - return this.stateMap.get(sessionID); - } - - snapshot(sessionID: string): SessionState | undefined { - const s = this.stateMap.get(sessionID); - if (!s) return undefined; - const snapshot: SessionState = { - ...s, - workingContextPaths: new Set(s.workingContextPaths), - }; - if (s.ruleSnapshots) { - snapshot.ruleSnapshots = s.ruleSnapshots.map(rule => ({ ...rule })); - } - return snapshot; - } - - reset(): void { - this.stateMap.clear(); - this.max = 100; - this.tick = 0; - } - - upsert(sessionID: string, mutator: (state: SessionState) => void): void { - let state = this.stateMap.get(sessionID); - if (!state) { - state = this.createDefaultState(); - this.stateMap.set(sessionID, state); - } - - mutator(state); - - // Match existing semantics: overwrite lastUpdated after mutation. - state.lastUpdated = ++this.tick; - - while (this.stateMap.size > this.max) { - let oldestID: string | null = null; - let oldestTime = Infinity; - - for (const [id, st] of this.stateMap.entries()) { - if (st.lastUpdated < oldestTime) { - oldestTime = st.lastUpdated; - oldestID = id; - } - } - - if (oldestID) { - this.stateMap.delete(oldestID); - } - } - } - - private createDefaultState(): SessionState { - // Match existing semantics: tick increments on creation, then again on upsert. - return { - workingContextPaths: new Set(), - lastUpdated: ++this.tick, - workingContextSeeded: false, - }; - } -} diff --git a/src/file-observation-context.test.ts b/src/session/file-observation-context.test.ts similarity index 100% rename from src/file-observation-context.test.ts rename to src/session/file-observation-context.test.ts diff --git a/src/file-observation-context.ts b/src/session/file-observation-context.ts similarity index 59% rename from src/file-observation-context.ts rename to src/session/file-observation-context.ts index 9af4576..c4d56a3 100644 --- a/src/file-observation-context.ts +++ b/src/session/file-observation-context.ts @@ -3,11 +3,11 @@ import { type FileObservation, } from './file-observation.js'; import type { RawToolEvent } from './file-observation.js'; -import { normalizeContextPath } from './message-context.js'; +import { normalizeContextPath } from './message-extraction.js'; +import { BoundedSessionMap } from '../shared/bounded-session-map.js'; interface ObservationSession { observations: FileObservation[]; - lastUpdated: number; } interface FileObservationContextOptions { @@ -15,20 +15,15 @@ interface FileObservationContextOptions { maxSessions?: number; } -/** - * Runtime-owned per-session store of File observations. Populated only by - * live successful `tool.execute.after` events and retained monotonically — - * including repeated paths — for the resident session. - */ +// Live `tool.execute.after` observations are the sole matching source, so +// this store is retained monotonically — including repeated paths — for +// the resident session. export interface FileObservationContext { - /** Normalize one live event, record it, and return the stored copies. */ recordToolEvent(sessionID: string, event: RawToolEvent): FileObservation[]; - /** Record already normalized observations. */ recordObservations( sessionID: string, observations: readonly FileObservation[] ): void; - /** Sorted, detached copies for rule matching. */ getForMatching(sessionID: string): FileObservation[]; } @@ -38,30 +33,13 @@ const pathComparator = (a: FileObservation, b: FileObservation): number => export function createFileObservationContext( options: FileObservationContextOptions ): FileObservationContext { - const sessions = new Map(); - const maxSessions = Math.max(1, options.maxSessions ?? 100); - let tick = 0; + const sessions = new BoundedSessionMap({ + minBound: 1, + max: options.maxSessions ?? 100, + }); const getSession = (sessionID: string): ObservationSession => { - let session = sessions.get(sessionID); - if (!session) { - session = { observations: [], lastUpdated: 0 }; - sessions.set(sessionID, session); - } - session.lastUpdated = ++tick; - while (sessions.size > maxSessions) { - let oldestID: string | undefined; - let oldestUpdate = Infinity; - for (const [id, candidate] of sessions) { - if (candidate.lastUpdated < oldestUpdate) { - oldestID = id; - oldestUpdate = candidate.lastUpdated; - } - } - if (!oldestID) break; - sessions.delete(oldestID); - } - return session; + return sessions.ensure(sessionID, () => ({ observations: [] })); }; const normalizeForContext = ( @@ -91,9 +69,8 @@ export function createFileObservationContext( recordNormalized(sessionID, observations.map(normalizeForContext)); }, getForMatching: sessionID => { - const session = sessions.get(sessionID); + const session = sessions.touch(sessionID); if (!session) return []; - session.lastUpdated = ++tick; return session.observations .map(observation => ({ ...observation })) .sort(pathComparator); diff --git a/src/file-observation-history.test.ts b/src/session/file-observation-history-parts.test.ts similarity index 100% rename from src/file-observation-history.test.ts rename to src/session/file-observation-history-parts.test.ts diff --git a/src/file-observation-lsp.integration.test.ts b/src/session/file-observation-lsp.integration.test.ts similarity index 97% rename from src/file-observation-lsp.integration.test.ts rename to src/session/file-observation-lsp.integration.test.ts index 9deb43e..58d8d7b 100644 --- a/src/file-observation-lsp.integration.test.ts +++ b/src/session/file-observation-lsp.integration.test.ts @@ -11,7 +11,7 @@ import { getTestDirs, setupTestDirs, teardownTestDirs, -} from './test-fixtures.js'; +} from '../test-fixtures.js'; const LSP_OPERATIONS = [ 'goToDefinition', @@ -53,7 +53,7 @@ describe('LSP observation admission through the server hook', () => { }> = []; const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const hooks = await plugin( createMockPluginInput({ testDir, @@ -119,7 +119,7 @@ describe('LSP observation admission through the server hook', () => { }> = []; const { default: { server: plugin }, - } = await import('./index.js'); + } = await import('../index.js'); const hooks = await plugin( createMockPluginInput({ testDir, diff --git a/src/file-observation-patch.test.ts b/src/session/file-observation-patch.test.ts similarity index 100% rename from src/file-observation-patch.test.ts rename to src/session/file-observation-patch.test.ts diff --git a/src/file-observation-read.test.ts b/src/session/file-observation-read.test.ts similarity index 100% rename from src/file-observation-read.test.ts rename to src/session/file-observation-read.test.ts diff --git a/src/file-observation.test.ts b/src/session/file-observation.test.ts similarity index 96% rename from src/file-observation.test.ts rename to src/session/file-observation.test.ts index d2dc644..ea95130 100644 --- a/src/file-observation.test.ts +++ b/src/session/file-observation.test.ts @@ -130,8 +130,6 @@ describe('normalizeToolObservation: lsp', () => { }); it('has no path arg mapped for lsp', () => { - // lsp only produces observations with a filePath arg but no output text - // mapping beyond the raw output keyed to filePath. expect(normalizeObservations(readEvent({ tool: 'lsp', args: {} }))).toEqual( [] ); diff --git a/src/file-observation.ts b/src/session/file-observation.ts similarity index 77% rename from src/file-observation.ts rename to src/session/file-observation.ts index 0d10ab7..202ae2b 100644 --- a/src/file-observation.ts +++ b/src/session/file-observation.ts @@ -1,11 +1,7 @@ -/** - * File observation normalization. - * - * One successful file-handling tool event yields one File observation per - * file: a flat `{ path, tool, content }` record where `content` is that - * file's contribution text. Both `globs` and `fileContains` evaluate the - * same record. Paths are consumed verbatim as the after-hook receives them. - */ +// One successful file-handling tool event yields one File observation per +// file: a flat { path, tool, content } record where content is that file's +// contribution text. Both globs and fileContains evaluate the same record. +// Paths are consumed verbatim as the after-hook receives them. export interface FileObservation { path: string; @@ -13,14 +9,12 @@ export interface FileObservation { content: string; } -/** What the runtime's tool hooks and history tool parts share. */ export interface RawToolEvent { tool: string; args: unknown; output?: string; } -/** A persisted OpenCode tool part with a completed state. */ export interface HistoryToolPart { type?: unknown; tool?: unknown; @@ -44,7 +38,6 @@ const OBSERVATION_TOOLS = new Set([ 'lsp', ]); -/** Only completed history parts are successful events. */ function completedInput(part: HistoryToolPart): unknown { if (typeof part.tool !== 'string') return undefined; if (part.state?.status !== 'completed') return undefined; @@ -55,12 +48,9 @@ function asString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined; } -/** - * Reconstruct read content: strip wrapper tags and `{lineNumber}: ` - * prefixes, join returned lines with newlines. Directory output yields no - * observation; binary, image, PDF, and unrecognized formats fail closed - * with empty content (path still matches globs). - */ +// Directory listings yield no observation; binary, image, PDF, and other +// unrecognized formats fail closed with empty content (path still matches +// globs). function readContent(output: string | undefined): string | null | undefined { if (output === undefined) return undefined; if (/directory<\/type>/i.test(output)) return null; @@ -68,8 +58,6 @@ function readContent(output: string | undefined): string | null | undefined { if (!contentMatch) { return ''; } - // Strip wrapper blank lines left by tags, then the - // `{lineNumber}: ` prefixes on returned lines. return contentMatch[1] .replace(/^\n+/, '') .replace(/\n+$/, '') @@ -78,11 +66,7 @@ function readContent(output: string | undefined): string | null | undefined { .join('\n'); } -/** - * Parse codex-style patch text into per-file content contributions. - * Delete File sections are path-only: their lines never become content. - * Returns undefined when the patch does not parse. - */ +// Delete File sections are path-only: their lines never become content. function parsePatch(patchText: string): FileObservation[] | undefined { const isPatch = patchText.includes('*** Begin Patch') || @@ -123,8 +107,6 @@ function parsePatch(patchText: string): FileObservation[] | undefined { continue; } if (moveTo) { - // A following Move header retargets the buffered file before its - // hunks arrive. currentPath = moveTo[1].trim(); continue; } @@ -154,7 +136,6 @@ function summaryPaths(output: string | undefined): FileObservation[] { return result; } -/** Normalize one live tool event into zero or more File observations. */ export function normalizeObservations(event: RawToolEvent): FileObservation[] { if (!OBSERVATION_TOOLS.has(event.tool)) return []; if (!event.args || typeof event.args !== 'object') return []; @@ -169,9 +150,8 @@ export function normalizeObservations(event: RawToolEvent): FileObservation[] { : []; case 'edit': { if (!path) return []; - // Content requires both fields to be submitted strings — empty ones - // included, so pure deletions keep their removed text. Malformed args - // degrade to a path-only observation (globs still match). + // Empty submitted strings count, so pure deletions keep their removed + // text; malformed args degrade to a path-only observation. const oldString = typeof args.oldString === 'string' ? args.oldString : undefined; const newString = @@ -188,14 +168,13 @@ export function normalizeObservations(event: RawToolEvent): FileObservation[] { const parsed = parsePatch(patchText); if (parsed) return parsed; } - // A successfully applied but unparseable patch falls back to - // path-only observations from the model-visible summary. + // An applied but unparseable patch still yielded file writes; the + // model-visible summary is the only record of which paths. return summaryPaths(event.output); } case 'read': case 'lsp': { if (!path) return []; - // Read reconstructs the returned slice; LSP output is raw text. const content = event.tool === 'lsp' ? (asString(event.output) ?? '') @@ -214,10 +193,6 @@ export function normalizeObservations(event: RawToolEvent): FileObservation[] { } } -/** - * Extract File observations from persisted history tool parts. Only - * successfully completed parts with string tool names contribute. - */ export function extractObservationsFromMessageParts( parts: readonly unknown[] ): FileObservation[] { @@ -248,7 +223,7 @@ export function extractObservationsFromMessageParts( } } - // Legacy shape: AI SDK tool-invocation parts (no observable output). + // Legacy AI SDK part shape, carrying no observable output. if (part.type === 'tool-invocation') { const invocation = part.toolInvocation; const toolName = diff --git a/src/matched-rules-state.test.ts b/src/session/matched-rules-state.test.ts similarity index 95% rename from src/matched-rules-state.test.ts rename to src/session/matched-rules-state.test.ts index 82ad808..0ebe9b8 100644 --- a/src/matched-rules-state.test.ts +++ b/src/session/matched-rules-state.test.ts @@ -12,7 +12,6 @@ describe('matched-rules-state', () => { let store: MatchedRulesStateStore; beforeEach(async () => { - // Create a temp directory for tests const testDir = await fs.mkdtemp( path.join(os.tmpdir(), 'matched-rules-test-') ); @@ -21,15 +20,11 @@ describe('matched-rules-state', () => { }); afterEach(async () => { - // Clean up test directory if (testStateDir) { try { - // Go up one level to remove the whole temp dir const parentDir = path.dirname(testStateDir); await fs.rm(parentDir, { recursive: true }); - } catch { - // Ignore cleanup errors - } + } catch {} } }); @@ -178,7 +173,6 @@ describe('matched-rules-state', () => { await store.write(sessionID, matchedPaths); - // Check that no temp files remain const files = await fs.readdir(testStateDir); const tempFiles = files.filter(f => f.endsWith('.tmp')); @@ -188,7 +182,6 @@ describe('matched-rules-state', () => { it('serializes concurrent writes for same session', async () => { const sessionID = 'ses_concurrent'; - // Fire multiple writes concurrently const first = store.write(sessionID, ['path1']); const second = store.write(sessionID, ['path2']); const third = store.write(sessionID, ['path3']); @@ -197,7 +190,6 @@ describe('matched-rules-state', () => { await Promise.all([first, second, third]); - // The final state should reflect the last write const state = await readMatchedRulesState(sessionID, { stateDir: testStateDir, }); @@ -220,7 +212,6 @@ describe('matched-rules-state', () => { const sessionID = 'ses_newdir'; const matchedPaths = ['/rule.md']; - // Verify directory doesn't exist yet await expect(fs.access(testStateDir)).rejects.toThrow(); await store.write(sessionID, matchedPaths); diff --git a/src/matched-rules-state.ts b/src/session/matched-rules-state.ts similarity index 86% rename from src/matched-rules-state.ts rename to src/session/matched-rules-state.ts index ccb918d..a9c7831 100644 --- a/src/matched-rules-state.ts +++ b/src/session/matched-rules-state.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import * as crypto from 'node:crypto'; -import { createDebugLog, logWarning } from './debug.js'; +import { createDebugLog, logWarning } from '../shared/debug.js'; const debugLog = createDebugLog(); @@ -16,7 +16,7 @@ interface MatchedRulesStateStoreOptions { stateDir?: string; } -// Strict pattern for safe sessionID: alphanumeric, underscore, hyphen only +// Session IDs become filename components; this pattern gates what is accepted. const SAFE_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/; function isValidSessionID(sessionID: string): boolean { @@ -42,8 +42,6 @@ export class MatchedRulesStateStore { this.stateDir = opts.stateDir ?? resolveStateDir(); } - /** Replace semantics for full durable turns. - * @throws {Error} If sessionID fails validation. */ write(sessionID: string, matchedPaths: readonly string[]): Promise { this.assertValidSessionID(sessionID); return this.enqueue(sessionID, async () => ({ @@ -53,9 +51,6 @@ export class MatchedRulesStateStore { })); } - /** Union semantics for mid-session admissions: atomically merges the new - * paths with the persisted state so existing matched rules survive. - * @throws {Error} If sessionID fails validation. */ merge(sessionID: string, matchedPaths: readonly string[]): Promise { this.assertValidSessionID(sessionID); return this.enqueue(sessionID, async () => { @@ -78,8 +73,8 @@ export class MatchedRulesStateStore { } } - /** Serialize per-session writes; each operation computes its state inside - * the queue so concurrent merges cannot interleave read-modify-write. */ + // Each operation computes its state inside the queue so concurrent + // merges cannot interleave read-modify-write. private enqueue( sessionID: string, operation: () => Promise @@ -119,14 +114,11 @@ export class MatchedRulesStateStore { try { await fs.unlink(tempPath); - } catch { - // Ignore cleanup errors - } + } catch {} } } } -/** Read matched rules state. @throws {Error} If sessionID fails validation. */ export async function readMatchedRulesState( sessionID: string, options: { stateDir?: string } = {} diff --git a/src/message-context.test.ts b/src/session/message-extraction.test.ts similarity index 95% rename from src/message-context.test.ts rename to src/session/message-extraction.test.ts index c2649aa..ec6c216 100644 --- a/src/message-context.test.ts +++ b/src/session/message-extraction.test.ts @@ -8,8 +8,8 @@ import { extractSlashCommand, extractTextFromParts, MessageWithInfo, -} from './message-context.js'; -import { extractFilePathsFromMessages } from './message-paths.js'; +} from './message-extraction.js'; +import { extractFilePathsFromMessages } from './message-extraction.js'; describe('message-context', () => { it('sanitizes control characters and truncates', () => { @@ -163,11 +163,8 @@ describe('extractSlashCommand', () => { }); it('handles punctuation and format edge cases', () => { - // Trailing punctuation is part of token (not stripped) expect(extractSlashCommand('/plan,')).toBe('/plan,'); - // Double slash is a valid token (starts with /, length > 1) expect(extractSlashCommand('//plan')).toBe('//plan'); - // Slash with only punctuation is still valid (length > 1) expect(extractSlashCommand('/!')).toBe('/!'); }); }); diff --git a/src/message-paths.ts b/src/session/message-extraction.ts similarity index 54% rename from src/message-paths.ts rename to src/session/message-extraction.ts index 8264f28..9f9b9f0 100644 --- a/src/message-paths.ts +++ b/src/session/message-extraction.ts @@ -1,10 +1,5 @@ -/** - * Message path extraction utilities - */ +import path from 'node:path'; -/** - * Message part types from OpenCode plugin API - */ interface ToolInvocationPart { type: 'tool-invocation'; toolInvocation: { @@ -35,13 +30,6 @@ export interface Message { parts: MessagePart[]; } -/** - * Extract file paths from conversation messages for conditional rule filtering. - * Parses tool call arguments and scans message content for path-like strings. - * - * @param messages - Array of conversation messages - * @returns Deduplicated array of file paths found in messages - */ export function extractFilePathsFromMessages(messages: Message[]): string[] { const paths = new Set(); @@ -49,7 +37,6 @@ export function extractFilePathsFromMessages(messages: Message[]): string[] { for (const part of message.parts) { if ((part as { synthetic?: boolean }).synthetic) continue; - // Extract from tool invocations if (part.type === 'tool-invocation') { const toolPart = part as ToolInvocationPart; for (const path of extractToolCallPaths( @@ -60,7 +47,6 @@ export function extractFilePathsFromMessages(messages: Message[]): string[] { } } - // Extract from persisted OpenCode tool parts if (part.type === 'tool') { const toolPart = part as OpenCodeToolPart; for (const path of extractToolCallPaths( @@ -71,7 +57,6 @@ export function extractFilePathsFromMessages(messages: Message[]): string[] { } } - // Extract from text content if (part.type === 'text') { const textPart = part as TextPart; extractPathsFromText(textPart.text, paths); @@ -82,17 +67,9 @@ export function extractFilePathsFromMessages(messages: Message[]): string[] { return Array.from(paths); } -/** - * Tool-name to context-path argument mapping, shared by live tool execution - * and history extraction so identical calls contribute identical paths either - * way: - * - * - read / edit / write -> filePath - * - grep -> path only (pattern/include are search terms, not paths) - * - glob -> directory derived from pattern, plus explicit path - * - bash -> workdir - * - unknown tools -> nothing - */ +// Tool-name to context-path argument mapping, shared by live tool execution +// and history extraction so identical calls contribute identical paths +// either way. const PATH_ARG_TOOLS: ReadonlyMap = new Map([ ['read', ['filePath']], ['edit', ['filePath']], @@ -102,9 +79,6 @@ const PATH_ARG_TOOLS: ReadonlyMap = new Map([ ['bash', ['workdir']], ]); -/** - * Extract the context paths a single tool call contributes. - */ export function extractToolCallPaths( toolName: string, args: unknown @@ -118,7 +92,7 @@ export function extractToolCallPaths( for (const argName of argNames) { const value = (args as Record)[argName]; if (typeof value === 'string' && value.length > 0) { - // For glob patterns, extract the directory part + // The pattern's non-glob prefix is a directory. if (argName === 'pattern') { const dirPart = extractDirFromGlob(value); if (dirPart) paths.push(dirPart); @@ -131,11 +105,8 @@ export function extractToolCallPaths( return paths; } -/** - * Extract directory path from a glob pattern - */ +// A no-slash prefix like `src*.ts` is a file prefix, not a directory. function extractDirFromGlob(pattern: string): string | null { - // Find the first glob character const globChars = ['*', '?', '[', '{']; let firstGlobIndex = pattern.length; @@ -148,26 +119,17 @@ function extractDirFromGlob(pattern: string): string | null { if (firstGlobIndex === 0) return null; - // Get the directory part before the glob const beforeGlob = pattern.substring(0, firstGlobIndex); const lastSlash = beforeGlob.lastIndexOf('/'); if (lastSlash === -1) { - // If no slash and pattern has glob characters, it's just a file prefix, not a directory if (firstGlobIndex < pattern.length) return null; return beforeGlob; } return beforeGlob.substring(0, lastSlash); } -/** - * Extract file paths from text content using regex - */ function extractPathsFromText(text: string, paths: Set): void { - // Match paths that look like file paths: - // - Start with ./, ../, /, or a word character - // - Contain at least one / - // - End with a file extension or directory const pathRegex = /(?:^|[\s"'`(])((\.{0,2}\/)?[\w./-]+\/[\w./-]+(?:\.\w+)?)/gm; @@ -175,10 +137,8 @@ function extractPathsFromText(text: string, paths: Set): void { while ((match = pathRegex.exec(text)) !== null) { let potentialPath = match[1]; - // Trim trailing punctuation that likely belongs to prose, not the path potentialPath = potentialPath.replace(/[.,!?:;]+$/, ''); - // Filter out URLs and other non-paths if ( potentialPath.includes('://') || potentialPath.startsWith('http') || @@ -187,9 +147,126 @@ function extractPathsFromText(text: string, paths: Set): void { continue; } - // Must have a reasonable structure (not just slashes) if (potentialPath.replace(/[/.]/g, '').length > 0) { paths.add(potentialPath); } } } + +export interface MessagePartWithSession { + type?: string; + text?: string; + sessionID?: string; + synthetic?: boolean; + id?: string; + callID?: string; + tool?: string; + state?: { + input?: unknown; + }; +} + +export interface MessageWithInfo { + info?: { + id?: string; + role?: string; + sessionID?: string; + }; + parts?: MessagePartWithSession[]; +} + +export function extractTextFromParts( + parts: Array<{ type?: string; text?: string; synthetic?: boolean }> +): string { + const textParts: string[] = []; + for (const part of parts) { + if (part.synthetic) continue; + + if (part.type === 'text' && part.text) { + textParts.push(part.text); + } else if (typeof part.text === 'string' && !part.type) { + textParts.push(part.text); + } + } + + return textParts + .map(t => t.trim()) + .filter(Boolean) + .join(' ') + .trim(); +} + +export function normalizeContextPath( + filePath: string, + baseDir: string +): string { + if (!path.isAbsolute(filePath)) return filePath; + const rel = path.relative(baseDir, filePath); + return rel.split(path.sep).join('/'); +} + +export function sanitizePathForContext(filePath: string): string { + return filePath.replace(/[\r\n\t]/g, ' ').slice(0, 300); +} + +export function extractSessionID( + messages: MessageWithInfo[] +): string | undefined { + for (const message of messages) { + if (message.info?.sessionID) { + return message.info.sessionID; + } + if (message.parts) { + for (const part of message.parts) { + if (part.sessionID) { + return part.sessionID; + } + } + } + } + return undefined; +} + +export function extractLatestUserPrompt( + messages: MessageWithInfo[] +): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.info?.role !== 'user') continue; + const parts = message.parts || []; + + const userPrompt = extractTextFromParts(parts); + if (userPrompt) { + return userPrompt; + } + } + + return undefined; +} + +export function filterValidMessages(messages: MessageWithInfo[]): Message[] { + const result: Message[] = []; + for (const msg of messages) { + const role = msg.info?.role; + if ( + typeof role === 'string' && + Array.isArray(msg.parts) && + msg.parts.length > 0 + ) { + result.push({ + role, + parts: msg.parts as MessagePart[], + }); + } + } + return result; +} + +export function extractSlashCommand(prompt?: string): string | undefined { + if (!prompt) return undefined; + const first = prompt.trim().split(/\s+/, 1)[0]; + if (first.length > 1 && first.startsWith('/')) { + return first; + } + return undefined; +} diff --git a/src/session-store.test.ts b/src/session/session-store.test.ts similarity index 71% rename from src/session-store.test.ts rename to src/session/session-store.test.ts index 70f3372..d1c92e3 100644 --- a/src/session-store.test.ts +++ b/src/session/session-store.test.ts @@ -5,9 +5,9 @@ describe('SessionStore', () => { it('prunes oldest sessions when over max', () => { const store = new SessionStore({ max: 2 }); - store.upsert('ses_1', s => void (s.lastUpdated = 1)); - store.upsert('ses_2', s => void (s.lastUpdated = 2)); - store.upsert('ses_3', s => void (s.lastUpdated = 3)); + store.upsert('ses_1', () => {}); + store.upsert('ses_2', () => {}); + store.upsert('ses_3', () => {}); const ids = store.ids(); expect(ids).toHaveLength(2); @@ -15,6 +15,19 @@ describe('SessionStore', () => { expect(ids).toContain('ses_3'); }); + it('drains to empty when max is set to 0', () => { + // Ticket #64 decision: setMax keeps the raw limit, so a limit of 0 + // drains the store to empty on the next upsert. + const store = new SessionStore(); + store.upsert('ses_a', () => {}); + store.upsert('ses_b', () => {}); + + store.setMax(0); + store.upsert('ses_c', () => {}); + + expect(store.ids()).toHaveLength(0); + }); + it('snapshots working-context paths without aliasing the live set', () => { const store = new SessionStore(); store.upsert('ses_snap', s => { @@ -34,6 +47,7 @@ describe('SessionStore rule snapshots', () => { store.upsert('ses_clone', state => { state.ruleSnapshots = [ { + name: 'plan', filePath: '/rules/plan.mdc', relativePath: 'plan.mdc', metadata: { agent: ['plan'] }, diff --git a/src/session/session-store.ts b/src/session/session-store.ts new file mode 100644 index 0000000..dac590b --- /dev/null +++ b/src/session/session-store.ts @@ -0,0 +1,68 @@ +import type { RuleSnapshot } from '../rules/rule-discovery.js'; +import { BoundedSessionMap } from '../shared/bounded-session-map.js'; +export interface SessionState { + /** Only SessionWorkingContext production code reads or mutates these. */ + workingContextPaths: Set; + lastUserPrompt?: string; + workingContextSeeded: boolean; + lastModelID?: string; + lastAgentType?: string; + ruleSnapshots?: RuleSnapshot[]; +} + +interface SessionStoreOptions { + max?: number; +} + +export class SessionStore { + private readonly states: BoundedSessionMap; + + constructor(opts: SessionStoreOptions = {}) { + this.states = new BoundedSessionMap({ + max: opts.max ?? 100, + }); + } + + setMax(limit: number): void { + this.states.setMax(limit); + } + + ids(): string[] { + return this.states.ids(); + } + + get(sessionID: string): SessionState | undefined { + return this.states.get(sessionID); + } + + snapshot(sessionID: string): SessionState | undefined { + const s = this.states.get(sessionID); + if (!s) return undefined; + const snapshot: SessionState = { + ...s, + workingContextPaths: new Set(s.workingContextPaths), + }; + if (s.ruleSnapshots) { + snapshot.ruleSnapshots = s.ruleSnapshots.map(rule => ({ ...rule })); + } + return snapshot; + } + + reset(): void { + this.states.reset(); + } + + upsert(sessionID: string, mutator: (state: SessionState) => void): void { + const state = this.states.ensure(sessionID, () => + this.createDefaultState() + ); + mutator(state); + } + + private createDefaultState(): SessionState { + return { + workingContextPaths: new Set(), + workingContextSeeded: false, + }; + } +} diff --git a/src/session-working-context.test.ts b/src/session/session-working-context.test.ts similarity index 99% rename from src/session-working-context.test.ts rename to src/session/session-working-context.test.ts index dc8da48..7f447a7 100644 --- a/src/session-working-context.test.ts +++ b/src/session/session-working-context.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; import { createSessionWorkingContext } from './session-working-context.js'; import { SessionStore } from './session-store.js'; -import type { RawHistoryResult } from './rule-delivery-history.js'; -import type { MessageWithInfo } from './message-context.js'; +import type { RawHistoryResult } from '../delivery/rule-delivery-history.js'; +import type { MessageWithInfo } from './message-extraction.js'; type Upstream = (sessionID: string) => Promise; const storesByContext = new WeakMap(); diff --git a/src/session-working-context.ts b/src/session/session-working-context.ts similarity index 78% rename from src/session-working-context.ts rename to src/session/session-working-context.ts index f846d76..496cc69 100644 --- a/src/session-working-context.ts +++ b/src/session/session-working-context.ts @@ -7,53 +7,37 @@ import { sanitizePathForContext, filterValidMessages, type MessageWithInfo, -} from './message-context.js'; -import type { RawHistoryResult } from './rule-delivery-history.js'; +} from './message-extraction.js'; +import type { RawHistoryResult } from '../delivery/rule-delivery-history.js'; import type { SessionStore } from './session-store.js'; -import type { DebugLog } from './debug.js'; +import type { DebugLog } from '../shared/debug.js'; -/** Prefetched history entries are consumed by the first durable turn; a - * transform-first seed can leave them unconsumed. A small bound keeps the - * worst case (full histories per session) negligible. */ +// A transform-first seed can leave prefetched histories unconsumed; this +// bound keeps the worst case (a full history per session) negligible. const MAX_PENDING_HISTORY_PREFETCH = 8; const COMPACT_PROJECTION_MAX_PATHS = 20; -/** - * Runtime-owned per-session Working context: the monotonic set of observed - * file paths retained for compaction projection. It is rebuilt from eligible - * history parts but is never a Rule-matching source; live File observations - * in the separate FileObservationContext are the only matching input. - */ +// Rebuilt from eligible history parts, but never a rule-matching source: +// live File observations in the FileObservationContext are the only +// matching input. Exists for compaction projection. export interface WorkingContext { - /** Seed from the supplied transform messages. First successful source - * wins, including a successful empty message set. Returns true when this - * call performed the seeding. */ seedFromSuppliedMessages( sessionID: string, messages: readonly MessageWithInfo[] ): boolean; - /** Prepare a durable turn: seed from fetched history when unseeded. - * Concurrent preparation for one session shares one in-flight read, and - * a settled read seeds at most once for its generation. */ prepareDurableTurn(sessionID: string): Promise; - /** Accumulate paths from the current user message's tool parts. */ recordMessageParts(sessionID: string, parts: readonly unknown[]): void; - /** Accumulate paths from already normalized observations. */ recordObservations( sessionID: string, observations: readonly FileObservation[] ): void; - /** Invalidate in-flight and prefetched history reads for the session. - * Observed paths are never subtracted. */ invalidateHistoryReads(sessionID: string): void; - /** Invalidate history reads and return the optional compaction projection. */ prepareForCompaction(sessionID: string): string | undefined; } -/** Construction returns two narrow facets backed by one implementation: - * the runtime learns Working-context operations, RuleDelivery only learns - * raw-history reads. */ +// Two narrow facets over one implementation: the runtime learns +// Working-context operations, RuleDelivery only learns raw-history reads. export interface SessionWorkingContext { workingContext: WorkingContext; rawHistory: { readHistory(sessionID: string): Promise }; @@ -78,12 +62,11 @@ export function createSessionWorkingContext( ): SessionWorkingContext { const { sessionStore, projectDirectory, readHistory, debugLog } = opts; - /** Completed, unconsumed history reads retained once for RuleDelivery. */ + // Completed, unconsumed history reads retained once for RuleDelivery. const pendingHistoryPrefetch = new Map(); - /** Bumped on message removal and compaction so settled reads from before - * the invalidation can no longer apply. */ + // Bumped on message removal and compaction so settled reads from before + // the invalidation can no longer apply. const historyRevisions = new Map(); - /** Shared in-flight reads, keyed by session. */ const inFlightReads = new Map>(); const addObservations = ( @@ -211,12 +194,11 @@ export function createSessionWorkingContext( const currentRevision = historyRevisions.get(sessionID) ?? 0; if (currentRevision !== settled.revision) { - // The read was invalidated by message removal or compaction after it - // started: it may not seed Working context or refill the prefetch. + // Invalidated mid-read (message removal or compaction): seeding from + // or refilling the prefetch would resurrect stale state. return; } if (seeded(sessionID)) { - // A concurrent caller already applied this generation's result. return; } diff --git a/src/shared/bounded-session-map.test.ts b/src/shared/bounded-session-map.test.ts new file mode 100644 index 0000000..ce69b6c --- /dev/null +++ b/src/shared/bounded-session-map.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi } from 'vitest'; +import { BoundedSessionMap } from './bounded-session-map.js'; + +describe('BoundedSessionMap ensure', () => { + it('returns the existing entry without recreating it', () => { + const map = new BoundedSessionMap<{ value: number }>(); + const first = map.ensure('ses_1', () => ({ value: 1 })); + first.value = 2; + const second = map.ensure('ses_1', () => ({ value: 99 })); + expect(second).toBe(first); + expect(second.value).toBe(2); + }); + + it('creates a missing entry via the create callback', () => { + const map = new BoundedSessionMap<{ id: string }>(); + const entry = map.ensure('ses_1', () => ({ id: 'created' })); + expect(entry.id).toBe('created'); + }); + + it('auto-evicts the least-recently-stamped entry at the bound', () => { + const map = new BoundedSessionMap<{ n: number }>({ max: 2 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.ensure('ses_2', () => ({ n: 2 })); + map.ensure('ses_3', () => ({ n: 3 })); + expect(map.ids()).toEqual(['ses_2', 'ses_3']); + }); + + it('calls create only once per session', () => { + const create = vi.fn(() => ({ n: 0 })); + const map = new BoundedSessionMap>({ max: 2 }); + map.ensure('ses_1', create); + map.ensure('ses_1', create); + expect(create).toHaveBeenCalledTimes(1); + }); +}); + +describe('BoundedSessionMap recency', () => { + it('evicts by recency stamps, not insertion order', () => { + const map = new BoundedSessionMap<{ n: number }>({ max: 2 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.ensure('ses_2', () => ({ n: 2 })); + // Re-stamp ses_1 so ses_2 becomes least-recently-stamped. + map.touch('ses_1'); + map.ensure('ses_3', () => ({ n: 3 })); + expect(map.ids()).toEqual(['ses_1', 'ses_3']); + }); + + it('touch re-stamps a present entry without creating it', () => { + const map = new BoundedSessionMap<{ n: number }>({ max: 2 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.ensure('ses_2', () => ({ n: 2 })); + map.touch('ses_1'); + map.ensure('ses_3', () => ({ n: 3 })); + expect(map.ids()).toEqual(['ses_1', 'ses_3']); + }); + + it('touch is a no-op for a missing entry', () => { + const map = new BoundedSessionMap<{ n: number }>(); + map.touch('ses_missing'); + expect(map.ids()).toEqual([]); + }); + + it('evict() runs the scan alone without stamping', () => { + const map = new BoundedSessionMap<{ n: number }>({ max: 2 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.ensure('ses_2', () => ({ n: 2 })); + // setMax shrinks the bound; evict() applies it without any stamping. + map.setMax(1); + map.evict(); + expect(map.ids()).toEqual(['ses_2']); + }); +}); + +describe('BoundedSessionMap reads', () => { + it('get returns the stored value without stamping it', () => { + const map = new BoundedSessionMap<{ n: number }>({ max: 2 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.ensure('ses_2', () => ({ n: 2 })); + // An unstamped get leaves ses_1 least-recent, so ses_3 evicts it. + expect(map.get('ses_1')?.n).toBe(1); + map.ensure('ses_3', () => ({ n: 3 })); + expect(map.ids()).toEqual(['ses_2', 'ses_3']); + }); + + it('get returns undefined for a missing entry', () => { + const map = new BoundedSessionMap<{ n: number }>(); + expect(map.get('ses_missing')).toBeUndefined(); + }); + + it('touch returns the stamped value', () => { + const map = new BoundedSessionMap<{ n: number }>({ max: 2 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.ensure('ses_2', () => ({ n: 2 })); + expect(map.touch('ses_1')?.n).toBe(1); + map.ensure('ses_3', () => ({ n: 3 })); + expect(map.ids()).toEqual(['ses_1', 'ses_3']); + }); + + it('touch returns undefined for a missing entry', () => { + const map = new BoundedSessionMap<{ n: number }>(); + expect(map.touch('ses_missing')).toBeUndefined(); + expect(map.ids()).toEqual([]); + }); +}); + +describe('BoundedSessionMap protection', () => { + it('never evicts an entry the isEvictable predicate protects', () => { + const map = new BoundedSessionMap<{ n: number }>({ + max: 2, + isEvictable: sessionID => sessionID !== 'ses_protected', + }); + map.ensure('ses_protected', () => ({ n: 1 })); + map.ensure('ses_free', () => ({ n: 2 })); + map.ensure('ses_new', () => ({ n: 3 })); + expect(map.ids()).toEqual(['ses_protected', 'ses_new']); + }); + + it('stops without deleting when every entry is protected', () => { + const map = new BoundedSessionMap<{ n: number }>({ + max: 1, + isEvictable: () => false, + }); + map.ensure('ses_a', () => ({ n: 1 })); + map.ensure('ses_b', () => ({ n: 2 })); + expect(map.ids()).toEqual(['ses_a', 'ses_b']); + }); + + it('can evict a previously protected entry once it becomes evictable', () => { + let isProtected = true; + const map = new BoundedSessionMap<{ n: number }>({ + max: 1, + isEvictable: () => !isProtected, + }); + map.ensure('ses_a', () => ({ n: 1 })); + map.ensure('ses_b', () => ({ n: 2 })); + expect(map.ids()).toEqual(['ses_a', 'ses_b']); + isProtected = false; + map.evict(); + expect(map.ids()).toEqual(['ses_b']); + }); +}); + +describe('BoundedSessionMap bound configuration', () => { + it('drains to empty when the bound is 0', () => { + // SessionStore needs an unclamped bound so setMax(0) can drain the + // store; callers that must never drain to empty pass minBound: 1. + const map = new BoundedSessionMap<{ n: number }>({ max: 0 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.ensure('ses_2', () => ({ n: 2 })); + expect(map.ids()).toEqual([]); + }); + + it('clamps the bound to minBound', () => { + const map = new BoundedSessionMap<{ n: number }>({ minBound: 1, max: 0 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.ensure('ses_2', () => ({ n: 2 })); + expect(map.ids()).toEqual(['ses_2']); + }); + + it('setMax respects minBound', () => { + const map = new BoundedSessionMap<{ n: number }>({ minBound: 1, max: 3 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.setMax(0); + map.ensure('ses_2', () => ({ n: 2 })); + expect(map.ids()).toEqual(['ses_2']); + }); + + it('setMax drains to empty when set to 0', () => { + const map = new BoundedSessionMap<{ n: number }>({ max: 2 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.setMax(0); + map.ensure('ses_2', () => ({ n: 2 })); + expect(map.ids()).toEqual([]); + }); + + it('setMax raises the bound without evicting present entries', () => { + const map = new BoundedSessionMap<{ n: number }>({ max: 1 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.setMax(3); + map.ensure('ses_2', () => ({ n: 2 })); + map.ensure('ses_3', () => ({ n: 3 })); + expect(map.ids()).toEqual(['ses_1', 'ses_2', 'ses_3']); + }); + + it('setMax below current size does not evict until the next ensure', () => { + const map = new BoundedSessionMap<{ n: number }>({ max: 3 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.ensure('ses_2', () => ({ n: 2 })); + map.ensure('ses_3', () => ({ n: 3 })); + map.setMax(1); + expect(map.ids()).toEqual(['ses_1', 'ses_2', 'ses_3']); + map.ensure('ses_4', () => ({ n: 4 })); + expect(map.ids()).toEqual(['ses_4']); + }); + + it('defaults the bound to 100', () => { + const map = new BoundedSessionMap<{ n: number }>(); + for (let i = 0; i <= 100; i++) { + map.ensure(`ses_${i}`, () => ({ n: i })); + } + expect(map.ids()).toHaveLength(100); + expect(map.ids()[0]).toBe('ses_1'); + expect(map.ids()[99]).toBe('ses_100'); + }); + + it('reset clears entries and restores the default bound', () => { + const map = new BoundedSessionMap<{ n: number }>({ max: 2 }); + map.ensure('ses_1', () => ({ n: 1 })); + map.setMax(1); + map.reset(); + expect(map.ids()).toEqual([]); + // Default bound (100) is restored: 101 ensures leave 100 entries. + for (let i = 0; i <= 100; i++) { + map.ensure(`ses_${i}`, () => ({ n: i })); + } + expect(map.ids()).toHaveLength(100); + }); +}); + +describe('BoundedSessionMap surface', () => { + it('exposes exactly the agreed methods and no recency stamping API', () => { + const methods = Object.getOwnPropertyNames( + BoundedSessionMap.prototype + ).filter(name => name !== 'constructor'); + expect([...methods].sort()).toEqual([ + 'ensure', + 'evict', + 'get', + 'ids', + 'reset', + 'setMax', + 'touch', + ]); + }); +}); diff --git a/src/shared/bounded-session-map.ts b/src/shared/bounded-session-map.ts new file mode 100644 index 0000000..6e3987e --- /dev/null +++ b/src/shared/bounded-session-map.ts @@ -0,0 +1,87 @@ +interface BoundedSessionMapOptions { + /** Maximum retained sessions. Defaults to 100; clamps to at least minBound. */ + max?: number; + /** Lower clamp for the bound. Defaults to 0; 1 means the map never drains to empty. */ + minBound?: number; + isEvictable?: (sessionID: string) => boolean; +} + +const DEFAULT_MAX = 100; + +interface Entry { + /** Stamped only by ensure/touch; the eviction scan never re-stamps. */ + tick: number; + value: T; +} + +function clampBound(minBound: number, limit: number): number { + return Math.max(minBound, limit); +} + +export class BoundedSessionMap { + private readonly entries = new Map>(); + private readonly isEvictable: (sessionID: string) => boolean; + private readonly minBound: number; + private max: number; + private tick = 0; + + constructor(options: BoundedSessionMapOptions = {}) { + this.minBound = Math.max(0, options.minBound ?? 0); + this.max = clampBound(this.minBound, options.max ?? DEFAULT_MAX); + this.isEvictable = options.isEvictable ?? (() => true); + } + + ensure(sessionID: string, create: () => T): T { + let entry = this.entries.get(sessionID); + if (!entry) { + entry = { tick: 0, value: create() }; + this.entries.set(sessionID, entry); + } + entry.tick = ++this.tick; + this.evict(); + return entry.value; + } + + get(sessionID: string): T | undefined { + return this.entries.get(sessionID)?.value; + } + + touch(sessionID: string): T | undefined { + const entry = this.entries.get(sessionID); + if (!entry) return undefined; + entry.tick = ++this.tick; + this.evict(); + return entry.value; + } + + evict(): void { + while (this.entries.size > this.max) { + let evictableID: string | undefined; + let oldestTick = Infinity; + for (const [sessionID, entry] of this.entries) { + if (!this.isEvictable(sessionID)) continue; + // Strict < keeps the first-seen entry on tick ties. + if (entry.tick < oldestTick) { + oldestTick = entry.tick; + evictableID = sessionID; + } + } + if (!evictableID) return; + this.entries.delete(evictableID); + } + } + + setMax(limit: number): void { + this.max = clampBound(this.minBound, limit); + } + + ids(): string[] { + return Array.from(this.entries.keys()); + } + + reset(): void { + this.entries.clear(); + this.max = clampBound(this.minBound, DEFAULT_MAX); + this.tick = 0; + } +} diff --git a/src/debug.test.ts b/src/shared/debug.test.ts similarity index 100% rename from src/debug.test.ts rename to src/shared/debug.test.ts diff --git a/src/debug.ts b/src/shared/debug.ts similarity index 80% rename from src/debug.ts rename to src/shared/debug.ts index 2db705d..57652ef 100644 --- a/src/debug.ts +++ b/src/shared/debug.ts @@ -10,18 +10,15 @@ export function createDebugLog(prefix = '[opencode-rules]'): DebugLog { }; } -/** Format an unknown error value into a human-readable message. */ export function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -/** Log a warning with the standard opencode-rules prefix. */ export function logWarning(context: string, error: unknown): void { if (!DEBUG_ENABLED) return; console.warn(`[opencode-rules] Warning: ${context}: ${formatError(error)}`); } -/** Log an error with the standard opencode-rules prefix. */ export function logError(context: string, error: unknown): void { if (!DEBUG_ENABLED) return; console.error(`[opencode-rules] ${context}:`, error); diff --git a/src/test-fixtures.ts b/src/test-fixtures.ts index 3dd4658..cec2209 100644 --- a/src/test-fixtures.ts +++ b/src/test-fixtures.ts @@ -1,16 +1,8 @@ -/** - * Shared test fixtures, builders, and helpers for opencode-rules tests. - * Extracted to reduce duplication and tighten typing across test files. - */ import path from 'node:path'; import os from 'node:os'; import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { __testOnly } from './index.js'; -import type { MatchedRulesStateStore } from './matched-rules-state.js'; - -// ============================================================================ -// Test Directory Management -// ============================================================================ +import type { MatchedRulesStateStore } from './session/matched-rules-state.js'; interface TestDirs { testDir: string; @@ -44,10 +36,6 @@ export function getTestDirs(): TestDirs { return currentTestDirs; } -// ============================================================================ -// CI Environment Helpers -// ============================================================================ - const CI_ENV_VARS = [ 'CI', 'CONTINUOUS_INTEGRATION', @@ -87,10 +75,6 @@ export function restoreCiEnvVars(saved: CiEnvSnapshot): void { } } -// ============================================================================ -// Mock Plugin Input Helpers -// ============================================================================ - interface MockPluginInput { testDir: string; toolIds?: string[]; @@ -107,9 +91,6 @@ interface MockPluginInput { }) => Promise; } -/** - * Creates a typed mock input object for the plugin function. - */ export function createMockPluginInput(opts: MockPluginInput): { client: { tool: { ids: () => Promise<{ data: string[] }> }; @@ -162,15 +143,7 @@ export function createMockPluginInput(opts: MockPluginInput): { }; } -// ============================================================================ -// Shared Plugin-Runtime Test Seam -// ============================================================================ - -/** - * Creates plugin hooks with an injected matched-rules state store so tests - * never touch the real ~/.opencode state directory. Accepts a pre-built mock - * input so tests can pass a custom `sessionPrompt` spy. - */ +// Injecting the store keeps tests off the real ~/.opencode state directory. export function createHooksWithStore( mockInput: ReturnType, store: MatchedRulesStateStore @@ -205,33 +178,17 @@ export type HookChatOutput = { }>; }; -// ============================================================================ -// Generic Environment Snapshot Helpers -// ============================================================================ - -/** - * Snapshot of environment variables. Uses a symbol marker to distinguish - * between "key was undefined" vs "key not tracked". - */ export type EnvSnapshot = Map; -/** - * Saves the current value of specified environment keys (including undefined). - * Returns a snapshot that can be passed to restoreEnv() to restore original state. - */ export function saveEnv(...keys: string[]): EnvSnapshot { const saved: EnvSnapshot = new Map(); for (const key of keys) { - // Store the value even if undefined - this is crucial for proper restore + // Map.set preserves the undefined value, distinguishing it from absent. saved.set(key, process.env[key]); } return saved; } -/** - * Restores environment variables to their snapshotted state. - * Keys that were undefined in the snapshot are deleted from process.env. - */ export function restoreEnv(saved: EnvSnapshot): void { for (const [key, value] of saved) { if (value === undefined) { diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index c81cf3c..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Stable public API surface for OpenCode Rules Plugin. - * - * This barrel file intentionally re-exports a focused subset of modules - * that external consumers (plugins, TUI, tests) should depend on. - * It isolates consumers from internal module restructuring and provides - * a single import point for the plugin's public surface. - * - * Modules intentionally NOT re-exported (internal implementation): - * - debug.ts: internal logging utilities - * - message-context.ts: internal message helpers - * - mcp-tools.ts: internal MCP integration - * - runtime.ts: internal orchestration (entry point is index.ts) - * - runtime-chat.ts: internal chat hook handler - * - runtime-context.ts: internal match context builder - * - session-store.ts: internal session state - * - * Re-exported public modules: - * - rule-discovery.ts: File discovery and caching - * - rule-metadata.ts: Frontmatter parsing - * - rule-filter.ts: Rule matching and lifetime classification - * - message-paths.ts: Message path extraction - * - rule-hooks.ts: Hook evaluation and serialization - */ - -// Re-export from rule-discovery -export { - discoverRuleFiles, - getCachedRule, - clearRuleCache, - type DiscoveredRule, -} from './rule-discovery.js'; - -// Re-export from rule-metadata -export { - parseRuleMetadata, - hasConditions, - type RuleMetadata, -} from './rule-metadata.js'; - -// Re-export from rule-filter -export { - promptMatchesKeywords, - toolsMatchAvailable, - type RuleMatchContext, -} from './rule-filter.js'; - -// Re-export from message-paths -export { - extractFilePathsFromMessages, - type Message, - type MessagePart, -} from './message-paths.js'; - -// Re-export from matched-rules-state (needed by TUI and external consumers) -export { readMatchedRulesState } from './matched-rules-state.js'; - -// Re-export from rule-hooks -export { - evaluateHooks, - serializeToolArgs, - type HookEvaluationContext, -} from './rule-hooks.js'; diff --git a/tui/data/rules.test.ts b/tui/data/rules.test.ts index 49eac3b..c50ddb4 100644 --- a/tui/data/rules.test.ts +++ b/tui/data/rules.test.ts @@ -1,4 +1,3 @@ -// tui/data/rules.test.ts import { describe, it, @@ -17,7 +16,7 @@ import { writeFileSync, chmodSync, } from 'node:fs'; -import { clearRuleCache } from '../../src/rule-discovery.js'; +import { clearRuleCache } from '../../src/rules/rule-discovery.js'; import { classifyRuleScope, hasConditions, @@ -41,10 +40,6 @@ afterAll(() => { } }); -// ────────────────────────────────────────────── -// classifyRuleScope -// ────────────────────────────────────────────── - describe('classifyRuleScope', () => { it('returns "global" when projectDir is null', () => { expect( @@ -71,17 +66,12 @@ describe('classifyRuleScope', () => { }); it('does not match partial path prefixes', () => { - // /project/.opencode/rules-extra/ should NOT match /project/.opencode/rules/ expect( classifyRuleScope('/project/.opencode/rules-extra/foo.md', '/project') ).toBe('global'); }); }); -// ────────────────────────────────────────────── -// hasConditions -// ────────────────────────────────────────────── - describe('hasConditions', () => { it('returns false for undefined metadata', () => { expect(hasConditions(undefined)).toBe(false); @@ -97,7 +87,6 @@ describe('hasConditions', () => { it('returns true when fileContains is declared', () => { expect(hasConditions({ fileContains: ['TODO'] })).toBe(true); - // Declared but invalid still counts as a declared condition. expect(hasConditions({ fileContains: [] })).toBe(true); }); @@ -117,10 +106,6 @@ describe('hasConditions', () => { }); }); -// ────────────────────────────────────────────── -// formatConditionSummary -// ────────────────────────────────────────────── - describe('formatConditionSummary', () => { it('formats single array field', () => { expect(formatConditionSummary({ globs: ['**/*.ts'] })).toBe( @@ -172,10 +157,6 @@ describe('formatConditionSummary', () => { }); }); -// ────────────────────────────────────────────── -// disambiguateNames -// ────────────────────────────────────────────── - describe('disambiguateNames', () => { it('assigns filename stem for unique names', () => { const entries: SidebarRuleEntry[] = [ @@ -204,10 +185,8 @@ describe('disambiguateNames', () => { makeEntry({ path: 'other/security.md' }), ]; disambiguateNames(entries); - // web/security appears twice, falls back to full path (with extension) for those expect(entries[0]!.name).toBe('apps/web/security.mdc'); expect(entries[1]!.name).toBe('packages/web/security.mdc'); - // other/security is unique after parent prefix expect(entries[2]!.name).toBe('other/security'); }); @@ -217,8 +196,6 @@ describe('disambiguateNames', () => { makeEntry({ path: 'dup.mdc' }), ]; disambiguateNames(entries); - // Both stem to "dup", no parent dir to prefix (dirname is "."). - // Falls back to full path with extension. expect(entries[0]!.name).toBe('dup.md'); expect(entries[1]!.name).toBe('dup.mdc'); }); @@ -229,8 +206,6 @@ describe('disambiguateNames', () => { makeEntry({ path: 'rules/dup.mdc' }), ]; disambiguateNames(entries); - // Both stem to "dup", same parent "rules" -> "rules/dup" for both. - // Still ambiguous, falls back to full path with extension. expect(entries[0]!.name).toBe('rules/dup.md'); expect(entries[1]!.name).toBe('rules/dup.mdc'); }); @@ -241,16 +216,11 @@ describe('disambiguateNames', () => { makeEntry({ path: 'other.md' }), ]; disambiguateNames(entries); - // lastIndexOf('.') gives "my.config", not "my" expect(entries[0]!.name).toBe('my.config'); expect(entries[1]!.name).toBe('other'); }); }); -// ────────────────────────────────────────────── -// loadSidebarRules (integration) -// ────────────────────────────────────────────── - describe('loadSidebarRules', () => { let testDir: string; let savedXDG: string | undefined; @@ -307,7 +277,6 @@ describe('loadSidebarRules', () => { const { rules } = await loadSidebarRules(projDir); expect(rules).toHaveLength(2); - // Project rules sort first expect(rules[0]!.source).toBe('project'); expect(rules[1]!.source).toBe('global'); }); @@ -364,7 +333,6 @@ describe('loadSidebarRules', () => { const { rules, skippedCount } = await loadSidebarRules(null); - // One readable, one unreadable expect(rules).toHaveLength(1); expect(rules[0]!.name).toBe('readable'); expect(skippedCount).toBe(1); @@ -372,16 +340,11 @@ describe('loadSidebarRules', () => { // getCachedRule() keeps the warning silent unless debug logging is enabled. expect(warnSpy).not.toHaveBeenCalled(); - // Restore permissions for cleanup chmodSync(unreadable, 0o644); warnSpy.mockRestore(); }); }); -// ────────────────────────────────────────────── -// loadSidebarRules isActive behavior -// ────────────────────────────────────────────── - describe('loadSidebarRules isActive behavior', () => { let testDir: string; let stateDir: string; diff --git a/tui/data/rules.ts b/tui/data/rules.ts index b77803c..df91203 100644 --- a/tui/data/rules.ts +++ b/tui/data/rules.ts @@ -1,52 +1,32 @@ -// tui/data/rules.ts import { discoverRuleFiles, getCachedRule, - readMatchedRulesState, +} from '../../src/rules/rule-discovery.js'; +import { readMatchedRulesState } from '../../src/session/matched-rules-state.js'; +import { hasConditions, type RuleMetadata, -} from '../../src/utils.js'; +} from '../../src/rules/rule-metadata.js'; export { hasConditions }; import path from 'node:path'; -/** Represents a rule as displayed in the sidebar */ export interface SidebarRuleEntry { - /** Display name (filename stem, disambiguated if needed) */ name: string; - /** Relative file path from the rules directory root */ path: string; - /** Whether this rule came from global or project-local rules dir */ source: 'global' | 'project'; - /** Whether the rule has any conditional metadata */ isConditional: boolean; - /** Human-readable condition summary */ conditionSummary: string; - /** Full metadata for expanded view */ metadata: RuleMetadata; - /** - * Active state of the rule. - * - true: rule is active (matched by evaluation or unconditional without state file) - * - false: rule is not active (not matched by evaluation) - * - null: state not yet determined (conditional rule without state file) - */ + // null means "not yet determined": a conditional rule with no state file. isActive: boolean | null; } export interface LoadSidebarRulesResult { rules: SidebarRuleEntry[]; skippedCount: number; - /** Whether matched rules state was successfully read from disk */ hasEvaluationState: boolean; } -/** - * Load all discovered rules formatted for sidebar display. - * Reuses discoverRuleFiles/getCachedRule from the server plugin. - * - * @param projectDir - Project directory or null (global rules only) - * @param sessionId - Optional session ID to read matched rules state - * @param options - Optional state read configuration - */ export async function loadSidebarRules( projectDir: string | null, sessionId?: string, @@ -55,7 +35,6 @@ export async function loadSidebarRules( // discoverRuleFiles accepts string | undefined, not null const discovered = await discoverRuleFiles(projectDir ?? undefined); - // Read matched rules state if sessionId provided const matchedState = sessionId ? await readMatchedRulesState(sessionId, options) : null; @@ -70,8 +49,6 @@ export async function loadSidebarRules( for (const rule of discovered) { const cached = await getCachedRule(rule.filePath); if (!cached) { - // getCachedRule() already logs a warning for read failures, - // so we only increment the counter here — no duplicate log. skippedCount++; continue; } @@ -83,18 +60,15 @@ export async function loadSidebarRules( ? formatConditionSummary(meta!) : 'always active'; - // Determine isActive based on state file or fallback logic let isActive: boolean | null; if (matchedPathsSet !== null) { - // With state file: check if this rule's absolute path is in matchedPaths isActive = matchedPathsSet.has(rule.filePath); } else { - // Without state file: unconditional = true, conditional = null isActive = isConditional ? null : true; } entries.push({ - name: '', // placeholder — set in disambiguation pass + name: '', path: rule.relativePath, source, isConditional, @@ -106,7 +80,6 @@ export async function loadSidebarRules( disambiguateNames(entries); - // Sort: project first, then global. Active rules to top, then alpha by name. const sortPriority = (v: boolean | null): number => v === true ? 0 : v === null ? 1 : 2; entries.sort((a, b) => { @@ -121,11 +94,7 @@ export async function loadSidebarRules( return { rules: entries, skippedCount, hasEvaluationState }; } -/** - * Determine if a rule file is project-local or global. - * Uses path.sep boundary check to avoid matching partial prefixes - * (e.g., /project/.opencode/rules-extra/ should not match). - */ +// Prefix boundary matters: /project/.opencode/rules-extra/ must not match. export function classifyRuleScope( filePath: string, projectDir: string | null @@ -136,11 +105,6 @@ export function classifyRuleScope( return filePath.startsWith(projectRulesPrefix) ? 'project' : 'global'; } -/** - * Format a concise summary of which conditions a rule has. - * Build a human-readable, comma-separated summary of active conditions. - * E.g., "globs: src/*.ts, keywords: auth, security" - */ export function formatConditionSummary(meta: RuleMetadata): string { const parts: string[] = []; @@ -175,24 +139,16 @@ export function formatConditionSummary(meta: RuleMetadata): string { return parts.join(', '); } -/** - * Three-pass name disambiguation. - * Pass 1: Extract filename stem from each entry's path. - * Pass 2: For duplicate stems, prefix with parent directory. - * Pass 3: If still ambiguous (same parent or root-level), use full relative - * path (including extension) as the display name. - * - * Mutates entries[].name in place. - */ +// Three-pass disambiguation: filename stem, then parent-dir prefix for +// duplicates, then full relative path if still ambiguous. Mutates +// entries[].name in place. export function disambiguateNames(entries: SidebarRuleEntry[]): void { - // Pass 1: assign stem names (filename without extension, using last dot) for (const entry of entries) { const basename = path.basename(entry.path); const dotIndex = basename.lastIndexOf('.'); entry.name = dotIndex > 0 ? basename.substring(0, dotIndex) : basename; } - // Pass 2: detect and resolve collisions with parent directory prefix const stemCounts = new Map(); for (const entry of entries) { stemCounts.set(entry.name, (stemCounts.get(entry.name) ?? 0) + 1); @@ -208,7 +164,7 @@ export function disambiguateNames(entries: SidebarRuleEntry[]): void { } } - // Pass 3: if still ambiguous, use full relative path WITH extension + // Pass 3: still-ambiguous names fall back to the full path with extension. const nameCounts = new Map(); for (const entry of entries) { nameCounts.set(entry.name, (nameCounts.get(entry.name) ?? 0) + 1); diff --git a/tui/index.tsx b/tui/index.tsx index 11a9818..0df5129 100644 --- a/tui/index.tsx +++ b/tui/index.tsx @@ -1,4 +1,3 @@ -// tui/index.tsx /** @jsxImportSource @opentui/solid */ import type { TuiPlugin } from '@opencode-ai/plugin/tui'; import { SidebarContent } from './slots/sidebar-content.js'; diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx index 0e10e9c..fecc098 100644 --- a/tui/slots/sidebar-content.tsx +++ b/tui/slots/sidebar-content.tsx @@ -1,4 +1,3 @@ -// tui/slots/sidebar-content.tsx /** @jsxImportSource @opentui/solid */ import { createSignal, @@ -11,8 +10,8 @@ import { import type { TuiPluginApi, TuiTheme } from '@opencode-ai/plugin/tui'; import { loadSidebarRules, type SidebarRuleEntry } from '../data/rules.js'; import { createRulesLoadCoordinator } from '../data/rules-load-coordinator.js'; -import type { RuleMetadata } from '../../src/utils.js'; -import { logError } from '../../src/debug.js'; +import type { RuleMetadata } from '../../src/rules/rule-metadata.js'; +import { logError } from '../../src/shared/debug.js'; const metadataFieldDescriptors: Array<{ key: keyof RuleMetadata; @@ -165,7 +164,6 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { return props.api.state.path.directory ?? null; }; - // Debounce timer for event-driven refresh let debounceTimer: ReturnType | null = null; const rulesLoadCoordinator = createRulesLoadCoordinator({ @@ -189,19 +187,15 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { }, }); - // Effect 1: Initial load on session/directory change createEffect(() => { const currentSessionId = props.sessionId; const currentDir = resolveProjectDir(); - // Check if session or directory changed if (currentSessionId !== lastSessionId() || currentDir !== lastDir()) { - // Clear pending debounce from previous session if (debounceTimer !== null) { clearTimeout(debounceTimer); debounceTimer = null; } - // Reset UI state on session/directory change setExpandedIndex(null); setProjectOpen(false); setGlobalOpen(false); @@ -212,7 +206,6 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { } }); - // Effect 2: Refresh on event-driven updates (refreshCounter changes) createEffect(() => { const counter = refreshCounter(); if (counter > 0) { @@ -220,13 +213,11 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { } }); - // Subscribe to OpenCode events with debounce const triggerRefresh = (event: { type: string; properties: Record; }): void => { - // Filter events to current sessionId before debouncing - // OpenCode SDK events nest sessionID inside properties: { type, properties: { sessionID, ... } } + // SDK events nest sessionID inside properties. const eventSessionID = event.properties.sessionID; if ( typeof eventSessionID === 'string' && diff --git a/tui/types/opencode-plugin-tui.d.ts b/tui/types/opencode-plugin-tui.d.ts index 4e11948..c52c40c 100644 --- a/tui/types/opencode-plugin-tui.d.ts +++ b/tui/types/opencode-plugin-tui.d.ts @@ -1,11 +1,6 @@ -// tui/types/opencode-plugin-tui.d.ts -// -// Vendored type declarations for @opencode-ai/plugin/tui. -// Allows tsc to compile TUI code without requiring the optional -// peer dependency to be installed at compile time. -// -// Source: @opencode-ai/plugin v1.3.7 (packages/plugin/src/tui.ts) -// If bumping @opencode-ai/plugin, re-verify these types match. +// Vendored from @opencode-ai/plugin v1.3.7 (packages/plugin/src/tui.ts) so +// tsc can compile TUI code without the optional peer dependency installed. +// Re-verify against upstream when bumping @opencode-ai/plugin. declare module '@opencode-ai/plugin/tui' { export interface TuiTheme {