@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
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A CLI tool and native opencode plugin that manages the two AI agent caches (`.ai/external-context-gatherer_cache/` and `.ai/local-context-gatherer_cache/`) with a uniform interface.
4
4
 
5
- It handles advisory locking for safe concurrent writes, keyword search across all entries, HTTP freshness checking for external URLs, and file-change detection for local scans.
5
+ It handles advisory locking for safe concurrent writes, keyword search across all entries, and file-change detection for local scans.
6
6
 
7
7
  ---
8
8
 
@@ -46,26 +46,32 @@ src/index.ts cache_ctrl.ts
46
46
  └──────────┬──────────────┘
47
47
 
48
48
  Command Layer
49
- src/commands/{list, inspect, flush,
50
- invalidate, touch, prune,
51
- checkFreshness, checkFiles, search,
52
- write}.ts
53
-
54
- Core Services
55
- cacheManager ← read/write + advisory lock
56
- externalCache ← external staleness logic
57
- localCachelocal scan path logic
58
- freshnessCheckerHTTP HEAD requests
59
- changeDetector mtime/hash comparison
49
+ src/commands/{list, inspect, inspectExternal,
50
+ inspectLocal, flush, invalidate,
51
+ touch, prune,
52
+ checkFiles, search, writeLocal,
53
+ writeExternal, install, update, uninstall,
54
+ graph, map, watch, version}.ts
55
+
56
+ Core Services
57
+ cacheManager read/write + advisory lock
58
+ externalCacheexternal staleness logic
59
+ localCache local scan path logic
60
+ graphCache ← graph.json read/write path
61
+ changeDetector ← mtime/hash comparison
60
62
  keywordSearch ← scoring engine
61
-
63
+ analysis/symbolExtractor ← import/export AST pass
64
+ analysis/graphBuilder ← dependency graph construction
65
+ analysis/pageRank ← Personalized PageRank ranking
66
+
62
67
  Cache Directories (on disk)
63
68
  .ai/external-context-gatherer_cache/
64
69
  ├── <subject>.json
65
70
  └── <subject>.json.lock (advisory)
66
71
  .ai/local-context-gatherer_cache/
67
72
  ├── context.json
68
- └── context.json.lock (advisory)
73
+ ├── context.json.lock (advisory)
74
+ └── graph.json (dependency graph; written by watch daemon)
69
75
  ```
70
76
 
71
77
  **Key design decisions:**
@@ -73,6 +79,7 @@ src/index.ts cache_ctrl.ts
73
79
  - The CLI and plugin share the same command functions — no duplicated business logic.
74
80
  - All operations return `Result<T, CacheError>` — nothing throws into the caller.
75
81
  - `writeCache` defaults to merging updates onto the existing object (preserving unknown agent fields). Local writes use per-path merge — submitted `tracked_files` entries replace existing entries for those paths; entries for other paths are preserved; entries for files no longer present on disk are evicted automatically.
82
+ - `write.ts` and `inspect.ts` are thin routers; all business logic lives in `writeLocal.ts`, `writeExternal.ts`, `inspectLocal.ts`, `inspectExternal.ts`.
76
83
 
77
84
  ---
78
85
 
@@ -129,6 +136,90 @@ Both operations are idempotent — re-running `cache-ctrl install` after `npm up
129
136
 
130
137
  ---
131
138
 
