@thecat69/cache-ctrl 1.0.0 → 1.1.1

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 (43) hide show
  1. package/README.md +202 -28
  2. package/cache_ctrl.ts +125 -13
  3. package/package.json +2 -1
  4. package/skills/cache-ctrl-caller/SKILL.md +45 -31
  5. package/skills/cache-ctrl-external/SKILL.md +20 -45
  6. package/skills/cache-ctrl-local/SKILL.md +95 -86
  7. package/src/analysis/graphBuilder.ts +85 -0
  8. package/src/analysis/pageRank.ts +167 -0
  9. package/src/analysis/symbolExtractor.ts +240 -0
  10. package/src/cache/cacheManager.ts +52 -2
  11. package/src/cache/externalCache.ts +41 -64
  12. package/src/cache/graphCache.ts +12 -0
  13. package/src/cache/localCache.ts +2 -0
  14. package/src/commands/checkFiles.ts +7 -4
  15. package/src/commands/checkFreshness.ts +19 -19
  16. package/src/commands/flush.ts +9 -2
  17. package/src/commands/graph.ts +131 -0
  18. package/src/commands/inspect.ts +13 -181
  19. package/src/commands/inspectExternal.ts +79 -0
  20. package/src/commands/inspectLocal.ts +134 -0
  21. package/src/commands/install.ts +6 -0
  22. package/src/commands/invalidate.ts +19 -2
  23. package/src/commands/list.ts +11 -11
  24. package/src/commands/map.ts +87 -0
  25. package/src/commands/prune.ts +20 -8
  26. package/src/commands/search.ts +9 -2
  27. package/src/commands/touch.ts +9 -2
  28. package/src/commands/version.ts +14 -0
  29. package/src/commands/watch.ts +253 -0
  30. package/src/commands/writeExternal.ts +51 -0
  31. package/src/commands/writeLocal.ts +123 -0
  32. package/src/files/changeDetector.ts +15 -0
  33. package/src/files/gitFiles.ts +15 -0
  34. package/src/files/openCodeInstaller.ts +21 -2
  35. package/src/http/freshnessChecker.ts +23 -1
  36. package/src/index.ts +253 -28
  37. package/src/search/keywordSearch.ts +24 -0
  38. package/src/types/cache.ts +42 -18
  39. package/src/types/commands.ts +99 -1
  40. package/src/types/result.ts +27 -7
  41. package/src/utils/errors.ts +14 -0
  42. package/src/utils/traversal.ts +42 -0
  43. package/src/commands/write.ts +0 -170
