@thecat69/cache-ctrl 1.0.0 → 1.2.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.
Files changed (45) hide show
  1. package/README.md +289 -78
  2. package/cache_ctrl.ts +107 -25
  3. package/package.json +2 -1
  4. package/skills/cache-ctrl-caller/SKILL.md +53 -114
  5. package/skills/cache-ctrl-external/SKILL.md +29 -89
  6. package/skills/cache-ctrl-local/SKILL.md +82 -164
  7. package/src/analysis/graphBuilder.ts +85 -0
  8. package/src/analysis/pageRank.ts +164 -0
  9. package/src/analysis/symbolExtractor.ts +240 -0
  10. package/src/cache/cacheManager.ts +53 -4
  11. package/src/cache/externalCache.ts +72 -77
  12. package/src/cache/graphCache.ts +12 -0
  13. package/src/cache/localCache.ts +2 -0
  14. package/src/commands/checkFiles.ts +9 -6
  15. package/src/commands/flush.ts +9 -2
  16. package/src/commands/graph.ts +131 -0
  17. package/src/commands/inspect.ts +13 -181
  18. package/src/commands/inspectExternal.ts +79 -0
  19. package/src/commands/inspectLocal.ts +134 -0
  20. package/src/commands/install.ts +6 -0
  21. package/src/commands/invalidate.ts +24 -24
  22. package/src/commands/list.ts +11 -11
  23. package/src/commands/map.ts +87 -0
  24. package/src/commands/prune.ts +20 -8
  25. package/src/commands/search.ts +9 -2
  26. package/src/commands/touch.ts +15 -25
  27. package/src/commands/uninstall.ts +103 -0
  28. package/src/commands/update.ts +65 -0
  29. package/src/commands/version.ts +14 -0
  30. package/src/commands/watch.ts +270 -0
  31. package/src/commands/writeExternal.ts +51 -0
  32. package/src/commands/writeLocal.ts +121 -0
  33. package/src/files/changeDetector.ts +15 -0
  34. package/src/files/gitFiles.ts +15 -0
  35. package/src/files/openCodeInstaller.ts +21 -2
  36. package/src/index.ts +314 -58
  37. package/src/search/keywordSearch.ts +24 -0
  38. package/src/types/cache.ts +38 -26
  39. package/src/types/commands.ts +123 -22
  40. package/src/types/result.ts +26 -9
  41. package/src/utils/errors.ts +14 -0
  42. package/src/utils/traversal.ts +42 -0
  43. package/src/commands/checkFreshness.ts +0 -123
  44. package/src/commands/write.ts +0 -170
  45. package/src/http/freshnessChecker.ts +0 -116