139
+ ### `update`
140
+
141
+ ```
142
+ cache-ctrl update [--config-dir <path>]
143
+ ```
144
+
145
+ Updates the globally installed npm package to the latest version, then re-runs the OpenCode integration install to refresh the tool wrapper and skill files.
146
+
147
+ 1. Runs `npm install -g @thecat69/cache-ctrl@latest`.
148
+ 2. Re-runs `cache-ctrl install` (idempotent — regenerates the wrapper with the new package path).
149
+
150
+ If the `npm install` step fails, the error is recorded in `warnings[]` and the integration install still proceeds.
151
+
152
+ **Options:**
153
+
154
+ | Flag | Description |
155
+ |---|---|
156
+ | `--config-dir <path>` | Override the detected OpenCode config directory |
157
+
158
+ ```jsonc
159
+ // cache-ctrl update --pretty
160
+ {
161
+ "ok": true,
162
+ "value": {
163
+ "packageUpdated": true,
164
+ "installedPaths": [
165
+ "/home/user/.config/opencode/tools/cache_ctrl.ts",
166
+ "/home/user/.config/opencode/skills/cache-ctrl-caller/SKILL.md",
167
+ "/home/user/.config/opencode/skills/cache-ctrl-local/SKILL.md",
168
+ "/home/user/.config/opencode/skills/cache-ctrl-external/SKILL.md"
169
+ ],
170
+ "warnings": []
171
+ }
172
+ }
173
+ ```
174
+
175
+ **Error codes**: `INVALID_ARGS` if `--config-dir` is outside the user home directory. `FILE_WRITE_ERROR` if the integration files cannot be written.
176
+
177
+ ---
178
+
179
+ ### `uninstall`
180
+
181
+ ```
182
+ cache-ctrl uninstall [--config-dir <path>]
183
+ ```
184
+
185
+ Removes the cache-ctrl OpenCode integration and uninstalls the global npm package.
186
+
187
+ Removes, in order:
188
+ 1. `<configDir>/tools/cache_ctrl.ts`
189
+ 2. All `<configDir>/skills/cache-ctrl-*` directories (recursive)
190
+ 3. `~/.local/bin/cache-ctrl`
191
+ 4. Runs `npm uninstall -g @thecat69/cache-ctrl`
192
+
193
+ Missing files are not treated as errors — they produce a `warnings[]` entry instead. If the `npm uninstall` step fails, the error is recorded in `warnings[]`.
194
+
195
+ **Options:**
196
+
197
+ | Flag | Description |
198
+ |---|---|
199
+ | `--config-dir <path>` | Override the detected OpenCode config directory |
200
+
201
+ ```jsonc
202
+ // cache-ctrl uninstall --pretty
203
+ {
204
+ "ok": true,
205
+ "value": {
206
+ "removed": [
207
+ "/home/user/.config/opencode/tools/cache_ctrl.ts",
208
+ "/home/user/.config/opencode/skills/cache-ctrl-caller",
209
+ "/home/user/.config/opencode/skills/cache-ctrl-local",
210
+ "/home/user/.config/opencode/skills/cache-ctrl-external",
211
+ "/home/user/.local/bin/cache-ctrl"
212
+ ],
213
+ "packageUninstalled": true,
214
+ "warnings": []
215
+ }
216
+ }
217
+ ```
218
+
219
+ **Error codes**: `INVALID_ARGS` if `--config-dir` is outside the user home directory. `UNKNOWN` for unexpected filesystem errors.
220
+
221
+ ---
222
+
132
223
  ### `help`
133
224
 
134
225
  ```
@@ -277,7 +368,7 @@ cache-ctrl prune [--agent external|local|all] [--max-age <duration>] [--delete]
277
368
 
278
369
  Finds entries older than `--max-age` and invalidates them (default) or deletes them (`--delete`).
279
370
 
280
- **Duration format**: `<number><unit>` — `h` for hours, `d` for days. Examples: `24h`, `7d`, `1d`.
371
+ **Duration format**: `<number><unit>` — `s` for seconds, `m` for minutes, `h` for hours, `d` for days. Examples: `30s`, `15m`, `24h`, `7d`.
281
372
 
282
373
  **Defaults**: `--agent all`, `--max-age 24h` for external. Local cache **always** matches (no TTL).
283
374
 
@@ -292,36 +383,6 @@ cache-ctrl prune --agent external --max-age 1d --delete
292
383
 
293
384
  ---
294
385
 
295
- ### `check-freshness`
296
-
297
- ```
298
- cache-ctrl check-freshness <subject-keyword> [--url <url>] [--pretty]
299
- ```
300
-
301
- Sends HTTP HEAD requests to each URL in the matched external entry's `sources[]`. Uses conditional headers (`If-None-Match`, `If-Modified-Since`) from stored `header_metadata`. Updates `header_metadata` in-place after checking.
302
-
303
- - HTTP 304 → `fresh`
304
- - HTTP 200 → `stale` (resource changed)
305
- - Network / 4xx / 5xx → `error` (does not update metadata for that URL)
306
-
307
- With `--url`: checks only that specific URL (must exist in `sources[]`).
308
-
309
- ```jsonc
310
- // cache-ctrl check-freshness opencode-skills --pretty
311
- {
312
- "ok": true,
313
- "value": {
314
- "subject": "opencode-skills",
315
- "sources": [
316
- { "url": "https://example.com/docs", "status": "fresh", "http_status": 304 }
317
- ],
318
- "overall": "fresh"
319
- }
320
- }
321
- ```
322
-
323
- ---
324
-
325
386
  ### `check-files`
326
387
 
327
388
  ```
