Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@yriveiro/opencode-cache-core

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@yriveiro/opencode-cache-core - npm Package Compare versions

Comparing version
0.2.0
to
0.3.0
+73
src/git-cache-paths.ts
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
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,

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);
}

@@ -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;
},
});
}

@@ -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>;
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