@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,87 @@
1
+ import { findRepoRoot, readCache } from "../cache/cacheManager.js";
2
+ import { resolveLocalCachePath } from "../cache/localCache.js";
3
+ import { LocalCacheFileSchema } from "../types/cache.js";
4
+ import type { MapArgs, MapDepth, MapResult } from "../types/commands.js";
5
+ import { ErrorCode, type Result } from "../types/result.js";
6
+ import { toUnknownResult } from "../utils/errors.js";
7
+
8
+ /**
9
+ * Returns a semantic map view of local context cache content.
10
+ *
11
+ * @param args - {@link MapArgs} command arguments.
12
+ * @returns Promise<Result<MapResult["value"]>>; common failures include FILE_NOT_FOUND,
13
+ * PARSE_ERROR, FILE_READ_ERROR, and UNKNOWN.
14
+ */
15
+ export async function mapCommand(args: MapArgs): Promise<Result<MapResult["value"]>> {
16
+ try {
17
+ const depth: MapDepth = args.depth ?? "overview";
18
+ const repoRoot = await findRepoRoot(process.cwd());
19
+ const contextPath = resolveLocalCachePath(repoRoot);
20
+
21
+ const readResult = await readCache(contextPath);
22
+ if (!readResult.ok) {
23
+ if (readResult.code === ErrorCode.FILE_NOT_FOUND) {
24
+ return {
25
+ ok: false,
26
+ error: "context.json not found — run local-context-gatherer to populate the cache",
27
+ code: ErrorCode.FILE_NOT_FOUND,
28
+ };
29
+ }
30
+ return readResult;
31
+ }
32
+
33
+ const parseResult = LocalCacheFileSchema.safeParse(readResult.value);
34
+ if (!parseResult.success) {
35
+ return {
36
+ ok: false,
37
+ error: `Malformed local cache file: ${contextPath}`,
38
+ code: ErrorCode.PARSE_ERROR,
39
+ };
40
+ }
41
+
42
+ const parsed = parseResult.data;
43
+ const allFacts = parsed.facts ?? {};
44
+ const folderPrefix = args.folder;
45
+ const filteredFacts =
46
+ folderPrefix !== undefined
47
+ ? Object.fromEntries(
48
+ Object.entries(allFacts).filter(
49
+ ([filePath]) => filePath === folderPrefix || filePath.startsWith(`${folderPrefix}/`),
50
+ ),
51
+ )
52
+ : allFacts;
53
+
54
+ const files = Object.entries(filteredFacts)
55
+ .map(([path, fileFacts]) => ({
56
+ path,
57
+ ...(fileFacts.summary !== undefined ? { summary: fileFacts.summary } : {}),
58
+ ...(fileFacts.role !== undefined ? { role: fileFacts.role } : {}),
59
+ ...(fileFacts.importance !== undefined ? { importance: fileFacts.importance } : {}),
60
+ ...(depth === "full" && fileFacts.facts !== undefined ? { facts: fileFacts.facts } : {}),
61
+ }))
62
+ .sort((a, b) => {
63
+ const aImportance = a.importance ?? Number.POSITIVE_INFINITY;
64
+ const bImportance = b.importance ?? Number.POSITIVE_INFINITY;
65
+ if (aImportance !== bImportance) {
66
+ return aImportance - bImportance;
67
+ }
68
+ return a.path.localeCompare(b.path);
69
+ });
70
+
71
+ return {
72
+ ok: true,
73
+ value: {
74
+ depth,
75
+ global_facts: parsed.global_facts ?? [],
76
+ files,
77
+ ...((depth === "modules" || depth === "full") && parsed.modules !== undefined
78
+ ? { modules: parsed.modules }
79
+ : {}),
80
+ total_files: files.length,
81
+ ...(args.folder !== undefined ? { folder_filter: args.folder } : {}),
82
+ },
83
+ };
84
+ } catch (err) {
85
+ return toUnknownResult(err);
86
+ }
87
+ }
@@ -6,17 +6,32 @@ import { ExternalCacheFileSchema } from "../types/cache.js";
6
6
  import { ErrorCode, type Result } from "../types/result.js";