@@ -346,11 +407,15 @@ If `tracked_files` is absent or empty → returns `{ status: "unchanged", ... }`
346
407
  "status": "unchanged",
347
408
  "changed_files": [],
348
409
  "unchanged_files": ["lua/plugins/ui/bufferline.lua"],
349
- "missing_files": []
410
+ "missing_files": [],
411
+ "new_files": [],
412
+ "deleted_git_files": []
350
413
  }
351
414
  }
352
415
  ```
353
416
 
417
+ `new_files` lists non-ignored files absent from cache (includes git-tracked and untracked non-ignored files). `deleted_git_files` lists git-tracked files removed from the working tree.
418
+
354
419
  ---
355
420
 
356
421
  ### `search`
@@ -378,11 +443,11 @@ cache-ctrl search neovim --pretty
378
443
 
379
444
  ---
380
445
 
381
- ### `write`
446
+ ### `write-local` / `write-external`
382
447
 
383
448
  ```
384
- cache-ctrl write external <subject> --data '<json>' [--pretty]
385
- cache-ctrl write local --data '<json>' [--pretty]
449
+ cache-ctrl write-external <subject> --data '<json>' [--pretty]
450
+ cache-ctrl write-local --data '<json>' [--pretty]
386
451
  ```
387
452
 
388
453
  Writes a validated cache entry to disk. The `--data` argument must be a valid JSON string matching the ExternalCacheFile or LocalCacheFile schema. Schema validation runs first — all required fields must be present in `--data` or the write is rejected with `VALIDATION_ERROR`.
@@ -395,18 +460,148 @@ Writes a validated cache entry to disk. The `--data` argument must be a valid JS
395
460
 
396
461
  > The `subject` parameter (external agent) must match `/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/` and be at most 128 characters. Returns `INVALID_ARGS` if it fails validation.
397
462
 
398
- **Always use this command (or `cache_ctrl_write`) instead of writing cache files directly.** Direct writes skip schema validation and risk corrupting the cache.
463
+ **Always use these commands (or `cache_ctrl_write_local` / `cache_ctrl_write_external`) instead of writing cache files directly.** Direct writes skip schema validation and risk corrupting the cache.
399
464
 
400
465
  ```json
401
- // cache-ctrl write external mysubject --data '{"subject":"mysubject","description":"...","fetched_at":"2026-04-05T10:00:00Z","sources":[],"header_metadata":{}}' --pretty
466
+ // cache-ctrl write-external mysubject --data '{"subject":"mysubject","description":"...","fetched_at":"2026-04-05T10:00:00Z","sources":[]}' --pretty
402
467
  { "ok": true, "value": { "file": "/path/to/.ai/external-context-gatherer_cache/mysubject.json" } }
