@yriveiro/opencode-cache-core
Advanced tools
| import { join } from "node:path"; | ||
| import type { | ||
| GitCacheArtifactSpec, | ||
| GitCacheSourceSpec, | ||
| GitCacheSpec, | ||
| } from "./git-cache-schema"; | ||
| import { getGitCacheIndexFile as getStateIndexFile } from "./git-cache-state"; | ||
| export function getGitSourceDirectoryName( | ||
| source: Pick<GitCacheSourceSpec, "id" | "directory">, | ||
| ): string { | ||
| return source.directory ?? source.id; | ||
| } | ||
| export function getGitSourceDir<TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| cacheDir: string, | ||
| sourceId: string, | ||
| ): string { | ||
| const source = spec.sources.find((entry) => entry.id === sourceId); | ||
| if (source == null) { | ||
| throw new Error(`Unknown source '${sourceId}'.`); | ||
| } | ||
| return join(cacheDir, getGitSourceDirectoryName(source)); | ||
| } | ||
| export function getGitArtifact<TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| artifactId: string, | ||
| ): GitCacheArtifactSpec { | ||
| const artifact = (spec.artifacts ?? []).find((entry) => entry.id === artifactId); | ||
| if (artifact == null) { | ||
| throw new Error(`Unknown artifact '${artifactId}'.`); | ||
| } | ||
| return artifact; | ||
| } | ||
| export function getGitArtifactDir<TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| cacheDir: string, | ||
| artifactId: string, | ||
| ): string { | ||
| const artifact = getGitArtifact(spec, artifactId); | ||
| return join(getGitSourceDir(spec, cacheDir, artifact.source), artifact.path); | ||
| } | ||
| export function getGitArtifactReadinessPath<TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| cacheDir: string, | ||
| artifactId: string, | ||
| ): string { | ||
| const artifact = getGitArtifact(spec, artifactId); | ||
| const artifactDir = getGitArtifactDir(spec, cacheDir, artifactId); | ||
| return artifact.readiness == null ? artifactDir : join(artifactDir, artifact.readiness); | ||
| } | ||
| export function getGitSectionBaseDir<TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| cacheDir: string, | ||
| scope: TScope, | ||
| ): string { | ||
| const section = spec.sections[scope]; | ||
| return section.root.kind === "source" | ||
| ? getGitSourceDir(spec, cacheDir, section.root.id) | ||
| : getGitArtifactDir(spec, cacheDir, section.root.id); | ||
| } | ||
| export function getGitCacheIndexPath(cacheDir: string): string { | ||
| return getStateIndexFile(cacheDir); | ||
| } |
| import { spawn } from "node:child_process"; | ||
| import { mkdir } from "node:fs/promises"; | ||
| import { homedir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { tool, type Hooks, type Plugin, type ToolDefinition } from "@opencode-ai/plugin"; | ||
| import { buildIndex } from "./indexing"; | ||
| import { | ||
| defineGitCacheSpec, | ||
| getGitCacheScopes, | ||
| resolveReadySourceId, | ||
| type GitCacheSourceSpec, | ||
| type GitCacheSpec, | ||
| } from "./git-cache-schema"; | ||
| import { | ||
| getGitArtifactDir, | ||
| getGitArtifactReadinessPath, | ||
| getGitCacheIndexPath, | ||
| getGitSectionBaseDir, | ||
| getGitSourceDir, | ||
| getGitSourceDirectoryName, | ||
| } from "./git-cache-paths"; | ||
| import { | ||
| createInitialGitCacheState, | ||
| getGitCacheFreshness, | ||
| getGitCacheStateFile, | ||
| loadGitCacheState, | ||
| type GitCacheArtifactState, | ||
| type GitCacheState, | ||
| writeGitCacheState, | ||
| } from "./git-cache-state"; | ||
| import { buildNotification, createNotificationSender } from "./notifications"; | ||
| import { createPermissionHandler } from "./permissions"; | ||
| import { searchIndex } from "./search"; | ||
| import { pathExists, readIndex, writeIndex } from "./storage"; | ||
| import { createSearchTool, createStatusTool } from "./tools"; | ||
| import { ALL_SCOPE, type Index, type SearchResult } from "./types"; | ||
| export const DEFAULT_GIT_CACHE_MAX_AGE_SECONDS = 86_400; | ||
| export interface CommandResult { | ||
| exitCode: number; | ||
| stdout: string; | ||
| stderr: string; | ||
| } | ||
| function formatErrorMessage(prefix: string, error: unknown): string { | ||
| const details = error instanceof Error ? error.message : String(error); | ||
| return `${prefix}: ${details}`; | ||
| } | ||
| async function logInitialization(input: { | ||
| client: Parameters<Plugin>[0]["client"]; | ||
| service: string; | ||
| message: string; | ||
| }): Promise<void> { | ||
| await input.client.app.log({ | ||
| body: { | ||
| service: input.service, | ||
| level: "info", | ||
| message: input.message, | ||
| }, | ||
| }); | ||
| } | ||
| function createEmptyArtifactState(directory: string): GitCacheArtifactState { | ||
| return { | ||
| directory, | ||
| builtAt: null, | ||
| ready: false, | ||
| message: null, | ||
| }; | ||
| } | ||
| function getSparseEntries(source: Pick<GitCacheSourceSpec, "sparse">): string[] { | ||
| return source.sparse == null ? [] : [...source.sparse]; | ||
| } | ||
| function resolveGitCacheDir( | ||
| envVar: string, | ||
| defaultCacheSubdir: string, | ||
| homeDirectory = homedir(), | ||
| ): string { | ||
| const configuredCacheDir = process.env[envVar]; | ||
| if (configuredCacheDir != null && configuredCacheDir.length > 0) { | ||
| return configuredCacheDir; | ||
| } | ||
| return join(homeDirectory, ".cache", "opencode", "skills", defaultCacheSubdir); | ||
| } | ||
| export function formatCommandFailure(title: string, result: CommandResult): string { | ||
| const details = [result.stdout, result.stderr] | ||
| .filter((value) => value.length > 0) | ||
| .join("\n"); | ||
| return details.length > 0 ? `${title}\n${details}` : title; | ||
| } | ||
| export async function runCommand( | ||
| command: string, | ||
| args: readonly string[], | ||
| cwd?: string, | ||
| ): Promise<CommandResult> { | ||
| return new Promise((resolve, reject) => { | ||
| const child = spawn(command, [...args], { | ||
| cwd, | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| }); | ||
| let stdout = ""; | ||
| let stderr = ""; | ||
| child.stdout?.on("data", (chunk) => { | ||
| stdout += chunk.toString(); | ||
| }); | ||
| child.stderr?.on("data", (chunk) => { | ||
| stderr += chunk.toString(); | ||
| }); | ||
| child.on("error", (error) => { | ||
| reject(error); | ||
| }); | ||
| child.on("close", (code) => { | ||
| resolve({ | ||
| exitCode: code ?? -1, | ||
| stdout: stdout.trimEnd(), | ||
| stderr: stderr.trimEnd(), | ||
| }); | ||
| }); | ||
| }); | ||
| } | ||
| async function syncGitSource( | ||
| sourceDir: string, | ||
| source: GitCacheSourceSpec, | ||
| ): Promise<string[]> { | ||
| const sourceName = getGitSourceDirectoryName(source); | ||
| const installed = await pathExists(join(sourceDir, ".git")); | ||
| const lines: string[] = []; | ||
| if (!installed) { | ||
| lines.push(`Cloning ${sourceName}...`); | ||
| let result: CommandResult; | ||
| if (source.sparse != null) { | ||
| result = await runCommand("git", [ | ||
| "clone", | ||
| "--filter=blob:none", | ||
| "--no-checkout", | ||
| "--depth", | ||
| "1", | ||
| "--branch", | ||
| source.branch, | ||
| source.url, | ||
| sourceDir, | ||
| ]); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error(formatCommandFailure(`Failed to clone ${sourceName}.`, result)); | ||
| } | ||
| result = await runCommand("git", ["sparse-checkout", "init", "--cone"], sourceDir); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error( | ||
| formatCommandFailure( | ||
| `Failed to initialize sparse checkout for ${sourceName}.`, | ||
| result, | ||
| ), | ||
| ); | ||
| } | ||
| result = await runCommand( | ||
| "git", | ||
| ["sparse-checkout", "set", ...getSparseEntries(source)], | ||
| sourceDir, | ||
| ); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error( | ||
| formatCommandFailure( | ||
| `Failed to configure sparse checkout for ${sourceName}.`, | ||
| result, | ||
| ), | ||
| ); | ||
| } | ||
| result = await runCommand("git", ["checkout", source.branch], sourceDir); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error( | ||
| formatCommandFailure(`Failed to checkout ${source.branch}.`, result), | ||
| ); | ||
| } | ||
| } else { | ||
| result = await runCommand("git", [ | ||
| "clone", | ||
| "--depth", | ||
| "1", | ||
| "--branch", | ||
| source.branch, | ||
| source.url, | ||
| sourceDir, | ||
| ]); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error(formatCommandFailure(`Failed to clone ${sourceName}.`, result)); | ||
| } | ||
| } | ||
| lines.push(` Cloned ${sourceName}`); | ||
| return lines; | ||
| } | ||
| lines.push(`Updating ${sourceName}...`); | ||
| let result = await runCommand( | ||
| "git", | ||
| ["fetch", "--depth", "1", "origin", source.branch], | ||
| sourceDir, | ||
| ); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error(formatCommandFailure(`Failed to fetch ${sourceName}.`, result)); | ||
| } | ||
| result = await runCommand( | ||
| "git", | ||
| ["reset", "--hard", `origin/${source.branch}`], | ||
| sourceDir, | ||
| ); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error(formatCommandFailure(`Failed to reset ${sourceName}.`, result)); | ||
| } | ||
| if (source.sparse != null) { | ||
| result = await runCommand( | ||
| "git", | ||
| ["sparse-checkout", "set", ...getSparseEntries(source)], | ||
| sourceDir, | ||
| ); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error( | ||
| formatCommandFailure( | ||
| `Failed to refresh sparse checkout for ${sourceName}.`, | ||
| result, | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
| lines.push(` Updated ${sourceName}`); | ||
| return lines; | ||
| } | ||
| async function readGitRevision(directory: string): Promise<string | null> { | ||
| const result = await runCommand("git", ["rev-parse", "--short", "HEAD"], directory); | ||
| return result.exitCode === 0 && result.stdout.length > 0 ? result.stdout : null; | ||
| } | ||
| function isIndexForScopes<TScope extends string>( | ||
| value: unknown, | ||
| scopes: readonly TScope[], | ||
| ): value is Index<TScope> { | ||
| if (typeof value !== "object" || value == null) { | ||
| return false; | ||
| } | ||
| const sections = (value as { sections?: unknown }).sections; | ||
| if (typeof sections !== "object" || sections == null) { | ||
| return false; | ||
| } | ||
| for (const scope of scopes) { | ||
| const section = (sections as Record<string, unknown>)[scope]; | ||
| if (typeof section !== "object" || section == null) { | ||
| return false; | ||
| } | ||
| const files = (section as { files?: unknown }).files; | ||
| if (!Array.isArray(files)) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
| export async function buildGitCacheSearchIndex<TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| cacheDir: string, | ||
| ): Promise<Index<TScope>> { | ||
| const sections = {} as Record<TScope, { baseDir: string; patterns: readonly string[] }>; | ||
| for (const scope of getGitCacheScopes(spec)) { | ||
| sections[scope] = { | ||
| baseDir: getGitSectionBaseDir(spec, cacheDir, scope), | ||
| patterns: spec.sections[scope].patterns, | ||
| }; | ||
| } | ||
| return buildIndex({ | ||
| cacheDir, | ||
| sections, | ||
| }); | ||
| } | ||
| export async function searchGitCacheIndex<TScope extends string>( | ||
| index: Index<TScope>, | ||
| query: string, | ||
| options?: { | ||
| scope?: TScope | typeof ALL_SCOPE; | ||
| regex?: boolean; | ||
| caseSensitive?: boolean; | ||
| limit?: number; | ||
| }, | ||
| ): Promise<SearchResult<TScope>> { | ||
| return searchIndex(index, query, options); | ||
| } | ||
| export function formatGitCacheIndexCounts<TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| index: Index<TScope>, | ||
| ): string[] { | ||
| return getGitCacheScopes(spec).map((scope) => { | ||
| return `${spec.sections[scope].label} files: ${index.sections[scope].files.length}`; | ||
| }); | ||
| } | ||
| export interface GitCacheRuntimeContext<TScope extends string> { | ||
| client: Parameters<Plugin>[0]["client"]; | ||
| spec: GitCacheSpec<TScope>; | ||
| cacheDir: string; | ||
| indexFile: string; | ||
| stateFile: string; | ||
| maxAgeSeconds: number; | ||
| buildNotification(output: string): string; | ||
| notify(sessionID: string, output: string): Promise<void>; | ||
| log(message: string, level?: "debug" | "error" | "info" | "warn"): Promise<void>; | ||
| getSourceDir(sourceId: string): string; | ||
| getArtifactDir(artifactId: string): string; | ||
| getArtifactReadinessPath(artifactId: string): string; | ||
| isSourceReady(sourceId: string): Promise<boolean>; | ||
| isArtifactReady(artifactId: string): Promise<boolean>; | ||
| getSourceRevision(sourceId: string): Promise<string | null>; | ||
| readState(): Promise<GitCacheState>; | ||
| writeState(state: GitCacheState): Promise<void>; | ||
| updateState(mutator: (state: GitCacheState) => void): Promise<GitCacheState>; | ||
| loadIndex(): Promise<Index<TScope> | null>; | ||
| refreshIndex(): Promise<Index<TScope>>; | ||
| formatIndexCounts(index: Index<TScope>): string[]; | ||
| syncSources(): Promise<string[]>; | ||
| runCommand: typeof runCommand; | ||
| formatCommandFailure: typeof formatCommandFailure; | ||
| } | ||
| export interface GitCachePluginOptions<TScope extends string> { | ||
| spec: GitCacheSpec<TScope>; | ||
| maxAgeSeconds?: number; | ||
| extendStatus?: (context: GitCacheRuntimeContext<TScope>) => Promise<readonly string[]>; | ||
| extraTools?: (context: GitCacheRuntimeContext<TScope>) => Record<string, ToolDefinition>; | ||
| } | ||
| function createRuntimeContext<TScope extends string>(input: { | ||
| client: Parameters<Plugin>[0]["client"]; | ||
| spec: GitCacheSpec<TScope>; | ||
| cacheDir: string; | ||
| sendNotification: (sessionID: string, message: string) => Promise<void>; | ||
| maxAgeSeconds: number; | ||
| }): GitCacheRuntimeContext<TScope> { | ||
| const indexFile = getGitCacheIndexPath(input.cacheDir); | ||
| const stateFile = getGitCacheStateFile(input.cacheDir); | ||
| const scopes = getGitCacheScopes(input.spec); | ||
| const readySourceId = resolveReadySourceId(input.spec); | ||
| const readState = async (): Promise<GitCacheState> => { | ||
| return loadGitCacheState(input.spec, input.cacheDir, { | ||
| getSourceDir: (sourceId) => getGitSourceDir(input.spec, input.cacheDir, sourceId), | ||
| getArtifactDir: (artifactId) => getGitArtifactDir(input.spec, input.cacheDir, artifactId), | ||
| }); | ||
| }; | ||
| const writeState = async (state: GitCacheState): Promise<void> => { | ||
| await writeGitCacheState(stateFile, state); | ||
| }; | ||
| const updateState = async ( | ||
| mutator: (state: GitCacheState) => void, | ||
| ): Promise<GitCacheState> => { | ||
| const currentState = await readState(); | ||
| mutator(currentState); | ||
| await writeState(currentState); | ||
| return currentState; | ||
| }; | ||
| const refreshIndex = async (): Promise<Index<TScope>> => { | ||
| const index = await buildGitCacheSearchIndex(input.spec, input.cacheDir); | ||
| await writeIndex(indexFile, index); | ||
| await updateState((state) => { | ||
| state.indexedAt = index.createdAt; | ||
| state.indexFile = indexFile; | ||
| }); | ||
| return index; | ||
| }; | ||
| const loadIndex = async (): Promise<Index<TScope> | null> => { | ||
| if (!(await pathExists(join(getGitSourceDir(input.spec, input.cacheDir, readySourceId), ".git")))) { | ||
| return null; | ||
| } | ||
| const storedIndex = await readIndex<Index<TScope>>(indexFile); | ||
| return isIndexForScopes(storedIndex, scopes) ? storedIndex : refreshIndex(); | ||
| }; | ||
| const syncSources = async (): Promise<string[]> => { | ||
| const now = new Date().toISOString(); | ||
| const state = await readState(); | ||
| const lines: string[] = []; | ||
| for (const source of input.spec.sources) { | ||
| const sourceDir = getGitSourceDir(input.spec, input.cacheDir, source.id); | ||
| try { | ||
| lines.push(...(await syncGitSource(sourceDir, source))); | ||
| const revision = await readGitRevision(sourceDir); | ||
| state.sources[source.id] = { | ||
| directory: sourceDir, | ||
| revision, | ||
| syncedAt: now, | ||
| ready: await pathExists(join(sourceDir, ".git")), | ||
| message: null, | ||
| }; | ||
| } catch (error: unknown) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| state.sources[source.id] = { | ||
| directory: sourceDir, | ||
| revision: await readGitRevision(sourceDir), | ||
| syncedAt: state.sources[source.id]?.syncedAt ?? null, | ||
| ready: await pathExists(join(sourceDir, ".git")), | ||
| message, | ||
| }; | ||
| lines.push(` Error with ${source.id}: ${message}`); | ||
| } | ||
| } | ||
| state.updatedAt = now; | ||
| state.warnings = []; | ||
| await writeState(state); | ||
| return lines; | ||
| }; | ||
| return { | ||
| client: input.client, | ||
| spec: input.spec, | ||
| cacheDir: input.cacheDir, | ||
| indexFile, | ||
| stateFile, | ||
| maxAgeSeconds: input.maxAgeSeconds, | ||
| buildNotification(output: string): string { | ||
| return buildNotification(input.spec.title, output); | ||
| }, | ||
| async notify(sessionID: string, output: string): Promise<void> { | ||
| await input.sendNotification(sessionID, buildNotification(input.spec.title, output)); | ||
| }, | ||
| async log( | ||
| message: string, | ||
| level: "debug" | "error" | "info" | "warn" = "info", | ||
| ): Promise<void> { | ||
| await input.client.app.log({ | ||
| body: { | ||
| service: input.spec.service, | ||
| level, | ||
| message, | ||
| }, | ||
| }); | ||
| }, | ||
| getSourceDir(sourceId: string): string { | ||
| return getGitSourceDir(input.spec, input.cacheDir, sourceId); | ||
| }, | ||
| getArtifactDir(artifactId: string): string { | ||
| return getGitArtifactDir(input.spec, input.cacheDir, artifactId); | ||
| }, | ||
| getArtifactReadinessPath(artifactId: string): string { | ||
| return getGitArtifactReadinessPath(input.spec, input.cacheDir, artifactId); | ||
| }, | ||
| async isSourceReady(sourceId: string): Promise<boolean> { | ||
| return pathExists(join(getGitSourceDir(input.spec, input.cacheDir, sourceId), ".git")); | ||
| }, | ||
| async isArtifactReady(artifactId: string): Promise<boolean> { | ||
| return pathExists(getGitArtifactReadinessPath(input.spec, input.cacheDir, artifactId)); | ||
| }, | ||
| async getSourceRevision(sourceId: string): Promise<string | null> { | ||
| return readGitRevision(getGitSourceDir(input.spec, input.cacheDir, sourceId)); | ||
| }, | ||
| readState, | ||
| writeState, | ||
| updateState, | ||
| loadIndex, | ||
| refreshIndex, | ||
| formatIndexCounts(index: Index<TScope>): string[] { | ||
| return formatGitCacheIndexCounts(input.spec, index); | ||
| }, | ||
| syncSources, | ||
| runCommand, | ||
| formatCommandFailure, | ||
| }; | ||
| } | ||
| function buildSourceRepositoryLines<TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| state: GitCacheState, | ||
| ): string[] { | ||
| const lines = ["Sources:"]; | ||
| for (const source of spec.sources) { | ||
| const sourceState = state.sources[source.id] | ||
| ?? createInitialGitCacheState(spec, state.cacheDir, { | ||
| getSourceDir: (sourceId) => getGitSourceDir(spec, state.cacheDir, sourceId), | ||
| getArtifactDir: (artifactId) => getGitArtifactDir(spec, state.cacheDir, artifactId), | ||
| }).sources[source.id]; | ||
| const label = source.label ?? source.id; | ||
| const revision = sourceState.revision ?? "unknown"; | ||
| const syncedAt = sourceState.syncedAt ?? "never"; | ||
| lines.push(` ${label}: ${sourceState.ready ? "ready" : "missing"} (${revision}, synced ${syncedAt})`); | ||
| if (sourceState.message != null) { | ||
| lines.push(` ${sourceState.message}`); | ||
| } | ||
| } | ||
| return lines; | ||
| } | ||
| function buildArtifactLines<TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| state: GitCacheState, | ||
| ): string[] { | ||
| const artifacts = spec.artifacts ?? []; | ||
| if (artifacts.length === 0) { | ||
| return []; | ||
| } | ||
| const lines = ["Artifacts:"]; | ||
| for (const artifact of artifacts) { | ||
| const artifactState = state.artifacts[artifact.id] | ||
| ?? createEmptyArtifactState(getGitArtifactDir(spec, state.cacheDir, artifact.id)); | ||
| const label = artifact.label ?? artifact.id; | ||
| const builtAt = artifactState.builtAt ?? "never"; | ||
| lines.push(` ${label}: ${artifactState.ready ? "ready" : "missing"} (built ${builtAt})`); | ||
| if (artifactState.message != null) { | ||
| lines.push(` ${artifactState.message}`); | ||
| } | ||
| } | ||
| return lines; | ||
| } | ||
| export function createGitCachePlugin<TScope extends string>( | ||
| input: GitCachePluginOptions<TScope>, | ||
| ): Plugin { | ||
| return async ({ client }) => { | ||
| const spec = defineGitCacheSpec(input.spec); | ||
| const cacheDir = resolveGitCacheDir(spec.envVar, spec.defaultCacheSubdir); | ||
| const sendNotification = createNotificationSender({ | ||
| client, | ||
| service: spec.service, | ||
| }); | ||
| const runtime = createRuntimeContext({ | ||
| client, | ||
| spec, | ||
| cacheDir, | ||
| sendNotification, | ||
| maxAgeSeconds: input.maxAgeSeconds ?? DEFAULT_GIT_CACHE_MAX_AGE_SECONDS, | ||
| }); | ||
| const scopeValues = [ALL_SCOPE, ...getGitCacheScopes(spec)] as const; | ||
| const tools: Hooks["tool"] = { | ||
| [spec.updateTool.name]: tool({ | ||
| description: spec.updateTool.description, | ||
| args: { | ||
| force: tool.schema.boolean().optional().describe("Force refresh even when the cache is fresh."), | ||
| }, | ||
| async execute(args, context) { | ||
| try { | ||
| await mkdir(runtime.cacheDir, { recursive: true }); | ||
| const state = await runtime.readState(); | ||
| const freshness = getGitCacheFreshness(state.updatedAt, runtime.maxAgeSeconds); | ||
| const readySourceId = resolveReadySourceId(spec); | ||
| if (!args.force && (await runtime.isSourceReady(readySourceId)) && freshness.fresh) { | ||
| const index = (await runtime.loadIndex()) ?? (await runtime.refreshIndex()); | ||
| const message = [ | ||
| `Cache is fresh (${freshness.hoursAgo}h old).`, | ||
| `Cache directory: ${runtime.cacheDir}`, | ||
| `State file: ${runtime.stateFile}`, | ||
| ...runtime.formatIndexCounts(index), | ||
| "Use force=true to refresh anyway.", | ||
| ].join("\n"); | ||
| await runtime.notify(context.sessionID, message); | ||
| return message; | ||
| } | ||
| const lines = await runtime.syncSources(); | ||
| const index = await runtime.refreshIndex(); | ||
| lines.push(`\nState file: ${runtime.stateFile}`); | ||
| lines.push(`Search index: ${runtime.indexFile}`); | ||
| lines.push(...runtime.formatIndexCounts(index)); | ||
| const output = lines.join("\n"); | ||
| await runtime.notify(context.sessionID, output); | ||
| await runtime.log(spec.updateTool.successLogMessage); | ||
| return output; | ||
| } catch (error: unknown) { | ||
| const message = formatErrorMessage(spec.updateTool.failureLabel, error); | ||
| await runtime.notify(context.sessionID, message); | ||
| return message; | ||
| } | ||
| }, | ||
| }), | ||
| [spec.statusTool.name]: createStatusTool({ | ||
| description: spec.statusTool.description, | ||
| notificationTitle: spec.title, | ||
| sendNotification, | ||
| buildOutput: async () => { | ||
| const state = await runtime.readState(); | ||
| const index = await runtime.loadIndex(); | ||
| const freshness = getGitCacheFreshness(state.updatedAt, runtime.maxAgeSeconds); | ||
| const lines = [ | ||
| `Cache directory: ${runtime.cacheDir}`, | ||
| `State file: ${runtime.stateFile}`, | ||
| `Search index: ${runtime.indexFile}`, | ||
| ]; | ||
| if (freshness.timestamp == null) { | ||
| lines.push("Cache status: not initialized"); | ||
| } else { | ||
| lines.push(`Cache status: ${freshness.fresh ? "fresh" : "stale"} (${freshness.hoursAgo}h old)`); | ||
| lines.push(`Last update: ${new Date(freshness.timestamp * 1000).toISOString()}`); | ||
| } | ||
| lines.push(""); | ||
| lines.push(...buildSourceRepositoryLines(spec, state)); | ||
| const artifactLines = buildArtifactLines(spec, state); | ||
| if (artifactLines.length > 0) { | ||
| lines.push(""); | ||
| lines.push(...artifactLines); | ||
| } | ||
| if (input.extendStatus != null) { | ||
| const extraLines = [...(await input.extendStatus(runtime))]; | ||
| if (extraLines.length > 0) { | ||
| lines.push(""); | ||
| lines.push(...extraLines); | ||
| } | ||
| } | ||
| lines.push(""); | ||
| if (index == null) { | ||
| lines.push("Search corpus: missing"); | ||
| } else { | ||
| lines.push(`Search corpus: ${runtime.indexFile}`); | ||
| lines.push(...runtime.formatIndexCounts(index)); | ||
| } | ||
| return lines.join("\n"); | ||
| }, | ||
| }), | ||
| [spec.searchTool.name]: createSearchTool<Index<TScope>, TScope>({ | ||
| description: spec.searchTool.description, | ||
| notificationTitle: spec.title, | ||
| scopeValues, | ||
| scopeDescription: spec.searchTool.scopeDescription, | ||
| missingOutput: spec.searchTool.missingMessage, | ||
| failureLabel: spec.searchTool.failureLabel, | ||
| loadIndex: runtime.loadIndex, | ||
| search: async ({ | ||
| index, | ||
| query, | ||
| scope, | ||
| regex, | ||
| caseSensitive, | ||
| limit, | ||
| }: { | ||
| index: Index<TScope>; | ||
| query: string; | ||
| scope?: TScope | typeof ALL_SCOPE; | ||
| regex?: boolean; | ||
| caseSensitive?: boolean; | ||
| limit?: number; | ||
| }): Promise<SearchResult<TScope>> => { | ||
| return searchIndex(index, query, { | ||
| scope, | ||
| regex, | ||
| caseSensitive, | ||
| limit, | ||
| }); | ||
| }, | ||
| sendNotification, | ||
| }), | ||
| }; | ||
| if (input.extraTools != null) { | ||
| Object.assign(tools, input.extraTools(runtime)); | ||
| } | ||
| void logInitialization({ | ||
| client, | ||
| service: spec.service, | ||
| message: spec.initMessage, | ||
| }); | ||
| return { | ||
| tool: tools, | ||
| "permission.ask": createPermissionHandler(runtime.cacheDir), | ||
| }; | ||
| }; | ||
| } |
| import * as z from "zod"; | ||
| const NonEmptyStringSchema = z.string().trim().min(1); | ||
| export interface GitCacheToolConfig { | ||
| name: string; | ||
| description: string; | ||
| } | ||
| export interface GitCacheUpdateToolConfig extends GitCacheToolConfig { | ||
| failureLabel: string; | ||
| successLogMessage: string; | ||
| } | ||
| export interface GitCacheSearchToolConfig extends GitCacheToolConfig { | ||
| missingMessage: string; | ||
| scopeDescription?: string; | ||
| failureLabel: string; | ||
| } | ||
| export interface GitCacheSourceSpec { | ||
| id: string; | ||
| url: string; | ||
| branch: string; | ||
| directory?: string; | ||
| sparse?: readonly string[]; | ||
| ready?: boolean; | ||
| label?: string; | ||
| } | ||
| export interface GitCacheArtifactSpec { | ||
| id: string; | ||
| source: string; | ||
| path: string; | ||
| readiness?: string; | ||
| label?: string; | ||
| } | ||
| export type GitCacheSectionRoot = | ||
| | { | ||
| kind: "source"; | ||
| id: string; | ||
| } | ||
| | { | ||
| kind: "artifact"; | ||
| id: string; | ||
| }; | ||
| export interface GitCacheSectionSpec { | ||
| label: string; | ||
| root: GitCacheSectionRoot; | ||
| patterns: readonly string[]; | ||
| } | ||
| type BaseGitCacheSpec = { | ||
| schemaVersion?: 1; | ||
| title: string; | ||
| service: string; | ||
| envVar: string; | ||
| defaultCacheSubdir: string; | ||
| initMessage: string; | ||
| updateTool: GitCacheUpdateToolConfig; | ||
| statusTool: GitCacheToolConfig; | ||
| searchTool: GitCacheSearchToolConfig; | ||
| sources: readonly GitCacheSourceSpec[]; | ||
| artifacts?: readonly GitCacheArtifactSpec[]; | ||
| sections: Record<string, GitCacheSectionSpec>; | ||
| readySource?: string; | ||
| }; | ||
| export interface GitCacheSpec<TScope extends string = string> | ||
| extends Omit<BaseGitCacheSpec, "sections"> { | ||
| sections: Record<TScope, GitCacheSectionSpec>; | ||
| } | ||
| export const GitCacheToolConfigSchema = z.strictObject({ | ||
| name: NonEmptyStringSchema, | ||
| description: NonEmptyStringSchema, | ||
| }); | ||
| export const GitCacheUpdateToolConfigSchema = z.strictObject({ | ||
| ...GitCacheToolConfigSchema.shape, | ||
| failureLabel: NonEmptyStringSchema, | ||
| successLogMessage: NonEmptyStringSchema, | ||
| }); | ||
| export const GitCacheSearchToolConfigSchema = z.strictObject({ | ||
| ...GitCacheToolConfigSchema.shape, | ||
| missingMessage: NonEmptyStringSchema, | ||
| scopeDescription: NonEmptyStringSchema.optional(), | ||
| failureLabel: NonEmptyStringSchema, | ||
| }); | ||
| export const GitCacheSourceSpecSchema = z.strictObject({ | ||
| id: NonEmptyStringSchema, | ||
| url: z.url(), | ||
| branch: NonEmptyStringSchema, | ||
| directory: NonEmptyStringSchema.optional(), | ||
| sparse: z.array(NonEmptyStringSchema).min(1).optional(), | ||
| ready: z.boolean().optional(), | ||
| label: NonEmptyStringSchema.optional(), | ||
| }); | ||
| export const GitCacheArtifactSpecSchema = z.strictObject({ | ||
| id: NonEmptyStringSchema, | ||
| source: NonEmptyStringSchema, | ||
| path: NonEmptyStringSchema, | ||
| readiness: NonEmptyStringSchema.optional(), | ||
| label: NonEmptyStringSchema.optional(), | ||
| }); | ||
| export const GitCacheSectionRootSchema = z.discriminatedUnion("kind", [ | ||
| z.strictObject({ | ||
| kind: z.literal("source"), | ||
| id: NonEmptyStringSchema, | ||
| }), | ||
| z.strictObject({ | ||
| kind: z.literal("artifact"), | ||
| id: NonEmptyStringSchema, | ||
| }), | ||
| ]); | ||
| export const GitCacheSectionSpecSchema = z.strictObject({ | ||
| label: NonEmptyStringSchema, | ||
| root: GitCacheSectionRootSchema, | ||
| patterns: z.array(NonEmptyStringSchema).min(1), | ||
| }); | ||
| export const GitCacheSpecSchema = z | ||
| .strictObject({ | ||
| schemaVersion: z.literal(1).optional(), | ||
| title: NonEmptyStringSchema, | ||
| service: NonEmptyStringSchema, | ||
| envVar: NonEmptyStringSchema, | ||
| defaultCacheSubdir: NonEmptyStringSchema, | ||
| initMessage: NonEmptyStringSchema, | ||
| updateTool: GitCacheUpdateToolConfigSchema, | ||
| statusTool: GitCacheToolConfigSchema, | ||
| searchTool: GitCacheSearchToolConfigSchema, | ||
| sources: z.array(GitCacheSourceSpecSchema).min(1), | ||
| artifacts: z.array(GitCacheArtifactSpecSchema).optional(), | ||
| sections: z.record(NonEmptyStringSchema, GitCacheSectionSpecSchema), | ||
| readySource: NonEmptyStringSchema.optional(), | ||
| }) | ||
| .superRefine((spec, context) => { | ||
| const sourceIds = new Set<string>(); | ||
| for (const [index, source] of spec.sources.entries()) { | ||
| if (sourceIds.has(source.id)) { | ||
| context.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: `Duplicate source id '${source.id}'.`, | ||
| path: ["sources", index, "id"], | ||
| }); | ||
| } | ||
| sourceIds.add(source.id); | ||
| } | ||
| const readySources = spec.sources.filter((source) => source.ready === true); | ||
| if (readySources.length > 1) { | ||
| context.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: "Only one source can be marked as ready.", | ||
| path: ["sources"], | ||
| }); | ||
| } | ||
| const artifacts = spec.artifacts ?? []; | ||
| const artifactIds = new Set<string>(); | ||
| for (const [index, artifact] of artifacts.entries()) { | ||
| if (!sourceIds.has(artifact.source)) { | ||
| context.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: `Artifact '${artifact.id}' references missing source '${artifact.source}'.`, | ||
| path: ["artifacts", index, "source"], | ||
| }); | ||
| } | ||
| if (artifactIds.has(artifact.id)) { | ||
| context.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: `Duplicate artifact id '${artifact.id}'.`, | ||
| path: ["artifacts", index, "id"], | ||
| }); | ||
| } | ||
| artifactIds.add(artifact.id); | ||
| } | ||
| const scopes = Object.keys(spec.sections); | ||
| if (scopes.length === 0) { | ||
| context.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: "At least one section is required.", | ||
| path: ["sections"], | ||
| }); | ||
| } | ||
| for (const scope of scopes) { | ||
| const section = spec.sections[scope]; | ||
| if (section.root.kind === "source" && !sourceIds.has(section.root.id)) { | ||
| context.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: `Section '${scope}' references missing source '${section.root.id}'.`, | ||
| path: ["sections", scope, "root", "id"], | ||
| }); | ||
| } | ||
| if (section.root.kind === "artifact" && !artifactIds.has(section.root.id)) { | ||
| context.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: `Section '${scope}' references missing artifact '${section.root.id}'.`, | ||
| path: ["sections", scope, "root", "id"], | ||
| }); | ||
| } | ||
| } | ||
| if (spec.readySource != null && !sourceIds.has(spec.readySource)) { | ||
| context.addIssue({ | ||
| code: z.ZodIssueCode.custom, | ||
| message: `readySource '${spec.readySource}' does not exist.`, | ||
| path: ["readySource"], | ||
| }); | ||
| } | ||
| }); | ||
| export function defineGitCacheSpec<const TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| ): GitCacheSpec<TScope> { | ||
| return GitCacheSpecSchema.parse(spec) as GitCacheSpec<TScope>; | ||
| } | ||
| export function resolveReadySourceId<TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| ): string { | ||
| if (spec.readySource != null) { | ||
| return spec.readySource; | ||
| } | ||
| const markedReadySource = spec.sources.find((source) => source.ready === true); | ||
| if (markedReadySource != null) { | ||
| return markedReadySource.id; | ||
| } | ||
| return spec.sources[0]!.id; | ||
| } | ||
| export function getGitCacheScopes<const TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| ): TScope[] { | ||
| return Object.keys(spec.sections) as TScope[]; | ||
| } |
| import { mkdir, readFile, writeFile } from "node:fs/promises"; | ||
| import { dirname } from "node:path"; | ||
| import * as z from "zod"; | ||
| import type { GitCacheSpec } from "./git-cache-schema"; | ||
| const NullableIsoDateTimeSchema = z.iso.datetime().nullable(); | ||
| export const GIT_CACHE_STATE_FILE = "cache-state.json"; | ||
| export const GIT_CACHE_INDEX_FILE = "search-index.json"; | ||
| export const GitCacheSourceStateSchema = z.strictObject({ | ||
| directory: z.string(), | ||
| revision: z.string().nullable(), | ||
| syncedAt: NullableIsoDateTimeSchema, | ||
| ready: z.boolean(), | ||
| message: z.string().nullable(), | ||
| }); | ||
| export const GitCacheArtifactStateSchema = z.strictObject({ | ||
| directory: z.string(), | ||
| builtAt: NullableIsoDateTimeSchema, | ||
| ready: z.boolean(), | ||
| message: z.string().nullable(), | ||
| }); | ||
| export const GitCacheStateSchema = z.strictObject({ | ||
| schemaVersion: z.literal(1), | ||
| cacheDir: z.string(), | ||
| stateFile: z.string(), | ||
| indexFile: z.string(), | ||
| updatedAt: NullableIsoDateTimeSchema, | ||
| indexedAt: NullableIsoDateTimeSchema, | ||
| sources: z.record(z.string(), GitCacheSourceStateSchema), | ||
| artifacts: z.record(z.string(), GitCacheArtifactStateSchema), | ||
| warnings: z.array(z.string()), | ||
| }); | ||
| export type GitCacheState = z.infer<typeof GitCacheStateSchema>; | ||
| export type GitCacheSourceState = z.infer<typeof GitCacheSourceStateSchema>; | ||
| export type GitCacheArtifactState = z.infer<typeof GitCacheArtifactStateSchema>; | ||
| export interface GitCacheFreshness { | ||
| ageSeconds: number; | ||
| fresh: boolean; | ||
| hoursAgo: number; | ||
| timestamp: number | null; | ||
| } | ||
| export function getGitCacheIndexFile(cacheDir: string): string { | ||
| return `${cacheDir}/${GIT_CACHE_INDEX_FILE}`; | ||
| } | ||
| export function getGitCacheStateFile(cacheDir: string): string { | ||
| return `${cacheDir}/${GIT_CACHE_STATE_FILE}`; | ||
| } | ||
| export function createInitialGitCacheState<TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| cacheDir: string, | ||
| input: { | ||
| getSourceDir: (sourceId: string) => string; | ||
| getArtifactDir: (artifactId: string) => string; | ||
| }, | ||
| ): GitCacheState { | ||
| const stateFile = getGitCacheStateFile(cacheDir); | ||
| const indexFile = getGitCacheIndexFile(cacheDir); | ||
| const sources = Object.fromEntries( | ||
| spec.sources.map((source) => [ | ||
| source.id, | ||
| { | ||
| directory: input.getSourceDir(source.id), | ||
| revision: null, | ||
| syncedAt: null, | ||
| ready: false, | ||
| message: null, | ||
| }, | ||
| ]), | ||
| ); | ||
| const artifacts = Object.fromEntries( | ||
| (spec.artifacts ?? []).map((artifact) => [ | ||
| artifact.id, | ||
| { | ||
| directory: input.getArtifactDir(artifact.id), | ||
| builtAt: null, | ||
| ready: false, | ||
| message: null, | ||
| }, | ||
| ]), | ||
| ); | ||
| return { | ||
| schemaVersion: 1, | ||
| cacheDir, | ||
| stateFile, | ||
| indexFile, | ||
| updatedAt: null, | ||
| indexedAt: null, | ||
| sources, | ||
| artifacts, | ||
| warnings: [], | ||
| }; | ||
| } | ||
| export async function readGitCacheState(stateFile: string): Promise<GitCacheState | null> { | ||
| try { | ||
| const content = await readFile(stateFile, "utf8"); | ||
| const parsed = JSON.parse(content); | ||
| const result = GitCacheStateSchema.safeParse(parsed); | ||
| return result.success ? result.data : null; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| export async function writeGitCacheState(stateFile: string, state: GitCacheState): Promise<void> { | ||
| await mkdir(dirname(stateFile), { recursive: true }); | ||
| await writeFile(stateFile, JSON.stringify(state, null, 2), "utf8"); | ||
| } | ||
| export async function loadGitCacheState<TScope extends string>( | ||
| spec: GitCacheSpec<TScope>, | ||
| cacheDir: string, | ||
| input: { | ||
| getSourceDir: (sourceId: string) => string; | ||
| getArtifactDir: (artifactId: string) => string; | ||
| }, | ||
| ): Promise<GitCacheState> { | ||
| const stateFile = getGitCacheStateFile(cacheDir); | ||
| return (await readGitCacheState(stateFile)) | ||
| ?? createInitialGitCacheState(spec, cacheDir, input); | ||
| } | ||
| export function getGitCacheFreshness( | ||
| timestamp: string | null, | ||
| maxAgeSeconds: number, | ||
| ): GitCacheFreshness { | ||
| if (timestamp == null) { | ||
| return { | ||
| ageSeconds: 0, | ||
| fresh: false, | ||
| hoursAgo: 0, | ||
| timestamp: null, | ||
| }; | ||
| } | ||
| const parsed = Date.parse(timestamp); | ||
| if (Number.isNaN(parsed)) { | ||
| return { | ||
| ageSeconds: 0, | ||
| fresh: false, | ||
| hoursAgo: 0, | ||
| timestamp: null, | ||
| }; | ||
| } | ||
| const ageSeconds = Math.max(0, Math.floor((Date.now() - parsed) / 1000)); | ||
| return { | ||
| ageSeconds, | ||
| fresh: ageSeconds < maxAgeSeconds, | ||
| hoursAgo: Math.floor(ageSeconds / 3600), | ||
| timestamp: Math.floor(parsed / 1000), | ||
| }; | ||
| } |
+9
-7
| { | ||
| "name": "@yriveiro/opencode-cache-core", | ||
| "version": "0.2.0", | ||
| "version": "0.3.0", | ||
| "type": "module", | ||
@@ -14,4 +14,3 @@ "main": "dist/index.js", | ||
| }, | ||
| "opencodePlugin": true, | ||
| "description": "OpenCode plugin for cache and search via DevDocs", | ||
| "description": "Git-first cache runtime and search utilities for OpenCode plugins", | ||
| "author": "yriveiro", | ||
@@ -28,5 +27,6 @@ "license": "MIT", | ||
| "keywords": [ | ||
| "opencode-plugin", | ||
| "opencode", | ||
| "cache", | ||
| "search" | ||
| "search", | ||
| "git" | ||
| ], | ||
@@ -38,3 +38,4 @@ "files": [ | ||
| "peerDependencies": { | ||
| "@opencode-ai/plugin": "^1.3.17" | ||
| "@opencode-ai/plugin": "^1.3.17", | ||
| "zod": "^4.1.8" | ||
| }, | ||
@@ -58,4 +59,5 @@ "scripts": { | ||
| "tailwindcss": "^4.2.2", | ||
| "typescript": "^5.9.3" | ||
| "typescript": "^5.9.3", | ||
| "zod": "^4.3.6" | ||
| } | ||
| } |
+265
-56
| # OpenCode Cache Core | ||
| Concrete OpenCode local plugin package for cache indexing and search. | ||
| Git-first cache runtime and search utilities for OpenCode plugins. | ||
| ## Repository layout | ||
| `@yriveiro/opencode-cache-core` is for plugins that keep a local cache of external projects in Git checkouts, build optional derived artifacts from those checkouts, and search that cache locally. | ||
| - `src/`: plugin source files and type shims. | ||
| - `dist/`: generated build output. | ||
| - `docs/`: documentation source files. | ||
| - `docs-dist/`: generated documentation site. | ||
| - `tests/`: test modules and fixtures. | ||
| ## Quick Start | ||
| The package default export returns a plugin object with a `tools` map containing the cache tools and a compatibility `permission.ask` hook that auto-allows access to the configured cache directory. | ||
| ```ts | ||
| import { createGitCachePlugin, defineGitCacheSpec } from "@yriveiro/opencode-cache-core"; | ||
| ## Tools | ||
| const SPEC = defineGitCacheSpec({ | ||
| schemaVersion: 1, | ||
| title: "Example Cache", | ||
| service: "example-cache", | ||
| envVar: "OPENCODE_EXAMPLE_CACHE_DIR", | ||
| defaultCacheSubdir: "example", | ||
| initMessage: "example cache plugin initialized", | ||
| updateTool: { | ||
| name: "example-cache-update", | ||
| description: "Clone or refresh the local example cache.", | ||
| failureLabel: "Failed to update example cache", | ||
| successLogMessage: "example cache update completed", | ||
| }, | ||
| statusTool: { | ||
| name: "example-cache-status", | ||
| description: "Report cache freshness and search corpus status.", | ||
| }, | ||
| searchTool: { | ||
| name: "example-cache-search", | ||
| description: "Search cached example content locally.", | ||
| missingMessage: "Example cache is not initialized. Run example-cache-update first.", | ||
| failureLabel: "Failed to search example cache", | ||
| }, | ||
| sources: [ | ||
| { | ||
| id: "repo", | ||
| url: "https://github.com/example/project.git", | ||
| branch: "main", | ||
| ready: true, | ||
| }, | ||
| ], | ||
| artifacts: [ | ||
| { | ||
| id: "site", | ||
| source: "repo", | ||
| path: "dist/docs", | ||
| readiness: "index.html", | ||
| }, | ||
| ], | ||
| sections: { | ||
| docs: { | ||
| label: "Docs", | ||
| root: { kind: "source", id: "repo" }, | ||
| patterns: ["docs/**/*.md", "docs/**/*.mdx"], | ||
| }, | ||
| built: { | ||
| label: "Built docs", | ||
| root: { kind: "artifact", id: "site" }, | ||
| patterns: ["**/*.html"], | ||
| }, | ||
| }, | ||
| }); | ||
| - `cache_status`: reports cache/index readiness, freshness, and configured scopes. | ||
| - `cache_search`: searches the cached files across all scopes or a selected scope. | ||
| export default createGitCachePlugin({ | ||
| spec: SPEC, | ||
| }); | ||
| ``` | ||
| ## Compatibility permission hook | ||
| ## What The Package Handles | ||
| - `permission.ask`: compatibility hook that auto-allows permission requests for the configured cache directory when the host asks for external directory access. | ||
| - validate a cache spec | ||
| - resolve a local cache directory | ||
| - clone or update one or more Git sources | ||
| - track source and artifact state in `cache-state.json` | ||
| - build and persist `search-index.json` | ||
| - expose update, status, and search tools for OpenCode | ||
| - provide a runtime context for package-specific tools and status extensions | ||
| ## Configuration | ||
| ## Defining A Cache | ||
| The plugin reads configuration from the plugin context config object and from environment variables. Context config wins over env values. | ||
| ### Top-level spec fields | ||
| Supported fields: | ||
| - `title`: title used in notifications | ||
| - `service`: service name used for host logging | ||
| - `envVar`: environment variable that overrides the cache directory | ||
| - `defaultCacheSubdir`: default subdirectory under `~/.cache/opencode/skills` | ||
| - `initMessage`: initialization log message | ||
| - `updateTool`: name, description, failure label, and success log message | ||
| - `statusTool`: name and description | ||
| - `searchTool`: name, description, missing message, failure label, and optional scope description | ||
| - `readySource`: optional source ID used to decide whether the cache is initialized | ||
| - `cacheDir` / `OPENCODE_CACHE_DIR` | ||
| - `indexFile` / `OPENCODE_CACHE_INDEX_FILE` | ||
| - `readyPath` / `OPENCODE_CACHE_READY_PATH` | ||
| - `sections` / `OPENCODE_CACHE_SECTIONS_JSON` | ||
| - `maxAgeSeconds` / `OPENCODE_CACHE_MAX_AGE_SECONDS` | ||
| - `statusToolName` / `OPENCODE_CACHE_STATUS_TOOL_NAME` | ||
| - `searchToolName` / `OPENCODE_CACHE_SEARCH_TOOL_NAME` | ||
| ### Sources | ||
| `sections` must be a JSON object whose keys are scope names: | ||
| Each entry in `sources` describes one repository checkout. | ||
| ```json | ||
| { | ||
| "docs": { | ||
| "baseDir": "/absolute/path/to/cache/docs", | ||
| "patterns": ["**/*.md", "**/*.txt"] | ||
| }, | ||
| "code": { | ||
| "baseDir": "/absolute/path/to/cache/code", | ||
| "patterns": ["**/*.ts", "**/*.tsx", "**/*.js"] | ||
| } | ||
| } | ||
| ``` | ||
| - `id`: stable identifier used by artifacts and sections | ||
| - `url`: Git repository URL | ||
| - `branch`: branch to clone and refresh | ||
| - `directory`: optional directory name under the cache root; defaults to `id` | ||
| - `sparse`: optional sparse-checkout paths | ||
| - `ready`: optional marker used when selecting the source that gates initialization | ||
| - `label`: optional display name in status output | ||
| Defaults: | ||
| ### Artifacts | ||
| - `cacheDir`: `~/.cache/opencode-cache` | ||
| - `indexFile`: `<cacheDir>/.opencode-plugin/index.json` | ||
| - `readyPath`: `<cacheDir>/.opencode-plugin/ready` | ||
| - `sections`: `{ cache: { baseDir: cacheDir, patterns: ["**/*"] } }` | ||
| - `maxAgeSeconds`: `86400` | ||
| Each entry in `artifacts` describes a build output rooted in a source checkout. | ||
| ## Build | ||
| - `id`: stable identifier used by sections | ||
| - `source`: source ID that owns the artifact | ||
| - `path`: path relative to the source directory | ||
| - `readiness`: optional file or directory checked by `runtime.isArtifactReady()` | ||
| - `label`: optional display name in status output | ||
| ```sh | ||
| bun run build | ||
| ### Sections | ||
| Each entry in `sections` becomes a search scope. | ||
| - `label`: display label used in status output | ||
| - `root`: either `{ kind: "source", id: "..." }` or `{ kind: "artifact", id: "..." }` | ||
| - `patterns`: file globs included in the search index for that scope | ||
| ## Files On Disk | ||
| The cache directory resolves as: | ||
| - `process.env[spec.envVar]`, when set | ||
| - otherwise `~/.cache/opencode/skills/<spec.defaultCacheSubdir>` | ||
| Inside that directory, the runtime writes: | ||
| ```text | ||
| <cacheDir>/ | ||
| <source.directory ?? source.id>/ | ||
| <source.directory ?? source.id>/<artifact.path>/ | ||
| cache-state.json | ||
| search-index.json | ||
| ``` | ||
| The source entrypoint is `src/index.ts`. | ||
| The compiled plugin entrypoint is `dist/index.js`, which matches `package.json#main`. | ||
| `cache-state.json` contains: | ||
| ## Documentation site | ||
| - `updatedAt` and `indexedAt` | ||
| - one state record per source | ||
| - one state record per artifact | ||
| - per-source revision, sync timestamp, readiness, and message | ||
| - per-artifact build timestamp, readiness, and message | ||
| - warning strings collected by the runtime | ||
| The repo includes a static documentation site under `docs/`. | ||
| `search-index.json` contains: | ||
| The documentation is provided as a pre-built HTML file (`docs/index.html`) and does not require a build process. | ||
| - the index creation timestamp | ||
| - the resolved cache directory | ||
| - one file list per section/scope | ||
| Documentation coverage includes: | ||
| ## Generated Plugin Behavior | ||
| - feature and architecture overview | ||
| - configuration and environment-variable mapping | ||
| - tool behavior and compatibility hooks | ||
| - a code walkthrough with explanation next to live source excerpts | ||
| - Mermaid diagrams for lifecycle and request flow | ||
| ### Update tool | ||
| The update tool: | ||
| - ensures the cache directory exists | ||
| - syncs every source defined in the spec | ||
| - updates `cache-state.json` | ||
| - rebuilds `search-index.json` | ||
| - sends a notification to the active session | ||
| If the ready source is already present and the cache is still fresh, the tool returns the current cache summary unless `force` is set. | ||
| ### Status tool | ||
| The status tool reports: | ||
| - cache directory | ||
| - state file path | ||
| - search index path | ||
| - freshness and last update time | ||
| - source readiness and revisions | ||
| - artifact readiness and build times | ||
| - indexed file counts per section | ||
| `extendStatus(runtime)` can append package-specific lines. | ||
| ### Search tool | ||
| The generated search tool supports: | ||
| - `query` | ||
| - `scope` | ||
| - `regex` | ||
| - `case_sensitive` | ||
| - `limit` | ||
| It loads the persisted index on demand, runs a scoped search, formats hits as text, and sends the same result back to the session as a notification. | ||
| The lower-level `searchGitCacheIndex()` helper returns structured search results when a plugin needs the raw data instead of the formatted tool output. | ||
| ### Permission hook | ||
| The generated plugin also registers `permission.ask` for the resolved cache directory. It only allows `external_directory` requests whose title or patterns include that cache path. | ||
| ## Runtime Context | ||
| `extraTools(runtime)` and `extendStatus(runtime)` receive a `GitCacheRuntimeContext`. | ||
| Useful properties and methods: | ||
| ### State and index | ||
| - `cacheDir` | ||
| - `stateFile` | ||
| - `indexFile` | ||
| - `readState()` | ||
| - `writeState()` | ||
| - `updateState()` | ||
| - `loadIndex()` | ||
| - `refreshIndex()` | ||
| - `formatIndexCounts()` | ||
| ### Paths and readiness | ||
| - `getSourceDir()` | ||
| - `getArtifactDir()` | ||
| - `getArtifactReadinessPath()` | ||
| - `isSourceReady()` | ||
| - `isArtifactReady()` | ||
| - `getSourceRevision()` | ||
| - `syncSources()` | ||
| ### Host and subprocess helpers | ||
| - `notify()` | ||
| - `log()` | ||
| - `runCommand()` | ||
| - `formatCommandFailure()` | ||
| Typical downstream uses: | ||
| - run a docs build after updating sources | ||
| - mark an artifact as ready after generating output | ||
| - refresh the search index after a build step | ||
| - append extra revision/build details to the status tool | ||
| ## Key Exports | ||
| High-level exports: | ||
| - `defineGitCacheSpec` | ||
| - `createGitCachePlugin` | ||
| - `buildGitCacheSearchIndex` | ||
| - `searchGitCacheIndex` | ||
| State helpers: | ||
| - `createInitialGitCacheState` | ||
| - `loadGitCacheState` | ||
| - `readGitCacheState` | ||
| - `writeGitCacheState` | ||
| - `getGitCacheFreshness` | ||
| Path helpers: | ||
| - `getGitSourceDir` | ||
| - `getGitArtifactDir` | ||
| - `getGitArtifactReadinessPath` | ||
| - `getGitSectionBaseDir` | ||
| - `getGitCacheIndexPath` | ||
| Tool helpers: | ||
| - `createStatusTool` | ||
| - `createSearchTool` | ||
| - `createPermissionHandler` | ||
| - `buildNotification` | ||
| - `createNotificationSender` | ||
| ## Validation | ||
| Run the core checks with Bun: | ||
| ```sh | ||
| bun run typecheck | ||
| bun test | ||
| bun run build | ||
| ``` | ||
| ## Docs | ||
| - `README.md`: package guide | ||
| - `docs/index.html`: browsable docs page using the same content model | ||
| - `tests/git-cache-core.test.ts`: focused tests for spec validation, paths, freshness, search, and permission handling |
+54
-35
| export { | ||
| ALL_SCOPE, | ||
| type Freshness, | ||
| type Index, | ||
@@ -14,43 +13,63 @@ type PermissionRequest, | ||
| export { | ||
| createIndexLoader, | ||
| loadIndex, | ||
| pathExists, | ||
| readFreshness, | ||
| readIndex, | ||
| writeIndex, | ||
| writeTimestamp, | ||
| } from "./storage"; | ||
| export { searchIndex } from "./search"; | ||
| export { | ||
| type CommandResult, | ||
| DEFAULT_GIT_CACHE_MAX_AGE_SECONDS, | ||
| buildGitCacheSearchIndex, | ||
| createGitCachePlugin, | ||
| formatCommandFailure, | ||
| formatGitCacheIndexCounts, | ||
| searchGitCacheIndex, | ||
| type GitCachePluginOptions, | ||
| type GitCacheRuntimeContext, | ||
| } from "./git-cache-plugin"; | ||
| export { | ||
| defineGitCacheSpec, | ||
| getGitCacheScopes, | ||
| resolveReadySourceId, | ||
| GitCacheSpecSchema, | ||
| GitCacheSourceSpecSchema, | ||
| GitCacheArtifactSpecSchema, | ||
| GitCacheSectionSpecSchema, | ||
| type GitCacheArtifactSpec, | ||
| type GitCacheSearchToolConfig, | ||
| type GitCacheSectionRoot, | ||
| type GitCacheSectionSpec, | ||
| type GitCacheSourceSpec, | ||
| type GitCacheSpec, | ||
| type GitCacheToolConfig, | ||
| type GitCacheUpdateToolConfig, | ||
| } from "./git-cache-schema"; | ||
| export { | ||
| GIT_CACHE_INDEX_FILE, | ||
| GIT_CACHE_STATE_FILE, | ||
| GitCacheArtifactStateSchema, | ||
| GitCacheSourceStateSchema, | ||
| GitCacheStateSchema, | ||
| createInitialGitCacheState, | ||
| getGitCacheFreshness, | ||
| getGitCacheIndexFile, | ||
| getGitCacheStateFile, | ||
| loadGitCacheState, | ||
| readGitCacheState, | ||
| writeGitCacheState, | ||
| type GitCacheArtifactState, | ||
| type GitCacheFreshness, | ||
| type GitCacheSourceState, | ||
| type GitCacheState, | ||
| } from "./git-cache-state"; | ||
| export { | ||
| getGitArtifactDir, | ||
| getGitArtifactReadinessPath, | ||
| getGitCacheIndexPath, | ||
| getGitSectionBaseDir, | ||
| getGitSourceDir, | ||
| getGitSourceDirectoryName, | ||
| } from "./git-cache-paths"; | ||
| export { buildNotification, createNotificationSender } from "./notifications"; | ||
| export { createPermissionHandler } from "./permissions"; | ||
| export { createSearchTool, createStatusTool } from "./tools"; | ||
| export { | ||
| DEFAULT_MAX_AGE_SECONDS, | ||
| MARKER_FILE, | ||
| SEARCH_INDEX_FILE, | ||
| buildSearchIndex as buildRepositorySearchIndex, | ||
| createRepositoryCachePlugin, | ||
| createRepositoryCacheSearchTool, | ||
| formatCommandFailure, | ||
| formatIndexCounts as formatRepositoryIndexCounts, | ||
| getMarkerFile, | ||
| getRepositoryDir, | ||
| getSearchIndexFile, | ||
| readSearchIndex as readRepositorySearchIndex, | ||
| refreshSearchIndex as refreshRepositorySearchIndex, | ||
| resolveCacheDir, | ||
| runCommand, | ||
| searchCacheIndex, | ||
| syncRepository, | ||
| writeSearchIndex as writeRepositorySearchIndex, | ||
| type CommandResult, | ||
| type RepositoryConfig, | ||
| type SearchToolConfig, | ||
| type SectionConfig, | ||
| type SectionInfo, | ||
| type ThinCachePluginConfig, | ||
| type ToolConfig, | ||
| type UpdateToolConfig, | ||
| } from "./repository-cache"; | ||
| export { createPluginHooks } from "./plugin"; | ||
| export { default } from "./plugin"; |
@@ -1,2 +0,2 @@ | ||
| import type { ClientLike } from "./types"; | ||
| import type { PluginInput } from "@opencode-ai/plugin"; | ||
@@ -8,3 +8,3 @@ export function buildNotification(title: string, output: string): string { | ||
| export function createNotificationSender(input: { | ||
| client: ClientLike; | ||
| client: PluginInput["client"]; | ||
| service: string; | ||
@@ -11,0 +11,0 @@ }): (sessionID: string, message: string) => Promise<void> { |
@@ -1,3 +0,5 @@ | ||
| import type { PermissionAskLike, PermissionRequest } from "./types"; | ||
| import type { Hooks } from "@opencode-ai/plugin"; | ||
| import type { PermissionRequest } from "./types"; | ||
| function matchesPermission( | ||
@@ -25,3 +27,3 @@ cacheDir: string, | ||
| cacheDir: string, | ||
| ): PermissionAskLike { | ||
| ): NonNullable<Hooks["permission.ask"]> { | ||
| return async ( | ||
@@ -28,0 +30,0 @@ input, |
+0
-75
| import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; | ||
| import { dirname } from "node:path"; | ||
| import type { Freshness } from "./types"; | ||
| export async function pathExists(path: string): Promise<boolean> { | ||
@@ -15,40 +13,2 @@ try { | ||
| export async function readFreshness( | ||
| markerFile: string, | ||
| maxAgeSeconds: number, | ||
| nowSeconds = Math.floor(Date.now() / 1000), | ||
| ): Promise<Freshness> { | ||
| try { | ||
| const content = await readFile(markerFile, "utf8"); | ||
| const timestamp = Number.parseInt(content.trim(), 10); | ||
| if (!Number.isFinite(timestamp)) { | ||
| throw new Error("Invalid cache timestamp"); | ||
| } | ||
| const ageSeconds = nowSeconds - timestamp; | ||
| return { | ||
| ageSeconds, | ||
| fresh: ageSeconds < maxAgeSeconds, | ||
| hoursAgo: Math.floor(ageSeconds / 3600), | ||
| timestamp, | ||
| }; | ||
| } catch { | ||
| return { | ||
| ageSeconds: -1, | ||
| fresh: false, | ||
| hoursAgo: -1, | ||
| timestamp: null, | ||
| }; | ||
| } | ||
| } | ||
| export async function writeTimestamp( | ||
| markerFile: string, | ||
| timestamp = Math.floor(Date.now() / 1000), | ||
| ): Promise<void> { | ||
| await mkdir(dirname(markerFile), { recursive: true }); | ||
| await writeFile(markerFile, `${timestamp}\n`, "utf8"); | ||
| } | ||
| export async function readIndex<TIndex>( | ||
@@ -73,36 +33,1 @@ indexFile: string, | ||
| } | ||
| export async function loadIndex<TIndex>(input: { | ||
| indexFile: string; | ||
| readyPath: string; | ||
| createIndex: () => Promise<TIndex>; | ||
| persistIndex?: (index: TIndex) => Promise<void>; | ||
| }): Promise<TIndex | null> { | ||
| const storedIndex = await readIndex<TIndex>(input.indexFile); | ||
| if (storedIndex != null) { | ||
| return storedIndex; | ||
| } | ||
| const ready = await pathExists(input.readyPath); | ||
| if (!ready) { | ||
| return null; | ||
| } | ||
| const index = await input.createIndex(); | ||
| if (input.persistIndex != null) { | ||
| await input.persistIndex(index); | ||
| } else { | ||
| await writeIndex(input.indexFile, index); | ||
| } | ||
| return index; | ||
| } | ||
| export function createIndexLoader<TIndex>(input: { | ||
| indexFile: string; | ||
| readyPath: string; | ||
| createIndex: () => Promise<TIndex>; | ||
| persistIndex?: (index: TIndex) => Promise<void>; | ||
| }): () => Promise<TIndex | null> { | ||
| return async (): Promise<TIndex | null> => loadIndex(input); | ||
| } |
+55
-34
@@ -8,8 +8,17 @@ import { | ||
| import { buildNotification } from "./notifications"; | ||
| import type { | ||
| ALL_SCOPE, | ||
| SearchResult, | ||
| ToolDefinitionLike, | ||
| } from "./types"; | ||
| import { ALL_SCOPE, type SearchResult } from "./types"; | ||
| function formatErrorMessage(prefix: string, error: unknown): string { | ||
| const details = error instanceof Error ? error.message : String(error); | ||
| return `${prefix}: ${details}`; | ||
| } | ||
| function buildSearchScopeDescription(input: { | ||
| scopeValues: readonly string[]; | ||
| scopeDescription?: string; | ||
| }): string { | ||
| return input.scopeDescription | ||
| ?? `Search scope: ${input.scopeValues.join(", ")}.`; | ||
| } | ||
| function formatSearchOutput<TScope extends string>( | ||
@@ -56,8 +65,2 @@ query: string, | ||
| export function asToolDefinitionLike( | ||
| toolDefinition: ToolDefinition, | ||
| ): ToolDefinitionLike { | ||
| return toolDefinition as unknown as ToolDefinitionLike; | ||
| } | ||
| export function createSearchTool<TIndex, TScope extends string>(input: { | ||
@@ -68,2 +71,4 @@ description: string; | ||
| scopeValues: readonly [typeof ALL_SCOPE, ...TScope[]]; | ||
| scopeDescription?: string; | ||
| failureLabel?: string; | ||
| loadIndex: () => Promise<TIndex | null>; | ||
@@ -89,3 +94,8 @@ search: (input: { | ||
| .optional() | ||
| .describe(`Search scope: ${input.scopeValues.join(", ")}.`), | ||
| .describe( | ||
| buildSearchScopeDescription({ | ||
| scopeValues: input.scopeValues, | ||
| scopeDescription: input.scopeDescription, | ||
| }), | ||
| ), | ||
| regex: tool.schema | ||
@@ -108,30 +118,41 @@ .boolean() | ||
| async execute(args, context: ToolContext) { | ||
| const index = await input.loadIndex(); | ||
| if (index == null) { | ||
| try { | ||
| const index = await input.loadIndex(); | ||
| if (index == null) { | ||
| await input.sendNotification( | ||
| context.sessionID, | ||
| buildNotification(input.notificationTitle, input.missingOutput), | ||
| ); | ||
| return input.missingOutput; | ||
| } | ||
| const output = formatSearchOutput( | ||
| args.query, | ||
| await input.search({ | ||
| index, | ||
| query: args.query, | ||
| scope: args.scope, | ||
| regex: args.regex, | ||
| caseSensitive: args.case_sensitive, | ||
| limit: args.limit, | ||
| }), | ||
| ); | ||
| await input.sendNotification( | ||
| context.sessionID, | ||
| buildNotification(input.notificationTitle, input.missingOutput), | ||
| buildNotification(input.notificationTitle, output), | ||
| ); | ||
| return input.missingOutput; | ||
| return output; | ||
| } catch (error: unknown) { | ||
| const message = input.failureLabel == null | ||
| ? formatErrorMessage("Invalid search query", error) | ||
| : formatErrorMessage(input.failureLabel, error); | ||
| await input.sendNotification( | ||
| context.sessionID, | ||
| buildNotification(input.notificationTitle, message), | ||
| ); | ||
| return message; | ||
| } | ||
| const output = formatSearchOutput( | ||
| args.query, | ||
| await input.search({ | ||
| index, | ||
| query: args.query, | ||
| scope: args.scope, | ||
| regex: args.regex, | ||
| caseSensitive: args.case_sensitive, | ||
| limit: args.limit, | ||
| }), | ||
| ); | ||
| await input.sendNotification( | ||
| context.sessionID, | ||
| buildNotification(input.notificationTitle, output), | ||
| ); | ||
| return output; | ||
| }, | ||
| }); | ||
| } |
+0
-56
@@ -26,9 +26,2 @@ export const ALL_SCOPE = "all" as const; | ||
| export interface Freshness { | ||
| ageSeconds: number; | ||
| fresh: boolean; | ||
| hoursAgo: number; | ||
| timestamp: number | null; | ||
| } | ||
| export interface SearchOptions<TScope extends string = string> { | ||
@@ -52,50 +45,1 @@ scope?: TScope | typeof ALL_SCOPE; | ||
| } | ||
| export interface ClientLike { | ||
| app: { | ||
| log(input: unknown): Promise<unknown> | unknown; | ||
| }; | ||
| session: { | ||
| prompt(input: unknown): Promise<unknown> | unknown; | ||
| }; | ||
| } | ||
| export interface ToolContextLike { | ||
| sessionID: string; | ||
| } | ||
| export interface ToolDefinitionLike { | ||
| description: string; | ||
| args: Readonly<Record<string, object>>; | ||
| execute( | ||
| args: Record<string, unknown>, | ||
| context: ToolContextLike, | ||
| ): Promise<string>; | ||
| } | ||
| export interface PermissionOutputLike { | ||
| status?: string; | ||
| } | ||
| export type PermissionAskLike = ( | ||
| input: PermissionRequest, | ||
| output: PermissionOutputLike, | ||
| ) => Promise<void>; | ||
| export interface HooksLike { | ||
| tool?: Record<string, ToolDefinitionLike>; | ||
| "permission.ask"?: PermissionAskLike; | ||
| } | ||
| export interface PluginInputLike { | ||
| client: ClientLike; | ||
| directory: string; | ||
| config?: unknown; | ||
| env?: unknown; | ||
| cwd?: unknown; | ||
| } | ||
| export type PluginLike = ( | ||
| context: PluginInputLike, | ||
| options?: unknown, | ||
| ) => Promise<HooksLike>; |
-451
| import { homedir } from "node:os"; | ||
| import { isAbsolute, join, resolve } from "node:path"; | ||
| import type { Hooks, Plugin, PluginInput, PluginOptions } from "@opencode-ai/plugin"; | ||
| import { buildIndex } from "./indexing"; | ||
| import { createNotificationSender } from "./notifications"; | ||
| import { createPermissionHandler } from "./permissions"; | ||
| import { searchIndex } from "./search"; | ||
| import { | ||
| createIndexLoader, | ||
| pathExists, | ||
| readFreshness, | ||
| writeIndex, | ||
| } from "./storage"; | ||
| import { asToolDefinitionLike, createSearchTool, createStatusTool } from "./tools"; | ||
| import { | ||
| ALL_SCOPE, | ||
| type ClientLike, | ||
| type HooksLike, | ||
| type Index, | ||
| type PluginInputLike, | ||
| type SearchResult, | ||
| type SectionDefinition, | ||
| } from "./types"; | ||
| interface PluginConfig { | ||
| cacheDir?: string; | ||
| indexFile?: string; | ||
| readyPath?: string; | ||
| sections?: Record<string, SectionDefinition>; | ||
| maxAgeSeconds?: number; | ||
| statusToolName?: string; | ||
| searchToolName?: string; | ||
| } | ||
| interface ResolvedPluginConfig { | ||
| cacheDir: string; | ||
| indexFile: string; | ||
| readyPath: string; | ||
| sections: Record<string, SectionDefinition>; | ||
| maxAgeSeconds: number; | ||
| statusToolName: string; | ||
| searchToolName: string; | ||
| } | ||
| type PluginContext = PluginInputLike & { | ||
| config?: PluginOptions; | ||
| env?: unknown; | ||
| cwd?: unknown; | ||
| }; | ||
| function isRecord(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === "object" && value !== null; | ||
| } | ||
| function readString(value: unknown): string | undefined { | ||
| return typeof value === "string" && value.length > 0 ? value : undefined; | ||
| } | ||
| function readInteger(value: unknown): number | undefined { | ||
| return typeof value === "number" && Number.isInteger(value) && value > 0 | ||
| ? value | ||
| : undefined; | ||
| } | ||
| function readStringArray(value: unknown): string[] | undefined { | ||
| if (!Array.isArray(value)) { | ||
| return undefined; | ||
| } | ||
| const entries = value | ||
| .map((entry) => readString(entry)) | ||
| .filter((entry): entry is string => entry != null); | ||
| return entries.length > 0 ? entries : undefined; | ||
| } | ||
| function resolvePath(pathValue: string, baseDir: string): string { | ||
| return isAbsolute(pathValue) ? pathValue : resolve(baseDir, pathValue); | ||
| } | ||
| function readSections( | ||
| value: unknown, | ||
| baseDir: string, | ||
| ): Record<string, SectionDefinition> | undefined { | ||
| if (!isRecord(value)) { | ||
| return undefined; | ||
| } | ||
| const sections = Object.entries(value).reduce< | ||
| Record<string, SectionDefinition> | ||
| >((accumulator, [scope, definition]) => { | ||
| if (!isRecord(definition)) { | ||
| return accumulator; | ||
| } | ||
| const sectionBaseDir = readString(definition.baseDir); | ||
| const patterns = readStringArray(definition.patterns); | ||
| if (sectionBaseDir == null || patterns == null) { | ||
| return accumulator; | ||
| } | ||
| accumulator[scope] = { | ||
| baseDir: resolvePath(sectionBaseDir, baseDir), | ||
| patterns, | ||
| }; | ||
| return accumulator; | ||
| }, {}); | ||
| return Object.keys(sections).length > 0 ? sections : undefined; | ||
| } | ||
| function readEnv(context: PluginContext): Record<string, string | undefined> { | ||
| const merged = { ...process.env }; | ||
| if (!isRecord(context.env)) { | ||
| return merged; | ||
| } | ||
| for (const [key, value] of Object.entries(context.env)) { | ||
| const envValue = typeof value === "string" ? value : undefined; | ||
| if (envValue !== undefined || value === undefined) { | ||
| merged[key] = envValue; | ||
| } | ||
| } | ||
| return merged; | ||
| } | ||
| function readConfig( | ||
| context: PluginContext, | ||
| workingDirectory: string, | ||
| ): PluginConfig { | ||
| if (!isRecord(context.config)) { | ||
| return {}; | ||
| } | ||
| const nestedConfig = isRecord(context.config.cache) | ||
| ? context.config.cache | ||
| : {}; | ||
| return { | ||
| cacheDir: readString(nestedConfig.cacheDir ?? context.config.cacheDir), | ||
| indexFile: readString(nestedConfig.indexFile ?? context.config.indexFile), | ||
| readyPath: readString(nestedConfig.readyPath ?? context.config.readyPath), | ||
| sections: readSections( | ||
| nestedConfig.sections ?? context.config.sections, | ||
| workingDirectory, | ||
| ), | ||
| maxAgeSeconds: readInteger( | ||
| nestedConfig.maxAgeSeconds ?? context.config.maxAgeSeconds, | ||
| ), | ||
| statusToolName: readString( | ||
| nestedConfig.statusToolName ?? context.config.statusToolName, | ||
| ), | ||
| searchToolName: readString( | ||
| nestedConfig.searchToolName ?? context.config.searchToolName, | ||
| ), | ||
| }; | ||
| } | ||
| function readEnvValue( | ||
| env: Record<string, string | undefined>, | ||
| ...keys: string[] | ||
| ): string | undefined { | ||
| for (const key of keys) { | ||
| const value = readString(env[key]); | ||
| if (value != null) { | ||
| return value; | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
| function readEnvSections( | ||
| env: Record<string, string | undefined>, | ||
| baseDir: string, | ||
| ): Record<string, SectionDefinition> | undefined { | ||
| const rawSections = readEnvValue( | ||
| env, | ||
| "OPENCODE_CACHE_SECTIONS_JSON", | ||
| "OC_CACHE_SECTIONS_JSON", | ||
| ); | ||
| if (rawSections == null) { | ||
| return undefined; | ||
| } | ||
| try { | ||
| return readSections(JSON.parse(rawSections), baseDir); | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
| function buildDefaultSections( | ||
| cacheDir: string, | ||
| ): Record<string, SectionDefinition> { | ||
| return { | ||
| cache: { | ||
| baseDir: cacheDir, | ||
| patterns: ["**/*"], | ||
| }, | ||
| }; | ||
| } | ||
| function buildScopeValues(scopes: string[]): [typeof ALL_SCOPE, ...string[]] { | ||
| return [ALL_SCOPE, ...scopes]; | ||
| } | ||
| function countIndexedFiles(index: Index<string>): number { | ||
| return Object.values(index.sections).reduce( | ||
| (total, section) => total + section.files.length, | ||
| 0, | ||
| ); | ||
| } | ||
| function buildStatusLines( | ||
| config: ResolvedPluginConfig, | ||
| ready: boolean, | ||
| index: Index<string> | null, | ||
| freshnessHours: number, | ||
| freshnessText: string, | ||
| ): string[] { | ||
| const lines = [ | ||
| `Cache directory: ${config.cacheDir}`, | ||
| `Index file: ${config.indexFile}`, | ||
| `Ready marker: ${config.readyPath}`, | ||
| `Ready marker present: ${ready ? "yes" : "no"}`, | ||
| `Freshness: ${freshnessText}`, | ||
| `Scopes: ${Object.keys(config.sections).join(", ")}`, | ||
| ]; | ||
| if (index == null) { | ||
| lines.push("Index status: unavailable"); | ||
| } else { | ||
| lines.push(`Index status: loaded (${countIndexedFiles(index)} files)`); | ||
| lines.push(`Index created at: ${index.createdAt}`); | ||
| } | ||
| for (const [scope, section] of Object.entries(config.sections)) { | ||
| lines.push( | ||
| `- ${scope}: ${section.baseDir} [${section.patterns.join(", ")}]`, | ||
| ); | ||
| } | ||
| if (freshnessHours >= 0) { | ||
| lines.push(`Freshness age hours: ${freshnessHours}`); | ||
| } | ||
| return lines; | ||
| } | ||
| function resolvePluginConfig(context: PluginContext): ResolvedPluginConfig { | ||
| const env = readEnv(context); | ||
| const workingDirectory = readString(context.cwd) ?? context.directory; | ||
| const config = readConfig(context, workingDirectory); | ||
| const defaultCacheDir = join(homedir(), ".cache", "opencode-cache"); | ||
| const cacheDir = resolvePath( | ||
| config.cacheDir ?? | ||
| readEnvValue(env, "OPENCODE_CACHE_DIR", "OC_CACHE_DIR") ?? | ||
| defaultCacheDir, | ||
| workingDirectory, | ||
| ); | ||
| const indexFile = resolvePath( | ||
| config.indexFile ?? | ||
| readEnvValue(env, "OPENCODE_CACHE_INDEX_FILE", "OC_CACHE_INDEX_FILE") ?? | ||
| join(cacheDir, ".opencode-plugin", "index.json"), | ||
| workingDirectory, | ||
| ); | ||
| const readyPath = resolvePath( | ||
| config.readyPath ?? | ||
| readEnvValue(env, "OPENCODE_CACHE_READY_PATH", "OC_CACHE_READY_PATH") ?? | ||
| join(cacheDir, ".opencode-plugin", "ready"), | ||
| workingDirectory, | ||
| ); | ||
| const sections = | ||
| config.sections ?? | ||
| readEnvSections(env, workingDirectory) ?? | ||
| buildDefaultSections(cacheDir); | ||
| const maxAgeSeconds = | ||
| config.maxAgeSeconds ?? | ||
| Number.parseInt( | ||
| readEnvValue( | ||
| env, | ||
| "OPENCODE_CACHE_MAX_AGE_SECONDS", | ||
| "OC_CACHE_MAX_AGE_SECONDS", | ||
| ) ?? "86400", | ||
| 10, | ||
| ); | ||
| return { | ||
| cacheDir, | ||
| indexFile, | ||
| readyPath, | ||
| sections, | ||
| maxAgeSeconds: | ||
| Number.isFinite(maxAgeSeconds) && maxAgeSeconds > 0 | ||
| ? maxAgeSeconds | ||
| : 86400, | ||
| statusToolName: | ||
| config.statusToolName ?? | ||
| readEnvValue( | ||
| env, | ||
| "OPENCODE_CACHE_STATUS_TOOL_NAME", | ||
| "OC_CACHE_STATUS_TOOL_NAME", | ||
| ) ?? | ||
| "cache_status", | ||
| searchToolName: | ||
| config.searchToolName ?? | ||
| readEnvValue( | ||
| env, | ||
| "OPENCODE_CACHE_SEARCH_TOOL_NAME", | ||
| "OC_CACHE_SEARCH_TOOL_NAME", | ||
| ) ?? | ||
| "cache_search", | ||
| }; | ||
| } | ||
| function resolveClient(context: PluginContext): ClientLike { | ||
| return context.client; | ||
| } | ||
| async function logInitialization(input: { | ||
| client: ClientLike; | ||
| service: string; | ||
| message: string; | ||
| }): Promise<void> { | ||
| await input.client.app.log({ | ||
| body: { | ||
| service: input.service, | ||
| level: "info", | ||
| message: input.message, | ||
| }, | ||
| }); | ||
| } | ||
| async function buildStatusOutput( | ||
| config: ResolvedPluginConfig, | ||
| loadIndex: () => Promise<Index<string> | null>, | ||
| ): Promise<string> { | ||
| const [ready, freshness, index] = await Promise.all([ | ||
| pathExists(config.readyPath), | ||
| readFreshness(config.readyPath, config.maxAgeSeconds), | ||
| loadIndex(), | ||
| ]); | ||
| const freshnessText = | ||
| freshness.timestamp == null | ||
| ? "missing" | ||
| : freshness.fresh | ||
| ? `fresh (${freshness.ageSeconds}s old)` | ||
| : `stale (${freshness.ageSeconds}s old)`; | ||
| return buildStatusLines( | ||
| config, | ||
| ready, | ||
| index, | ||
| freshness.hoursAgo, | ||
| freshnessText, | ||
| ).join("\n"); | ||
| } | ||
| export function createPluginHooks(input: { | ||
| client: ClientLike; | ||
| service: string; | ||
| initMessage: string; | ||
| tools: HooksLike["tool"]; | ||
| permissionAsk?: HooksLike["permission.ask"]; | ||
| }): HooksLike { | ||
| void logInitialization({ | ||
| client: input.client, | ||
| service: input.service, | ||
| message: input.initMessage, | ||
| }); | ||
| return { | ||
| tool: input.tools, | ||
| "permission.ask": input.permissionAsk, | ||
| }; | ||
| } | ||
| const createPlugin: Plugin = async ( | ||
| context: PluginInput, | ||
| options?: PluginOptions, | ||
| ): Promise<Hooks> => { | ||
| const pluginContext: PluginContext = { | ||
| ...context, | ||
| config: options, | ||
| }; | ||
| const config = resolvePluginConfig(pluginContext); | ||
| const client = resolveClient(pluginContext); | ||
| const sendNotification = createNotificationSender({ | ||
| client, | ||
| service: "opencode-cache-core-plugin", | ||
| }); | ||
| const loadIndex = createIndexLoader<Index<string>>({ | ||
| indexFile: config.indexFile, | ||
| readyPath: config.readyPath, | ||
| createIndex: async () => | ||
| buildIndex({ | ||
| cacheDir: config.cacheDir, | ||
| sections: config.sections, | ||
| }), | ||
| persistIndex: async (index) => writeIndex(config.indexFile, index), | ||
| }); | ||
| const scopes = Object.keys(config.sections); | ||
| const tools = { | ||
| [config.statusToolName]: asToolDefinitionLike(createStatusTool({ | ||
| description: | ||
| "Report cache index readiness, freshness, and configured scopes.", | ||
| notificationTitle: "Cache status", | ||
| buildOutput: async () => buildStatusOutput(config, loadIndex), | ||
| sendNotification, | ||
| })), | ||
| [config.searchToolName]: asToolDefinitionLike(createSearchTool<Index<string>, string>({ | ||
| description: | ||
| "Search the configured cache index by substring or regular expression.", | ||
| notificationTitle: "Cache search", | ||
| missingOutput: | ||
| "Cache index is unavailable. Confirm the ready marker path or build the cache index.", | ||
| scopeValues: buildScopeValues(scopes), | ||
| loadIndex, | ||
| search: async ({ | ||
| index, | ||
| query, | ||
| scope, | ||
| regex, | ||
| caseSensitive, | ||
| limit, | ||
| }): Promise<SearchResult<string>> => | ||
| searchIndex(index, query, { | ||
| scope, | ||
| regex, | ||
| caseSensitive, | ||
| limit, | ||
| }), | ||
| sendNotification, | ||
| })), | ||
| }; | ||
| return createPluginHooks({ | ||
| client, | ||
| service: "opencode-cache-core-plugin", | ||
| initMessage: `initialized cache plugin for ${config.cacheDir}`, | ||
| permissionAsk: createPermissionHandler(config.cacheDir), | ||
| tools, | ||
| }) as unknown as Hooks; | ||
| }; | ||
| export default createPlugin; |
| import { spawn } from "node:child_process"; | ||
| import { mkdir } from "node:fs/promises"; | ||
| import { homedir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { tool } from "@opencode-ai/plugin"; | ||
| import { buildIndex } from "./indexing"; | ||
| import { buildNotification, createNotificationSender } from "./notifications"; | ||
| import { createPermissionHandler } from "./permissions"; | ||
| import { createPluginHooks } from "./plugin"; | ||
| import { searchIndex } from "./search"; | ||
| import { | ||
| pathExists, | ||
| readFreshness, | ||
| readIndex, | ||
| writeIndex, | ||
| writeTimestamp, | ||
| } from "./storage"; | ||
| import { createStatusTool } from "./tools"; | ||
| import { | ||
| type HooksLike, | ||
| ALL_SCOPE, | ||
| type Index, | ||
| type PluginLike, | ||
| type SearchOptions, | ||
| type SearchResult, | ||
| type ToolDefinitionLike, | ||
| } from "./types"; | ||
| export const DEFAULT_MAX_AGE_SECONDS = 86_400; | ||
| export const SEARCH_INDEX_FILE = "search-index.json"; | ||
| export const MARKER_FILE = ".last_update"; | ||
| export interface CommandResult { | ||
| exitCode: number; | ||
| stdout: string; | ||
| stderr: string; | ||
| } | ||
| export interface RepositoryConfig { | ||
| name: string; | ||
| url: string; | ||
| branch: string; | ||
| sparse?: readonly string[]; | ||
| } | ||
| export interface SectionInfo { | ||
| label: string; | ||
| } | ||
| export interface SectionConfig extends SectionInfo { | ||
| baseDir: (cacheDir: string) => string; | ||
| patterns: readonly string[]; | ||
| } | ||
| export interface ToolConfig { | ||
| name: string; | ||
| description: string; | ||
| } | ||
| export interface SearchToolConfig extends ToolConfig { | ||
| missingMessage: string; | ||
| scopeDescription?: string; | ||
| failureLabel: string; | ||
| } | ||
| export interface UpdateToolConfig extends ToolConfig { | ||
| failureLabel: string; | ||
| successLogMessage: string; | ||
| } | ||
| export interface ThinCachePluginConfig<TScope extends string> { | ||
| title: string; | ||
| service: string; | ||
| envVar: string; | ||
| defaultCacheSubdir: string; | ||
| scopes: readonly [typeof ALL_SCOPE, ...TScope[]]; | ||
| repositories: readonly RepositoryConfig[]; | ||
| sections: Record<TScope, SectionConfig>; | ||
| initMessage: string; | ||
| updateTool: UpdateToolConfig; | ||
| statusTool: ToolConfig; | ||
| searchTool: SearchToolConfig; | ||
| readyRepository?: string; | ||
| } | ||
| function typedEntries<TKey extends string, TValue>( | ||
| value: Record<TKey, TValue>, | ||
| ): Array<[TKey, TValue]> { | ||
| return Object.entries(value) as Array<[TKey, TValue]>; | ||
| } | ||
| function buildErrorMessage(prefix: string, error: unknown): string { | ||
| const details = error instanceof Error ? error.message : String(error); | ||
| return `${prefix}: ${details}`; | ||
| } | ||
| function buildSearchScopeDescription<TScope extends string>(input: { | ||
| scopeValues: readonly [typeof ALL_SCOPE, ...TScope[]]; | ||
| scopeDescription?: string; | ||
| }): string { | ||
| return input.scopeDescription ?? `Search scope: ${input.scopeValues.join(", ")}.`; | ||
| } | ||
| function formatSearchOutput<TScope extends string>( | ||
| query: string, | ||
| result: SearchResult<TScope>, | ||
| ): string { | ||
| const lines = [ | ||
| `Query: ${query}`, | ||
| `Scope: ${result.scope}`, | ||
| `Files scanned: ${result.scannedFiles}`, | ||
| `Matches: ${result.hits.length}`, | ||
| ]; | ||
| if (result.hits.length > 0) { | ||
| lines.push(""); | ||
| for (const hit of result.hits) { | ||
| lines.push(`- ${hit.file}:${hit.line}: ${hit.excerpt}`); | ||
| } | ||
| } | ||
| return lines.join("\n"); | ||
| } | ||
| function getSparseEntries(repository: RepositoryConfig): string[] { | ||
| return repository.sparse == null ? [] : [...repository.sparse]; | ||
| } | ||
| export function resolveCacheDir( | ||
| envVar: string, | ||
| defaultCacheSubdir: string, | ||
| homeDirectory = homedir(), | ||
| ): string { | ||
| const configuredCacheDir = process.env[envVar]; | ||
| if (configuredCacheDir != null && configuredCacheDir.length > 0) { | ||
| return configuredCacheDir; | ||
| } | ||
| return join(homeDirectory, `.cache/opencode/skills/${defaultCacheSubdir}`); | ||
| } | ||
| export function getRepositoryDir( | ||
| cacheDir: string, | ||
| repository: Pick<RepositoryConfig, "name"> | string, | ||
| ): string { | ||
| const repositoryName = typeof repository === "string" ? repository : repository.name; | ||
| return join(cacheDir, repositoryName); | ||
| } | ||
| export function getMarkerFile(cacheDir: string): string { | ||
| return join(cacheDir, MARKER_FILE); | ||
| } | ||
| export function getSearchIndexFile(cacheDir: string): string { | ||
| return join(cacheDir, SEARCH_INDEX_FILE); | ||
| } | ||
| export async function buildSearchIndex<TScope extends string>( | ||
| cacheDir: string, | ||
| sections: Record<TScope, SectionConfig>, | ||
| ): Promise<Index<TScope>> { | ||
| const definitions = {} as Record<TScope, { baseDir: string; patterns: readonly string[] }>; | ||
| for (const [scope, section] of typedEntries(sections)) { | ||
| definitions[scope] = { | ||
| baseDir: section.baseDir(cacheDir), | ||
| patterns: section.patterns, | ||
| }; | ||
| } | ||
| return buildIndex({ | ||
| cacheDir, | ||
| sections: definitions, | ||
| }); | ||
| } | ||
| export async function readSearchIndex<TScope extends string>( | ||
| cacheDir: string, | ||
| ): Promise<Index<TScope> | null> { | ||
| return readIndex<Index<TScope>>(getSearchIndexFile(cacheDir)); | ||
| } | ||
| export async function writeSearchIndex<TScope extends string>( | ||
| cacheDir: string, | ||
| index: Index<TScope>, | ||
| ): Promise<void> { | ||
| await writeIndex(getSearchIndexFile(cacheDir), index); | ||
| } | ||
| export async function refreshSearchIndex<TScope extends string>( | ||
| cacheDir: string, | ||
| sections: Record<TScope, SectionConfig>, | ||
| ): Promise<Index<TScope>> { | ||
| const index = await buildSearchIndex(cacheDir, sections); | ||
| await writeSearchIndex(cacheDir, index); | ||
| return index; | ||
| } | ||
| export async function searchCacheIndex<TScope extends string>( | ||
| index: Index<TScope>, | ||
| query: string, | ||
| options?: SearchOptions<TScope>, | ||
| ): Promise<SearchResult<TScope>> { | ||
| return searchIndex(index, query, options); | ||
| } | ||
| export function formatIndexCounts<TScope extends string>( | ||
| index: Index<TScope>, | ||
| sections: Record<TScope, SectionInfo>, | ||
| ): string[] { | ||
| return typedEntries(sections).map(([scope, section]) => { | ||
| return `${section.label} files: ${index.sections[scope].files.length}`; | ||
| }); | ||
| } | ||
| export function formatCommandFailure(title: string, result: CommandResult): string { | ||
| const details = [result.stdout, result.stderr] | ||
| .filter((value) => value.length > 0) | ||
| .join("\n"); | ||
| return details.length > 0 ? `${title}\n${details}` : title; | ||
| } | ||
| export async function runCommand( | ||
| command: string, | ||
| args: readonly string[], | ||
| cwd?: string, | ||
| ): Promise<CommandResult> { | ||
| return new Promise((resolve, reject) => { | ||
| const child = spawn(command, [...args], { | ||
| cwd, | ||
| stdio: ["ignore", "pipe", "pipe"], | ||
| }); | ||
| let stdout = ""; | ||
| let stderr = ""; | ||
| child.stdout?.on("data", (chunk) => { | ||
| stdout += chunk.toString(); | ||
| }); | ||
| child.stderr?.on("data", (chunk) => { | ||
| stderr += chunk.toString(); | ||
| }); | ||
| child.on("error", (error) => { | ||
| reject(error); | ||
| }); | ||
| child.on("close", (code) => { | ||
| resolve({ | ||
| exitCode: code ?? -1, | ||
| stdout: stdout.trimEnd(), | ||
| stderr: stderr.trimEnd(), | ||
| }); | ||
| }); | ||
| }); | ||
| } | ||
| export async function syncRepository( | ||
| cacheDir: string, | ||
| repository: RepositoryConfig, | ||
| ): Promise<string[]> { | ||
| const repositoryDir = getRepositoryDir(cacheDir, repository); | ||
| const gitDir = join(repositoryDir, ".git"); | ||
| const installed = await pathExists(gitDir); | ||
| const lines: string[] = []; | ||
| if (!installed) { | ||
| lines.push(`Cloning ${repository.name}...`); | ||
| let result: CommandResult; | ||
| if (repository.sparse != null) { | ||
| result = await runCommand("git", [ | ||
| "clone", | ||
| "--filter=blob:none", | ||
| "--no-checkout", | ||
| "--depth", | ||
| "1", | ||
| "--branch", | ||
| repository.branch, | ||
| repository.url, | ||
| repositoryDir, | ||
| ]); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error(formatCommandFailure(`Failed to clone ${repository.name}.`, result)); | ||
| } | ||
| result = await runCommand("git", ["-C", repositoryDir, "sparse-checkout", "init", "--cone"]); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error( | ||
| formatCommandFailure(`Failed to initialize sparse checkout for ${repository.name}.`, result), | ||
| ); | ||
| } | ||
| result = await runCommand("git", [ | ||
| "-C", | ||
| repositoryDir, | ||
| "sparse-checkout", | ||
| "set", | ||
| ...getSparseEntries(repository), | ||
| ]); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error( | ||
| formatCommandFailure(`Failed to configure sparse checkout for ${repository.name}.`, result), | ||
| ); | ||
| } | ||
| result = await runCommand("git", ["-C", repositoryDir, "checkout", repository.branch]); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error(formatCommandFailure(`Failed to checkout ${repository.branch}.`, result)); | ||
| } | ||
| } else { | ||
| result = await runCommand("git", [ | ||
| "clone", | ||
| "--depth", | ||
| "1", | ||
| "--branch", | ||
| repository.branch, | ||
| repository.url, | ||
| repositoryDir, | ||
| ]); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error(formatCommandFailure(`Failed to clone ${repository.name}.`, result)); | ||
| } | ||
| } | ||
| lines.push(` Cloned ${repository.name}`); | ||
| return lines; | ||
| } | ||
| lines.push(`Updating ${repository.name}...`); | ||
| let result = await runCommand("git", [ | ||
| "-C", | ||
| repositoryDir, | ||
| "fetch", | ||
| "--depth", | ||
| "1", | ||
| "origin", | ||
| repository.branch, | ||
| ]); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error(formatCommandFailure(`Failed to fetch ${repository.name}.`, result)); | ||
| } | ||
| result = await runCommand("git", [ | ||
| "-C", | ||
| repositoryDir, | ||
| "reset", | ||
| "--hard", | ||
| `origin/${repository.branch}`, | ||
| ]); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error(formatCommandFailure(`Failed to reset ${repository.name}.`, result)); | ||
| } | ||
| if (repository.sparse != null) { | ||
| result = await runCommand("git", [ | ||
| "-C", | ||
| repositoryDir, | ||
| "sparse-checkout", | ||
| "set", | ||
| ...getSparseEntries(repository), | ||
| ]); | ||
| if (result.exitCode !== 0) { | ||
| throw new Error( | ||
| formatCommandFailure(`Failed to refresh sparse checkout for ${repository.name}.`, result), | ||
| ); | ||
| } | ||
| } | ||
| lines.push(` Updated ${repository.name}`); | ||
| return lines; | ||
| } | ||
| async function buildRepositoryCacheStatusOutput<TScope extends string>(input: { | ||
| cacheDir: string; | ||
| markerFile: string; | ||
| repositories: readonly RepositoryConfig[]; | ||
| sections: Record<TScope, SectionConfig>; | ||
| }): Promise<string> { | ||
| const lines: string[] = [`Cache directory: ${input.cacheDir}`]; | ||
| const freshness = await readFreshness(input.markerFile, DEFAULT_MAX_AGE_SECONDS); | ||
| const index = await readSearchIndex<TScope>(input.cacheDir); | ||
| if (freshness.timestamp == null) { | ||
| lines.push("Cache status: not initialized"); | ||
| } else { | ||
| lines.push(`Cache status: ${freshness.fresh ? "fresh" : "stale"} (${freshness.hoursAgo}h old)`); | ||
| lines.push(`Last updated: ${new Date(freshness.timestamp * 1000).toISOString()}`); | ||
| } | ||
| lines.push("\nRepositories:"); | ||
| for (const repository of input.repositories) { | ||
| const installed = await pathExists(join(getRepositoryDir(input.cacheDir, repository), ".git")); | ||
| lines.push(` ${repository.name}: ${installed ? "installed" : "missing"}`); | ||
| } | ||
| if (index == null) { | ||
| lines.push("\nSearch corpus: missing"); | ||
| } else { | ||
| lines.push(`\nSearch corpus: ${getSearchIndexFile(input.cacheDir)}`); | ||
| lines.push(...formatIndexCounts(index, input.sections)); | ||
| } | ||
| return lines.join("\n"); | ||
| } | ||
| export function createRepositoryCacheSearchTool<TScope extends string>(input: { | ||
| description: string; | ||
| notificationTitle: string; | ||
| scopeValues: readonly [typeof ALL_SCOPE, ...TScope[]]; | ||
| missingOutput: string; | ||
| failureLabel: string; | ||
| scopeDescription?: string; | ||
| loadIndex: () => Promise<Index<TScope> | null>; | ||
| search: (input: { | ||
| index: Index<TScope>; | ||
| query: string; | ||
| scope?: TScope | typeof ALL_SCOPE; | ||
| regex?: boolean; | ||
| caseSensitive?: boolean; | ||
| limit?: number; | ||
| }) => Promise<SearchResult<TScope>>; | ||
| sendNotification: (sessionID: string, message: string) => Promise<void>; | ||
| }) { | ||
| return tool({ | ||
| description: input.description, | ||
| args: { | ||
| query: tool.schema.string().describe("Substring or regular expression to search for."), | ||
| scope: tool.schema | ||
| .enum(input.scopeValues) | ||
| .optional() | ||
| .describe( | ||
| buildSearchScopeDescription({ | ||
| scopeValues: input.scopeValues, | ||
| scopeDescription: input.scopeDescription, | ||
| }), | ||
| ), | ||
| regex: tool.schema.boolean().optional().describe("Treat query as a regular expression."), | ||
| case_sensitive: tool.schema.boolean().optional().describe("Use case-sensitive matching."), | ||
| limit: tool.schema | ||
| .number() | ||
| .int() | ||
| .min(1) | ||
| .max(100) | ||
| .optional() | ||
| .describe("Maximum number of hits to return."), | ||
| }, | ||
| async execute(args, context) { | ||
| try { | ||
| const index = await input.loadIndex(); | ||
| if (index == null) { | ||
| await input.sendNotification( | ||
| context.sessionID, | ||
| buildNotification(input.notificationTitle, input.missingOutput), | ||
| ); | ||
| return input.missingOutput; | ||
| } | ||
| let result: SearchResult<TScope>; | ||
| try { | ||
| result = await input.search({ | ||
| index, | ||
| query: args.query, | ||
| scope: args.scope, | ||
| regex: args.regex, | ||
| caseSensitive: args.case_sensitive, | ||
| limit: args.limit, | ||
| }); | ||
| } catch (error: unknown) { | ||
| const message = buildErrorMessage("Invalid search query", error); | ||
| await input.sendNotification( | ||
| context.sessionID, | ||
| buildNotification(input.notificationTitle, message), | ||
| ); | ||
| return message; | ||
| } | ||
| const output = formatSearchOutput(args.query, result); | ||
| await input.sendNotification( | ||
| context.sessionID, | ||
| buildNotification(input.notificationTitle, output), | ||
| ); | ||
| return output; | ||
| } catch (error: unknown) { | ||
| const message = buildErrorMessage(input.failureLabel, error); | ||
| await input.sendNotification( | ||
| context.sessionID, | ||
| buildNotification(input.notificationTitle, message), | ||
| ); | ||
| return message; | ||
| } | ||
| }, | ||
| }) as unknown as ToolDefinitionLike; | ||
| } | ||
| export function createRepositoryCachePlugin<TScope extends string>( | ||
| config: ThinCachePluginConfig<TScope>, | ||
| ): PluginLike { | ||
| return async ({ client }) => { | ||
| const cacheDir = resolveCacheDir(config.envVar, config.defaultCacheSubdir); | ||
| const markerFile = getMarkerFile(cacheDir); | ||
| const readyRepositoryName = config.readyRepository ?? config.repositories[0]?.name; | ||
| if (readyRepositoryName == null) { | ||
| throw new Error(`${config.service} requires at least one repository.`); | ||
| } | ||
| const readyPath = join(getRepositoryDir(cacheDir, readyRepositoryName), ".git"); | ||
| const sendNotification = createNotificationSender({ | ||
| client, | ||
| service: config.service, | ||
| }); | ||
| const loadIndex = async (): Promise<Index<TScope> | null> => { | ||
| if (!(await pathExists(readyPath))) { | ||
| return null; | ||
| } | ||
| return (await readSearchIndex<TScope>(cacheDir)) | ||
| ?? (await refreshSearchIndex(cacheDir, config.sections)); | ||
| }; | ||
| const tools: NonNullable<HooksLike["tool"]> = { | ||
| [config.updateTool.name]: tool({ | ||
| description: config.updateTool.description, | ||
| args: { | ||
| force: tool.schema | ||
| .boolean() | ||
| .optional() | ||
| .describe("Force update even if cache is fresh (< 24h old)"), | ||
| }, | ||
| async execute(args, context) { | ||
| try { | ||
| const freshness = await readFreshness(markerFile, DEFAULT_MAX_AGE_SECONDS); | ||
| if (!args.force && freshness.fresh) { | ||
| const index = (await readSearchIndex<TScope>(cacheDir)) | ||
| ?? (await refreshSearchIndex(cacheDir, config.sections)); | ||
| const message = [ | ||
| `Cache is fresh (${freshness.hoursAgo}h old).`, | ||
| `Cache directory: ${cacheDir}`, | ||
| ...formatIndexCounts(index, config.sections), | ||
| "Use force=true to refresh anyway.", | ||
| ].join("\n"); | ||
| await sendNotification( | ||
| context.sessionID, | ||
| buildNotification(config.title, message), | ||
| ); | ||
| return message; | ||
| } | ||
| await mkdir(cacheDir, { recursive: true }); | ||
| const results: string[] = []; | ||
| for (const repository of config.repositories) { | ||
| try { | ||
| results.push(...(await syncRepository(cacheDir, repository))); | ||
| } catch (error: unknown) { | ||
| const details = error instanceof Error ? error.message : String(error); | ||
| results.push(` Error with ${repository.name}: ${details}`); | ||
| } | ||
| } | ||
| await writeTimestamp(markerFile); | ||
| const index = await refreshSearchIndex(cacheDir, config.sections); | ||
| results.push(`\nCache updated at: ${cacheDir}`); | ||
| results.push(`Search index: ${getSearchIndexFile(cacheDir)}`); | ||
| results.push(...formatIndexCounts(index, config.sections)); | ||
| const output = results.join("\n"); | ||
| await sendNotification( | ||
| context.sessionID, | ||
| buildNotification(config.title, output), | ||
| ); | ||
| await client.app.log({ | ||
| body: { | ||
| service: config.service, | ||
| level: "info", | ||
| message: config.updateTool.successLogMessage, | ||
| }, | ||
| }); | ||
| return output; | ||
| } catch (error: unknown) { | ||
| const message = buildErrorMessage(config.updateTool.failureLabel, error); | ||
| await sendNotification( | ||
| context.sessionID, | ||
| buildNotification(config.title, message), | ||
| ); | ||
| return message; | ||
| } | ||
| }, | ||
| }) as unknown as ToolDefinitionLike, | ||
| [config.statusTool.name]: createStatusTool({ | ||
| description: config.statusTool.description, | ||
| notificationTitle: config.title, | ||
| sendNotification, | ||
| buildOutput: async () => | ||
| buildRepositoryCacheStatusOutput({ | ||
| cacheDir, | ||
| markerFile, | ||
| repositories: config.repositories, | ||
| sections: config.sections, | ||
| }), | ||
| }) as unknown as ToolDefinitionLike, | ||
| [config.searchTool.name]: createRepositoryCacheSearchTool({ | ||
| description: config.searchTool.description, | ||
| notificationTitle: config.title, | ||
| scopeValues: config.scopes, | ||
| missingOutput: config.searchTool.missingMessage, | ||
| failureLabel: config.searchTool.failureLabel, | ||
| scopeDescription: config.searchTool.scopeDescription, | ||
| loadIndex, | ||
| search: async ({ index, query, scope, regex, caseSensitive, limit }) => { | ||
| return searchCacheIndex(index, query, { | ||
| scope, | ||
| regex, | ||
| caseSensitive, | ||
| limit, | ||
| }); | ||
| }, | ||
| sendNotification, | ||
| }), | ||
| }; | ||
| return createPluginHooks({ | ||
| client, | ||
| service: config.service, | ||
| initMessage: config.initMessage, | ||
| permissionAsk: createPermissionHandler(cacheDir), | ||
| tools, | ||
| }); | ||
| }; | ||
| } |
| declare const process: { | ||
| argv: string[]; | ||
| env: Record<string, string | undefined>; | ||
| exitCode?: number; | ||
| stderr: { | ||
| write(chunk: string): void; | ||
| }; | ||
| }; | ||
| declare const Bun: { | ||
| build(options: { | ||
| entrypoints: string[]; | ||
| outdir: string; | ||
| target: "node"; | ||
| format: "esm"; | ||
| external?: string[]; | ||
| sourcemap?: "none"; | ||
| }): Promise<{ | ||
| success: boolean; | ||
| logs: Array<{ | ||
| message: string; | ||
| }>; | ||
| }>; | ||
| }; | ||
| declare module "node:fs/promises" { | ||
| export interface Dirent { | ||
| name: string; | ||
| isDirectory(): boolean; | ||
| isFile(): boolean; | ||
| } | ||
| export interface Stats { | ||
| isDirectory(): boolean; | ||
| } | ||
| export function access(path: string): Promise<void>; | ||
| export function mkdir( | ||
| path: string, | ||
| options?: { recursive?: boolean }, | ||
| ): Promise<string | undefined>; | ||
| export function readFile(path: string, encoding: "utf8"): Promise<string>; | ||
| export function readdir( | ||
| path: string, | ||
| options: { withFileTypes: true }, | ||
| ): Promise<Dirent[]>; | ||
| export function stat(path: string): Promise<Stats>; | ||
| export function rm( | ||
| path: string, | ||
| options?: { force?: boolean; recursive?: boolean }, | ||
| ): Promise<void>; | ||
| export function writeFile( | ||
| path: string, | ||
| data: string, | ||
| encoding: "utf8", | ||
| ): Promise<void>; | ||
| } | ||
| declare module "node:os" { | ||
| export function homedir(): string; | ||
| } | ||
| declare module "node:path" { | ||
| export function dirname(path: string): string; | ||
| export function isAbsolute(path: string): boolean; | ||
| export function join(...paths: string[]): string; | ||
| export function resolve(...paths: string[]): string; | ||
| } |
Sorry, the diff of this file is too big to display
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
682680
28.92%15
7.14%19492
29.94%292
251.81%2
100%7
16.67%