@thecat69/cache-ctrl 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ import { findRepoRoot, readCache, writeCache } from "../cache/cacheManager.js";
2
+ import { isExternalStale, mergeHeaderMetadata, resolveTopExternalMatch } from "../cache/externalCache.js";
3
+ import { checkFreshness } from "../http/freshnessChecker.js";
4
+ import type { ExternalCacheFile } from "../types/cache.js";
5
+ import { ExternalCacheFileSchema } from "../types/cache.js";
6
+ import { ErrorCode, type Result } from "../types/result.js";
7
+ import type { CheckFreshnessArgs, CheckFreshnessResult } from "../types/commands.js";
8
+ import type { HeaderMeta } from "../cache/externalCache.js";
9
+ import { getFileStem } from "../utils/fileStem.js";
10
+
11
+ export async function checkFreshnessCommand(args: CheckFreshnessArgs): Promise<Result<CheckFreshnessResult["value"]>> {
12
+ try {
13
+ const repoRoot = await findRepoRoot(process.cwd());
14
+
15
+ // Find the best-matching external cache entry file path
16
+ const matchResult = await resolveTopExternalMatch(repoRoot, args.subject);
17
+ if (!matchResult.ok) return matchResult;
18
+
19
+ const filePath = matchResult.value;
20
+
21
+ // Load the matched entry's data
22
+ const readResult = await readCache(filePath);
23
+ if (!readResult.ok) return readResult;
24
+
25
+ const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
26
+ if (!parseResult.success) {
27
+ return { ok: false, error: `Malformed external cache file: ${filePath}`, code: ErrorCode.PARSE_ERROR };
28
+ }
29
+
30
+ const cacheEntry = parseResult.data;
31
+ const stem = getFileStem(filePath);
32
+ const subject = cacheEntry.subject ?? stem;
33
+
34
+ // Determine which URLs to check
35
+ const sources = cacheEntry.sources ?? [];
36
+ let urlsToCheck = sources;
37
+
38
+ if (args.url) {
39
+ const found = sources.find((s) => s.url === args.url);
40
+ if (!found) {
41
+ return {
42
+ ok: false,
43
+ error: `URL not found in sources for subject '${subject}'`,
44
+ code: ErrorCode.URL_NOT_FOUND,
45
+ };
46
+ }
47
+ urlsToCheck = [found];
48
+ } else {
49
+ urlsToCheck = sources;
50
+ }
51
+
52
+ // Check freshness for each URL
53
+ const sourceResults: Array<{
54
+ url: string;
55
+ status: "fresh" | "stale" | "error";
56
+ http_status?: number;
57
+ error?: string;
58
+ }> = [];
59
+
60
+ const headerUpdates: Record<string, HeaderMeta> = {};
61
+
62
+ for (const source of urlsToCheck) {
63
+ const stored = cacheEntry.header_metadata?.[source.url];
64
+ const result = await checkFreshness({
65
+ url: source.url,
66
+ ...(stored?.etag !== undefined ? { etag: stored.etag } : {}),
67
+ ...(stored?.last_modified !== undefined ? { last_modified: stored.last_modified } : {}),
68
+ });
69
+
70
+ sourceResults.push({
71
+ url: result.url,
72
+ status: result.status,
73
+ ...(result.http_status !== undefined ? { http_status: result.http_status } : {}),
74
+ ...(result.error !== undefined ? { error: result.error } : {}),
75
+ });
76
+
77
+ if (result.status !== "error") {
78
+ headerUpdates[source.url] = {
79
+ ...(result.etag !== undefined ? { etag: result.etag } : {}),
80
+ ...(result.last_modified !== undefined ? { last_modified: result.last_modified } : {}),
81
+ checked_at: new Date().toISOString(),
82
+ status: result.status,
83
+ };
84
+ }
85
+ }
86
+
87
+ // Only write back if at least one URL succeeded
88
+ const hasSuccessfulChecks = Object.keys(headerUpdates).length > 0;
89
+ if (hasSuccessfulChecks) {
90
+ const updated = mergeHeaderMetadata(cacheEntry, headerUpdates);
91
+ const writeResult = await writeCache(filePath, { header_metadata: updated.header_metadata });
92
+ if (!writeResult.ok) return writeResult;
93
+ }
94
+
95
+ // Determine overall status: entryIsOld always wins (stale by age), then anyStale, then allError
96
+ const allError = sourceResults.every((r) => r.status === "error");
97
+ const anyStale = sourceResults.some((r) => r.status === "stale");
98
+ const entryIsOld = isExternalStale(cacheEntry);
99
+
100
+ let overall: "fresh" | "stale" | "error";
101
+ if (entryIsOld) {
102
+ overall = "stale";
103
+ } else if (anyStale) {
104
+ overall = "stale";
105
+ } else if (allError && sourceResults.length > 0) {
106
+ overall = "error";
107
+ } else {
108
+ overall = "fresh";
109
+ }
110
+
111
+ return {
112
+ ok: true,
113
+ value: {
114
+ subject,
115
+ sources: sourceResults,
116
+ overall,
117
+ },
118
+ };
119
+ } catch (err) {
120
+ const error = err as Error;
121
+ return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
122
+ }
123
+ }
@@ -0,0 +1,55 @@
1
+ import { unlink } from "node:fs/promises";
2
+ import { findRepoRoot, listCacheFiles } from "../cache/cacheManager.js";
3
+ import { resolveLocalCachePath } from "../cache/localCache.js";
4
+ import { ErrorCode, type Result } from "../types/result.js";
5
+ import type { FlushArgs, FlushResult } from "../types/commands.js";
6
+
7
+ export async function flushCommand(args: FlushArgs): Promise<Result<FlushResult["value"]>> {
8
+ if (!args.confirm) {
9
+ return {
10
+ ok: false,
11
+ error: "flush requires --confirm flag to prevent accidental data loss",
12
+ code: ErrorCode.CONFIRMATION_REQUIRED,
13
+ };
14
+ }
15
+
16
+ try {
17
+ const repoRoot = await findRepoRoot(process.cwd());
18
+ const deleted: string[] = [];
19
+
20
+ if (args.agent === "external" || args.agent === "all") {
21
+ const filesResult = await listCacheFiles("external", repoRoot);
22
+ if (!filesResult.ok) return filesResult;
23
+
24
+ for (const filePath of filesResult.value) {
25
+ try {
26
+ await unlink(filePath);
27
+ deleted.push(filePath);
28
+ } catch (err) {
29
+ const error = err as NodeJS.ErrnoException;
30
+ if (error.code !== "ENOENT") {
31
+ return { ok: false, error: `Failed to delete ${filePath}: ${error.message}`, code: ErrorCode.FILE_WRITE_ERROR };
32
+ }
33
+ }
34
+ }
35
+ }
36
+
37
+ if (args.agent === "local" || args.agent === "all") {
38
+ const localPath = resolveLocalCachePath(repoRoot);
39
+ try {
40
+ await unlink(localPath);
41
+ deleted.push(localPath);
42
+ } catch (err) {
43
+ const error = err as NodeJS.ErrnoException;
44
+ if (error.code !== "ENOENT") {
45
+ return { ok: false, error: `Failed to delete ${localPath}: ${error.message}`, code: ErrorCode.FILE_WRITE_ERROR };
46
+ }
47
+ }
48
+ }
49
+
50
+ return { ok: true, value: { deleted, count: deleted.length } };
51
+ } catch (err) {
52
+ const error = err as Error;
53
+ return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
54
+ }
55
+ }
@@ -0,0 +1,184 @@
1
+ import { normalize } from "node:path";
2
+
3
+ import { findRepoRoot, loadExternalCacheEntries, readCache } from "../cache/cacheManager.js";
4
+ import { resolveLocalCachePath } from "../cache/localCache.js";
5
+ import { scoreEntry } from "../search/keywordSearch.js";
6
+ import type { CacheEntry, ExternalCacheFile, LocalCacheFile } from "../types/cache.js";
7
+ import { ExternalCacheFileSchema, LocalCacheFileSchema } from "../types/cache.js";
8
+ import { ErrorCode, type Result } from "../types/result.js";
9
+ import type { InspectArgs, InspectResult } from "../types/commands.js";
10
+
11
+ function filterFacts(
12
+ facts: Record<string, string[]>,
13
+ keywords: string[],
14
+ ): Record<string, string[]> {
15
+ if (keywords.length === 0) return facts;
16
+ const lower = keywords.map((k) => k.toLowerCase());
17
+ return Object.fromEntries(
18
+ Object.entries(facts).filter(([path]) => lower.some((kw) => path.toLowerCase().includes(kw))),
19
+ );
20
+ }
21
+
22
+ export async function inspectCommand(args: InspectArgs): Promise<Result<InspectResult["value"]>> {
23
+ try {
24
+ // Step 0 — folder guard: validate before any I/O.
25
+ if (args.folder !== undefined) {
26
+ if (args.agent === "external") {
27
+ return {
28
+ ok: false,
29
+ error: "--folder is only supported for local cache",
30
+ code: ErrorCode.INVALID_ARGS,
31
+ };
32
+ }
33
+
34
+ const normalizedFolder = args.folder.replace(/\\/g, "/").replace(/\/+$/, "");
35
+
36
+ if (normalizedFolder.length === 0) {
37
+ return {
38
+ ok: false,
39
+ error: "--folder must not be an empty string",
40
+ code: ErrorCode.INVALID_ARGS,
41
+ };
42
+ }
43
+
44
+ if (normalize(normalizedFolder).split("/").includes("..")) {
45
+ return {
46
+ ok: false,
47
+ error: "--folder must not contain '..' path segments",
48
+ code: ErrorCode.INVALID_ARGS,
49
+ };
50
+ }
51
+ }
52
+
53
+ const repoRoot = await findRepoRoot(process.cwd());
54
+
55
+ const candidates: Array<{ entry: CacheEntry; content: ExternalCacheFile | LocalCacheFile; file: string }> = [];
56
+
57
+ if (args.agent === "external") {
58
+ const entriesResult = await loadExternalCacheEntries(repoRoot);
59
+ if (!entriesResult.ok) return entriesResult;
60
+
61
+ for (const entry of entriesResult.value) {
62
+ const readResult = await readCache(entry.file);
63
+ if (!readResult.ok) continue;
64
+ const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
65
+ if (!parseResult.success) continue;
66
+ candidates.push({ entry, content: parseResult.data, file: entry.file });
67
+ }
68
+ } else {
69
+ const localPath = resolveLocalCachePath(repoRoot);
70
+ const readResult = await readCache(localPath);
71
+ if (!readResult.ok) return readResult;
72
+
73
+ const parseResult = LocalCacheFileSchema.safeParse(readResult.value);
74
+ if (!parseResult.success) {
75
+ return { ok: false, error: `Malformed local cache file: ${localPath}`, code: ErrorCode.PARSE_ERROR };
76
+ }
77
+ const data = parseResult.data;
78
+ const entry: CacheEntry = {
79
+ file: localPath,
80
+ agent: "local",
81
+ subject: data.topic ?? "local",
82
+ description: data.description,
83
+ fetched_at: data.timestamp ?? "",
84
+ };
85
+ candidates.push({ entry, content: data, file: localPath });
86
+ }
87
+
88
+ if (candidates.length === 0) {
89
+ return { ok: false, error: `No cache entries found for agent "${args.agent}"`, code: ErrorCode.FILE_NOT_FOUND };
90
+ }
91
+
92
+ // Score all candidates
93
+ const keywords = [args.subject];
94
+ const scored = candidates.map((c) => ({
95
+ ...c,
96
+ score: scoreEntry(c.entry, keywords),
97
+ }));
98
+
99
+ // Filter out zero-score entries
100
+ const matched = scored.filter((s) => s.score > 0);
101
+
102
+ if (matched.length === 0) {
103
+ return { ok: false, error: `No cache entry matched keyword "${args.subject}"`, code: ErrorCode.FILE_NOT_FOUND };
104
+ }
105
+
106
+ // Sort by score descending
107
+ matched.sort((a, b) => b.score - a.score);
108
+
109
+ const top = matched[0]!;
110
+ const second = matched[1];
111
+
112
+ if (second && top.score === second.score) {
113
+ return {
114
+ ok: false,
115
+ error: `Ambiguous match: multiple entries scored equally for "${args.subject}"`,
116
+ code: ErrorCode.AMBIGUOUS_MATCH,
117
+ };
118
+ }
119
+
120
+ if (args.agent === "local") {
121
+ // Destructure to strip tracked_files (internal operational metadata, never exposed
122
+ // to callers) and to extract facts for filtering. All other fields — including
123
+ // global_facts, topic, description, timestamp, cache_miss_reason — flow through
124
+ // via ...rest and are always included in the response.
125
+ const { tracked_files: _dropped, facts, ...rest } = top.content as LocalCacheFile;
126
+
127
+ // Step 1 — folder filter: keep only entries under the specified folder prefix.
128
+ let filteredFacts = facts;
129
+ if (filteredFacts !== undefined && args.folder !== undefined) {
130
+ const normalizedFolder = args.folder.replace(/\\/g, "/").replace(/\/+$/, "");
131
+ filteredFacts = Object.fromEntries(
132
+ Object.entries(filteredFacts).filter(([key]) => {
133
+ const normalizedPath = key.replace(/\\/g, "/");
134
+ return (
135
+ normalizedPath === normalizedFolder ||
136
+ normalizedPath.startsWith(normalizedFolder + "/")
137
+ );
138
+ }),
139
+ );
140
+ }
141
+
142
+ // Step 2 — path keyword filter (existing --filter logic applied to already-folder-filtered set).
143
+ if (filteredFacts !== undefined) {
144
+ filteredFacts = filterFacts(filteredFacts, args.filter ?? []);
145
+ }
146
+
147
+ // Step 3 — search-facts filter: keep entries where any fact string contains any keyword.
148
+ if (filteredFacts !== undefined && args.searchFacts !== undefined) {
149
+ const kwsLower = args.searchFacts.map((k) => k.toLowerCase());
150
+ filteredFacts = Object.fromEntries(
151
+ Object.entries(filteredFacts).filter(([, factStrings]) =>
152
+ factStrings.some((f) => kwsLower.some((kw) => f.toLowerCase().includes(kw))),
153
+ ),
154
+ );
155
+ }
156
+
157
+ return {
158
+ ok: true,
159
+ value: {
160
+ ...rest,
161
+ ...(filteredFacts !== undefined ? { facts: filteredFacts } : {}),
162
+ file: top.file,
163
+ agent: args.agent,
164
+ // The cast is safe: tracked_files is intentionally stripped from the local
165
+ // response. LocalCacheFile uses z.looseObject so the runtime shape is valid;
166
+ // the static type just cannot express the intentional omission without a
167
+ // separate type definition.
168
+ } as unknown as InspectResult["value"],
169
+ };
170
+ }
171
+
172
+ return {
173
+ ok: true,
174
+ value: {
175
+ ...top.content,
176
+ file: top.file,
177
+ agent: args.agent,
178
+ },
179
+ };
180
+ } catch (err) {
181
+ const error = err as Error;
182
+ return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
183
+ }
184
+ }
@@ -0,0 +1,13 @@
1
+ import path from "node:path";
2
+
3
+ import { installOpenCodeIntegration, resolveOpenCodeConfigDir } from "../files/openCodeInstaller.js";
4
+ import type { InstallArgs, InstallResult } from "../types/commands.js";
5
+ import type { Result } from "../types/result.js";
6
+
7
+ export async function installCommand(
8
+ args: InstallArgs,
9
+ packageRoot: string = path.resolve(import.meta.dir, "../.."),
10
+ ): Promise<Result<InstallResult>> {
11
+ const configDir = resolveOpenCodeConfigDir(args.configDir);
12
+ return installOpenCodeIntegration(configDir, packageRoot);
13
+ }
@@ -0,0 +1,53 @@
1
+ import { findRepoRoot, listCacheFiles, writeCache, readCache } from "../cache/cacheManager.js";
2
+ import { resolveTopExternalMatch } from "../cache/externalCache.js";
3
+ import { resolveLocalCachePath } from "../cache/localCache.js";
4
+ import { ErrorCode, type Result } from "../types/result.js";
5
+ import type { InvalidateArgs, InvalidateResult } from "../types/commands.js";
6
+ import { validateSubject } from "../utils/validate.js";
7
+
8
+ export async function invalidateCommand(args: InvalidateArgs): Promise<Result<InvalidateResult["value"]>> {
9
+ try {
10
+ const repoRoot = await findRepoRoot(process.cwd());
11
+ const invalidated: string[] = [];
12
+
13
+ if (args.agent === "external") {
14
+ let filesToInvalidate: string[];
15
+
16
+ if (args.subject) {
17
+ const subjectCheck = validateSubject(args.subject);
18
+ if (!subjectCheck.ok) return subjectCheck;
19
+ const matchResult = await resolveTopExternalMatch(repoRoot, args.subject);
20
+ if (!matchResult.ok) return matchResult;
21
+ filesToInvalidate = [matchResult.value];
22
+ } else {
23
+ const filesResult = await listCacheFiles("external", repoRoot);
24
+ if (!filesResult.ok) return filesResult;
25
+ filesToInvalidate = filesResult.value;
26
+ }
27
+
28
+ for (const filePath of filesToInvalidate) {
29
+ const writeResult = await writeCache(filePath, { fetched_at: "" });
30
+ if (!writeResult.ok) return writeResult;
31
+ invalidated.push(filePath);
32
+ }
33
+ } else {
34
+ // local — only invalidate if the file already exists
35
+ const localPath = resolveLocalCachePath(repoRoot);
36
+ const readResult = await readCache(localPath);
37
+ if (!readResult.ok) {
38
+ if (readResult.code === ErrorCode.FILE_NOT_FOUND) {
39
+ return { ok: false, error: `Local cache file not found: ${localPath}`, code: ErrorCode.FILE_NOT_FOUND };
40
+ }
41
+ return readResult;
42
+ }
43
+ const writeResult = await writeCache(localPath, { timestamp: "" });
44
+ if (!writeResult.ok) return writeResult;
45
+ invalidated.push(localPath);
46
+ }
47
+
48
+ return { ok: true, value: { invalidated } };
49
+ } catch (err) {
50
+ const error = err as Error;
51
+ return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
52
+ }
53
+ }
@@ -0,0 +1,83 @@
1
+ import { findRepoRoot, loadExternalCacheEntries, readCache } from "../cache/cacheManager.js";
2
+ import { getAgeHuman } from "../cache/externalCache.js";
3
+ import { resolveLocalCachePath } from "../cache/localCache.js";
4
+ import { checkFilesCommand } from "./checkFiles.js";
5
+ import { LocalCacheFileSchema } from "../types/cache.js";
6
+ import { ErrorCode, type Result } from "../types/result.js";
7
+ import type { ListArgs, ListEntry, ListResult } from "../types/commands.js";
8
+
9
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000;
10
+
11
+ function isEntryStale(fetchedAt: string): boolean {
12
+ if (!fetchedAt) return true;
13
+ return Date.now() - new Date(fetchedAt).getTime() > MAX_AGE_MS;
14
+ }
15
+
16
+ export async function listCommand(args: ListArgs): Promise<Result<ListResult["value"]>> {
17
+ try {
18
+ const repoRoot = await findRepoRoot(process.cwd());
19
+ const agent = args.agent ?? "all";
20
+ const entries: ListEntry[] = [];
21
+
22
+ if (agent === "external" || agent === "all") {
23
+ const entriesResult = await loadExternalCacheEntries(repoRoot);
24
+ if (!entriesResult.ok) return entriesResult;
25
+
26
+ for (const entry of entriesResult.value) {
27
+ entries.push({
28
+ file: entry.file,
29
+ agent: "external",
30
+ subject: entry.subject,
31
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
32
+ fetched_at: entry.fetched_at,
33
+ age_human: getAgeHuman(entry.fetched_at),
34
+ is_stale: isEntryStale(entry.fetched_at),
35
+ });
36
+ }
37
+ }
38
+
39
+ if (agent === "local" || agent === "all") {
40
+ const localPath = resolveLocalCachePath(repoRoot);
41
+ const readResult = await readCache(localPath);
42
+
43
+ if (readResult.ok) {
44
+ const parseResult = LocalCacheFileSchema.safeParse(readResult.value);
45
+ if (parseResult.success) {
46
+ const data = parseResult.data;
47
+ const timestamp = data.timestamp ?? "";
48
+ const description = data.description;
49
+
50
+ const checkResult = await checkFilesCommand();
51
+ if (!checkResult.ok) {
52
+ process.stderr.write(
53
+ `[cache-ctrl] Warning: could not compute local cache staleness: ${checkResult.error}\n`,
54
+ );
55
+ }
56
+ // Local entry is stale if:
57
+ // 1. The timestamp has been zeroed (entry was invalidated), OR
58
+ // 2. check-files reports changed files
59
+ const isStale = !timestamp || !checkResult.ok || checkResult.value.status === "changed";
60
+
61
+ entries.push({
62
+ file: localPath,
63
+ agent: "local",
64
+ subject: data.topic ?? "local",
65
+ ...(description !== undefined ? { description } : {}),
66
+ fetched_at: timestamp,
67
+ age_human: getAgeHuman(timestamp),
68
+ is_stale: isStale,
69
+ });
70
+ } else {
71
+ process.stderr.write(`[cache-ctrl] Warning: malformed local cache file: ${localPath}\n`);
72
+ }
73
+ } else if (readResult.code !== ErrorCode.FILE_NOT_FOUND) {
74
+ return readResult;
75
+ }
76
+ }
77
+
78
+ return { ok: true, value: entries };
79
+ } catch (err) {
80
+ const msg = err instanceof Error ? err.message : String(err);
81
+ return { ok: false, error: msg, code: ErrorCode.UNKNOWN };
82
+ }
83
+ }
@@ -0,0 +1,110 @@
1
+ import { unlink } from "node:fs/promises";
2
+ import { findRepoRoot, listCacheFiles, writeCache, readCache } from "../cache/cacheManager.js";
3
+ import { isExternalStale } from "../cache/externalCache.js";
4
+ import { resolveLocalCachePath } from "../cache/localCache.js";
5
+ import { ExternalCacheFileSchema } from "../types/cache.js";
6
+ import { ErrorCode, type Result } from "../types/result.js";
7
+ import type { PruneArgs, PruneResult } from "../types/commands.js";
8
+ import { getFileStem } from "../utils/fileStem.js";
9
+
10
+ export function parseDurationMs(duration: string): number | null {
11
+ const match = /^(\d+)(h|d)$/.exec(duration);
12
+ if (!match) return null;
13
+ const value = parseInt(match[1]!, 10);
14
+ const unit = match[2]!;
15
+ if (unit === "h") return value * 3_600_000;
16
+ if (unit === "d") return value * 86_400_000;
17
+ return null;
18
+ }
19
+
20
+ export async function pruneCommand(args: PruneArgs): Promise<Result<PruneResult["value"]>> {
21
+ try {
22
+ const repoRoot = await findRepoRoot(process.cwd());
23
+ const agent = args.agent ?? "all";
24
+ const doDelete = args.delete ?? false;
25
+ const matched: Array<{ file: string; agent: "external" | "local"; subject: string }> = [];
26
+
27
+ // Parse maxAge for external (default 24h)
28
+ const externalMaxAgeMs = args.maxAge ? parseDurationMs(args.maxAge) : 24 * 3_600_000;
29
+ if (args.maxAge && externalMaxAgeMs === null) {
30
+ return { ok: false, error: `Invalid duration format: "${args.maxAge}". Use format like "24h" or "7d"`, code: ErrorCode.INVALID_ARGS };
31
+ }
32
+
33
+ if (agent === "external" || agent === "all") {
34
+ const filesResult = await listCacheFiles("external", repoRoot);
35
+ if (!filesResult.ok) return filesResult;
36
+
37
+ for (const filePath of filesResult.value) {
38
+ const readResult = await readCache(filePath);
39
+ if (!readResult.ok) continue;
40
+
41
+ const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
42
+ if (!parseResult.success) continue;
43
+ const data = parseResult.data;
44
+
45
+ if (isExternalStale(data, externalMaxAgeMs ?? undefined)) {
46
+ const stem = getFileStem(filePath);
47
+ const subject = data.subject ?? stem;
48
+ matched.push({ file: filePath, agent: "external", subject });
49
+ if (doDelete) {
50
+ try {
51
+ await unlink(filePath);
52
+ } catch (err) {
53
+ const error = err as NodeJS.ErrnoException;
54
+ if (error.code !== "ENOENT") {
55
+ return { ok: false, error: `Failed to delete ${filePath}: ${error.message}`, code: ErrorCode.FILE_WRITE_ERROR };
56
+ }
57
+ }
58
+ } else {
59
+ const writeResult = await writeCache(filePath, { fetched_at: "" });
60
+ if (!writeResult.ok) return writeResult;
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ if (agent === "local" || agent === "all") {
67
+ // Local always matches (age 0 rule)
68
+ const localPath = resolveLocalCachePath(repoRoot);
69
+
70
+ if (doDelete) {
71
+ try {
72
+ await unlink(localPath);
73
+ matched.push({ file: localPath, agent: "local", subject: "local" });
74
+ } catch (err) {
75
+ const error = err as NodeJS.ErrnoException;
76
+ if (error.code !== "ENOENT") {
77
+ return { ok: false, error: `Failed to delete ${localPath}: ${error.message}`, code: ErrorCode.FILE_WRITE_ERROR };
78
+ }
79
+ // File didn't exist — nothing pruned, don't add to matched
80
+ }
81
+ } else {
82
+ // Only invalidate if the file already exists
83
+ const readResult = await readCache(localPath);
84
+ if (!readResult.ok) {
85
+ if (readResult.code === ErrorCode.FILE_NOT_FOUND) {
86
+ // Nothing to prune — skip silently
87
+ } else {
88
+ return readResult;
89
+ }
90
+ } else {
91
+ const writeResult = await writeCache(localPath, { timestamp: "" });
92
+ if (!writeResult.ok) return writeResult;
93
+ matched.push({ file: localPath, agent: "local", subject: "local" });
94
+ }
95
+ }
96
+ }
97
+
98
+ return {
99
+ ok: true,
100
+ value: {
101
+ matched,
102
+ action: doDelete ? "deleted" : "invalidated",
103
+ count: matched.length,
104
+ },
105
+ };
106
+ } catch (err) {
107
+ const error = err as Error;
108
+ return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
109
+ }
110
+ }
@@ -0,0 +1,57 @@
1
+ import { findRepoRoot, loadExternalCacheEntries, readCache } from "../cache/cacheManager.js";
2
+ import { resolveLocalCachePath } from "../cache/localCache.js";
3
+ import { rankResults } from "../search/keywordSearch.js";
4
+ import type { CacheEntry } from "../types/cache.js";
5
+ import { LocalCacheFileSchema } from "../types/cache.js";
6
+ import { ErrorCode, type Result } from "../types/result.js";
7
+ import type { SearchArgs, SearchResult } from "../types/commands.js";
8
+
9
+ export async function searchCommand(args: SearchArgs): Promise<Result<SearchResult["value"]>> {
10
+ try {
11
+ const repoRoot = await findRepoRoot(process.cwd());
12
+ const entries: CacheEntry[] = [];
13
+
14
+ // Collect external entries
15
+ const externalEntriesResult = await loadExternalCacheEntries(repoRoot);
16
+ if (!externalEntriesResult.ok) return externalEntriesResult;
17
+ entries.push(...externalEntriesResult.value);
18
+
19
+ // Collect local entry
20
+ const localPath = resolveLocalCachePath(repoRoot);
21
+ const localReadResult = await readCache(localPath);
22
+ if (localReadResult.ok) {
23
+ const parseResult = LocalCacheFileSchema.safeParse(localReadResult.value);
24
+ if (parseResult.success) {
25
+ const data = parseResult.data;
26
+ entries.push({
27
+ file: localPath,
28
+ agent: "local",
29
+ subject: data.topic ?? "local",
30
+ description: data.description,
31
+ fetched_at: data.timestamp ?? "",
32
+ });
33
+ } else {
34
+ process.stderr.write(`[cache-ctrl] Warning: malformed local cache file: ${localPath}\n`);
35
+ }
36
+ } else if (localReadResult.code !== ErrorCode.FILE_NOT_FOUND) {
37
+ process.stderr.write(`[cache-ctrl] Warning: could not read local cache: ${localReadResult.error}\n`);
38
+ }
39
+
40
+ const ranked = rankResults(entries, args.keywords);
41
+
42
+ return {
43
+ ok: true,
44
+ value: ranked.map((entry) => ({
45
+ file: entry.file,
46
+ subject: entry.subject,
47
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
48
+ agent: entry.agent,
49
+ fetched_at: entry.fetched_at,
50
+ score: entry.score ?? 0,
51
+ })),
52
+ };
53
+ } catch (err) {
54
+ const error = err as Error;
55
+ return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
56
+ }
57
+ }