403
468
  ```
404
469
 
405
470
  ---
406
471
 
472
+ ### `graph`
473
+
474
+ ```
475
+ cache-ctrl graph [--max-tokens <number>] [--seed <path>[,<path>...]] [--pretty]
476
+ ```
477
+
478
+ Returns a PageRank-ranked dependency graph within a token budget. Reads from `graph.json` computed by the `watch` daemon. Files are ranked by their centrality in the import graph; use `--seed` to personalize the ranking toward specific files (e.g. recently changed files).
479
+
480
+ **Options:**
481
+
482
+ | Flag | Description |
483
+ |---|---|
484
+ | `--max-tokens <number>` | Token budget for `ranked_files` output (default: 1024, clamped 64–128000) |
485
+ | `--seed <path>[,<path>...]` | Personalize PageRank toward these file paths (repeat `--seed` for multiple values) |
486
+
487
+ Returns `FILE_NOT_FOUND` if `graph.json` does not exist — run `cache-ctrl watch` to generate it.
488
+
489
+ ```jsonc
490
+ // cache-ctrl graph --max-tokens 512 --pretty
491
+ {
492
+ "ok": true,
493
+ "value": {
494
+ "ranked_files": [
495
+ {
496
+ "path": "src/cache/cacheManager.ts",
497
+ "rank": 0.142,
498
+ "deps": ["src/utils/validate.ts"],
499
+ "defs": ["readCache", "writeCache", "findRepoRoot"],
500
+ "ref_count": 12
501
+ }
502
+ ],
503
+ "total_files": 36,
504
+ "computed_at": "2026-04-11T10:00:00Z",
505
+ "token_estimate": 487
506
+ }
507
+ }
508
+ ```
509
+
510
+ ---
511
+
512
+ ### `map`
513
+
514
+ ```
515
+ cache-ctrl map [--depth overview|modules|full] [--folder <path-prefix>] [--pretty]
516
+ ```
517
+
518
+ Returns a semantic map of the local `context.json` using the structured `FileFacts` metadata. Files are sorted by `importance` (ascending) then path. Use `--folder` to scope the output to a subtree.
519
+
520
+ **Options:**
521
+
522
+ | Flag | Description |
523
+ |---|---|
524
+ | `--depth overview\|modules\|full` | Output depth (default: `overview`) |
525
+ | `--folder <path-prefix>` | Restrict output to files whose path equals or starts with this prefix |
526
+
527
+ **Depth values:**
528
+ - `overview` — includes `summary`, `role`, `importance` per file (no individual facts)
529
+ - `modules` — same as `overview` plus the `modules` grouping from `context.json`
530
+ - `full` — includes all per-file `facts[]` strings
531
+
532
+ Returns `FILE_NOT_FOUND` if `context.json` does not exist.
533
+
534
+ ```jsonc
535
+ // cache-ctrl map --depth overview --folder src/commands --pretty
536
+ {
537
+ "ok": true,
538
+ "value": {
539
+ "depth": "overview",
540
+ "global_facts": ["TypeScript CLI, Bun runtime"],
541
+ "files": [
542
+ {
543
+ "path": "src/commands/graph.ts",
544
+ "summary": "Reads graph.json and returns PageRank-ranked file list",
545
+ "role": "implementation",
546
+ "importance": 2
547
+ }
548
+ ],
549
+ "total_files": 1,
550
+ "folder_filter": "src/commands"
551
+ }
552
+ }
553
+ ```
554
+
555
+ ---
556
+
557
+ ### `watch`
558
+
559
+ ```
560
+ cache-ctrl watch [--verbose]
561
+ ```
562
+
563
+ Long-running daemon that watches the repo for source file changes (`.ts`, `.tsx`, `.js`, `.jsx`) and incrementally rebuilds `graph.json`. On startup it performs an initial full graph build. Subsequent file changes trigger a debounced rebuild (200 ms). Rebuilds are serialized — concurrent changes are queued.
564
+
565
+ Writes to `.ai/local-context-gatherer_cache/graph.json`. The graph is then available to `cache-ctrl graph` and `cache_ctrl_graph`.
566
+
567
+ **Options:**
568
+
569
+ | Flag | Description |
570
+ |---|---|
571
+ | `--verbose` | Log watcher lifecycle events and rebuild completion to stdout |
572
+
573
+ The process runs until `SIGINT` or `SIGTERM`, which trigger a clean shutdown. Exit code `1` on startup failure (e.g., `Bun.watch` unavailable or graph write error).
574
+
575
+ ```sh
576
+ # Start the daemon in the background
577
+ cache-ctrl watch &
578
+
579
+ # Or run it in a dedicated terminal with verbose output
580
+ cache-ctrl watch --verbose
581
+ ```
582
+
583
+ ---
584
+
585
+ ### `version`
586
+
587
+ ```
588
+ cache-ctrl version
589
+ ```
590
+
591
+ Prints the current package version as JSON and exits.
592
+
593
+ No flags or arguments.
594
+
595
+ ```jsonc
596
+ // cache-ctrl version
597
+ { "ok": true, "value": { "version": "1.1.1" } }
598
+ ```
599
+
600
+ ---
601
+
407
602
  ## opencode Plugin Tools
408
603
 
409
- The plugin (`cache_ctrl.ts`) is auto-discovered via `~/.config/opencode/tools/cache_ctrl.ts` and registers 7 tools that call the same command functions as the CLI:
604
+ The plugin (`cache_ctrl.ts`) is auto-discovered via `~/.config/opencode/tools/cache_ctrl.ts` and registers 9 tools that call the same command functions as the CLI:
410
605
 
411
606
  | Tool | Description |
412
607
  |---|---|
@@ -414,13 +609,15 @@ The plugin (`cache_ctrl.ts`) is auto-discovered via `~/.config/opencode/tools/ca
414
609
  | `cache_ctrl_list` | List entries with age and staleness flags |
