@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.
- package/README.md +558 -0
- package/cache_ctrl.ts +153 -0
- package/package.json +35 -0
- package/skills/cache-ctrl-caller/SKILL.md +154 -0
- package/skills/cache-ctrl-external/SKILL.md +130 -0
- package/skills/cache-ctrl-local/SKILL.md +213 -0
- package/src/cache/cacheManager.ts +241 -0
- package/src/cache/externalCache.ts +127 -0
- package/src/cache/localCache.ts +9 -0
- package/src/commands/checkFiles.ts +83 -0
- package/src/commands/checkFreshness.ts +123 -0
- package/src/commands/flush.ts +55 -0
- package/src/commands/inspect.ts +184 -0
- package/src/commands/install.ts +13 -0
- package/src/commands/invalidate.ts +53 -0
- package/src/commands/list.ts +83 -0
- package/src/commands/prune.ts +110 -0
- package/src/commands/search.ts +57 -0
- package/src/commands/touch.ts +47 -0
- package/src/commands/write.ts +170 -0
- package/src/files/changeDetector.ts +122 -0
- package/src/files/gitFiles.ts +41 -0
- package/src/files/openCodeInstaller.ts +66 -0
- package/src/http/freshnessChecker.ts +116 -0
- package/src/index.ts +557 -0
- package/src/search/keywordSearch.ts +59 -0
- package/src/types/cache.ts +91 -0
- package/src/types/commands.ts +192 -0
- package/src/types/result.ts +36 -0
- package/src/utils/fileStem.ts +7 -0
- package/src/utils/validate.ts +50 -0
|
@@ -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
|
+
}
|