@@ -0,0 +1,131 @@
1
+ import { computePageRank } from "../analysis/pageRank.js";
2
+ import { findRepoRoot, readCache } from "../cache/cacheManager.js";
3
+ import { resolveGraphCachePath } from "../cache/graphCache.js";
4
+ import { GraphCacheFileSchema } from "../types/cache.js";
5
+ import { ErrorCode, type Result } from "../types/result.js";
6
+ import type { DependencyGraph } from "../analysis/graphBuilder.js";
7
+ import type { GraphArgs, GraphResult } from "../types/commands.js";
8
+ import { toUnknownResult } from "../utils/errors.js";
9
+
10
+ interface RankedFileEntry {
11
+ path: string;
12
+ rank: number;
13
+ deps: string[];
14
+ defs: string[];
15
+ ref_count: number;
16
+ }
17
+
18
+ function estimateEntryTokens(entry: RankedFileEntry): number {
19
+ return Math.ceil(JSON.stringify(entry).length / 4);
20
+ }
21
+
22
+ function countReferences(graph: DependencyGraph): Map<string, number> {
23
+ const refCounts = new Map<string, number>();
24
+
25
+ for (const nodePath of graph.keys()) {
26
+ refCounts.set(nodePath, 0);
27
+ }
28
+
29
+ for (const graphNode of graph.values()) {
30
+ const uniqueDeps = new Set(graphNode.deps);
31
+ for (const depPath of uniqueDeps) {
32
+ if (!refCounts.has(depPath)) {
33
+ continue;
34
+ }
35
+ const currentCount = refCounts.get(depPath) ?? 0;
36
+ refCounts.set(depPath, currentCount + 1);
37
+ }
38
+ }
39
+
40
+ return refCounts;
41
+ }
42
+
43
+ /**
44
+ * Returns a token-budgeted, PageRank-ranked projection of the dependency graph cache.
45
+ *
46
+ * @param args - {@link GraphArgs} command arguments.
47
+ * @returns Promise<Result<GraphResult["value"]>>; common failures include FILE_NOT_FOUND,
48
+ * PARSE_ERROR, FILE_READ_ERROR, and UNKNOWN.
49
+ */
50
+ export async function graphCommand(args: GraphArgs): Promise<Result<GraphResult["value"]>> {
51
+ try {
52
+ const repoRoot = await findRepoRoot(process.cwd());
53
+ const graphPath = resolveGraphCachePath(repoRoot);
54
+
55
+ const readResult = await readCache(graphPath);
56
+ if (!readResult.ok) {
57
+ if (readResult.code === ErrorCode.FILE_NOT_FOUND) {
58
+ return {
59
+ ok: false,
60
+ error:
61
+ "graph.json not found — run 'cache-ctrl watch' or wait for the background daemon to compute the graph",
62
+ code: ErrorCode.FILE_NOT_FOUND,
63
+ };
64
+ }
65
+ return readResult;
66
+ }
67
+
68
+ const parseResult = GraphCacheFileSchema.safeParse(readResult.value);
69
+ if (!parseResult.success) {
70
+ return {
71
+ ok: false,
72
+ error: `Malformed graph cache file: ${graphPath}`,
73
+ code: ErrorCode.PARSE_ERROR,
74
+ };
75
+ }
76
+
77
+ const parsed = parseResult.data;
78
+ const graph: DependencyGraph = new Map(
79
+ Object.entries(parsed.files).map(([nodePath, node]) => [
80
+ nodePath,
81
+ {
82
+ deps: node.deps,
83
+ defs: node.defs,
84
+ },
85
+ ]),
86
+ );
87
+
88
+ const refCounts = countReferences(graph);
89
+ const ranks = computePageRank(graph, {
90
+ ...(args.seed !== undefined ? { seedFiles: args.seed } : {}),
91
+ });
92
+
93
+ const rankedEntries = [...ranks.entries()]
94
+ .map(([nodePath, rank]): RankedFileEntry => {
95
+ const node = graph.get(nodePath);
96
+ return {
97
+ path: nodePath,
98
+ rank,
99
+ deps: node?.deps ?? [],
100
+ defs: node?.defs ?? [],
101
+ ref_count: refCounts.get(nodePath) ?? 0,
102
+ };
103
+ })
104
+ .sort((a, b) => b.rank - a.rank);
105
+
106
+ const tokenBudget = Math.max(64, Math.min(args.maxTokens ?? 1024, 128_000));
107
+ let tokenEstimate = 0;
108
+ const budgetedEntries: RankedFileEntry[] = [];
109
+
110
+ for (const entry of rankedEntries) {
111
+ const estimatedTokens = estimateEntryTokens(entry);
112
+ if (tokenEstimate + estimatedTokens > tokenBudget) {
113
+ break;
114
+ }
115
+ budgetedEntries.push(entry);
116
+ tokenEstimate += estimatedTokens;
117
+ }
118
+
119
+ return {
120
+ ok: true,
121
+ value: {
122
+ ranked_files: budgetedEntries,
123
+ total_files: graph.size,
124
+ computed_at: parsed.computed_at,
125
+ token_estimate: tokenEstimate,
126
+ },
127
+ };
128
+ } catch (err) {
129
+ return toUnknownResult(err);
130
+ }
131
+ }
@@ -1,184 +1,16 @@
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
1
  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
