@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/src/index.ts CHANGED
@@ -5,12 +5,19 @@ import { flushCommand } from "./commands/flush.js";
5
5
  import { invalidateCommand } from "./commands/invalidate.js";
6
6
  import { touchCommand } from "./commands/touch.js";
7
7
  import { pruneCommand } from "./commands/prune.js";
8
- import { checkFreshnessCommand } from "./commands/checkFreshness.js";
9
8
  import { checkFilesCommand } from "./commands/checkFiles.js";
10
9
  import { searchCommand } from "./commands/search.js";
11
- import { writeCommand } from "./commands/write.js";
10
+ import { writeLocalCommand } from "./commands/writeLocal.js";
11
+ import { writeExternalCommand } from "./commands/writeExternal.js";
12
12
  import { installCommand } from "./commands/install.js";
13
+ import { updateCommand } from "./commands/update.js";
14
+ import { uninstallCommand } from "./commands/uninstall.js";
15
+ import { graphCommand } from "./commands/graph.js";
16
+ import { mapCommand } from "./commands/map.js";
17
+ import { watchCommand } from "./commands/watch.js";
18
+ import { versionCommand } from "./commands/version.js";
13
19
  import { ErrorCode } from "./types/result.js";
20
+ import { toUnknownResult } from "./utils/errors.js";
14
21
 
15
22
  type CommandName =
16
23
  | "list"
@@ -19,11 +26,17 @@ type CommandName =
19
26
  | "invalidate"
20
27
  | "touch"
21
28
  | "prune"
22
- | "check-freshness"
23
29
  | "check-files"
24
30
  | "search"
25
- | "write"
26
- | "install";
31
+ | "write-local"
32
+ | "write-external"
33
+ | "install"
34
+ | "update"
35
+ | "uninstall"
36
+ | "graph"
37
+ | "map"
38
+ | "watch"
39
+ | "version";
27
40
 
28
41
  function isKnownCommand(cmd: string): cmd is CommandName {
29
42
  return Object.hasOwn(COMMAND_HELP as Record<string, unknown>, cmd);
@@ -118,19 +131,6 @@ const COMMAND_HELP: Record<CommandName, CommandHelp> = {
118
131
  " --delete Actually delete the stale entries (dry-run if omitted)",
119
132
  ].join("\n"),
120
133
  },
121
- "check-freshness": {
122
- usage: "check-freshness <subject-keyword> [--url <url>]",
123
- description: "Send HTTP HEAD requests to verify source freshness",
124
- details: [
125
- " Arguments:",
126
- " <subject-keyword> Keyword identifying the cache entry to check",
127
- "",
128
- " Options:",
129
- " --url <url> Override the URL used for the HEAD request",
130
- "",
131
- " Output: HTTP response metadata and freshness verdict.",
132
- ].join("\n"),
133
- },
134
134
  "check-files": {
135
135
  usage: "check-files",
136
136
  description: "Compare tracked local files against stored mtime/hash",
@@ -152,13 +152,25 @@ const COMMAND_HELP: Record<CommandName, CommandHelp> = {
152
152
  " Output: Ranked list of matching cache entries.",
153
153
  ].join("\n"),
154
154
  },
