@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
@@ -2,10 +2,12 @@ import type { AgentType, ExternalCacheFile, LocalCacheFile } from "./cache.js";
2
2
 
3
3
  // ── list ──────────────────────────────────────────────────────────────────────
4
4
 
5
+ /** Arguments accepted by the `list` command. */
5
6
  export interface ListArgs {
6
7
  agent?: AgentType | "all";
7
8
  }
8
9
 
10
+ /** One entry returned by the `list` command. */
9
11
  export interface ListEntry {
10
12
  file: string;
11
13
  agent: AgentType;
@@ -16,10 +18,16 @@ export interface ListEntry {
16
18
  is_stale: boolean;
17
19
  }
18
20
 
21
+ /** Success payload shape returned by the `list` command. */
19
22
  export type ListResult = { ok: true; value: ListEntry[] };
20
23
 
21
24
  // ── inspect ───────────────────────────────────────────────────────────────────
22
25
 
26
+ /**
27
+ * Arguments accepted by the `inspect` command.
28
+ * @remarks `agent` controls result shape: `external` returns an external entry, while
29
+ * `local` returns local cache content with `tracked_files` removed and optional facts filters.
30
+ */
23
31
  export interface InspectArgs {
24
32
  agent: AgentType;
25
33
  subject: string;
@@ -39,9 +47,10 @@ export interface InspectArgs {
39
47
  searchFacts?: string[];
40
48
  }
41
49
 
50
+ /** Success payload shape returned by the `inspect` command. */
42
51
  export type InspectResult = {
43
52
  ok: true;
44
- value: (ExternalCacheFile | LocalCacheFile) & {
53
+ value: (ExternalCacheFile | Omit<LocalCacheFile, "tracked_files">) & {
45
54
  file: string;
46
55
  agent: AgentType;
47
56
  };
@@ -49,11 +58,13 @@ export type InspectResult = {
49
58
 
50
59
  // ── flush ─────────────────────────────────────────────────────────────────────
51
60
 
61
+ /** Arguments accepted by the `flush` command. */
52
62
  export interface FlushArgs {
53
63
  agent: AgentType | "all";
54
64
  confirm: boolean;
55
65
  }
56
66
 
67
+ /** Success payload shape returned by the `flush` command. */
57
68
  export type FlushResult = {
58
69
  ok: true;
59
70
  value: {
@@ -64,11 +75,13 @@ export type FlushResult = {
64
75
 
65
76
  // ── invalidate ────────────────────────────────────────────────────────────────
66
77
 
78
+ /** Arguments accepted by the `invalidate` command. */
67
79
  export interface InvalidateArgs {
68
80
  agent: AgentType;
69
81
  subject?: string;
70
82
  }
71
83
 
84
+ /** Success payload shape returned by the `invalidate` command. */
72
85
  export type InvalidateResult = {
73
86
  ok: true;
74
87
  value: {
@@ -78,11 +91,13 @@ export type InvalidateResult = {
78
91
 
79
92
  // ── touch ─────────────────────────────────────────────────────────────────────
80
93
 
94
+ /** Arguments accepted by the `touch` command. */
81
95
  export interface TouchArgs {
82
96
  agent: AgentType;
83
97
  subject?: string;
84
98
  }
85
99
 
100
+ /** Success payload shape returned by the `touch` command. */
86
101
  export type TouchResult = {
87
102
  ok: true;
88
103
  value: {
@@ -93,12 +108,14 @@ export type TouchResult = {
93
108
 
94
109
  // ── prune ─────────────────────────────────────────────────────────────────────
95
110
 
111
+ /** Arguments accepted by the `prune` command. */
96
112
  export interface PruneArgs {
97
113
  agent?: AgentType | "all";
98
114
  maxAge?: string;
99
115
  delete?: boolean;
100
116
  }
101
117
 
118
+ /** Success payload shape returned by the `prune` command. */
102
119
  export type PruneResult = {
103
120
  ok: true;
104
121
  value: {
@@ -108,29 +125,9 @@ export type PruneResult = {
108
125
  };
109
126
  };
110
127
 
111
- // ── check-freshness ───────────────────────────────────────────────────────────
112
-
113
- export interface CheckFreshnessArgs {
114
- subject: string;
115
- url?: string;
116
- }
117
-
118
- export type CheckFreshnessResult = {
119
- ok: true;
120
- value: {
121
- subject: string;
122
- sources: Array<{
123
- url: string;
124
- status: "fresh" | "stale" | "error";
125
- http_status?: number;
126
- error?: string;
127
- }>;
128
- overall: "fresh" | "stale" | "error";
129
- };
130
- };
131
-
132
128
  // ── check-files ───────────────────────────────────────────────────────────────
133
129
 
130
+ /** Success payload shape returned by the `check-files` command. */
134
131
  export type CheckFilesResult = {
135
132
  ok: true;
136
133
  value: {
@@ -148,10 +145,12 @@ export type CheckFilesResult = {
148
145
 
149
146
  // ── search ────────────────────────────────────────────────────────────────────
150
147
 
148
+ /** Arguments accepted by the `search` command. */
151
149
  export interface SearchArgs {
152
150
  keywords: string[];
153
151
  }
154
152
 
153
+ /** Success payload shape returned by the `search` command. */
155
154
  export type SearchResult = {
156
155
  ok: true;
157
156
  value: Array<{
@@ -166,12 +165,18 @@ export type SearchResult = {
166
165
 
167
166
  // ── write ─────────────────────────────────────────────────────────────────────
168
167
 
168
+ /**
169
+ * Shared write-command input contract.
170
+ * @remarks `agent` selects write mode: `external` requires `subject`; `local` ignores
171
+ * `subject` and writes `context.json` using local schema semantics.
172
+ */
169
173
  export interface WriteArgs {
170
174
  agent: AgentType;
171
175
  subject?: string; // required for external, unused for local
172
176
  content: Record<string, unknown>;
173
177
  }
174
178
 
179
+ /** Success payload shape returned by write commands. */
175
180
  export type WriteResult = {
176
181
  ok: true;
177
182
  value: {
@@ -179,14 +184,110 @@ export type WriteResult = {
179
184
  };
180
185
  };
181
186
 
187
+ // ── graph ─────────────────────────────────────────────────────────────────────
188
+
189
+ /** Arguments accepted by the `graph` command. */
190
+ export interface GraphArgs {
191
+ maxTokens?: number;
192
+ seed?: string[];
193
+ }
194
+
195
+ // ── watch ─────────────────────────────────────────────────────────────────────
196
+
197
+ /** Arguments accepted by the `watch` command. */
198
+ export interface WatchArgs {
199
+ verbose?: boolean;
200
+ }
201
+
202
+ /** Success payload shape returned by the `graph` command. */
203
+ export interface GraphResult {
204
+ value: {
205
+ ranked_files: Array<{
206
+ path: string;
207
+ rank: number;
208
+ deps: string[];
209
+ defs: string[];
210
+ ref_count: number;
211
+ }>;
212
+ total_files: number;
213
+ computed_at: string;
214
+ token_estimate: number;
215
+ };
216
+ }
217
+
218
+ // ── map ───────────────────────────────────────────────────────────────────────
219
+
220
+ /** Output depth levels supported by the `map` command. */
221
+ export type MapDepth = "overview" | "modules" | "full";
222
+
223
+ /** Arguments accepted by the `map` command. */
224
+ export interface MapArgs {
225
+ depth?: MapDepth;
226
+ folder?: string;
227
+ }
228
+
229
+ /** Success payload shape returned by the `map` command. */
230
+ export interface MapResult {
231
+ value: {
232
+ depth: MapDepth;
233
+ global_facts: string[];
234
+ files: Array<{
235
+ path: string;
236
+ summary?: string;
237
+ role?: string;
238
+ importance?: number;
239
+ facts?: string[];
240
+ }>;
241
+ modules?: Record<string, string[]>;
242
+ total_files: number;
243
+ folder_filter?: string;
244
+ };
245
+ }
246
+
182
247
  // ── install ───────────────────────────────────────────────────────────────────
183
248
 
249
+ /** Arguments accepted by the `install` command. */
184
250
  export interface InstallArgs {
185
251
  configDir?: string;
186
252
  }
187
253
 
254
+ /** Success payload shape returned by the `install` command. */
188
255
  export interface InstallResult {
189
256
  toolPath: string;
190
257
  skillPaths: string[];
191
258
  configDir: string;
192
259
  }
260
+
261
+ // ── version ───────────────────────────────────────────────────────────────────
262
+
263
+ /** Arguments accepted by the `version` command. */
264
+ export type VersionArgs = Record<string, never>;
265
+
266
+ /** Success payload shape returned by the `version` command. */
267
+ export type VersionResult = { value: { version: string } };
268
+
269
+ // ── update / uninstall ────────────────────────────────────────────────────────
270
+
271
+ /** Arguments accepted by the `update` command. */
272
+ export interface UpdateArgs {
273
+ configDir?: string;
274
+ }
275
+
276
+ /** Success payload shape returned by the `update` command. */
277
+ export interface UpdateResult {
278
+ packageUpdated: boolean;
279
+ installedPaths: string[];
280
+ warnings: string[];
281
+ }
282
+
283
+ /** Arguments accepted by the `uninstall` command. */
284
+ export interface UninstallArgs {
285
+ configDir?: string;
286
+ }
287
+
288
+ /** Success payload shape returned by the `uninstall` command. */
289
+ export interface UninstallResult {
290
+ removed: string[];
291
+ packageUninstalled: boolean;
292
+ warnings: string[];
293
+ }
@@ -1,36 +1,53 @@
1
+ /**
2
+ * Enumerates all typed failure categories returned by cache-ctrl commands and services.
3
+ */
1
4
  export enum ErrorCode {
2
- // File system errors
5
+ /** Returned when an expected cache file is absent on disk. */
3
6
  FILE_NOT_FOUND = "FILE_NOT_FOUND",
7
+ /** Returned when a file cannot be read due to I/O or permission errors. */
4
8
  FILE_READ_ERROR = "FILE_READ_ERROR",
9
+ /** Returned when a write, rename, delete, or other mutating file operation fails. */
5
10
  FILE_WRITE_ERROR = "FILE_WRITE_ERROR",
11
+ /** Returned when file content is present but not valid JSON. */
6
12
  PARSE_ERROR = "PARSE_ERROR",
7
13
 
8
- // Lock errors
14
+ /** Returned when advisory lock acquisition exceeds the configured wait timeout. */
9
15
  LOCK_TIMEOUT = "LOCK_TIMEOUT",
16
+ /** Returned when lock file operations fail for reasons other than contention timeout. */
10
17
  LOCK_ERROR = "LOCK_ERROR",
11
18
 
12
- // Validation errors
19
+ /** Returned when CLI arguments are missing, malformed, or violate command constraints. */
13
20
  INVALID_ARGS = "INVALID_ARGS",
21
+ /** Returned when a destructive operation is requested without explicit confirmation. */
14
22
  CONFIRMATION_REQUIRED = "CONFIRMATION_REQUIRED",
23
+ /** Returned when schema or structural validation fails for user-supplied content. */
15
24
  VALIDATION_ERROR = "VALIDATION_ERROR",
16
25
 
17
- // Search/match errors
26
+ /** Returned when keyword matching yields zero candidates. */
18
27
  NO_MATCH = "NO_MATCH",
28
+ /** Returned when keyword matching yields multiple top-scoring candidates. */
19
29
  AMBIGUOUS_MATCH = "AMBIGUOUS_MATCH",
20
30
 
21
- // HTTP errors
22
- HTTP_REQUEST_FAILED = "HTTP_REQUEST_FAILED",
23
- URL_NOT_FOUND = "URL_NOT_FOUND",
24
-
25
- // Internal
31
+ /** Returned for unexpected internal exceptions converted at command boundaries. */
26
32
  UNKNOWN = "UNKNOWN",
27
33
  }
28
34
 
35
+ /**
36
+ * Canonical failure payload used by the error branch of {@link Result}.
37
+ */
29
38
  export interface CacheError {
30
39
  code: ErrorCode;
31
40
  error: string;
32
41
  }
33
42
 
43
+ /**
44
+ * Discriminated union used for recoverable operation outcomes.
45
+ *
46
+ * @typeParam T - Success payload type carried when `ok` is `true`.
47
+ * @typeParam E - Failure payload shape; defaults to {@link CacheError}.
48
+ * @remarks Consumers must branch on `ok`. The success branch contains `value`; the
49
+ * failure branch contains `error` and a typed `code`.
50
+ */
34
51
  export type Result<T, E extends CacheError = CacheError> =
35
52
  | { ok: true; value: T }
36
53
  | { ok: false; error: string; code: E["code"] };
@@ -0,0 +1,14 @@
1
+ import { ErrorCode } from "../types/result.js";
2
+
3
+ /**
4
+ * Converts an unknown thrown value into the canonical UNKNOWN result shape.
5
+ *
6
+ * @param err - Untrusted thrown value caught at a command boundary.
7
+ * @returns `Result<never>`-compatible failure payload with {@link ErrorCode.UNKNOWN}.
8
+ * @remarks This is the canonical catch-all converter used by command handlers to avoid
9
+ * leaking thrown exceptions across the Result-based API boundary.
10
+ */
11
+ export function toUnknownResult(err: unknown): { ok: false; error: string; code: ErrorCode.UNKNOWN } {
12
+ const error = err instanceof Error ? err.message : String(err);
13
+ return { ok: false, error, code: ErrorCode.UNKNOWN };
14
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Runtime guard for Zod `superRefine` contexts.
3
+ *
4
+ * @param value - Candidate refinement context value.
5
+ * @returns Type predicate that confirms `value` exposes Zod-compatible `addIssue`.
6
+ * @remarks This guard avoids unsafe assumptions when helper functions are reused outside
7
+ * Zod's refinement pipeline.
8
+ */
9
+ export function isRefinementContext(
10
+ value: unknown,
11
+ ): value is { addIssue: (issue: { code: "custom"; message: string; path: string[] }) => void } {
12
+ if (typeof value !== "object" || value === null) {
13
+ return false;
14
+ }
15
+
16
+ return typeof (value as Record<string, unknown>)["addIssue"] === "function";
17
+ }
18
+
19
+ /**
20
+ * Rejects object keys that can be interpreted as traversal-capable file paths.
21
+ *
22
+ * @param record - Object whose keys are validated (for example `facts` map keys).
23
+ * @param ctx - Zod refinement context used to report validation issues.
24
+ * @remarks Security control: blocks keys containing `..`, leading `/`, `\\`, or `\x00`
25
+ * to prevent path-traversal and null-byte injection when keys are later consumed as
26
+ * filesystem-relative paths.
27
+ */
28
+ export function rejectTraversalKeys(record: Record<string, unknown>, ctx: unknown): void {
29
+ if (!isRefinementContext(ctx)) {
30
+ return;
31
+ }
32
+
33
+ for (const key of Object.keys(record)) {
34
+ if (key.includes("..") || key.startsWith("/") || key.includes("\\") || key.includes("\x00")) {
35
+ ctx.addIssue({
36
+ code: "custom",
37
+ message: `facts key contains a path traversal or invalid character: "${key}"`,
38
+ path: [key],
39
+ });
40
+ }
41
+ }
42
+ }
@@ -1,123 +0,0 @@
1
- import { findRepoRoot, readCache, writeCache } from "../cache/cacheManager.js";
2
- import { isExternalStale, mergeHeaderMetadata, resolveTopExternalMatch } from "../cache/externalCache.js";
3
- import { checkFreshness } from "../http/freshnessChecker.js";
4
- import type { ExternalCacheFile } from "../types/cache.js";
5
- import { ExternalCacheFileSchema } from "../types/cache.js";
6
- import { ErrorCode, type Result } from "../types/result.js";
7
- import type { CheckFreshnessArgs, CheckFreshnessResult } from "../types/commands.js";
8
- import type { HeaderMeta } from "../cache/externalCache.js";
9
- import { getFileStem } from "../utils/fileStem.js";
10
-
11
- export async function checkFreshnessCommand(args: CheckFreshnessArgs): Promise<Result<CheckFreshnessResult["value"]>> {
12
- try {
13
- const repoRoot = await findRepoRoot(process.cwd());
14
-
15
- // Find the best-matching external cache entry file path
16
- const matchResult = await resolveTopExternalMatch(repoRoot, args.subject);
17
- if (!matchResult.ok) return matchResult;
18
-
19
- const filePath = matchResult.value;
20
-
21
- // Load the matched entry's data
22
- const readResult = await readCache(filePath);
23
- if (!readResult.ok) return readResult;
24
-
25
- const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
26
- if (!parseResult.success) {
27
- return { ok: false, error: `Malformed external cache file: ${filePath}`, code: ErrorCode.PARSE_ERROR };
28
- }
29
-
30
- const cacheEntry = parseResult.data;
31
- const stem = getFileStem(filePath);
32
- const subject = cacheEntry.subject ?? stem;
33
-
34
- // Determine which URLs to check
35
- const sources = cacheEntry.sources ?? [];
36
- let urlsToCheck = sources;
37
-
38
- if (args.url) {
39
- const found = sources.find((s) => s.url === args.url);
40
- if (!found) {
41
- return {
42
- ok: false,
43
- error: `URL not found in sources for subject '${subject}'`,
44
- code: ErrorCode.URL_NOT_FOUND,
45
- };
46
- }
47
- urlsToCheck = [found];
48
- } else {
49
- urlsToCheck = sources;
50
- }
51
-
52
- // Check freshness for each URL
53
- const sourceResults: Array<{
54
- url: string;
55
- status: "fresh" | "stale" | "error";
56
- http_status?: number;
57
- error?: string;
58
- }> = [];
59
-
60
- const headerUpdates: Record<string, HeaderMeta> = {};
61
-
62
- for (const source of urlsToCheck) {
63
- const stored = cacheEntry.header_metadata?.[source.url];
64
- const result = await checkFreshness({
65
- url: source.url,
66
- ...(stored?.etag !== undefined ? { etag: stored.etag } : {}),
67
- ...(stored?.last_modified !== undefined ? { last_modified: stored.last_modified } : {}),
68
- });
69
-
70
- sourceResults.push({
71
- url: result.url,
72
- status: result.status,
73
- ...(result.http_status !== undefined ? { http_status: result.http_status } : {}),
74
- ...(result.error !== undefined ? { error: result.error } : {}),
75
- });
76
-
77
- if (result.status !== "error") {
78
- headerUpdates[source.url] = {
79
- ...(result.etag !== undefined ? { etag: result.etag } : {}),
80
- ...(result.last_modified !== undefined ? { last_modified: result.last_modified } : {}),
81
- checked_at: new Date().toISOString(),
82
- status: result.status,
83
- };
84
- }
85
- }
86
-
87
- // Only write back if at least one URL succeeded
88
- const hasSuccessfulChecks = Object.keys(headerUpdates).length > 0;
89
- if (hasSuccessfulChecks) {
90
- const updated = mergeHeaderMetadata(cacheEntry, headerUpdates);
91
- const writeResult = await writeCache(filePath, { header_metadata: updated.header_metadata });
92
- if (!writeResult.ok) return writeResult;
93
- }
94
-
95
- // Determine overall status: entryIsOld always wins (stale by age), then anyStale, then allError
96
- const allError = sourceResults.every((r) => r.status === "error");
97
- const anyStale = sourceResults.some((r) => r.status === "stale");
98
- const entryIsOld = isExternalStale(cacheEntry);
99
-
100
- let overall: "fresh" | "stale" | "error";
101
- if (entryIsOld) {
102
- overall = "stale";
103
- } else if (anyStale) {
104
- overall = "stale";
105
- } else if (allError && sourceResults.length > 0) {
106
- overall = "error";
107
- } else {
108
- overall = "fresh";
109
- }
110
-
111
- return {
112
- ok: true,
113
- value: {
114
- subject,
115
- sources: sourceResults,
116
- overall,
117
- },
118
- };
119
- } catch (err) {
120
- const error = err as Error;
121
- return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
122
- }
123
- }
@@ -1,170 +0,0 @@
1
- import { join } from "node:path";
2
- import { ExternalCacheFileSchema, LocalCacheFileSchema } from "../types/cache.js";
3
- import { ErrorCode, type Result } from "../types/result.js";
4
- import type { WriteArgs, WriteResult } from "../types/commands.js";
5
- import { writeCache, findRepoRoot, resolveCacheDir, readCache } from "../cache/cacheManager.js";
6
- import { validateSubject, formatZodError } from "../utils/validate.js";
7
- import { resolveTrackedFileStats, filterExistingFiles } from "../files/changeDetector.js";
8
- import type { TrackedFile } from "../types/cache.js";
9
- import { TrackedFileSchema } from "../types/cache.js";
10
-
11
- function evictFactsForDeletedPaths(
12
- facts: Record<string, string[]>,
13
- survivingFiles: TrackedFile[],
14
- ): Record<string, string[]> {
15
- const survivingPaths = new Set(survivingFiles.map((f) => f.path));
16
- return Object.fromEntries(Object.entries(facts).filter(([path]) => survivingPaths.has(path)));
17
- }
18
-
19
- export async function writeCommand(args: WriteArgs): Promise<Result<WriteResult["value"]>> {
20
- try {
21
- const repoRoot = await findRepoRoot(process.cwd());
22
-
23
- if (args.agent === "external") {
24
- // subject is required
25
- if (!args.subject) {
26
- return { ok: false, error: "subject is required for external agent", code: ErrorCode.INVALID_ARGS };
27
- }
28
-
29
- const subjectValidation = validateSubject(args.subject);
30
- if (!subjectValidation.ok) return subjectValidation;
31
-
32
- // if content.subject is set but mismatches the subject param → error
33
- if (args.content["subject"] !== undefined && args.content["subject"] !== args.subject) {
34
- return {
35
- ok: false,
36
- error: `content.subject "${String(args.content["subject"])}" does not match subject argument "${args.subject}"`,
37
- code: ErrorCode.VALIDATION_ERROR,
38
- };
39
- }
40
-
41
- // inject subject into content if absent
42
- const contentWithSubject = { ...args.content, subject: args.subject };
43
-
44
- // validate against ExternalCacheFileSchema
45
- const parsed = ExternalCacheFileSchema.safeParse(contentWithSubject);
46
- if (!parsed.success) {
47
- const message = formatZodError(parsed.error);
48
- return { ok: false, error: `Validation failed: ${message}`, code: ErrorCode.VALIDATION_ERROR };
49
- }
50
-
51
- const cacheDir = resolveCacheDir("external", repoRoot);
52
- const filePath = join(cacheDir, `${args.subject}.json`);
53
- const writeResult = await writeCache(filePath, contentWithSubject);
54
- if (!writeResult.ok) return writeResult;
55
- return { ok: true, value: { file: filePath } };
56
- }
57
-
58
- // local — auto-inject server-side timestamp; agent must not control this field
59
- const contentWithTimestamp: Record<string, unknown> = {
60
- ...args.content,
61
- timestamp: new Date().toISOString(),
62
- };
63
-
64
- // Resolve real mtimes for submitted tracked_files if present
65
- const rawTrackedFiles = contentWithTimestamp["tracked_files"];
66
- let survivingSubmitted: TrackedFile[] = [];
67
- let guardedPaths = new Set<string>();
68
-
69
- if (Array.isArray(rawTrackedFiles)) {
70
- const validEntries = rawTrackedFiles
71
- .filter(
72
- (entry): entry is { path: string } =>
73
- entry !== null &&
74
- typeof entry === "object" &&
75
- typeof (entry as Record<string, unknown>)["path"] === "string",
76
- )
77
- .map((entry) => ({ path: entry.path }));
78
-
79
- guardedPaths = new Set(validEntries.map((e) => e.path));
80
-
81
- const resolved = await resolveTrackedFileStats(validEntries, repoRoot);
82
- // Evict submitted entries for files that are missing or path-traversal-rejected
83
- survivingSubmitted = resolved.filter((f) => f.mtime !== 0);
84
- }
85
-
86
- // Guard: submitted facts paths must be a strict subset of submitted tracked_files paths
87
- const rawSubmittedFacts = contentWithTimestamp["facts"];
88
- if (
89
- rawSubmittedFacts !== null &&
90
- rawSubmittedFacts !== undefined &&
91
- typeof rawSubmittedFacts === "object" &&
92
- !Array.isArray(rawSubmittedFacts)
93
- ) {
94
- const violatingPaths = Object.keys(rawSubmittedFacts as Record<string, string[]>).filter(
95
- (p) => !guardedPaths.has(p),
96
- );
97
- if (violatingPaths.length > 0) {
98
- return {
99
- ok: false,
100
- error: `facts contains paths not in submitted tracked_files: ${violatingPaths.join(", ")}`,
101
- code: ErrorCode.VALIDATION_ERROR,
102
- };
103
- }
104
- }
105
-
106
- // Read existing cache to perform per-path merge
107
- const localCacheDir = resolveCacheDir("local", repoRoot);
108
- const filePath = join(localCacheDir, "context.json");
109
-
110
- const readResult = await readCache(filePath);
111
- let existingContent: Record<string, unknown> = {};
112
- let existingTrackedFiles: TrackedFile[] = [];
113
-
114
- if (readResult.ok) {
115
- existingContent = readResult.value;
116
- // Validate the on-disk tracked_files against the schema — fall back to [] on corrupt/missing data
117
- const parseResult = TrackedFileSchema.array().safeParse(existingContent["tracked_files"]);
118
- existingTrackedFiles = parseResult.success ? parseResult.data : [];
119
- } else if (readResult.code !== ErrorCode.FILE_NOT_FOUND) {
120
- return { ok: false, error: readResult.error, code: readResult.code };
121
- }
122
-
123
- // Keep existing entries whose paths are NOT being replaced by the submitted set
124
- const submittedPaths = new Set(survivingSubmitted.map((f) => f.path));
125
- const existingNotSubmitted = existingTrackedFiles.filter((f) => !submittedPaths.has(f.path));
126
-
127
- // Evict deleted files from the preserved existing entries
128
- const survivingExisting = await filterExistingFiles(existingNotSubmitted, repoRoot);
129
-
130
- const mergedTrackedFiles = [...survivingExisting, ...survivingSubmitted];
131
-
132
- // Per-path merge for facts (mirrors tracked_files merge)
133
- const existingFactsRaw = existingContent["facts"];
134
- const submittedFactsRaw = contentWithTimestamp["facts"];
135
-
136
- const existingFacts =
137
- typeof existingFactsRaw === "object" && existingFactsRaw !== null && !Array.isArray(existingFactsRaw)
138
- ? (existingFactsRaw as Record<string, string[]>)
139
- : {};
140
- const submittedFacts =
141
- typeof submittedFactsRaw === "object" && submittedFactsRaw !== null && !Array.isArray(submittedFactsRaw)
142
- ? (submittedFactsRaw as Record<string, string[]>)
143
- : {};
144
-
145
- const rawMergedFacts = { ...existingFacts, ...submittedFacts };
146
- const mergedFacts = evictFactsForDeletedPaths(rawMergedFacts, mergedTrackedFiles);
147
-
148
- // Merge top-level fields: existing base → then submitted content (submitted wins)
149
- const processedContent: Record<string, unknown> = {
150
- ...existingContent,
151
- ...contentWithTimestamp,
152
- tracked_files: mergedTrackedFiles,
153
- facts: mergedFacts,
154
- };
155
-
156
- const parsed = LocalCacheFileSchema.safeParse(processedContent);
157
- if (!parsed.success) {
158
- const message = formatZodError(parsed.error);
159
- return { ok: false, error: `Validation failed: ${message}`, code: ErrorCode.VALIDATION_ERROR };
160
- }
161
-
162
- // processedContent is used (not parsed.data) to preserve loose fields not known to the schema — intentional merge semantics
163
- const writeResult = await writeCache(filePath, processedContent, "replace");
164
- if (!writeResult.ok) return writeResult;
165
- return { ok: true, value: { file: filePath } };
166
- } catch (err) {
167
- const error = err as Error;
168
- return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
169
- }
170
- }