-
2
+ import { inspectExternalCommand } from "./inspectExternal.js";
3
+ import { inspectLocalCommand } from "./inspectLocal.js";
4
+ import type { Result } from "../types/result.js";
5
+
6
+ /**
7
+ * Routes inspect requests to the agent-specific implementation.
8
+ *
9
+ * @param args - {@link InspectArgs} command arguments.
10
+ * @returns Promise<Result<InspectResult["value"]>>; may return INVALID_ARGS,
11
+ * FILE_NOT_FOUND, AMBIGUOUS_MATCH, PARSE_ERROR, or UNKNOWN.
12
+ */
22
13
  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
- }
14
+ if (args.agent === "local") return inspectLocalCommand(args);
15
+ return inspectExternalCommand(args);
184
16
  }
@@ -0,0 +1,79 @@
1
+ import { findRepoRoot, loadExternalCacheEntries, readCache } from "../cache/cacheManager.js";
2
+ import { scoreEntry } from "../search/keywordSearch.js";
3
+ import type { CacheEntry, ExternalCacheFile } from "../types/cache.js";
4
+ import type { InspectArgs, InspectResult } from "../types/commands.js";
5
+ import { ExternalCacheFileSchema } from "../types/cache.js";
6
+ import { ErrorCode, type Result } from "../types/result.js";
7
+ import { toUnknownResult } from "../utils/errors.js";
8
+
9
+ /**
10
+ * Inspects the best-matching external cache entry by subject keyword.
11
+ *
12
+ * @param args - {@link InspectArgs} with `agent: "external"`.
13
+ * @returns Promise<Result<InspectResult["value"]>>; common failures include INVALID_ARGS,
14
+ * FILE_NOT_FOUND, AMBIGUOUS_MATCH, PARSE_ERROR, and UNKNOWN.
15
+ */
16
+ export async function inspectExternalCommand(args: InspectArgs): Promise<Result<InspectResult["value"]>> {
17
+ try {
18
+ if (args.folder !== undefined) {
19
+ return {
20
+ ok: false,
21
+ error: "--folder is only supported for local cache",
22
+ code: ErrorCode.INVALID_ARGS,
23
+ };
24
+ }
25
+
26
+ const repoRoot = await findRepoRoot(process.cwd());
27
+ const entriesResult = await loadExternalCacheEntries(repoRoot);
28
+ if (!entriesResult.ok) return entriesResult;
29
+
30
+ const candidates: Array<{ entry: CacheEntry; content: ExternalCacheFile; file: string }> = [];
31
+
32
+ for (const entry of entriesResult.value) {
33
+ const readResult = await readCache(entry.file);
34
+ if (!readResult.ok) continue;
35
+ const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
36
+ if (!parseResult.success) continue;
37
+ candidates.push({ entry, content: parseResult.data, file: entry.file });
38
+ }
39
+
40
+ if (candidates.length === 0) {
41
+ return { ok: false, error: `No cache entries found for agent "${args.agent}"`, code: ErrorCode.FILE_NOT_FOUND };
42
+ }
43
+
44
+ const keywords = [args.subject];
45
+ const scored = candidates.map((candidate) => ({
46
+ ...candidate,
47
+ score: scoreEntry(candidate.entry, keywords),
48
+ }));
49
+ const matched = scored.filter((candidate) => candidate.score > 0);
50
+
51
+ if (matched.length === 0) {
52
+ return { ok: false, error: `No cache entry matched keyword "${args.subject}"`, code: ErrorCode.FILE_NOT_FOUND };
53
+ }
54
+
55
+ matched.sort((a, b) => b.score - a.score);
56
+
57
+ const top = matched[0]!;
58
+ const second = matched[1];
59
+
60
+ if (second && top.score === second.score) {
61
+ return {
62
+ ok: false,
63
+ error: `Ambiguous match: multiple entries scored equally for "${args.subject}"`,
64
+ code: ErrorCode.AMBIGUOUS_MATCH,
65
+ };
66
+ }
67
+
68
+ return {
69
+ ok: true,
70
+ value: {
71
+ ...top.content,
72
+ file: top.file,
73
+ agent: args.agent,
74
+ },
75
+ };
76
+ } catch (err) {
77
+ return toUnknownResult(err);
78
+ }
79
+ }
@@ -0,0 +1,134 @@
1
+ import { normalize } from "node:path";
2
+
3
+ import { findRepoRoot, readCache } from "../cache/cacheManager.js";
4
+ import { resolveLocalCachePath } from "../cache/localCache.js";
5
+ import { scoreEntry } from "../search/keywordSearch.js";
6
+ import type { CacheEntry, FileFacts } from "../types/cache.js";
7
+ import type { InspectArgs, InspectResult } from "../types/commands.js";
8
+ import { LocalCacheFileSchema } from "../types/cache.js";
9
+ import { ErrorCode, type Result } from "../types/result.js";
10
+ import { toUnknownResult } from "../utils/errors.js";
11
+
12
+ function filterFacts(
13
+ facts: Record<string, FileFacts>,
14
+ keywords: string[],
15
+ ): Record<string, FileFacts> {
16
+ if (keywords.length === 0) return facts;
17
+ const lowerKeywords = keywords.map((keyword) => keyword.toLowerCase());
18
+ return Object.fromEntries(
19
+ Object.entries(facts).filter(([path]) => lowerKeywords.some((keyword) => path.toLowerCase().includes(keyword))),
20
+ );
21
+ }
22
+
23
+ function normalizeFolderArg(folder: string): Result<string> {
24
+ const normalizedFolder = folder.replace(/\\/g, "/").replace(/\/+$/, "");
25
+
26
+ if (normalizedFolder.length === 0) {
27
+ return {
28
+ ok: false,
29
+ error: "--folder must not be an empty string",
30
+ code: ErrorCode.INVALID_ARGS,
31
+ };
32
+ }
33
+
34
+ if (normalize(normalizedFolder).split("/").includes("..")) {
35
+ return {
36
+ ok: false,
37
+ error: "--folder must not contain '..' path segments",
38
+ code: ErrorCode.INVALID_ARGS,
39
+ };
40
+ }
41
+
42
+ return { ok: true, value: normalizedFolder };
43
+ }
44
+
45
+ /**
46
+ * Inspects local context cache content with optional path/fact filters.
47
+ *
48
+ * @param args - {@link InspectArgs} with `agent: "local"`.
49
+ * @returns Promise<Result<InspectResult["value"]>>; common failures include INVALID_ARGS,
50
+ * FILE_NOT_FOUND, PARSE_ERROR, and UNKNOWN.
51
+ */
52
+ export async function inspectLocalCommand(args: InspectArgs): Promise<Result<InspectResult["value"]>> {
53
+ try {
54
+ let normalizedFolder: string | undefined;
55
+ if (args.folder !== undefined) {
56
+ const folderResult = normalizeFolderArg(args.folder);
57
+ if (!folderResult.ok) return folderResult;
58
+ normalizedFolder = folderResult.value;
59
+ }
60
+
61
+ const repoRoot = await findRepoRoot(process.cwd());
62
+ const localPath = resolveLocalCachePath(repoRoot);
63
+ const readResult = await readCache(localPath);
64
+ if (!readResult.ok) return readResult;
65
+
66
+ const parseResult = LocalCacheFileSchema.safeParse(readResult.value);
67
+ if (!parseResult.success) {
68
+ return { ok: false, error: `Malformed local cache file: ${localPath}`, code: ErrorCode.PARSE_ERROR };
69
+ }
70
+
71
+ const data = parseResult.data;
72
+ const entry: CacheEntry = {
73
+ file: localPath,
74
+ agent: "local",
75
+ subject: data.topic ?? "local",
76
+ description: data.description,
77
+ fetched_at: data.timestamp ?? "",
78
+ };
79
+
80
+ const scored = [
81
+ {
82
+ entry,
83
+ content: data,
84
+ file: localPath,
85
+ score: scoreEntry(entry, [args.subject]),
86
+ },
87
+ ];
88
+ const matched = scored.filter((candidate) => candidate.score > 0);
89
+
90
+ if (matched.length === 0) {
91
+ return { ok: false, error: `No cache entry matched keyword "${args.subject}"`, code: ErrorCode.FILE_NOT_FOUND };
92
+ }
93
+
94
+ const top = matched[0]!;
95
+ const { tracked_files: _dropped, facts, ...rest } = top.content;
96
+
97
+ let filteredFacts = facts;
98
+ if (filteredFacts !== undefined && normalizedFolder !== undefined) {
99
+ filteredFacts = Object.fromEntries(
100
+ Object.entries(filteredFacts).filter(([key]) => {
101
+ const normalizedPath = key.replace(/\\/g, "/");
102
+ return normalizedPath === normalizedFolder || normalizedPath.startsWith(normalizedFolder + "/");
103
+ }),
104
+ );
105
+ }
106
+
107
+ if (filteredFacts !== undefined) {
108
+ filteredFacts = filterFacts(filteredFacts, args.filter ?? []);
109
+ }
110
+
111
+ if (filteredFacts !== undefined && args.searchFacts !== undefined) {
112
+ const loweredKeywords = args.searchFacts.map((keyword) => keyword.toLowerCase());
113
+ filteredFacts = Object.fromEntries(
114
+ Object.entries(filteredFacts).filter(([, factEntry]) =>
115
+ (factEntry.facts ?? []).some((fact) => loweredKeywords.some((keyword) => fact.toLowerCase().includes(keyword))),
116
+ ),
117
+ );
118
+ }
119
+
120
+ const resultValue: InspectResult["value"] = {
121
+ ...rest,
122
+ ...(filteredFacts !== undefined ? { facts: filteredFacts } : {}),
123
+ file: top.file,
124
+ agent: args.agent,
125
+ };
126
+
127
+ return {
128
+ ok: true,
129
+ value: resultValue,
130
+ };
131
+ } catch (err) {
132
+ return toUnknownResult(err);
133
+ }
134
+ }
@@ -4,6 +4,12 @@ import { installOpenCodeIntegration, resolveOpenCodeConfigDir } from "../files/o
4
4
  import type { InstallArgs, InstallResult } from "../types/commands.js";