415
610
  | `cache_ctrl_inspect` | Return full content of a specific entry |
416
611
  | `cache_ctrl_invalidate` | Zero out a cache entry's timestamp |
417
- | `cache_ctrl_check_freshness` | HTTP HEAD check for external source URLs |
418
612
  | `cache_ctrl_check_files` | Compare tracked files against stored mtime/hash |
419
- | `cache_ctrl_write` | Write a validated cache entry; validates against ExternalCacheFile or LocalCacheFile schema |
613
+ | `cache_ctrl_write_local` | Write a validated local cache entry |
614
+ | `cache_ctrl_write_external` | Write a validated external cache entry |
615
+ | `cache_ctrl_graph` | Return a PageRank-ranked dependency graph within a token budget (reads `graph.json`) |
616
+ | `cache_ctrl_map` | Return a semantic map of `context.json` with per-file FileFacts metadata |
420
617
 
421
618
  No bash permission is required for agents that use the plugin tools directly.
422
619
 
423
- All 7 plugin tool responses include a `server_time` field at the outer JSON level:
620
+ All 9 plugin tool responses include a `server_time` field at the outer JSON level:
424
621
 
425
622
  ```json
426
623
  { "ok": true, "value": { ... }, "server_time": "2026-04-05T12:34:56.789Z" }
@@ -439,10 +636,6 @@ Use `server_time` to assess how stale stored timestamps are without requiring ba
439
636
  cache-ctrl list --agent external --pretty
440
637
  # If is_stale: false → skip fetch
441
638
 
442
- # For a precise HTTP freshness check on a borderline entry
443
- cache-ctrl check-freshness <subject>
444
- # If overall: "fresh" → skip re-fetch
445
-
446
639
  # After writing new cache content — mark entry fresh
447
640
  cache-ctrl touch external <subject>
448
641
 
@@ -476,14 +669,6 @@ cache-ctrl invalidate local
476
669
  "sources": [
477
670
  { "type": "github_api", "url": "https://..." }
478
671
  ],
479
- "header_metadata": {
480
- "https://...": {
481
- "etag": "\"abc123\"",
482
- "last_modified": "Fri, 04 Apr 2026 10:00:00 GMT",
483
- "checked_at": "2026-04-04T12:00:00Z",
484
- "status": "fresh"
485
- }
486
- }
487
672
  // Any additional agent fields are preserved unchanged
488
673
  }
489
674
  ```