7
7
  import type { PruneArgs, PruneResult } from "../types/commands.js";
8
8
  import { getFileStem } from "../utils/fileStem.js";
9
+ import { toUnknownResult } from "../utils/errors.js";
9
10
 
11
+ /**
12
+ * Parses duration text in `<number><unit>` form.
13
+ *
14
+ * @param duration - Duration string where unit is one of `s`, `m`, `h`, `d`.
15
+ * @returns Milliseconds value, or `null` when format is invalid.
16
+ */
10
17
  export function parseDurationMs(duration: string): number | null {
11
- const match = /^(\d+)(h|d)$/.exec(duration);
18
+ const match = /^(\d+)(s|m|h|d)$/.exec(duration);
12
19
  if (!match) return null;
13
20
  const value = parseInt(match[1]!, 10);
14
21
  const unit = match[2]!;
22
+ if (unit === "s") return value * 1_000;
23
+ if (unit === "m") return value * 60_000;
15
24
  if (unit === "h") return value * 3_600_000;
16
- if (unit === "d") return value * 86_400_000;
17
- return null;
25
+ return value * 86_400_000;
18
26
  }
19
27
 
28
+ /**
29
+ * Prunes stale cache entries by invalidating or deleting them.
30
+ *
31
+ * @param args - {@link PruneArgs} command arguments.
32
+ * @returns Promise<Result<PruneResult["value"]>>; common failures include INVALID_ARGS,
33
+ * FILE_READ_ERROR, FILE_WRITE_ERROR, and UNKNOWN.
34
+ */
20
35
  export async function pruneCommand(args: PruneArgs): Promise<Result<PruneResult["value"]>> {
21
36
  try {
22
37
  const repoRoot = await findRepoRoot(process.cwd());
@@ -82,9 +97,7 @@ export async function pruneCommand(args: PruneArgs): Promise<Result<PruneResult[
82
97
  // Only invalidate if the file already exists
83
98
  const readResult = await readCache(localPath);
84
99
  if (!readResult.ok) {
85
- if (readResult.code === ErrorCode.FILE_NOT_FOUND) {
86
- // Nothing to prune — skip silently
87
- } else {
100
+ if (readResult.code !== ErrorCode.FILE_NOT_FOUND) {
88
101
  return readResult;
89
102
  }
90
103
  } else {
@@ -104,7 +117,6 @@ export async function pruneCommand(args: PruneArgs): Promise<Result<PruneResult[
104
117
  },
105
118
  };
106
119
  } catch (err) {
107
- const error = err as Error;
108
- return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
120
+ return toUnknownResult(err);
109
121
  }
110
122
  }
@@ -5,7 +5,15 @@ import type { CacheEntry } from "../types/cache.js";
5
5
  import { LocalCacheFileSchema } from "../types/cache.js";
6
6
  import { ErrorCode, type Result } from "../types/result.js";
7
7
  import type { SearchArgs, SearchResult } from "../types/commands.js";
8
+ import { toUnknownResult } from "../utils/errors.js";
8
9
 
10
+ /**
11
+ * Searches cache entries across namespaces using keyword-based scoring.
12
+ *
13
+ * @param args - {@link SearchArgs} command arguments.
14
+ * @returns Promise<Result<SearchResult["value"]>>; common failures include FILE_READ_ERROR
15
+ * (external directory listing), PARSE_ERROR (malformed local cache), and UNKNOWN.
16
+ */
9
17
  export async function searchCommand(args: SearchArgs): Promise<Result<SearchResult["value"]>> {
10
18
  try {
11
19
  const repoRoot = await findRepoRoot(process.cwd());
@@ -51,7 +59,6 @@ export async function searchCommand(args: SearchArgs): Promise<Result<SearchResu
51
59
  })),
52
60
  };