5
5
  import type { Result } from "../types/result.js";
6
6
 
7
+ /**
8
+ * Installs OpenCode tool wrapper and bundled skill files.
9
+ *
10
+ * @param args - {@link InstallArgs} command arguments.
11
+ * @returns Promise<Result<InstallResult>>; common failures include FILE_WRITE_ERROR and UNKNOWN.
12
+ */
7
13
  export async function installCommand(
8
14
  args: InstallArgs,
9
15
  packageRoot: string = path.resolve(import.meta.dir, "../.."),
@@ -1,35 +1,27 @@
1
- import { findRepoRoot, listCacheFiles, writeCache, readCache } from "../cache/cacheManager.js";
2
- import { resolveTopExternalMatch } from "../cache/externalCache.js";
1
+ import { findRepoRoot, writeCache, readCache } from "../cache/cacheManager.js";
2
+ import { updateExternalFetchedAt } from "../cache/externalCache.js";
3
+ import { resolveGraphCachePath } from "../cache/graphCache.js";
3
4
  import { resolveLocalCachePath } from "../cache/localCache.js";
4
5
  import { ErrorCode, type Result } from "../types/result.js";
5
6
  import type { InvalidateArgs, InvalidateResult } from "../types/commands.js";
6
- import { validateSubject } from "../utils/validate.js";
7
+ import { toUnknownResult } from "../utils/errors.js";
7
8
 
9
+ /**
10
+ * Marks cache entries stale by zeroing their freshness timestamps.
11
+ *
12
+ * @param args - {@link InvalidateArgs} command arguments.
13
+ * @returns Promise<Result<InvalidateResult["value"]>>; common failures include INVALID_ARGS,
14
+ * NO_MATCH, FILE_NOT_FOUND, FILE_WRITE_ERROR, and UNKNOWN.
15
+ */
8
16
  export async function invalidateCommand(args: InvalidateArgs): Promise<Result<InvalidateResult["value"]>> {
9
17
  try {
10
18
  const repoRoot = await findRepoRoot(process.cwd());
11
19
  const invalidated: string[] = [];
12
20
 
13
21
  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
- }
22
+ const updateResult = await updateExternalFetchedAt(repoRoot, args.subject, "");
23
+ if (!updateResult.ok) return updateResult;
24
+ invalidated.push(...updateResult.value);
33
25
  } else {
34
26
  // local — only invalidate if the file already exists
35
27
  const localPath = resolveLocalCachePath(repoRoot);
@@ -43,11 +35,19 @@ export async function invalidateCommand(args: InvalidateArgs): Promise<Result<In
43
35
  const writeResult = await writeCache(localPath, { timestamp: "" });
44
36
  if (!writeResult.ok) return writeResult;
45
37
  invalidated.push(localPath);
38
+
39
+ const graphPath = resolveGraphCachePath(repoRoot);
40
+ const graphReadResult = await readCache(graphPath);
41
+ if (graphReadResult.ok) {
42
+ const graphWriteResult = await writeCache(graphPath, { computed_at: "" });
43
+ if (!graphWriteResult.ok) return graphWriteResult;
44
+ } else if (graphReadResult.code !== ErrorCode.FILE_NOT_FOUND) {
45
+ return graphReadResult;
46
+ }
46
47
  }
47
48
 
48
49
  return { ok: true, value: { invalidated } };
49
50
  } catch (err) {
50
- const error = err as Error;
51
- return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
51
+ return toUnknownResult(err);
52
52
  }
