@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,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
+ }
@@ -1,5 +1,4 @@
1
- import { readFile, writeFile, rename, stat, unlink, readdir, mkdir } from "node:fs/promises";
2
- import { open } from "node:fs/promises";
1
+ import { readFile, writeFile, rename, stat, unlink, readdir, mkdir, open } from "node:fs/promises";
3
2
  import { join, dirname } from "node:path";
4
3
  import { randomBytes } from "node:crypto";
5
4
  import type { AgentType, CacheEntry, ExternalCacheFile, LocalCacheFile } from "../types/cache.js";
@@ -11,6 +10,14 @@ const LOCK_RETRY_INTERVAL_MS = 50;
11
10
  const LOCK_TIMEOUT_MS = 5000;
12
11
  const LOCK_STALE_AGE_MS = 30_000;
13
12
 
13
+ /**
14
+ * Resolves the repository root by walking upward until a `.git` directory is found.
15
+ *
16
+ * @param startDir - Directory where upward detection starts.
17
+ * @returns Absolute path to detected repo root, or `startDir` when no `.git` is found.
18
+ * @remarks Uses a parent-directory walk; on filesystem root fallback it intentionally
19
+ * returns `startDir` so CLI behavior remains deterministic outside git repositories.
20
+ */
14
21
  export async function findRepoRoot(startDir: string): Promise<string> {
15
22
  let current = startDir;
16
23
  while (true) {
@@ -28,6 +35,7 @@ export async function findRepoRoot(startDir: string): Promise<string> {
28
35
  }
29
36
  }
30
37
 
38
+ /** Resolves the on-disk cache directory for a given agent namespace. */
31
39
  export function resolveCacheDir(agent: AgentType, repoRoot: string): string {
32
40
  if (agent === "external") {
33
41
  return join(repoRoot, ".ai", "external-context-gatherer_cache");
@@ -35,6 +43,14 @@ export function resolveCacheDir(agent: AgentType, repoRoot: string): string {
35
43
  return join(repoRoot, ".ai", "local-context-gatherer_cache");
36
44
  }
37
45
 
46
+ /**
47
+ * Reads and JSON-parses one cache file.
48
+ *
49
+ * @param filePath - Absolute cache file path.
50
+ * @returns Parsed object on success, or typed read/parse failures.
51
+ * @remarks Returns `FILE_NOT_FOUND` when the file is absent, and `PARSE_ERROR` when the
52
+ * file exists but contains invalid JSON. Low-level I/O failures return `FILE_READ_ERROR`.
53
+ */
38
54
  export async function readCache(filePath: string): Promise<Result<Record<string, unknown>>> {
39
55
  try {
40
56
  const content = await readFile(filePath, "utf-8");
@@ -53,6 +69,17 @@ export async function readCache(filePath: string): Promise<Result<Record<string,
53
69
  }
54
70
  }
55
71
 
72
+ /**
73
+ * Writes cache content using advisory locking and atomic rename.
74
+ *
75
+ * @param filePath - Absolute cache file path to write.
76
+ * @param updates - Partial updates (`merge`) or full replacement payload (`replace`).
77
+ * @param mode - `merge` overlays updates onto existing JSON; `replace` writes payload as-is.
78
+ * @remarks In `merge` mode the function performs read-modify-write preserving unknown fields.
79
+ * Writes use temp-file + `rename()` for atomic visibility. A per-file advisory lock is
80
+ * acquired before mutation and released in `finally`, preventing concurrent writers from
81
+ * interleaving updates.
82
+ */
56
83
  export async function writeCache(
57
84
  filePath: string,
58
85
  updates: Partial<ExternalCacheFile> | Partial<LocalCacheFile> | Record<string, unknown>,
@@ -101,6 +128,13 @@ export async function writeCache(
101
128
  }
102
129
  }
103
130
 
131
+ /**
132
+ * Lists JSON cache files for an agent namespace.
133
+ *
134
+ * @param agent - Cache namespace to inspect.
135
+ * @param repoRoot - Repository root used to resolve cache directory paths.
136
+ * @returns Absolute `.json` file paths; returns an empty array when directory is absent.
137
+ */
104
138
  export async function listCacheFiles(agent: AgentType, repoRoot: string): Promise<Result<string[]>> {
105
139
  const cacheDir = resolveCacheDir(agent, repoRoot);
106
140
  try {
@@ -157,6 +191,15 @@ export async function loadExternalCacheEntries(repoRoot: string): Promise<Result
157
191
  return { ok: true, value: entries };
158
192
  }
159
193
 
194
+ /**
195
+ * Acquires an advisory lock file for a cache path.
196
+ *
197
+ * @param filePath - Cache file path whose lock (`.lock`) should be acquired.
198
+ * @returns `ok: true` when lock is acquired, otherwise typed lock failure.
199
+ * @remarks Uses atomic `O_EXCL` create semantics to guarantee single-writer lock acquisition.
200
+ * Existing locks are checked for staleness via lock age and PID liveness (`process.kill(pid, 0)`).
201
+ * Retries every 50ms and fails with `LOCK_TIMEOUT` after 5 seconds.
202
+ */
160
203
  export async function acquireLock(filePath: string): Promise<Result<void>> {
161
204
  const lockPath = `${filePath}.lock`;
162
205
  const start = Date.now();
@@ -197,6 +240,12 @@ export async function acquireLock(filePath: string): Promise<Result<void>> {
197
240
  }
198
241
  }
199
242
 
243
+ /**
244
+ * Releases a previously acquired advisory lock file.
245
+ *
246
+ * @param filePath - Cache file path whose `.lock` file should be removed.
247
+ * @remarks `ENOENT` is intentionally ignored to keep release fire-and-forget safe.
248
+ */
200
249
  export async function releaseLock(filePath: string): Promise<void> {
201
250
  const lockPath = `${filePath}.lock`;
202
251
  try {
@@ -204,7 +253,7 @@ export async function releaseLock(filePath: string): Promise<void> {
204
253
  } catch (err) {
205
254
  const error = err as NodeJS.ErrnoException;
206
255
  if (error.code !== "ENOENT") {
207
- console.warn(`[cache-ctrl] Warning: failed to release lock ${lockPath}: ${error.message}`);
256
+ process.stderr.write(`[cache-ctrl] Warning: failed to release lock ${lockPath}: ${error.message}\n`);
208
257
  }
209
258
  }
210
259
  }
@@ -220,7 +269,7 @@ async function isLockStale(lockPath: string): Promise<boolean> {
220
269
  const content = await readFile(lockPath, "utf-8");
221
270
  const pidStr = content.trim();
222
271
  const pid = parseInt(pidStr, 10);
223
- if (isNaN(pid) || pid <= 0 || pid >= 4_194_304) {
272
+ if (Number.isNaN(pid) || pid <= 0 || pid >= 4_194_304) {
224
273
  return true;
225
274
  }
226
275
 
@@ -1,64 +1,43 @@
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 } from "../types/cache.js";
5
2
  import { ErrorCode, type Result } from "../types/result.js";
6
- import { readCache, listCacheFiles } from "./cacheManager.js";
3
+ import { listCacheFiles, loadExternalCacheEntries, writeCache } from "./cacheManager.js";
7
4
  import { scoreEntry } from "../search/keywordSearch.js";
8
- import { getFileStem } from "../utils/fileStem.js";
5
+ import { validateSubject } from "../utils/validate.js";
9
6
 
10
7
  const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
11
8
 
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;
9
+ /**
10
+ * Checks whether an external `fetched_at` timestamp exceeds staleness threshold.
11
+ *
12
+ * @param fetchedAt - ISO timestamp string stored in cache entry.
13
+ * @param maxAgeMs - Optional max age override in milliseconds.
14
+ * @returns `true` when timestamp is empty or older than the threshold.
15
+ */
16
+ export function isFetchedAtStale(fetchedAt: string, maxAgeMs?: number): boolean {
17
+ if (!fetchedAt) return true;
37
18
  const threshold = maxAgeMs ?? DEFAULT_MAX_AGE_MS;
38
- const age = Date.now() - new Date(entry.fetched_at).getTime();
19
+ const age = Date.now() - new Date(fetchedAt).getTime();
39
20
  return age > threshold;
40
21
  }
41
22
 
42
- export interface HeaderMeta {
43
- etag?: string;
44
- last_modified?: string;
45
- checked_at: string;
46
- status: "fresh" | "stale" | "unchecked";
47
- }
48
-
49
- export function mergeHeaderMetadata(
50
- existing: ExternalCacheFile,
51
- updates: Record<string, HeaderMeta>,
52
- ): ExternalCacheFile {
53
- return {
54
- ...existing,
55
- header_metadata: {
56
- ...existing.header_metadata,
57
- ...updates,
58
- },
59
- };
23
+ /**
24
+ * Evaluates staleness for a full external cache entry.
25
+ *
26
+ * @param entry - External cache entry.
27
+ * @param maxAgeMs - Optional max age override in milliseconds.
28
+ * @returns `true` when entry should be considered stale.
29
+ */
30
+ export function isExternalStale(entry: ExternalCacheFile, maxAgeMs?: number): boolean {
31
+ return isFetchedAtStale(entry.fetched_at ?? "", maxAgeMs);
60
32
  }
61
33
 
34
+ /**
35
+ * Formats human-readable age text from an external `fetched_at` timestamp.
36
+ *
37
+ * @param fetchedAt - ISO timestamp string from cache entry.
38
+ * @returns Relative age string such as `"just now"`, `"2 hours ago"`, or `"invalidated"`.
39
+ * @remarks Returns `"invalidated"` sentinel when `fetchedAt` is empty.
40
+ */
62
41
  export function getAgeHuman(fetchedAt: string): string {
63
42
  if (!fetchedAt) return "invalidated";
64
43
 
@@ -89,39 +68,55 @@ export function getAgeHuman(fetchedAt: string): string {
89
68
  * Returns NO_MATCH if no entry scores above zero.
90
69
  */
91
70
  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
- }
71
+ const entriesResult = await loadExternalCacheEntries(repoRoot);
72
+ if (!entriesResult.ok) return entriesResult;
115
73
 
116
74
  const keywords = [subject];
117
- const scored = candidates
118
- .map((c) => ({ ...c, score: scoreEntry(c.entry, keywords) }))
119
- .filter((c) => c.score > 0)
75
+ const scored = entriesResult.value
76
+ .map((entry) => ({ entry, score: scoreEntry(entry, keywords) }))
77
+ .filter((candidate) => candidate.score > 0)
120
78
  .sort((a, b) => b.score - a.score);
121
79
 
122
80
  if (scored.length === 0) {
123
81
  return { ok: false, error: `No cache entry matched keyword "${subject}"`, code: ErrorCode.NO_MATCH };
124
82
  }
125
83
 
126
- return { ok: true, value: scored[0]!.filePath };
84
+ return { ok: true, value: scored[0]!.entry.file };
85
+ }
86
+
87
+ /**
88
+ * Updates `fetched_at` for one external entry (best subject match) or all entries.
89
+ *
90
+ * @param repoRoot - Repository root.
91
+ * @param subject - Optional subject keyword; when provided, only top match is updated.
92
+ * @param fetchedAt - New ISO timestamp value (or empty string to invalidate).
93
+ * @returns Updated file paths.
94
+ */
95
+ export async function updateExternalFetchedAt(
96
+ repoRoot: string,
97
+ subject: string | undefined,
98
+ fetchedAt: string,
99
+ ): Promise<Result<string[]>> {
100
+ let filesToUpdate: string[];
101
+
102
+ if (subject) {
103
+ const subjectCheck = validateSubject(subject);
104
+ if (!subjectCheck.ok) return subjectCheck;
105
+ const matchResult = await resolveTopExternalMatch(repoRoot, subject);
106
+ if (!matchResult.ok) return matchResult;
107
+ filesToUpdate = [matchResult.value];
108
+ } else {
109
+ const filesResult = await listCacheFiles("external", repoRoot);
110
+ if (!filesResult.ok) return filesResult;
111
+ filesToUpdate = filesResult.value;
112
+ }
113
+
114
+ const updated: string[] = [];
115
+ for (const filePath of filesToUpdate) {
116
+ const writeResult = await writeCache(filePath, { fetched_at: fetchedAt });
117
+ if (!writeResult.ok) return writeResult;
118
+ updated.push(filePath);
119
+ }
120
+
121
+ return { ok: true, value: updated };
127
122
  }
@@ -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());
@@ -26,20 +30,20 @@ export async function checkFilesCommand(): Promise<Result<CheckFilesResult["valu
26
30
 
27
31
  const changedFiles: Array<{ path: string; reason: "mtime" | "hash" | "missing" }> = [];
28
32
  const unchangedFiles: string[] = [];
29
- const missingFiles: string[] = [];
30
33
 
31
34
  for (const trackedFile of trackedFiles) {
32
35
  const result = await compareTrackedFile(trackedFile, repoRoot);
33
36
  if (result.status === "unchanged") {
34
37
  unchangedFiles.push(trackedFile.path);
35
38
  } else if (result.status === "missing") {
36
- missingFiles.push(trackedFile.path);
37
39
  changedFiles.push({ path: trackedFile.path, reason: "missing" });
38
40
  } else {
39
41
  changedFiles.push({ path: trackedFile.path, reason: result.reason ?? "mtime" });
40
42
  }
41
43
  }
42
44
 
45
+ const missingFiles = changedFiles.filter((file) => file.reason === "missing").map((file) => file.path);
46
+
43
47
  const [gitTrackedFiles, deletedGitFiles, untrackedNonIgnoredFiles] = await Promise.all([
44
48
  getGitTrackedFiles(repoRoot),
45
49
  getGitDeletedFiles(repoRoot),
@@ -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
  }
@@ -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
  }