53
61
  } catch (err) {
54
- const error = err as Error;
55
- return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
62
+ return toUnknownResult(err);
56
63
  }
57
64
  }
@@ -3,8 +3,16 @@ import { resolveTopExternalMatch } from "../cache/externalCache.js";
3
3
  import { resolveLocalCachePath } from "../cache/localCache.js";
4
4
  import { ErrorCode, type Result } from "../types/result.js";
5
5
  import type { TouchArgs, TouchResult } from "../types/commands.js";
6
+ import { toUnknownResult } from "../utils/errors.js";
6
7
  import { validateSubject } from "../utils/validate.js";
7
8
 
9
+ /**
10
+ * Marks cache entries fresh by setting timestamps to current UTC time.
11
+ *
12
+ * @param args - {@link TouchArgs} command arguments.
13
+ * @returns Promise<Result<TouchResult["value"]>>; common failures include INVALID_ARGS,
14
+ * NO_MATCH, FILE_WRITE_ERROR, and UNKNOWN.
15
+ */
8
16
  export async function touchCommand(args: TouchArgs): Promise<Result<TouchResult["value"]>> {
9
17
  try {
10
18
  const repoRoot = await findRepoRoot(process.cwd());
@@ -41,7 +49,6 @@ export async function touchCommand(args: TouchArgs): Promise<Result<TouchResult[
41
49
 
42
50
  return { ok: true, value: { touched, new_timestamp: newTimestamp } };
43
51
  } catch (err) {
44
- const error = err as Error;
45
- return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
52
+ return toUnknownResult(err);
46
53
  }
47
54
  }
@@ -0,0 +1,14 @@
1
+ import type { VersionArgs, VersionResult } from "../types/commands.js";
2
+ import type { Result } from "../types/result.js";
3
+
4
+ import packageJson from "../../package.json" with { type: "json" };
5
+
6
+ /**
7
+ * Returns the package version from `package.json`.
8
+ *
9
+ * @param args - {@link VersionArgs} command arguments (unused).
10
+ * @returns `Result<VersionResult["value"]>` containing the CLI version.
11
+ */
12
+ export function versionCommand(_args: VersionArgs = {}): Result<VersionResult["value"]> {
13
+ return { ok: true, value: { version: packageJson.version } };
14
+ }
@@ -0,0 +1,253 @@
1
+ import path from "node:path";
2
+ import { realpath } from "node:fs/promises";
3
+
4
+ import { buildGraph, type DependencyGraph } from "../analysis/graphBuilder.js";
5
+ import { findRepoRoot, writeCache } from "../cache/cacheManager.js";
6
+ import { resolveGraphCachePath } from "../cache/graphCache.js";
7
+ import { getGitTrackedFiles } from "../files/gitFiles.js";
8
+ import type { GraphCacheFile } from "../types/cache.js";
9
+ import type { WatchArgs } from "../types/commands.js";
10
+ import { ErrorCode, type Result } from "../types/result.js";
11
+ import { toUnknownResult } from "../utils/errors.js";
12
+
13
+ const WATCH_DEBOUNCE_MS = 200;
14
+ const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
15
+
16
+ type TrackedFilesProvider = (repoRoot: string) => Promise<string[]>;
17
+ type WatchEvent = "rename" | "change";
18
+ type BunWatchCallback = (event: WatchEvent, filename: string | null) => void;
19
+
20
+ interface WatcherHandle {
21
+ close?: () => void;
22
+ stop?: () => void;
23
+ }
24
+
25
+ type BunWatchFunction = (
26
+ watchPath: string,
27
+ options: { recursive: boolean },
28
+ callback: BunWatchCallback,
29
+ ) => WatcherHandle;
30
+
31
+ function resolveBunWatch(): Result<BunWatchFunction> {
32
+ const watchFn = Reflect.get(Object(Bun), "watch");
33
+ if (typeof watchFn !== "function") {
34
+ return {
35
+ ok: false,
36
+ error: "Bun.watch is not available in this runtime",
37
+ code: ErrorCode.UNKNOWN,
38
+ };
39
+ }
40
+ return { ok: true, value: watchFn };
41
+ }
42
+
43
+ /**
44
+ * Checks whether a file path is a supported source file for graph analysis.
45
+ *
46
+ * @param filePath - Absolute or relative file path.
47
+ * @returns `true` when extension is one of `.ts`, `.tsx`, `.js`, `.jsx`.
48
+ */
49
+ export function isSourceFile(filePath: string): boolean {
50
+ const extension = path.extname(filePath).toLowerCase();
51
+ return SOURCE_EXTENSIONS.has(extension);
52
+ }
53
+
54
+ /**
55
+ * Converts in-memory dependency graph nodes into graph cache file shape.
56
+ *
57
+ * @param graph - Dependency graph to serialize.
58
+ * @returns `GraphCacheFile["files"]` payload suitable for `graph.json`.
59
+ */
60
+ export function serializeGraphToCache(graph: DependencyGraph): GraphCacheFile["files"] {
61
+ const files: GraphCacheFile["files"] = {};
62
+
63
+ for (const [filePath, node] of graph.entries()) {
64
+ files[filePath] = {
65
+ rank: 0.0,
66
+ deps: node.deps,
67
+ defs: node.defs,
68
+ };
69
+ }
70
+
71
+ return files;
72
+ }
73
+
74
+ /**
75
+ * Resolves tracked source files to safe absolute paths for graph building.
76
+ *
77
+ * @param repoRoot - Repository root used for path resolution.
78
+ * @param trackedFilesProvider - Optional provider for tracked paths (defaults to git).
79
+ * @returns Absolute, de-duplicated source file paths constrained to `repoRoot`.
80
+ */
81
+ export async function resolveSourceFilePaths(
82
+ repoRoot: string,
83
+ trackedFilesProvider: TrackedFilesProvider = getGitTrackedFiles,
84
+ ): Promise<string[]> {
85
+ const trackedFiles = await trackedFilesProvider(repoRoot);
86
+ const normalizedRepoRoot = path.resolve(repoRoot);
87
+ const repoPrefix = `${normalizedRepoRoot}${path.sep}`;
88
+ const sourcePaths = new Set<string>();
89
+
90
+ await Promise.all(
91
+ trackedFiles.filter(isSourceFile).map(async (relPath) => {
92
+ const absolutePath = path.join(normalizedRepoRoot, relPath);
93
+ try {
94
+ const resolvedPath = await realpath(absolutePath);
95
+ if (resolvedPath === normalizedRepoRoot || resolvedPath.startsWith(repoPrefix)) {
96
+ sourcePaths.add(resolvedPath);
97
+ }
98
+ } catch {
99
+ // Ignore missing or unreadable paths from tracked files.
100
+ }
101
+ }),
102
+ );
103
+
104
+ return [...sourcePaths];
105
+ }
106
+
107
+ async function rebuildGraphCache(repoRoot: string, changedPath: string | undefined, verbose: boolean): Promise<void> {
108
+ try {
109
+ const sourceFilePaths = await resolveSourceFilePaths(repoRoot);
110
+ const graph = await buildGraph(sourceFilePaths, repoRoot);
111
+ const graphCachePath = resolveGraphCachePath(repoRoot);
112
+ const graphPayload: GraphCacheFile = {
113
+ files: serializeGraphToCache(graph),
114
+ computed_at: new Date().toISOString(),
115
+ };
116
+ const writeResult = await writeCache(graphCachePath, graphPayload, "replace");
117
+ if (!writeResult.ok) {
118
+ process.stderr.write(`[watch] Failed to update graph cache: ${writeResult.error}\n`);
119
+ return;
120
+ }
121
+ if (verbose) {
122
+ if (changedPath !== undefined) {
123
+ process.stdout.write(`[watch] Graph updated: ${graph.size} files, changed: ${changedPath}\n`);
124
+ } else {
125
+ process.stdout.write(`[watch] Initial graph computed: ${graph.size} files\n`);
126
+ }
127
+ }
128
+ } catch (err) {
129
+ const message = err instanceof Error ? err.message : String(err);
130
+ process.stderr.write(`[watch] Failed to rebuild graph: ${message}\n`);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Starts the long-running graph watch daemon.
136
+ *
137
+ * @param args - {@link WatchArgs} command arguments.
138
+ * @returns Promise<Result<never>>; common failures include FILE_WRITE_ERROR via wrapped
139
+ * UNKNOWN, runtime unavailability errors, and UNKNOWN.
140
+ */
141
+ export async function watchCommand(args: WatchArgs): Promise<Result<never>> {
142
+ try {
143
+ const repoRoot = await findRepoRoot(process.cwd());
144
+
145
+ const initialSourceFiles = await resolveSourceFilePaths(repoRoot);
146
+ const initialGraph = await buildGraph(initialSourceFiles, repoRoot);
147
+ const graphCachePath = resolveGraphCachePath(repoRoot);
148
+ const initialPayload: GraphCacheFile = {
149
+ files: serializeGraphToCache(initialGraph),
150
+ computed_at: new Date().toISOString(),
151
+ };
152
+ const initialWriteResult = await writeCache(graphCachePath, initialPayload, "replace");
153
+ if (!initialWriteResult.ok) {
154
+ const errorMessage = `[watch] Failed to write initial graph cache: ${initialWriteResult.error}`;
155
+ process.stderr.write(`${errorMessage}\n`);
156
+ return {
157
+ ok: false,
158
+ error: errorMessage,
159
+ code: ErrorCode.UNKNOWN,
160
+ };
161
+ }
162
+ if (args.verbose) {
163
+ process.stdout.write(`[watch] Initial graph computed: ${initialGraph.size} files\n`);
164
+ }
165
+
166
+ let pendingChangedPath: string | undefined;
167
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
168
+ let rebuildInProgress = false;
169
+ let rebuildQueued = false;
170
+
171
+ const triggerRebuild = async (): Promise<void> => {
172
+ if (rebuildInProgress) {
173
+ rebuildQueued = true;
174
+ return;
175
+ }
176
+
177
+ rebuildInProgress = true;
178
+ try {
179
+ do {
180
+ rebuildQueued = false;
181
+ const changedPath = pendingChangedPath;
182
+ pendingChangedPath = undefined;
183
+ await rebuildGraphCache(repoRoot, changedPath, args.verbose === true);
184
+ } while (rebuildQueued);
185
+ } finally {
186
+ rebuildInProgress = false;
187
+ }
188
+ };
189
+
190
+ const scheduleRebuild = (changedPath: string): void => {
191
+ pendingChangedPath = changedPath;
192
+
193
+ if (debounceTimer !== undefined) {
194
+ clearTimeout(debounceTimer);
195
+ }
196
+
197
+ debounceTimer = setTimeout(() => {
198
+ debounceTimer = undefined;
199
+ void triggerRebuild();
200
+ }, WATCH_DEBOUNCE_MS);
201
+ };
202
+
203
+ const watchResult = resolveBunWatch();
204
+ if (!watchResult.ok) {
205
+ process.stderr.write(`[watch] ${watchResult.error}\n`);
206
+ return watchResult;
207
+ }
208
+
209
+ const watcher = watchResult.value(repoRoot, { recursive: true }, (_event, filename) => {
210
+ if (filename === null) {
211
+ return;
212
+ }
213
+
214
+ const absolutePath = path.join(repoRoot, filename);
215
+ if (!isSourceFile(absolutePath)) {
216
+ return;
217
+ }
218
+
219
+ if (args.verbose) {
220
+ process.stdout.write(`[watch] File changed: ${absolutePath}, recomputing...\n`);
221
+ }
222
+
223
+ scheduleRebuild(absolutePath);
224
+ });
225
+
226
+ const shutdown = (): void => {
227
+ if (debounceTimer !== undefined) {
228
+ clearTimeout(debounceTimer);
229
+ }
230
+ if (args.verbose) {
231
+ process.stdout.write("[watch] Shutting down\n");
232
+ }
233
+ if (typeof watcher.close === "function") {
234
+ watcher.close();
235
+ } else if (typeof watcher.stop === "function") {
236
+ watcher.stop();
237
+ }
238
+ process.exit(0);
239
+ };
240
+
241
+ process.once("SIGINT", shutdown);
242
+ process.once("SIGTERM", shutdown);
243
+
244
+ return new Promise<never>(() => {
245
+ // Keep command alive until signal-based shutdown.
246
+ });
247
+ } catch (err) {
248
+ const unknownError = toUnknownResult(err);
249
+ const message = unknownError.error;
250
+ process.stderr.write(`[watch] Failed to start watcher: ${message}\n`);
251
+ return unknownError;
252
+ }
253
+ }
@@ -0,0 +1,51 @@
1
+ import { join } from "node:path";
2
+
3
+ import { findRepoRoot, resolveCacheDir, writeCache } from "../cache/cacheManager.js";
4
+ import type { WriteArgs, WriteResult } 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
+ import { formatZodError, validateSubject } from "../utils/validate.js";
9
+
10
+ /**
11
+ * Validates and writes one external cache entry.
12
+ *
13
+ * @param args - {@link WriteArgs} command arguments for the external agent.
14
+ * @returns Promise<Result<WriteResult["value"]>>; common failures include INVALID_ARGS,
15
+ * VALIDATION_ERROR, FILE_WRITE_ERROR, LOCK_TIMEOUT/LOCK_ERROR, and UNKNOWN.
16
+ */
17
+ export async function writeExternalCommand(args: WriteArgs): Promise<Result<WriteResult["value"]>> {
18
+ try {
19
+ if (!args.subject) {
20
+ return { ok: false, error: "subject is required for external agent", code: ErrorCode.INVALID_ARGS };
21
+ }
22
+
23
+ const subjectValidation = validateSubject(args.subject);
24
+ if (!subjectValidation.ok) return subjectValidation;
25
+
26
+ if (args.content["subject"] !== undefined && args.content["subject"] !== args.subject) {
27
+ return {
28
+ ok: false,
29
+ error: `content.subject "${String(args.content["subject"])}" does not match subject argument "${args.subject}"`,
30
+ code: ErrorCode.VALIDATION_ERROR,
31
+ };
32
+ }
33
+
34
+ const contentWithSubject = { ...args.content, subject: args.subject };
35
+
36
+ const parsed = ExternalCacheFileSchema.safeParse(contentWithSubject);
37
+ if (!parsed.success) {
38
+ const message = formatZodError(parsed.error);
39
+ return { ok: false, error: `Validation failed: ${message}`, code: ErrorCode.VALIDATION_ERROR };
40
+ }
41
+
42
+ const repoRoot = await findRepoRoot(process.cwd());
43
+ const cacheDir = resolveCacheDir("external", repoRoot);
44
+ const filePath = join(cacheDir, `${args.subject}.json`);
45
+ const writeResult = await writeCache(filePath, contentWithSubject);
46
+ if (!writeResult.ok) return writeResult;
47
+ return { ok: true, value: { file: filePath } };
48
+ } catch (err) {
49
+ return toUnknownResult(err);
50
+ }
51
+ }
@@ -0,0 +1,123 @@
1
+ import { join } from "node:path";
2
+
3
+ import { findRepoRoot, readCache, resolveCacheDir, writeCache } from "../cache/cacheManager.js";
4
+ import { filterExistingFiles, resolveTrackedFileStats } from "../files/changeDetector.js";
5
+ import type { WriteArgs, WriteResult } from "../types/commands.js";
6
+ import { type FileFacts, LocalCacheFileSchema, type TrackedFile, TrackedFileSchema } from "../types/cache.js";
7
+ import { ErrorCode, type Result } from "../types/result.js";
8
+ import { toUnknownResult } from "../utils/errors.js";
9
+ import { formatZodError } from "../utils/validate.js";
10
+
11
+ function evictFactsForDeletedPaths(
12
+ facts: Record<string, unknown>,
13
+ survivingFiles: TrackedFile[],
14
+ ): Record<string, unknown> {
15
+ const survivingPaths = new Set(survivingFiles.map((file) => file.path));
16
+ return Object.fromEntries(Object.entries(facts).filter(([path]) => survivingPaths.has(path)));
17
+ }
18
+
19
+ function hasStringPath(entry: unknown): entry is { path: string } {
20
+ if (typeof entry !== "object" || entry === null) {
21
+ return false;
22
+ }
23
+ const pathValue = Reflect.get(entry, "path");
24
+ return typeof pathValue === "string";
25
+ }
26
+
27
+ function getSubmittedTrackedPaths(rawTrackedFiles: unknown): string[] {
28
+ if (!Array.isArray(rawTrackedFiles)) return [];
29
+
30
+ return rawTrackedFiles.filter(hasStringPath).map((entry) => entry.path);
31
+ }
32
+
33
+ function toPlainObjectRecord(rawValue: unknown): Record<string, unknown> {
34
+ if (typeof rawValue === "object" && rawValue !== null && !Array.isArray(rawValue)) {
35
+ return Object.fromEntries(Object.entries(rawValue));
36
+ }
37
+ return {};
38
+ }
39
+
40
+ /**
41
+ * Validates and writes local context cache content with per-path merge semantics.
42
+ *
43
+ * @param args - {@link WriteArgs} command arguments for the local agent.
44
+ * @returns Promise<Result<WriteResult["value"]>>; common failures include VALIDATION_ERROR,
45
+ * FILE_READ_ERROR/FILE_WRITE_ERROR, LOCK_TIMEOUT/LOCK_ERROR, and UNKNOWN.
46
+ */
47
+ export async function writeLocalCommand(args: WriteArgs): Promise<Result<WriteResult["value"]>> {
48
+ try {
49
+ const repoRoot = await findRepoRoot(process.cwd());
50
+
51
+ const contentWithTimestamp: Record<string, unknown> = {
52
+ ...args.content,
53
+ timestamp: new Date().toISOString(),
54
+ };
55
+
56
+ const submittedPaths = getSubmittedTrackedPaths(contentWithTimestamp["tracked_files"]);
57
+ const guardedPaths = new Set(submittedPaths);
58
+ const resolvedTrackedFiles = await resolveTrackedFileStats(submittedPaths.map((path) => ({ path })), repoRoot);
59
+ const survivingSubmitted = resolvedTrackedFiles.filter((trackedFile) => trackedFile.mtime !== 0);
60
+
61
+ const submittedFacts = toPlainObjectRecord(contentWithTimestamp["facts"]);
62
+ const rawSubmittedFacts = contentWithTimestamp["facts"];
63
+ if (typeof rawSubmittedFacts === "object" && rawSubmittedFacts !== null && !Array.isArray(rawSubmittedFacts)) {
64
+ const violatingPaths = Object.keys(submittedFacts).filter((path) => !guardedPaths.has(path));
65
+ if (violatingPaths.length > 0) {
66
+ return {
67
+ ok: false,
68
+ error: `facts contains paths not in submitted tracked_files: ${violatingPaths.join(", ")}`,
69
+ code: ErrorCode.VALIDATION_ERROR,
70
+ };
71
+ }
72
+ }
73
+
74
+ const localCacheDir = resolveCacheDir("local", repoRoot);
75
+ const filePath = join(localCacheDir, "context.json");
76
+
77
+ const readResult = await readCache(filePath);
78
+ let existingContent: Record<string, unknown> = {};
79
+ let existingTrackedFiles: TrackedFile[] = [];
80
+ let existingFacts: Record<string, FileFacts> = {};
81
+
82
+ if (readResult.ok) {
83
+ existingContent = readResult.value;
84
+ const localParseResult = LocalCacheFileSchema.safeParse(existingContent);
85
+ if (localParseResult.success) {
86
+ existingTrackedFiles = localParseResult.data.tracked_files;
87
+ existingFacts = localParseResult.data.facts ?? {};
88
+ } else {
89
+ const trackedFilesResult = TrackedFileSchema.array().safeParse(existingContent["tracked_files"]);
90
+ existingTrackedFiles = trackedFilesResult.success ? trackedFilesResult.data : [];
91
+ }
92
+ } else if (readResult.code !== ErrorCode.FILE_NOT_FOUND) {
93
+ return { ok: false, error: readResult.error, code: readResult.code };
94
+ }
95
+
96
+ const submittedTrackedPaths = new Set(survivingSubmitted.map((trackedFile) => trackedFile.path));
97
+ const existingNotSubmitted = existingTrackedFiles.filter((trackedFile) => !submittedTrackedPaths.has(trackedFile.path));
98
+ const survivingExisting = await filterExistingFiles(existingNotSubmitted, repoRoot);
99
+ const mergedTrackedFiles = [...survivingExisting, ...survivingSubmitted];
100
+
101
+ const rawMergedFacts = { ...existingFacts, ...submittedFacts };
102
+ const mergedFacts = evictFactsForDeletedPaths(rawMergedFacts, mergedTrackedFiles);
103
+
104
+ const processedContent: Record<string, unknown> = {
105
+ ...existingContent,
106
+ ...contentWithTimestamp,
107
+ tracked_files: mergedTrackedFiles,
108
+ facts: mergedFacts,
109
+ };
110
+
111
+ const parsed = LocalCacheFileSchema.safeParse(processedContent);
112
+ if (!parsed.success) {
113
+ const message = formatZodError(parsed.error);
114
+ return { ok: false, error: `Validation failed: ${message}`, code: ErrorCode.VALIDATION_ERROR };
115
+ }
116
+
117
+ const writeResult = await writeCache(filePath, processedContent, "replace");
118
+ if (!writeResult.ok) return writeResult;
119
+ return { ok: true, value: { file: filePath } };
120
+ } catch (err) {
121
+ return toUnknownResult(err);
122
+ }
123
+ }
@@ -3,12 +3,26 @@ import { createHash } from "node:crypto";
3
3
  import { resolve, isAbsolute } from "node:path";
4
4
  import type { TrackedFile } from "../types/cache.js";
5
5
 
6
+ /**
7
+ * Comparison outcome for one tracked file baseline.
8
+ *
9
+ * `status` and `reason` are coupled: `changed` uses `mtime`/`hash`, `missing` uses `missing`,
10
+ * and `unchanged` omits `reason`. `missing` covers both deleted files and traversal-rejected
11
+ * paths blocked by path-safety guards.
12
+ */
6
13
  export interface FileComparisonResult {
7
14
  path: string;
8
15
  status: "changed" | "unchanged" | "missing";
9
16
  reason?: "mtime" | "hash" | "missing";
10
17
  }
11
18
 
19
+ /**
20
+ * Compares one tracked file against current filesystem state.
21
+ *
22
+ * Uses `mtime` as a fast path and falls back to SHA-256 hash comparison when stored hash is
23
+ * available and `mtime` changed. Paths that escape `repoRoot` are rejected by traversal guard
24
+ * and reported as `missing`. `ENOENT` is also reported as `missing`.
25
+ */
12
26
  export async function compareTrackedFile(file: TrackedFile, repoRoot: string): Promise<FileComparisonResult> {
13
27
  const absolutePath = resolveTrackedFilePath(file.path, repoRoot);
14
28
 
@@ -47,6 +61,7 @@ export async function compareTrackedFile(file: TrackedFile, repoRoot: string): P
47
61
  }
48
62
  }
49
63
 
64
+ /** Computes SHA-256 hex digest for a file's current bytes. */
50
65
  export async function computeFileHash(filePath: string): Promise<string> {
51
66
  const content = await readFile(filePath);
52
67
  return createHash("sha256").update(content).digest("hex");