@@ -495,25 +680,53 @@ cache-ctrl invalidate local
495
680
  ```jsonc
496
681
  {
497
682
  "timestamp": "2026-04-04T12:00:00Z", // auto-set on write; "" when invalidated
498
- "topic": "neovim plugin configuration",
499
- "description": "Scan of nvim lua plugins",
683
+ "topic": "cache-ctrl source",
684
+ "description": "Scan of cache-ctrl TypeScript source",
500
685
  "cache_miss_reason": "files changed", // optional: why the previous cache was discarded
501
686
  "tracked_files": [
502
- { "path": "lua/plugins/ui/bufferline.lua", "mtime": 1743768000000, "hash": "sha256hex..." }
687
+ { "path": "src/commands/graph.ts", "mtime": 1743768000000, "hash": "sha256hex..." }
503
688
  // mtime is auto-populated by the write command; agents only need to supply path (and optionally hash)
504
689
  ],
505
690
  "global_facts": [ // optional: repo-level facts; last-write-wins; max 20 entries, each ≤ 300 chars
506
- "Kubuntu dotfiles repo",
507
- "StyLua for Lua (140 col, 2-space indent)"
691
+ "TypeScript CLI tool executed by Bun",
692
+ "All errors use Result<T,E> no thrown exceptions across command boundaries"
508
693
  ],
509
- "facts": { // optional: per-file facts; per-path merge; max 30 entries per file, each ≤ 800 chars
510
- "lua/plugins/ui/bufferline.lua": ["lazy-loaded via ft = lua", "uses catppuccin mocha theme"]
511
- // Facts for files deleted from disk are evicted automatically on the next write
694
+ "facts": { // optional: per-file structured FileFacts; per-path merge
695
+ "src/commands/graph.ts": {
696
+ "summary": "Reads graph.json and returns PageRank-ranked file list within a token budget",
697
+ "role": "implementation", // one of: entry-point | interface | implementation | test | config
698
+ "importance": 2, // 1 = critical, 2 = important, 3 = peripheral
699
+ "facts": [ // max 10 entries, each ≤ 300 chars
700
+ "Uses computePageRank with optional seed files for personalized ranking",
701
+ "Token budget clamped to 64–128000; defaults to 1024"
702
+ ]
703
+ }
704
+ // FileFacts entries for files deleted from disk are evicted automatically on the next write
705
+ },
706
+ "modules": { // optional: logical groupings of file paths
707
+ "commands": ["src/commands/graph.ts", "src/commands/map.ts"]
512
708
  }
513
709
  // Any additional agent fields are preserved unchanged
514
710
  }
515
711
  ```
516
712
 
713
+ ### Graph: `.ai/local-context-gatherer_cache/graph.json`
714
+
715
+ Written and maintained by the `watch` daemon. Read by `cache-ctrl graph` and `cache_ctrl_graph`. Agents do not write this file directly.
716
+
717
+ ```jsonc
718
+ {
719
+ "computed_at": "2026-04-11T10:00:00Z",
720
+ "files": {
721
+ "src/cache/cacheManager.ts": {
722
+ "rank": 0.0, // stored as 0.0; PageRank is recomputed on every graph command call
723
+ "deps": ["src/utils/validate.ts", "src/types/result.ts"],
724
+ "defs": ["readCache", "writeCache", "findRepoRoot"]
725
+ }
726
+ }
727
+ }
728
+ ```
729
+
517
730
  ---
518
731
 
519
732
  ## Error Codes
@@ -531,9 +744,7 @@ cache-ctrl invalidate local
531
744
  | `VALIDATION_ERROR` | Schema validation failed (e.g., missing required field or type mismatch in `write`) |
532
745
  | `NO_MATCH` | No cache file matched the keyword |
533
746
  | `AMBIGUOUS_MATCH` | Multiple files with identical top score |
534
- | `HTTP_REQUEST_FAILED` | Network error during HEAD request |
535
- | `URL_NOT_FOUND` | `--url` value not found in `sources[]` |
536
- | `UNKNOWN` | Unexpected internal error |
747
+ | `UNKNOWN` | Unexpected internal/runtime error (including unexpected HTTP client failures) |
537
748
 
538
749
  ---
539
750
 
package/cache_ctrl.ts CHANGED
@@ -2,11 +2,14 @@ import { tool } from "@opencode-ai/plugin";
2
2
  import { listCommand } from "./src/commands/list.js";
3
3
  import { inspectCommand } from "./src/commands/inspect.js";
4
4
  import { invalidateCommand } from "./src/commands/invalidate.js";
5
- import { checkFreshnessCommand } from "./src/commands/checkFreshness.js";
6
5
  import { checkFilesCommand } from "./src/commands/checkFiles.js";
7
6
  import { searchCommand } from "./src/commands/search.js";