155
- write: {
156
- usage: "write <agent> [subject] --data '<json>'",
157
- description: "Write a validated cache entry from JSON",
155
+ "write-local": {
156
+ usage: "write-local --data '<json>'",
157
+ description: "Write a validated local cache entry",
158
+ details: [
159
+ " Arguments:",
160
+ " (none)",
161
+ "",
162
+ " Options:",
163
+ " --data '<json>' JSON string containing the cache entry payload",
164
+ "",
165
+ " Output: Confirmation with the written entry's key.",
166
+ ].join("\n"),
167
+ },
168
+ "write-external": {
169
+ usage: "write-external <subject> --data '<json>'",
170
+ description: "Write a validated external cache entry",
158
171
  details: [
159
172
  " Arguments:",
160
- " <agent> Agent type: external or local",
161
- " [subject] Optional subject identifier (required for external agent)",
173
+ " <subject> Subject identifier for the external entry",
162
174
  "",
163
175
  " Options:",
164
176
  " --data '<json>' JSON string containing the cache entry payload",
@@ -179,6 +191,84 @@ const COMMAND_HELP: Record<CommandName, CommandHelp> = {
179
191
  " Output: JSON object describing installed tool/skill paths.",
180
192
  ].join("\n"),
181
193
  },
194
+ update: {
195
+ usage: "update [--config-dir <path>]",
196
+ description: "Update npm package globally and refresh OpenCode integration",
197
+ details: [
198
+ " Arguments:",
199
+ " (none)",
200
+ "",
201
+ " Options:",
202
+ " --config-dir <path> Override the OpenCode config directory (default: platform-specific)",
203
+ "",
204
+ " Output: JSON object with package update status, installed paths, and warnings.",
205
+ ].join("\n"),
206
+ },
207
+ uninstall: {
208
+ usage: "uninstall [--config-dir <path>]",
209
+ description: "Remove OpenCode integration files and uninstall global npm package",
210
+ details: [
211
+ " Arguments:",
212
+ " (none)",
213
+ "",
214
+ " Options:",
215
+ " --config-dir <path> Override the OpenCode config directory (default: platform-specific)",
216
+ "",
217
+ " Output: JSON object with removed paths, npm uninstall status, and warnings.",
218
+ ].join("\n"),
219
+ },
220
+ graph: {
221
+ usage: "graph [--max-tokens <number>] [--seed <path>[,<path>...]]",
222
+ description: "Return a PageRank-ranked dependency graph under a token budget",
223
+ details: [
224
+ " Arguments:",
225
+ " (none)",
226
+ "",
227
+ " Options:",
228
+ " --max-tokens <number> Token budget for ranked_files output (default: 1024)",
229
+ " --seed <path>[,<path>...] Personalize rank toward specific file path(s)",
230
+ " (repeat --seed to provide multiple values)",
231
+ "",
232
+ " Output: Ranked files with deps, defs, and ref_count from graph.json.",
233
+ ].join("\n"),
234
+ },
235
+ map: {
236
+ usage: "map [--depth overview|modules|full] [--folder <path-prefix>]",
237
+ description: "Return a semantic map of local context.json",
238
+ details: [
239
+ " Arguments:",
240
+ " (none)",
241
+ "",
242
+ " Options:",
243
+ " --depth overview|modules|full Output depth (default: overview)",
244
+ " --folder <path-prefix> Restrict map to files whose path starts with prefix",
245
+ "",
246
+ " Output: JSON object with global_facts, files, optional modules, and total_files.",
247
+ ].join("\n"),
248
+ },
249
+ watch: {
250
+ usage: "watch [--verbose]",
251
+ description: "Watch for file changes and recompute the dependency graph",
252
+ details: [
253
+ " Arguments:",
254
+ " (none)",
255
+ "",
256
+ " Options:",
257
+ " --verbose Log watcher lifecycle and rebuild events",
258
+ "",
259
+ " Output: Long-running daemon process that updates graph.json on source changes.",
260
+ ].join("\n"),
261
+ },
262
+ version: {
263
+ usage: "version",
264
+ description: "Show the current cache-ctrl package version",
265
+ details: [
266
+ " Arguments:",
267
+ " (none)",
268
+ "",
269
+ " Output: JSON object containing the current package version.",
270
+ ].join("\n"),
271
+ },
182
272
  };
183
273
 
184
274
  const GLOBAL_OPTIONS_SECTION = [
@@ -206,7 +296,7 @@ export function printHelp(command?: string): boolean {
206
296
  ...Object.values(COMMAND_HELP).map((h) => h.usage.length),
207
297
  );
208
298
 
209
- for (const [, help] of Object.entries(COMMAND_HELP) as [CommandName, CommandHelp][]) {
299
+ for (const help of Object.values(COMMAND_HELP)) {
210
300
  const paddedUsage = help.usage.padEnd(maxUsageLen);
211
301
  lines.push(` ${paddedUsage} ${help.description}`);
212
302
  }
@@ -216,12 +306,12 @@ export function printHelp(command?: string): boolean {
216
306
  return true;
217
307
  }
218
308
 
219
- const sanitized = command.replace(/[\x00-\x1F\x7F]/g, "");
220
-
221
309
  if (command === "help") {
222
310
  return printHelp();
223
311
  }
224
312
 
313
+ const sanitized = command.replace(/[\x00-\x1F\x7F]/g, "");
314
+
225
315
  if (!isKnownCommand(command)) {
226
316
  process.stderr.write(`Unknown command: "${sanitized}". Run 'cache-ctrl help' for available commands.\n`);
227
317
  return false;
@@ -257,6 +347,12 @@ function printError(error: { ok: false; error: string; code: string }, pretty: b
257
347
  }
258
348
  }
259
349
 
350
+ /**
351
+ * Prints a structured usage error and terminates the process.
352
+ *
353
+ * @param message - Human-readable usage failure detail.
354
+ * @remarks Always exits with process code `2` to distinguish usage failures from runtime errors.
355
+ */
260
356
  function usageError(message: string): never {
261
357
  process.stderr.write(JSON.stringify({ ok: false, error: message, code: ErrorCode.INVALID_ARGS }) + "\n");
262
358
  process.exit(2);
@@ -265,8 +361,44 @@ function usageError(message: string): never {
265
361
  export { usageError };
266
362
 
267
363
  /** Flags that consume the following token as their value. Boolean flags must NOT appear here. */
268
- const VALUE_FLAGS = new Set(["data", "agent", "url", "max-age", "filter", "folder", "search-facts", "config-dir"]);
364
+ const VALUE_FLAGS = new Set([
365
+ "data",
366
+ "agent",
367
+ "max-age",
368
+ "filter",
369
+ "folder",
370
+ "search-facts",
371
+ "config-dir",
372
+ "max-tokens",
373
+ "seed",
374
+ "depth",
375
+ ]);
376
+
377
+ function collectFlagValues(argv: string[], flagName: string): string[] {
378
+ const values: string[] = [];
379
+
380
+ for (let i = 0; i < argv.length; i += 1) {
381
+ if (argv[i] !== `--${flagName}`) {
382
+ continue;
383
+ }
384
+ const next = argv[i + 1];
385
+ if (next !== undefined) {
386
+ values.push(next);
387
+ i += 1;
388
+ }
389
+ }
390
+
391
+ return values;
392
+ }
269
393
 
394
+ /**
395
+ * Parses raw CLI argv tokens into positional args and flag key/value pairs.
396
+ *
397
+ * @param argv - Raw argument tokens (typically `process.argv.slice(2)`).
398
+ * @returns Parsed positional args and normalized flags map.
399
+ * @remarks Flags listed in `VALUE_FLAGS` consume the following token as their value;
400
+ * all other `--flag` tokens are treated as boolean flags.
401
+ */
270
402
  export function parseArgs(argv: string[]): { args: string[]; flags: Record<string, string | boolean> } {
271
403
  const positional: string[] = [];
272
404
  const flags: Record<string, string | boolean> = {};
@@ -305,7 +437,7 @@ async function main(): Promise<void> {
305
437
 
306
438
  const command = args[0];
307
439
  if (!command) {
308
- usageError("Usage: cache-ctrl <command> [args]. Commands: list, inspect, flush, invalidate, touch, prune, check-freshness, check-files, search, write, install");
440
+ usageError("Usage: cache-ctrl <command> [args]. Commands: list, inspect, flush, invalidate, touch, prune, check-files, search, write-local, write-external, install, update, uninstall, graph, map, watch, version");
309
441
  }
310
442
 
311
443
  switch (command) {
@@ -456,13 +588,8 @@ async function main(): Promise<void> {
456
588
  break;
457
589
  }
458
590
 
459
- case "check-freshness": {
460
- const subject = args[1];
461
- if (!subject) {
462
- usageError("Usage: cache-ctrl check-freshness <subject-keyword> [--url <url>]");
463
- }
464
- const url = typeof flags.url === "string" ? flags.url : undefined;
465
- const result = await checkFreshnessCommand({ subject, ...(url !== undefined ? { url } : {}) });
591
+ case "check-files": {
592
+ const result = await checkFilesCommand();
466
593
  if (result.ok) {
467
594
  printResult(result, pretty);
468
595
  } else {
@@ -472,8 +599,12 @@ async function main(): Promise<void> {
472
599
  break;
473
600
  }
474
601
 
475
- case "check-files": {
476
- const result = await checkFilesCommand();
602
+ case "search": {
603
+ const keywords = args.slice(1);
604
+ if (keywords.length === 0) {
605
+ usageError("Usage: cache-ctrl search <keyword> [<keyword>...]");
606
+ }
607
+ const result = await searchCommand({ keywords });
477
608
  if (result.ok) {
478
609
  printResult(result, pretty);
479
610
  } else {
@@ -483,12 +614,24 @@ async function main(): Promise<void> {
483
614
  break;
484
615
  }
485
616
 
486
- case "search": {
487
- const keywords = args.slice(1);
488
- if (keywords.length === 0) {
489
- usageError("Usage: cache-ctrl search <keyword> [<keyword>...]");
617
+ case "write-local": {
618
+ const dataStr = typeof flags.data === "string" ? flags.data : undefined;
619
+ if (!dataStr) {
620
+ usageError("Usage: cache-ctrl write-local --data '<json>'");
490
621
  }
491
- const result = await searchCommand({ keywords });
622
+ let content: Record<string, unknown>;
623
+ try {
624
+ content = JSON.parse(dataStr) as Record<string, unknown>; // JSON.parse returns any; writeLocalCommand validates the payload shape via Zod before use.
625
+ } catch {
626
+ usageError("--data must be valid JSON");
627
+ }
628
+ if (typeof content !== "object" || content === null || Array.isArray(content)) {
629
+ usageError("--data must be a JSON object");
630
+ }
631
+ const result = await writeLocalCommand({
632
+ agent: "local",
633
+ content,
634
+ });
492
635
  if (result.ok) {
493
636
  printResult(result, pretty);
494
637
  } else {
@@ -498,28 +641,27 @@ async function main(): Promise<void> {
498
641
  break;
499
642
  }
500
643
 
501
- case "write": {
502
- const agent = args[1];
503
- if (!agent) {
504
- usageError("Usage: cache-ctrl write <agent> [subject] --data '<json>'");
505
- }
506
- if (agent !== "external" && agent !== "local") {
507
- usageError(`Invalid agent: "${agent}". Must be external or local`);
644
+ case "write-external": {
645
+ const subject = args[1];
646
+ if (!subject) {
647
+ usageError("Usage: cache-ctrl write-external <subject> --data '<json>'");
508
648
  }
509
649
  const dataStr = typeof flags.data === "string" ? flags.data : undefined;
510
650
  if (!dataStr) {
511
- usageError("Usage: cache-ctrl write <agent> [subject] --data '<json>'");
651
+ usageError("Usage: cache-ctrl write-external <subject> --data '<json>'");
512
652
  }
513
653
  let content: Record<string, unknown>;
514
654
  try {
515
- content = JSON.parse(dataStr) as Record<string, unknown>;
655
+ content = JSON.parse(dataStr) as Record<string, unknown>; // JSON.parse returns any; writeExternalCommand validates the payload shape via Zod before use.
516
656
  } catch {
517
657
  usageError("--data must be valid JSON");
518
658
  }
519
- const subject = agent === "external" ? args[2] : undefined;
520
- const result = await writeCommand({
521
- agent,
522
- ...(subject !== undefined ? { subject } : {}),
659
+ if (typeof content !== "object" || content === null || Array.isArray(content)) {
660
+ usageError("--data must be a JSON object");
661
+ }
662
+ const result = await writeExternalCommand({
663
+ agent: "external",
664
+ subject,
523
665
  content,
524
666
  });
525
667
  if (result.ok) {
@@ -532,6 +674,9 @@ async function main(): Promise<void> {
532
674
  }
533
675
 
534
676
  case "install": {
677
+ if (flags["config-dir"] === true) {
678
+ usageError("--config-dir requires a value: --config-dir <path>");
679
+ }
535
680
  const configDir = typeof flags["config-dir"] === "string" ? flags["config-dir"] : undefined;
536
681
  const result = await installCommand({ ...(configDir !== undefined ? { configDir } : {}) });
537
682
  if (result.ok) {
@@ -543,15 +688,126 @@ async function main(): Promise<void> {
543
688
  break;
544
689
  }
545
690
 
691
+ case "update": {
692
+ if (flags["config-dir"] === true) {
693
+ usageError("--config-dir requires a value: --config-dir <path>");
694
+ }
695
+ const configDir = typeof flags["config-dir"] === "string" ? flags["config-dir"] : undefined;
696
+ const result = await updateCommand({ ...(configDir !== undefined ? { configDir } : {}) });
697
+ if (result.ok) {
698
+ printResult(result, pretty);
699
+ } else {
700
+ printError(result, pretty);
701
+ process.exit(1);
702
+ }
703
+ break;
704
+ }
705
+
706
+ case "uninstall": {
707
+ if (flags["config-dir"] === true) {
708
+ usageError("--config-dir requires a value: --config-dir <path>");
709
+ }
710
+ const configDir = typeof flags["config-dir"] === "string" ? flags["config-dir"] : undefined;
711
+ const result = await uninstallCommand({ ...(configDir !== undefined ? { configDir } : {}) });
712
+ if (result.ok) {
713
+ printResult(result, pretty);
714
+ } else {
715
+ printError(result, pretty);
716
+ process.exit(1);
717
+ }
718
+ break;
719
+ }
720
+
721
+ case "graph": {
722
+ if (flags["max-tokens"] === true) {
723
+ usageError("--max-tokens requires a numeric value");
724
+ }
725
+ const maxTokensRaw = typeof flags["max-tokens"] === "string" ? flags["max-tokens"] : undefined;
726
+ let maxTokensParsed: number | undefined;
727
+ if (maxTokensRaw !== undefined) {
728
+ const parsed = Number(maxTokensRaw);
729
+ if (!Number.isFinite(parsed) || parsed < 0) {
730
+ usageError(`Invalid --max-tokens value: "${maxTokensRaw}". Must be a non-negative number`);
731
+ }
732
+ maxTokensParsed = parsed;
733
+ }
734
+ if (flags.seed === true) {
735
+ usageError("--seed requires a value: --seed <path>[,<path>...]");
736
+ }
737
+ const seedFlagValues = collectFlagValues(rawArgs, "seed");
738
+ const seed = seedFlagValues
739
+ .flatMap((value) => value.split(","))
740
+ .map((value) => value.trim())
741
+ .filter((value) => value.length > 0);
742
+
743
+ const result = await graphCommand({
744
+ ...(maxTokensParsed !== undefined ? { maxTokens: maxTokensParsed } : {}),
745
+ ...(seed.length > 0 ? { seed } : {}),
746
+ });
747
+ if (result.ok) {
748
+ printResult(result, pretty);
749
+ } else {
750
+ printError(result, pretty);
751
+ process.exit(1);
752
+ }
753
+ break;
754
+ }
755
+
756
+ case "map": {
757
+ if (flags.depth === true) {
758
+ usageError("--depth requires a value: --depth overview|modules|full");
759
+ }
760
+ const depthRaw = typeof flags.depth === "string" ? flags.depth : undefined;
761
+ if (depthRaw !== undefined && depthRaw !== "overview" && depthRaw !== "modules" && depthRaw !== "full") {
762
+ usageError(`Invalid --depth value: "${depthRaw}". Must be overview, modules, or full`);
763
+ }
764
+ if (flags.folder === true) {
765
+ usageError("--folder requires a value: --folder <path-prefix>");
766
+ }
767
+ const folder = typeof flags.folder === "string" ? flags.folder : undefined;
768
+
769
+ const result = await mapCommand({
770
+ ...(depthRaw !== undefined ? { depth: depthRaw } : {}),
771
+ ...(folder !== undefined ? { folder } : {}),
772
+ });
773
+
774
+ if (result.ok) {
775
+ printResult(result, pretty);
776
+ } else {
777
+ printError(result, pretty);
778
+ process.exit(1);
779
+ }
780
+ break;
781
+ }
782
+
783
+ case "watch": {
784
+ const result = await watchCommand({ verbose: flags.verbose === true });
785
+ if (!result.ok) {
786
+ printError(result, pretty);
787
+ process.exit(1);
788
+ }
789
+ break;
790
+ }
791
+
792
+ case "version": {
793
+ const result = versionCommand({});
794
+ if (result.ok) {
795
+ printResult(result, pretty);
796
+ } else {
797
+ printError(result, pretty);
798
+ process.exit(1);
799
+ }
800
+ break;
801
+ }
802
+
546
803
  default:
547
- usageError(`Unknown command: "${command}". Commands: list, inspect, flush, invalidate, touch, prune, check-freshness, check-files, search, write, install`);
804
+ usageError(`Unknown command: "${command}". Commands: list, inspect, flush, invalidate, touch, prune, check-files, search, write-local, write-external, install, update, uninstall, graph, map, watch, version`);
548
805
  }
549
806
  }
550
807
 
551
808
  if (import.meta.main) {
552
809
  main().catch((err: unknown) => {
553
- const error = err as Error;
554
- process.stderr.write(JSON.stringify({ ok: false, error: error.message, code: ErrorCode.UNKNOWN }) + "\n");
810
+ process.stderr.write(JSON.stringify(toUnknownResult(err)) + "\n");
555
811
  process.exit(1);
556
812
  });
557
813
  }
@@ -1,6 +1,15 @@
1
1
  import type { CacheEntry } from "../types/cache.js";
2
2
  import { getFileStem } from "../utils/fileStem.js";
3
3
 
4
+ /**
5
+ * Scores a cache entry against one or more keywords.
6
+ *
7
+ * @param entry - Candidate cache entry.
8
+ * @param keywords - Search keywords.
9
+ * @returns Numeric relevance score (higher is better).
10
+ * Scoring matrix uses max-per-keyword weights: exact stem (100), stem substring (80),
11
+ * exact word in subject/topic (70), subject/topic substring (50), description substring (30).
12
+ */
4
13
  export function scoreEntry(entry: CacheEntry, keywords: string[]): number {
5
14
  const stem = getFileStem(entry.file).toLowerCase();
6
15
  const subject = entry.subject.toLowerCase();
@@ -37,6 +46,13 @@ export function scoreEntry(entry: CacheEntry, keywords: string[]): number {
37
46
  return total;
38
47
  }
39
48
 
49
+ /**
50
+ * Ranks cache entries by keyword relevance.
51
+ *
52
+ * @param entries - Candidate entries to rank.
53
+ * @param keywords - Search keywords.
54
+ * @returns Score-sorted entries with zero-score candidates removed.
55
+ */
40
56
  export function rankResults(entries: CacheEntry[], keywords: string[]): CacheEntry[] {
41
57
  const scored = entries.map((entry) => ({
42
58
  entry,
@@ -52,6 +68,14 @@ export function rankResults(entries: CacheEntry[], keywords: string[]): CacheEnt
52
68
  return matched.map((s) => ({ ...s.entry, score: s.score }));
53
69
  }
54
70
 
71
+ /**
72
+ * Checks whether a keyword appears as an exact word in text.
73
+ *
74
+ * @param text - Candidate text.
75
+ * @param keyword - Lowercased keyword to match.
76
+ * @returns `true` when an exact token match exists.
77
+ * @remarks Word matching is based on split boundaries (`space`, `_`, `-`, `.`, `/`).
78
+ */
55
79
  export function isExactWordMatch(text: string, keyword: string): boolean {
56
80
  // Match whole words — split on non-alphanumeric chars
57
81
  const words = text.split(/[\s\-_./]+/);
@@ -1,7 +1,11 @@
1
1
  import { z } from "zod";
2
2
 
3
+ /** Supported cache namespaces exposed by the CLI and plugin tools. */
3
4
  export type AgentType = "external" | "local";
4
5
 
6
+ /**
7
+ * Normalized cache entry summary returned by the `list` command prior to formatting.
8
+ */
5
9
  export interface CacheEntry {
6
10
  file: string;
7
11
  agent: AgentType;
@@ -17,27 +21,32 @@ const SourceSchema = z.object({
17
21
  version: z.string().optional(),
18
22
  });
19
23
 
20
- const HeaderMetaSchema = z.object({
21
- etag: z.string().optional(),
22
- last_modified: z.string().optional(),
23
- checked_at: z.string(),
24
- status: z.enum(["fresh", "stale", "unchecked"]),
25
- });
26
-
24
+ /**
25
+ * Validates external context cache JSON files stored under `.ai/external-context-gatherer_cache/`.
26
+ */
27
27
  export const ExternalCacheFileSchema = z.looseObject({
28
28
  subject: z.string(),
29
29
  description: z.string(),
30
30
  fetched_at: z.string(),
31
31
  sources: z.array(SourceSchema),
32
- header_metadata: z.record(z.string(), HeaderMetaSchema),
33
32
  });
34
33
 
34
+ /** Validates one tracked file baseline used by local file-change detection. */
35
35
  export const TrackedFileSchema = z.object({
36
36
  path: z.string(),
37
37
  mtime: z.number(),
38
38
  hash: z.string().optional(),
39
39
  });
40
40
 
41
+ const FileFactsSchema = z.object({
42
+ summary: z.string().max(300).optional(),
43
+ role: z
44
+ .enum(["entry-point", "interface", "implementation", "test", "config"])
45
+ .optional(),
46
+ importance: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(),
47
+ facts: z.array(z.string().max(300)).max(10).optional(),
48
+ });
49
+
41
50
  /**
42
51
  * Zod schema for the local context-gatherer cache file (`context.json`).
43
52
  *
@@ -47,8 +56,8 @@ export const TrackedFileSchema = z.object({
47
56
  * Size constraints enforced at write time:
48
57
  * - `global_facts`: max 20 entries; each string ≤ 300 characters.
49
58
  * For cross-cutting structural observations only (e.g. repo layout, toolchain).
50
- * - `facts`: max 30 entries per file path; each string ≤ 800 characters.
51
- * Facts must be concise observations — not raw file content or code snippets.
59
+ * - `facts`: per-file structured metadata with max 10 concise fact strings
60
+ * (each string 300 characters).
52
61
  */
53
62
  export const LocalCacheFileSchema = z.looseObject({
54
63
  timestamp: z.string(),
@@ -68,24 +77,27 @@ export const LocalCacheFileSchema = z.looseObject({
68
77
  "max 20 global facts — choose only cross-cutting structural observations",
69
78
  })
70
79
  .optional(),
71
- facts: z
72
- .record(
73
- z.string(),
74
- z
75
- .array(
76
- z.string().max(800, {
77
- message:
78
- "write concise observations, not file content (max 800 chars per fact)",
79
- }),
80
- )
81
- .max(30, {
82
- message:
83
- "max 30 facts per file — choose the most architecturally meaningful observations",
84
- }),
85
- )
86
- .optional(),
80
+ facts: z.record(z.string(), FileFactsSchema).optional(),
81
+ modules: z.record(z.string(), z.array(z.string())).optional(),
87
82
  });
88
83
 
89
84
  export type TrackedFile = z.infer<typeof TrackedFileSchema>;
85
+ export type FileFacts = z.infer<typeof FileFactsSchema>;
90
86
  export type ExternalCacheFile = z.infer<typeof ExternalCacheFileSchema>;
91
87
  export type LocalCacheFile = z.infer<typeof LocalCacheFileSchema>;
88
+
89
+ const GraphNodeSchema = z.object({
90
+ rank: z.number(),
91
+ deps: z.array(z.string()),
92
+ defs: z.array(z.string()),
93
+ });
94
+
95
+ /**
96
+ * Validates graph cache payloads written by `watch` and consumed by `graph`.
97
+ */
98
+ export const GraphCacheFileSchema = z.object({
99
+ files: z.record(z.string(), GraphNodeSchema),
100
+ computed_at: z.string(),
101
+ });
102
+
103
+ export type GraphCacheFile = z.infer<typeof GraphCacheFileSchema>;