@@ -0,0 +1,240 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, extname, resolve, sep } from "node:path";
3
+
4
+ import { parse } from "@typescript-eslint/typescript-estree";
5
+
6
+ export interface FileSymbols {
7
+ /** Resolved file paths this file imports from (relative imports only, resolved to absolute) */
8
+ deps: string[];
9
+ /** Exported symbol names declared in this file */
10
+ defs: string[];
11
+ }
12
+
13
+ interface AstNode {
14
+ type: string;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ const SUPPORTED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
19
+
20
+ function isAstNode(value: unknown): value is AstNode {
21
+ return typeof value === "object" && value !== null && "type" in value;
22
+ }
23
+
24
+ function walkAst(value: unknown, visitor: (node: AstNode) => void): void {
25
+ if (Array.isArray(value)) {
26
+ for (const item of value) {
27
+ walkAst(item, visitor);
28
+ }
29
+ return;
30
+ }
31
+
32
+ if (typeof value !== "object" || value === null) {
33
+ return;
34
+ }
35
+
36
+ if (isAstNode(value)) {
37
+ visitor(value);
38
+ }
39
+
40
+ for (const child of Object.values(value)) {
41
+ walkAst(child, visitor);
42
+ }
43
+ }
44
+
45
+ function collectPatternIdentifiers(pattern: unknown, defs: Set<string>): void {
46
+ if (!isAstNode(pattern)) {
47
+ return;
48
+ }
49
+
50
+ if (pattern.type === "Identifier") {
51
+ const name = pattern.name;
52
+ if (typeof name === "string") {
53
+ defs.add(name);
54
+ }
55
+ return;
56
+ }
57
+
58
+ if (pattern.type === "RestElement") {
59
+ collectPatternIdentifiers(pattern.argument, defs);
60
+ return;
61
+ }
62
+
63
+ if (pattern.type === "AssignmentPattern") {
64
+ collectPatternIdentifiers(pattern.left, defs);
65
+ return;
66
+ }
67
+
68
+ if (pattern.type === "ArrayPattern") {
69
+ const elements = pattern.elements;
70
+ if (Array.isArray(elements)) {
71
+ for (const element of elements) {
72
+ collectPatternIdentifiers(element, defs);
73
+ }
74
+ }
75
+ return;
76
+ }
77
+
78
+ if (pattern.type === "ObjectPattern") {
79
+ const properties = pattern.properties;
80
+ if (!Array.isArray(properties)) {
81
+ return;
82
+ }
83
+
84
+ for (const property of properties) {
85
+ if (!isAstNode(property)) {
86
+ continue;
87
+ }
88
+
89
+ if (property.type === "Property") {
90
+ collectPatternIdentifiers(property.value, defs);
91
+ }
92
+
93
+ if (property.type === "RestElement") {
94
+ collectPatternIdentifiers(property.argument, defs);
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ function collectNamesFromDeclaration(declaration: unknown, defs: Set<string>): void {
101
+ if (!isAstNode(declaration)) {
102
+ return;
103
+ }
104
+
105
+ if (declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") {
106
+ const id = declaration.id;
107
+ if (isAstNode(id) && id.type === "Identifier") {
108
+ const name = id.name;
109
+ if (typeof name === "string") {
110
+ defs.add(name);
111
+ }
112
+ }
113
+ return;
114
+ }
115
+
116
+ if (declaration.type === "VariableDeclaration") {
117
+ const declarations = declaration.declarations;
118
+ if (!Array.isArray(declarations)) {
119
+ return;
120
+ }
121
+
122
+ for (const variableDeclarator of declarations) {
123
+ if (!isAstNode(variableDeclarator) || variableDeclarator.type !== "VariableDeclarator") {
124
+ continue;
125
+ }
126
+ collectPatternIdentifiers(variableDeclarator.id, defs);
127
+ }
128
+ return;
129
+ }
130
+
131
+ const id = declaration.id;
132
+ if (isAstNode(id) && id.type === "Identifier") {
133
+ const name = id.name;
134
+ if (typeof name === "string") {
135
+ defs.add(name);
136
+ }
137
+ }
138
+ }
139
+
140
+ function collectExportSpecifierName(specifier: unknown): string | null {
141
+ if (!isAstNode(specifier)) {
142
+ return null;
143
+ }
144
+
145
+ const exported = specifier.exported;
146
+ if (!isAstNode(exported)) {
147
+ return null;
148
+ }
149
+
150
+ if (exported.type === "Identifier") {
151
+ return typeof exported.name === "string" ? exported.name : null;
152
+ }
153
+
154
+ if (exported.type === "Literal") {
155
+ return typeof exported.value === "string" ? exported.value : null;
156
+ }
157
+
158
+ return null;
159
+ }
160
+
161
+ /**
162
+ * Extract import dependencies and export definitions from a source file.
163
+ * Returns an empty FileSymbols if the file cannot be parsed.
164
+ * Never throws.
165
+ */
166
+ export async function extractSymbols(filePath: string, repoRoot: string): Promise<FileSymbols> {
167
+ try {
168
+ const extension = extname(filePath).toLowerCase();
169
+ if (!SUPPORTED_EXTENSIONS.has(extension)) {
170
+ return { deps: [], defs: [] };
171
+ }
172
+
173
+ const sourceCode = await readFile(filePath, "utf8");
174
+ const ast = parse(sourceCode, {
175
+ jsx: true,
176
+ range: false,
177
+ loc: false,
178
+ sourceType: "module",
179
+ errorOnUnknownASTType: false,
180
+ });
181
+
182
+ const normalizedRepoRoot = resolve(repoRoot);
183
+ const repoPrefix = `${normalizedRepoRoot}${sep}`;
184
+ const dependencies = new Set<string>();
185
+ const definitions = new Set<string>();
186
+
187
+ walkAst(ast, (node) => {
188
+ if (node.type === "ImportDeclaration") {
189
+ const source = node.source;
190
+ if (!isAstNode(source) || source.type !== "Literal") {
191
+ return;
192
+ }
193
+
194
+ const importValue = source.value;
195
+ if (typeof importValue !== "string") {
196
+ return;
197
+ }
198
+
199
+ if (!importValue.startsWith(".") && !importValue.startsWith("/")) {
200
+ return;
201
+ }
202
+
203
+ const resolvedDependency = resolve(dirname(filePath), importValue);
204
+ if (resolvedDependency === normalizedRepoRoot || resolvedDependency.startsWith(repoPrefix)) {
205
+ dependencies.add(resolvedDependency);
206
+ }
207
+ return;
208
+ }
209
+
210
+ if (node.type === "ExportNamedDeclaration") {
211
+ collectNamesFromDeclaration(node.declaration, definitions);
212
+
213
+ const specifiers = node.specifiers;
214
+ if (!Array.isArray(specifiers)) {
215
+ return;
216
+ }
217
+
218
+ for (const specifier of specifiers) {
219
+ const name = collectExportSpecifierName(specifier);
220
+ if (name !== null) {
221
+ definitions.add(name);
222
+ }
223
+ }
224
+ return;
225
+ }
226
+
227
+ if (node.type === "ExportDefaultDeclaration") {
228
+ definitions.add("default");
229
+ collectNamesFromDeclaration(node.declaration, definitions);
230
+ }
231
+ });
232
+
233
+ return {
234
+ deps: [...dependencies],
235
+ defs: [...definitions],
236
+ };
237
+ } catch {
238
+ return { deps: [], defs: [] };
239
+ }
240
+ }
@@ -11,6 +11,14 @@ const LOCK_RETRY_INTERVAL_MS = 50;
11
11
  const LOCK_TIMEOUT_MS = 5000;
12
12
  const LOCK_STALE_AGE_MS = 30_000;
13
13
 
14
+ /**
15
+ * Resolves the repository root by walking upward until a `.git` directory is found.
16
+ *
17
+ * @param startDir - Directory where upward detection starts.
18
+ * @returns Absolute path to detected repo root, or `startDir` when no `.git` is found.
19
+ * @remarks Uses a parent-directory walk; on filesystem root fallback it intentionally
20
+ * returns `startDir` so CLI behavior remains deterministic outside git repositories.
21
+ */
14
22
  export async function findRepoRoot(startDir: string): Promise<string> {
15
23
  let current = startDir;
16
24
  while (true) {
@@ -28,6 +36,7 @@ export async function findRepoRoot(startDir: string): Promise<string> {
28
36
  }
29
37
  }
30
38
 
39
+ /** Resolves the on-disk cache directory for a given agent namespace. */
31
40
  export function resolveCacheDir(agent: AgentType, repoRoot: string): string {
32
41
  if (agent === "external") {
33
42
  return join(repoRoot, ".ai", "external-context-gatherer_cache");
@@ -35,6 +44,14 @@ export function resolveCacheDir(agent: AgentType, repoRoot: string): string {
35
44
  return join(repoRoot, ".ai", "local-context-gatherer_cache");
36
45
  }
37
46
 
47
+ /**
48
+ * Reads and JSON-parses one cache file.
49
+ *
50
+ * @param filePath - Absolute cache file path.
51
+ * @returns Parsed object on success, or typed read/parse failures.
52
+ * @remarks Returns `FILE_NOT_FOUND` when the file is absent, and `PARSE_ERROR` when the
53
+ * file exists but contains invalid JSON. Low-level I/O failures return `FILE_READ_ERROR`.
54
+ */
38
55
  export async function readCache(filePath: string): Promise<Result<Record<string, unknown>>> {
39
56
  try {
40
57
  const content = await readFile(filePath, "utf-8");
@@ -53,6 +70,17 @@ export async function readCache(filePath: string): Promise<Result<Record<string,
53
70
  }
54
71
  }
55
72
 
73
+ /**
74
+ * Writes cache content using advisory locking and atomic rename.
75
+ *
76
+ * @param filePath - Absolute cache file path to write.
77
+ * @param updates - Partial updates (`merge`) or full replacement payload (`replace`).
78
+ * @param mode - `merge` overlays updates onto existing JSON; `replace` writes payload as-is.
79
+ * @remarks In `merge` mode the function performs read-modify-write preserving unknown fields.
80
+ * Writes use temp-file + `rename()` for atomic visibility. A per-file advisory lock is
81
+ * acquired before mutation and released in `finally`, preventing concurrent writers from
82
+ * interleaving updates.
83
+ */
56
84
  export async function writeCache(
57
85
  filePath: string,
58
86
  updates: Partial<ExternalCacheFile> | Partial<LocalCacheFile> | Record<string, unknown>,
@@ -101,6 +129,13 @@ export async function writeCache(
101
129
  }
102
130
  }
103
131
 
132
+ /**
133
+ * Lists JSON cache files for an agent namespace.
134
+ *
135
+ * @param agent - Cache namespace to inspect.
136
+ * @param repoRoot - Repository root used to resolve cache directory paths.
137
+ * @returns Absolute `.json` file paths; returns an empty array when directory is absent.
138
+ */
104
139
  export async function listCacheFiles(agent: AgentType, repoRoot: string): Promise<Result<string[]>> {
105
140
  const cacheDir = resolveCacheDir(agent, repoRoot);
106
141
  try {
@@ -157,6 +192,15 @@ export async function loadExternalCacheEntries(repoRoot: string): Promise<Result
157
192
  return { ok: true, value: entries };
158
193
  }
159
194
 
195
+ /**
196
+ * Acquires an advisory lock file for a cache path.
197
+ *
198
+ * @param filePath - Cache file path whose lock (`.lock`) should be acquired.
199
+ * @returns `ok: true` when lock is acquired, otherwise typed lock failure.
200
+ * @remarks Uses atomic `O_EXCL` create semantics to guarantee single-writer lock acquisition.
201
+ * Existing locks are checked for staleness via lock age and PID liveness (`process.kill(pid, 0)`).
202
+ * Retries every 50ms and fails with `LOCK_TIMEOUT` after 5 seconds.
203
+ */
160
204
  export async function acquireLock(filePath: string): Promise<Result<void>> {
161
205
  const lockPath = `${filePath}.lock`;
162
206
  const start = Date.now();
@@ -197,6 +241,12 @@ export async function acquireLock(filePath: string): Promise<Result<void>> {
197
241
  }
198
242
  }
199
243
 
244
+ /**
245
+ * Releases a previously acquired advisory lock file.
246
+ *
247
+ * @param filePath - Cache file path whose `.lock` file should be removed.
248
+ * @remarks `ENOENT` is intentionally ignored to keep release fire-and-forget safe.
249
+ */
200
250
  export async function releaseLock(filePath: string): Promise<void> {
201
251
  const lockPath = `${filePath}.lock`;
202
252
  try {
@@ -204,7 +254,7 @@ export async function releaseLock(filePath: string): Promise<void> {
204
254
  } catch (err) {
205
255
  const error = err as NodeJS.ErrnoException;
206
256
  if (error.code !== "ENOENT") {
207
- console.warn(`[cache-ctrl] Warning: failed to release lock ${lockPath}: ${error.message}`);
257
+ process.stderr.write(`[cache-ctrl] Warning: failed to release lock ${lockPath}: ${error.message}\n`);
208
258
  }
209
259
  }
210
260
  }
@@ -220,7 +270,7 @@ async function isLockStale(lockPath: string): Promise<boolean> {
220
270
  const content = await readFile(lockPath, "utf-8");
221
271
  const pidStr = content.trim();
222
272
  const pid = parseInt(pidStr, 10);
223
- if (isNaN(pid) || pid <= 0 || pid >= 4_194_304) {
273
+ if (Number.isNaN(pid) || pid <= 0 || pid >= 4_194_304) {
224
274
  return true;
225
275
  }
226
276
 
@@ -1,51 +1,42 @@
1
- import { readdir } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import type { ExternalCacheFile, CacheEntry } from "../types/cache.js";
4
- import { ExternalCacheFileSchema } from "../types/cache.js";
1
+ import type { ExternalCacheFile, HeaderMeta } from "../types/cache.js";
5
2
  import { ErrorCode, type Result } from "../types/result.js";
6
- import { readCache, listCacheFiles } from "./cacheManager.js";
3
+ import { loadExternalCacheEntries } from "./cacheManager.js";
7
4
  import { scoreEntry } from "../search/keywordSearch.js";
8
- import { getFileStem } from "../utils/fileStem.js";
9
5
 
10
6
  const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
11
7
 
12
- export function resolveExternalCacheDir(repoRoot: string): string {
13
- return join(repoRoot, ".ai", "external-context-gatherer_cache");
14
- }
15
-
16
- export async function resolveExternalFiles(repoRoot: string): Promise<Result<string[]>> {
17
- const cacheDir = resolveExternalCacheDir(repoRoot);
18
- try {
19
- const entries = await readdir(cacheDir);
20
- return {
21
- ok: true,
22
- value: entries
23
- .filter((name) => name.endsWith(".json") && !name.endsWith(".lock"))
24
- .map((name) => join(cacheDir, name)),
25
- };
26
- } catch (err) {
27
- const error = err as NodeJS.ErrnoException;
28
- if (error.code === "ENOENT") {
29
- return { ok: true, value: [] };
30
- }
31
- return { ok: false, error: `Failed to list external cache directory: ${error.message}`, code: ErrorCode.FILE_READ_ERROR };
32
- }
33
- }
34
-
35
- export function isExternalStale(entry: ExternalCacheFile, maxAgeMs?: number): boolean {
36
- if (!entry.fetched_at) return true;
8
+ /**
9
+ * Checks whether an external `fetched_at` timestamp exceeds staleness threshold.
10
+ *
11
+ * @param fetchedAt - ISO timestamp string stored in cache entry.
12
+ * @param maxAgeMs - Optional max age override in milliseconds.
13
+ * @returns `true` when timestamp is empty or older than the threshold.
14
+ */
15
+ export function isFetchedAtStale(fetchedAt: string, maxAgeMs?: number): boolean {
16
+ if (!fetchedAt) return true;
37
17
  const threshold = maxAgeMs ?? DEFAULT_MAX_AGE_MS;
38
- const age = Date.now() - new Date(entry.fetched_at).getTime();
18
+ const age = Date.now() - new Date(fetchedAt).getTime();
39
19
  return age > threshold;
40
20
  }
41
21
 
42
- export interface HeaderMeta {
43
- etag?: string;
44
- last_modified?: string;
45
- checked_at: string;
46
- status: "fresh" | "stale" | "unchecked";
22
+ /**
23
+ * Evaluates staleness for a full external cache entry.
24
+ *
25
+ * @param entry - External cache entry.
26
+ * @param maxAgeMs - Optional max age override in milliseconds.
27
+ * @returns `true` when entry should be considered stale.
28
+ */
29
+ export function isExternalStale(entry: ExternalCacheFile, maxAgeMs?: number): boolean {
30
+ return isFetchedAtStale(entry.fetched_at ?? "", maxAgeMs);
47
31
  }
48
32
 
33
+ /**
34
+ * Merges newly fetched header metadata into an external cache entry.
35
+ *
36
+ * @param existing - Existing external cache entry.
37
+ * @param updates - Per-URL header metadata updates.
38
+ * @returns New entry with merged `header_metadata`.
39
+ */
49
40
  export function mergeHeaderMetadata(
50
41
  existing: ExternalCacheFile,
51
42
  updates: Record<string, HeaderMeta>,
@@ -59,6 +50,13 @@ export function mergeHeaderMetadata(
59
50
  };
60
51
  }
61
52
 
53
+ /**
54
+ * Formats human-readable age text from an external `fetched_at` timestamp.
55
+ *
56
+ * @param fetchedAt - ISO timestamp string from cache entry.
57
+ * @returns Relative age string such as `"just now"`, `"2 hours ago"`, or `"invalidated"`.
58
+ * @remarks Returns `"invalidated"` sentinel when `fetchedAt` is empty.
59
+ */
62
60
  export function getAgeHuman(fetchedAt: string): string {
63
61
  if (!fetchedAt) return "invalidated";
64
62
 
@@ -89,39 +87,18 @@ export function getAgeHuman(fetchedAt: string): string {
89
87
  * Returns NO_MATCH if no entry scores above zero.
90
88
  */
91
89
  export async function resolveTopExternalMatch(repoRoot: string, subject: string): Promise<Result<string>> {
92
- const filesResult = await listCacheFiles("external", repoRoot);
93
- if (!filesResult.ok) return filesResult;
94
-
95
- const candidates: Array<{ filePath: string; entry: CacheEntry }> = [];
96
- for (const filePath of filesResult.value) {
97
- const readResult = await readCache(filePath);
98
- if (!readResult.ok) continue;
99
- const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
100
- if (!parseResult.success) continue;
101
- const data = parseResult.data;
102
- const stem = getFileStem(filePath);
103
- const entrySubject = data.subject ?? stem;
104
- candidates.push({
105
- filePath,
106
- entry: {
107
- file: filePath,
108
- agent: "external",
109
- subject: entrySubject,
110
- description: data.description,
111
- fetched_at: data.fetched_at ?? "",
112
- },
113
- });
114
- }
90
+ const entriesResult = await loadExternalCacheEntries(repoRoot);
91
+ if (!entriesResult.ok) return entriesResult;
115
92
 
116
93
  const keywords = [subject];
117
- const scored = candidates
118
- .map((c) => ({ ...c, score: scoreEntry(c.entry, keywords) }))
119
- .filter((c) => c.score > 0)
94
+ const scored = entriesResult.value
95
+ .map((entry) => ({ entry, score: scoreEntry(entry, keywords) }))
96
+ .filter((candidate) => candidate.score > 0)
120
97
  .sort((a, b) => b.score - a.score);
121
98
 
122
99
  if (scored.length === 0) {
123
100
  return { ok: false, error: `No cache entry matched keyword "${subject}"`, code: ErrorCode.NO_MATCH };
124
101
  }
125
102
 
126
- return { ok: true, value: scored[0]!.filePath };
103
+ return { ok: true, value: scored[0]!.entry.file };
127
104
  }
@@ -0,0 +1,12 @@
1
+ import { join } from "node:path";
2
+
3
+ import { resolveLocalCacheDir } from "./localCache.js";
4
+
5
+ export type { GraphCacheFile } from "../types/cache.js";
6
+
7
+ /**
8
+ * Resolves the absolute path to graph.json.
9
+ */
10
+ export function resolveGraphCachePath(repoRoot: string): string {
11
+ return join(resolveLocalCacheDir(repoRoot), "graph.json");
12
+ }
@@ -1,9 +1,11 @@
1
1
  import { join } from "node:path";
2
2
 
3
+ /** Resolves the local cache directory path under the repository root. */
3
4
  export function resolveLocalCacheDir(repoRoot: string): string {
4
5
  return join(repoRoot, ".ai", "local-context-gatherer_cache");
5
6
  }
6
7
 
8
+ /** Resolves the local context cache file path (`context.json`). */
7
9
  export function resolveLocalCachePath(repoRoot: string): string {
8
10
  return join(resolveLocalCacheDir(repoRoot), "context.json");
9
11
  }
@@ -6,9 +6,13 @@ import { getGitTrackedFiles, getGitDeletedFiles, getUntrackedNonIgnoredFiles } f
6
6
  import { LocalCacheFileSchema } from "../types/cache.js";
7
7
  import { ErrorCode, type Result } from "../types/result.js";
8
8
  import type { CheckFilesResult } from "../types/commands.js";
9
+ import { toUnknownResult } from "../utils/errors.js";
9
10
 
10
- const toPosix = (p: string) => p.split(sep).join(posix.sep);
11
-
11
+ /**
12
+ * Compares local tracked files against stored baselines and git file-set deltas.
13
+ * @returns Promise<Result<CheckFilesResult["value"]>>; common failures include FILE_NOT_FOUND,
14
+ * PARSE_ERROR, FILE_READ_ERROR, and UNKNOWN.
15
+ */
12
16
  export async function checkFilesCommand(): Promise<Result<CheckFilesResult["value"]>> {
13
17
  try {
14
18
  const repoRoot = await findRepoRoot(process.cwd());
@@ -77,7 +81,6 @@ export async function checkFilesCommand(): Promise<Result<CheckFilesResult["valu
77
81
  },
78
82
  };
79
83
  } catch (err) {
80
- const msg = err instanceof Error ? err.message : String(err);
81
- return { ok: false, error: msg, code: ErrorCode.UNKNOWN };
84
+ return toUnknownResult(err);
82
85
  }
83
86
  }
@@ -1,13 +1,20 @@
1
1
  import { findRepoRoot, readCache, writeCache } from "../cache/cacheManager.js";
2
2
  import { isExternalStale, mergeHeaderMetadata, resolveTopExternalMatch } from "../cache/externalCache.js";
3
3
  import { checkFreshness } from "../http/freshnessChecker.js";
4
- import type { ExternalCacheFile } from "../types/cache.js";
4
+ import type { ExternalCacheFile, HeaderMeta } from "../types/cache.js";
5
5
  import { ExternalCacheFileSchema } from "../types/cache.js";
6
6
  import { ErrorCode, type Result } from "../types/result.js";
7
7
  import type { CheckFreshnessArgs, CheckFreshnessResult } from "../types/commands.js";
8
- import type { HeaderMeta } from "../cache/externalCache.js";
9
8
  import { getFileStem } from "../utils/fileStem.js";
10
-
9
+ import { toUnknownResult } from "../utils/errors.js";
10
+
11
+ /**
12
+ * Performs HTTP freshness checks for the matched external cache entry sources.
13
+ *
14
+ * @param args - {@link CheckFreshnessArgs} command arguments.
15
+ * @returns Promise<Result<CheckFreshnessResult["value"]>>; common failures include
16
+ * NO_MATCH, URL_NOT_FOUND, PARSE_ERROR, FILE_READ_ERROR/FILE_WRITE_ERROR, and UNKNOWN.
17
+ */
11
18
  export async function checkFreshnessCommand(args: CheckFreshnessArgs): Promise<Result<CheckFreshnessResult["value"]>> {
12
19
  try {
13
20
  const repoRoot = await findRepoRoot(process.cwd());
@@ -33,21 +40,15 @@ export async function checkFreshnessCommand(args: CheckFreshnessArgs): Promise<R
33
40
 
34
41
  // Determine which URLs to check
35
42
  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;
43
+ const specificSource = args.url ? sources.find((source) => source.url === args.url) : undefined;
44
+ if (args.url && !specificSource) {
45
+ return {
46
+ ok: false,
47
+ error: `URL not found in sources for subject '${subject}'`,
48
+ code: ErrorCode.URL_NOT_FOUND,
49
+ };
50
50
  }
51
+ const urlsToCheck = specificSource ? [specificSource] : sources;
51
52
 
52
53
  // Check freshness for each URL
53
54
  const sourceResults: Array<{
@@ -117,7 +118,6 @@ export async function checkFreshnessCommand(args: CheckFreshnessArgs): Promise<R
117
118
  },
118
119
  };
119
120
  } catch (err) {
120
- const error = err as Error;
121
- return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
121
+ return toUnknownResult(err);
122
122
  }
123
123
  }
@@ -3,7 +3,15 @@ import { findRepoRoot, listCacheFiles } from "../cache/cacheManager.js";
3
3
  import { resolveLocalCachePath } from "../cache/localCache.js";
4
4
  import { ErrorCode, type Result } from "../types/result.js";
5
5
  import type { FlushArgs, FlushResult } from "../types/commands.js";
6
+ import { toUnknownResult } from "../utils/errors.js";
6
7
 
8
+ /**
9
+ * Deletes cache files for one or both agent namespaces.
10
+ *
11
+ * @param args - {@link FlushArgs} command arguments.
12
+ * @returns Promise<Result<FlushResult["value"]>>; common failures include
13
+ * CONFIRMATION_REQUIRED, FILE_WRITE_ERROR, FILE_READ_ERROR, and UNKNOWN.
14
+ */
7
15
  export async function flushCommand(args: FlushArgs): Promise<Result<FlushResult["value"]>> {
8
16
  if (!args.confirm) {
9
17
  return {
@@ -49,7 +57,6 @@ export async function flushCommand(args: FlushArgs): Promise<Result<FlushResult[
49
57
 
50
58
  return { ok: true, value: { deleted, count: deleted.length } };
51
59
  } catch (err) {
52
- const error = err as Error;
53
- return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
60
+ return toUnknownResult(err);
54
61
  }
55
62
  }