8
- import { writeCommand } from "./src/commands/write.js";
9
- import { ErrorCode } from "./src/types/result.js";
7
+ import { writeLocalCommand } from "./src/commands/writeLocal.js";
8
+ import { writeExternalCommand } from "./src/commands/writeExternal.js";
9
+ import { graphCommand } from "./src/commands/graph.js";
10
+ import { mapCommand } from "./src/commands/map.js";
11
+ import { toUnknownResult } from "./src/utils/errors.js";
12
+ import { rejectTraversalKeys } from "./src/utils/traversal.js";
10
13
 
11
14
  const z = tool.schema;
12
15
 
@@ -18,8 +21,7 @@ function withServerTime(result: unknown): string {
18
21
  }
19
22
 
20
23
  function handleUnknownError(err: unknown): string {
21
- const message = err instanceof Error ? err.message : String(err);
22
- return withServerTime({ ok: false, error: message, code: ErrorCode.UNKNOWN });
24
+ return withServerTime(toUnknownResult(err));
23
25
  }
24
26
 
25
27
  export const search = tool({
@@ -97,17 +99,87 @@ export const invalidate = tool({
97
99
  },
98
100
  });
99
101
 
100
- export const check_freshness = tool({
101
- description: "For external cache: send HTTP HEAD requests to all source URLs and return freshness status per URL.",
102
+ export const check_files = tool({
103
+ description:
104
+ "For local cache: compare tracked files against stored mtime/hash values and return which files changed. Also reports new_files (files not excluded by .gitignore that are absent from cache — includes both git-tracked and untracked-non-ignored files) and deleted_git_files (git-tracked files deleted from working tree).",
105
+ args: {},
106
+ async execute(_args) {
107
+ try {
108
+ const result = await checkFilesCommand();
109
+ return withServerTime(result);
110
+ } catch (err) {
111
+ return handleUnknownError(err);
112
+ }
113
+ },
114
+ });
115
+
116
+ export const write_local = tool({
117
+ description:
118
+ "Write a validated local cache entry. timestamp is auto-set to current UTC time — do not include it in content. tracked_files entries need only { path }; mtime/hash are computed by the command. Uses per-path merge and evicts entries for files deleted from disk.",
102
119
  args: {
103
- subject: z.string().min(1),
104
- url: z.string().url().optional(),
120
+ topic: z.string(),
121
+ description: z.string(),
122
+ tracked_files: z.array(z.object({ path: z.string() })),
123
+ global_facts: z.array(z.string().max(300)).max(20).optional(),
124
+ facts: z
125
+ .record(
126
+ z.string(),
127
+ z.object({
128
+ summary: z.string().optional(),
129
+ role: z.string().optional(),
130
+ importance: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(),
131
+ facts: z.array(z.string()).optional(),
132
+ }),
133
+ )
134
+ .superRefine(rejectTraversalKeys)
135
+ .optional(),
136
+ cache_miss_reason: z.string().optional(),
137
+ },
138
+ async execute(args) {
139
+ try {
140
+ const result = await writeLocalCommand({
141
+ agent: "local",
142
+ content: {
143
+ topic: args.topic,
144
+ description: args.description,
145
+ tracked_files: args.tracked_files,
146
+ ...(args.global_facts !== undefined ? { global_facts: args.global_facts } : {}),
147
+ ...(args.facts !== undefined ? { facts: args.facts } : {}),
148
+ ...(args.cache_miss_reason !== undefined ? { cache_miss_reason: args.cache_miss_reason } : {}),
149
+ },
150
+ });
151
+ return withServerTime(result);
152
+ } catch (err) {
153
+ return handleUnknownError(err);
154
+ }
155
+ },
156
+ });
157
+
158
+ export const write_external = tool({
159
+ description:
160
+ "Write a validated external cache entry to disk. Uses atomic write-with-merge so unknown fields are preserved.",
161
+ args: {
162
+ subject: z.string(),
163
+ description: z.string(),
164
+ fetched_at: z.string().datetime(),
165
+ sources: z.array(
166
+ z.object({
167
+ type: z.string(),
168
+ url: z.string(),
169
+ version: z.string().optional(),
170
+ }),
171
+ ),
105
172
  },
106
173
  async execute(args) {
107
174
  try {
108
- const result = await checkFreshnessCommand({
175
+ const result = await writeExternalCommand({
176
+ agent: "external",
109
177
  subject: args.subject,
110
- ...(args.url !== undefined ? { url: args.url } : {}),
178
+ content: {
179
+ description: args.description,
180
+ fetched_at: args.fetched_at,
181
+ sources: args.sources,
182
+ },
111
183
  });
112
184
  return withServerTime(result);
113
185
  } catch (err) {
@@ -116,13 +188,22 @@ export const check_freshness = tool({
116
188
  },
117
189
  });
118
190
 
119
- export const check_files = tool({
191
+ export const graph = tool({
120
192
  description:
121
- "For local cache: compare tracked files against stored mtime/hash values and return which files changed. Also reports new_files (files not excluded by .gitignore that are absent from cache — includes both git-tracked and untracked-non-ignored files) and deleted_git_files (git-tracked files deleted from working tree).",
122
- args: {},
123
- async execute(_args) {
193
+ "Return a PageRank-ranked file dependency graph within a token budget. Use this to understand which files are most central to recent changes. Reads from the pre-computed graph.json updated by 'cache-ctrl watch'.",
194
+ args: {
195
+ maxTokens: z.number().optional().describe("Token budget for the response (default: 1024)"),
196
+ seed: z
197
+ .array(z.string())
198
+ .optional()
199
+ .describe("File paths to personalize PageRank toward (e.g. recently changed files)"),
200
+ },
201
+ async execute(args) {
124
202
  try {
125
- const result = await checkFilesCommand();
203
+ const result = await graphCommand({
204
+ ...(args.maxTokens !== undefined ? { maxTokens: args.maxTokens } : {}),
205
+ ...(args.seed !== undefined ? { seed: args.seed } : {}),
206
+ });
126
207
  return withServerTime(result);
127
208
  } catch (err) {
128
209
  return handleUnknownError(err);
@@ -130,20 +211,21 @@ export const check_files = tool({
130
211
  },
131
212
  });
132
213
 
133
- export const write = tool({
214
+ export const map = tool({
134
215
  description:
135
- "Write a validated cache entry to disk. Validates the content object against the ExternalCacheFile or LocalCacheFile schema before writing. Returns VALIDATION_ERROR if required fields are missing or have wrong types. For 'external': subject arg is required and must match content.subject (or will be injected if absent). For 'local': omit subject; timestamp is auto-set to current UTC time — do not include it in content. In tracked_files, each entry needs only { path } — mtime and hash are auto-computed by the tool; any caller-provided mtime or hash values are stripped. For local: uses per-path merge — tracked_files entries are merged by path (submitted paths replace existing entries for those paths; other paths are preserved). Entries for files no longer present on disk are evicted automatically. On cold start (no existing cache), submit all relevant files. On subsequent writes, submit only new and changed files. For 'external': uses atomic write-with-merge — existing unknown fields in the file are preserved. Call cache_ctrl_schema or read the skill to see required fields before calling this.",
216
+ "Return a semantic mental map of the codebase from the local context cache. Use 'overview' (default) for a ~300-token summary of what each file does. Use 'modules' to see logical groupings. Use 'full' to include all per-file facts.",
136
217
  args: {
137
- agent: AgentRequiredSchema,
138
- subject: z.string().min(1).optional(),
139
- content: z.record(z.string(), z.unknown()),
218
+ depth: z
219
+ .enum(["overview", "modules", "full"])
220
+ .optional()
221
+ .describe("Map depth (default: 'overview')"),
222
+ folder: z.string().optional().describe("Restrict map to files under this path prefix"),
140
223
  },
141
224
  async execute(args) {
142
225
  try {
143
- const result = await writeCommand({
144
- agent: args.agent,
145
- ...(args.subject !== undefined ? { subject: args.subject } : {}),
146
- content: args.content,
226
+ const result = await mapCommand({
227
+ ...(args.depth !== undefined ? { depth: args.depth } : {}),
228
+ ...(args.folder !== undefined ? { folder: args.folder } : {}),
147
229
  });
148
230
  return withServerTime(result);
149
231
  } catch (err) {