53
53
  }
@@ -1,18 +1,19 @@
1
1
  import { findRepoRoot, loadExternalCacheEntries, readCache } from "../cache/cacheManager.js";
2
- import { getAgeHuman } from "../cache/externalCache.js";
2
+ import { getAgeHuman, isFetchedAtStale } from "../cache/externalCache.js";
3
3
  import { resolveLocalCachePath } from "../cache/localCache.js";
4
4
  import { checkFilesCommand } from "./checkFiles.js";
5
5
  import { LocalCacheFileSchema } from "../types/cache.js";
6
6
  import { ErrorCode, type Result } from "../types/result.js";
7
+ import { toUnknownResult } from "../utils/errors.js";
7
8
  import type { ListArgs, ListEntry, ListResult } from "../types/commands.js";
8
9
 
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
-
10
+ /**
11
+ * Lists cache entries for external and/or local namespaces.
12
+ *
13
+ * @param args - {@link ListArgs} command arguments.
14
+ * @returns Promise<Result<ListResult["value"]>>; common failures include FILE_READ_ERROR,
15
+ * FILE_NOT_FOUND, PARSE_ERROR, and UNKNOWN.
16
+ */
16
17
  export async function listCommand(args: ListArgs): Promise<Result<ListResult["value"]>> {
17
18
  try {
18
19
  const repoRoot = await findRepoRoot(process.cwd());
@@ -31,7 +32,7 @@ export async function listCommand(args: ListArgs): Promise<Result<ListResult["va
31
32
  ...(entry.description !== undefined ? { description: entry.description } : {}),
32
33
  fetched_at: entry.fetched_at,
33
34
  age_human: getAgeHuman(entry.fetched_at),
34
- is_stale: isEntryStale(entry.fetched_at),
35
+ is_stale: isFetchedAtStale(entry.fetched_at),
35
36
  });
36
37
  }
37
38
  }
@@ -77,7 +78,6 @@ export async function listCommand(args: ListArgs): Promise<Result<ListResult["va
77
78
 
78
79
  return { ok: true, value: entries };
79
80
  } catch (err) {
80
- const msg = err instanceof Error ? err.message : String(err);
81
- return { ok: false, error: msg, code: ErrorCode.UNKNOWN };
81
+ return toUnknownResult(err);
82
82
  }
83
83
  }