@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
@@ -10,6 +10,11 @@ function parseGitOutput(stdout: string): string[] {
10
10
  .filter((l) => l.length > 0);
11
11
  }
12
12
 
13
+ /**
14
+ * Returns git-tracked file paths for a repository.
15
+ *
16
+ * Falls back to `[]` when git is unavailable, command execution fails, or directory is not a git repo.
17
+ */
13
18
  export async function getGitTrackedFiles(repoRoot: string): Promise<string[]> {
14
19
  try {
15
20
  const result = await execFileAsync("git", ["ls-files"], { cwd: repoRoot, maxBuffer: 10 * 1024 * 1024 });
@@ -19,6 +24,11 @@ export async function getGitTrackedFiles(repoRoot: string): Promise<string[]> {
19
24
  }
20
25
  }
21
26
 
27
+ /**
28
+ * Returns git-tracked files deleted from the working tree.
29
+ *
30
+ * Falls back to `[]` when git is unavailable, command execution fails, or directory is not a git repo.
31
+ */
22
32
  export async function getGitDeletedFiles(repoRoot: string): Promise<string[]> {
23
33
  try {
24
34
  const result = await execFileAsync("git", ["ls-files", "--deleted"], { cwd: repoRoot, maxBuffer: 10 * 1024 * 1024 });
@@ -28,6 +38,11 @@ export async function getGitDeletedFiles(repoRoot: string): Promise<string[]> {
28
38
  }
29
39
  }
30
40
 
41
+ /**
42
+ * Returns untracked files that are not ignored by git.
43
+ *
44
+ * Falls back to `[]` when git is unavailable, command execution fails, or directory is not a git repo.
45
+ */
31
46
  export async function getUntrackedNonIgnoredFiles(repoRoot: string): Promise<string[]> {
32
47
  try {
33
48
  const result = await execFileAsync("git", ["ls-files", "--others", "--exclude-standard"], {
@@ -4,9 +4,18 @@ import path from "node:path";
4
4
 
5
5
  import type { InstallResult } from "../types/commands.js";
6
6
  import { ErrorCode, type Result } from "../types/result.js";
7
+ import { toUnknownResult } from "../utils/errors.js";
7
8
 
8
9
  const SKILL_NAMES = ["cache-ctrl-external", "cache-ctrl-local", "cache-ctrl-caller"] as const;
9
10
 
11
+ /**
12
+ * Resolves the OpenCode configuration directory.
13
+ *
14
+ * @param overrideDir - Explicit CLI override path.
15
+ * @returns Absolute config directory path used for tool and skill installation.
16
+ * @remarks Resolution order: explicit override → `%APPDATA%/opencode` on Windows →
17
+ * `$XDG_CONFIG_HOME/opencode` on Unix-like systems → `~/.config/opencode` fallback.
18
+ */
10
19
  export function resolveOpenCodeConfigDir(overrideDir?: string): string {
11
20
  if (overrideDir !== undefined) {
12
21
  return overrideDir;
@@ -21,6 +30,7 @@ export function resolveOpenCodeConfigDir(overrideDir?: string): string {
21
30
  return path.join(xdgConfigHome, "opencode");
22
31
  }
23
32
 
33
+ /** Builds the generated OpenCode tool wrapper file content. */
24
34
  export function buildToolWrapperContent(packageRoot: string): string {
25
35
  const normalizedPackageRoot = packageRoot.replace(/\\/g, "/");
26
36
 
@@ -31,6 +41,15 @@ export function buildToolWrapperContent(packageRoot: string): string {
31
41
  ].join("\n");
32
42
  }
33
43
 
44
+ /**
45
+ * Installs or refreshes OpenCode tool + skill integration files.
46
+ *
47
+ * @param configDir - Target OpenCode config directory.
48
+ * @param packageRoot - Installed package root used to resolve bundled assets.
49
+ * @returns Written tool path and copied skill file paths.
50
+ * @remarks Operation is idempotent: reruns overwrite the wrapper and recopy skill files
51
+ * to align the config directory with the currently installed package version.
52
+ */
34
53
  export async function installOpenCodeIntegration(configDir: string, packageRoot: string): Promise<Result<InstallResult>> {
35
54
  try {
36
55
  const toolDir = path.join(configDir, "tools");
@@ -60,7 +79,7 @@ export async function installOpenCodeIntegration(configDir: string, packageRoot:
60
79
  },
61
80
  };
62
81
  } catch (err) {
63
- const message = err instanceof Error ? err.message : String(err);
64
- return { ok: false, error: message, code: ErrorCode.FILE_WRITE_ERROR };
82
+ const unknownError = toUnknownResult(err);
83
+ return { ...unknownError, code: ErrorCode.FILE_WRITE_ERROR };
65
84
  }
66
85
  }
@@ -1,9 +1,16 @@
1
+ /** Input payload for one HTTP freshness check request. */
1
2
  export interface FreshnessCheckInput {
2
3
  url: string;
3
4
  etag?: string;
4
5
  last_modified?: string;
5
6
  }
6
7
 
8
+ /**
9
+ * Output payload for one HTTP freshness check request.
10
+ *
11
+ * @remarks Status semantics: HTTP 304 maps to `fresh`, HTTP 200 maps to `stale`, and
12
+ * all other outcomes (network errors, blocked URLs, non-200/304 responses) map to `error`.
13
+ */
7
14
  export interface FreshnessCheckOutput {
8
15
  url: string;
9
16
  status: "fresh" | "stale" | "error";
@@ -30,8 +37,15 @@ export interface FreshnessCheckOutput {
30
37
  * - ::ffff: IPv4-mapped IPv6
31
38
  */
32
39
  const PRIVATE_IP_PATTERN =
33
- /^(127\.|localhost$|10\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.0\.0\.0$|\[::1\]$|::1$|::ffff:|f[cd][0-9a-f]{0,2}:|\[f[cd][0-9a-f]{0,2}:)/i;
40
+ /^(127\.|localhost$|10\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.0\.0\.0$|\[::1\]$|::1$|::ffff:|\[::ffff:|f[cd][0-9a-f]{0,2}:|\[f[cd][0-9a-f]{0,2}:)/i;
34
41
 
42
+ /**
43
+ * Validates whether a URL is eligible for outbound freshness checks.
44
+ *
45
+ * @remarks Security control for SSRF risk reduction. Blocks non-HTTP(S) schemes and host
46
+ * patterns that target loopback/private address space or raw IP-style local endpoints
47
+ * (for example localhost, RFC1918 IPv4 ranges, loopback/link-local, and mapped/ULA IPv6).
48
+ */
35
49
  export function isAllowedUrl(url: string): { allowed: boolean; reason?: string } {
36
50
  try {
37
51
  const parsed = new URL(url);
@@ -47,6 +61,14 @@ export function isAllowedUrl(url: string): { allowed: boolean; reason?: string }
47
61
  }
48
62
  }
49
63
 
64
+ /**
65
+ * Performs a conditional HTTP HEAD freshness check.
66
+ *
67
+ * @param input - URL plus optional stored validators (`etag`, `last_modified`).
68
+ * @returns Freshness verdict and response metadata for one URL.
69
+ * @remarks Uses a 10-second abort timeout, sends conditional headers when available,
70
+ * maps 304→fresh and 200→stale, and reports all other outcomes as `error`.
71
+ */
50
72
  export async function checkFreshness(input: FreshnessCheckInput): Promise<FreshnessCheckOutput> {
51
73
  const allowCheck = isAllowedUrl(input.url);
52
74
  if (!allowCheck.allowed) {
package/src/index.ts CHANGED
@@ -8,9 +8,15 @@ import { pruneCommand } from "./commands/prune.js";
8
8
  import { checkFreshnessCommand } from "./commands/checkFreshness.js";
9
9
  import { checkFilesCommand } from "./commands/checkFiles.js";
10
10
  import { searchCommand } from "./commands/search.js";
11
- import { writeCommand } from "./commands/write.js";
11
+ import { writeLocalCommand } from "./commands/writeLocal.js";
12
+ import { writeExternalCommand } from "./commands/writeExternal.js";
12
13
  import { installCommand } from "./commands/install.js";
14
+ import { graphCommand } from "./commands/graph.js";
15
+ import { mapCommand } from "./commands/map.js";
16
+ import { watchCommand } from "./commands/watch.js";
17
+ import { versionCommand } from "./commands/version.js";
13
18
  import { ErrorCode } from "./types/result.js";
19
+ import { toUnknownResult } from "./utils/errors.js";
14
20
 
15
21
  type CommandName =
16
22
  | "list"
@@ -22,8 +28,13 @@ type CommandName =
22
28
  | "check-freshness"
23
29
  | "check-files"
24
30
  | "search"
25
- | "write"
26
- | "install";
31
+ | "write-local"
32
+ | "write-external"
33
+ | "install"
34
+ | "graph"
35
+ | "map"
36
+ | "watch"
37
+ | "version";
27
38
 
28
39
  function isKnownCommand(cmd: string): cmd is CommandName {
29
40
  return Object.hasOwn(COMMAND_HELP as Record<string, unknown>, cmd);
@@ -152,13 +163,25 @@ const COMMAND_HELP: Record<CommandName, CommandHelp> = {
152
163
  " Output: Ranked list of matching cache entries.",
153
164
  ].join("\n"),
154
165
  },
155
- write: {
156
- usage: "write <agent> [subject] --data '<json>'",
157
- description: "Write a validated cache entry from JSON",
166
+ "write-local": {
167
+ usage: "write-local --data '<json>'",
168
+ description: "Write a validated local cache entry",
158
169
  details: [
159
170
  " Arguments:",
160
- " <agent> Agent type: external or local",
161
- " [subject] Optional subject identifier (required for external agent)",
171
+ " (none)",
172
+ "",
173
+ " Options:",
174
+ " --data '<json>' JSON string containing the cache entry payload",
175
+ "",
176
+ " Output: Confirmation with the written entry's key.",
177
+ ].join("\n"),
178
+ },
179
+ "write-external": {
180
+ usage: "write-external <subject> --data '<json>'",
181
+ description: "Write a validated external cache entry",
182
+ details: [
183
+ " Arguments:",
184
+ " <subject> Subject identifier for the external entry",
162
185
  "",
163
186
  " Options:",
164
187
  " --data '<json>' JSON string containing the cache entry payload",
@@ -179,6 +202,58 @@ const COMMAND_HELP: Record<CommandName, CommandHelp> = {
179
202
  " Output: JSON object describing installed tool/skill paths.",
180
203
  ].join("\n"),
181
204
  },
205
+ graph: {
206
+ usage: "graph [--max-tokens <number>] [--seed <path>[,<path>...]]",
207
+ description: "Return a PageRank-ranked dependency graph under a token budget",
208
+ details: [
209
+ " Arguments:",
210
+ " (none)",
211
+ "",
212
+ " Options:",
213
+ " --max-tokens <number> Token budget for ranked_files output (default: 1024)",
214
+ " --seed <path>[,<path>...] Personalize rank toward specific file path(s)",
215
+ " (repeat --seed to provide multiple values)",
216
+ "",
217
+ " Output: Ranked files with deps, defs, and ref_count from graph.json.",
218
+ ].join("\n"),
219
+ },
220
+ map: {
221
+ usage: "map [--depth overview|modules|full] [--folder <path-prefix>]",
222
+ description: "Return a semantic map of local context.json",
223
+ details: [
224
+ " Arguments:",
225
+ " (none)",
226
+ "",
227
+ " Options:",
228
+ " --depth overview|modules|full Output depth (default: overview)",
229
+ " --folder <path-prefix> Restrict map to files whose path starts with prefix",
230
+ "",
231
+ " Output: JSON object with global_facts, files, optional modules, and total_files.",
232
+ ].join("\n"),
233
+ },
234
+ watch: {
235
+ usage: "watch [--verbose]",
236
+ description: "Watch for file changes and recompute the dependency graph",
237
+ details: [
238
+ " Arguments:",
239
+ " (none)",
240
+ "",
241
+ " Options:",
242
+ " --verbose Log watcher lifecycle and rebuild events",
243
+ "",
244
+ " Output: Long-running daemon process that updates graph.json on source changes.",
245
+ ].join("\n"),
246
+ },
247
+ version: {
248
+ usage: "version",
249
+ description: "Show the current cache-ctrl package version",
250
+ details: [
251
+ " Arguments:",
252
+ " (none)",
253
+ "",
254
+ " Output: JSON object containing the current package version.",
255
+ ].join("\n"),
256
+ },
182
257
  };
183
258
 
184
259
  const GLOBAL_OPTIONS_SECTION = [
@@ -206,7 +281,7 @@ export function printHelp(command?: string): boolean {
206
281
  ...Object.values(COMMAND_HELP).map((h) => h.usage.length),
207
282
  );
208
283
 
209
- for (const [, help] of Object.entries(COMMAND_HELP) as [CommandName, CommandHelp][]) {
284
+ for (const help of Object.values(COMMAND_HELP)) {
210
285
  const paddedUsage = help.usage.padEnd(maxUsageLen);
211
286
  lines.push(` ${paddedUsage} ${help.description}`);
212
287
  }
@@ -216,12 +291,12 @@ export function printHelp(command?: string): boolean {
216
291
  return true;
217
292
  }
218
293
 
219
- const sanitized = command.replace(/[\x00-\x1F\x7F]/g, "");
220
-
221
294
  if (command === "help") {
222
295
  return printHelp();
223
296
  }
224
297
 
298
+ const sanitized = command.replace(/[\x00-\x1F\x7F]/g, "");
299
+
225
300
  if (!isKnownCommand(command)) {
226
301
  process.stderr.write(`Unknown command: "${sanitized}". Run 'cache-ctrl help' for available commands.\n`);
227
302
  return false;
@@ -257,6 +332,12 @@ function printError(error: { ok: false; error: string; code: string }, pretty: b
257
332
  }
258
333
  }
259
334
 
335
+ /**
336
+ * Prints a structured usage error and terminates the process.
337
+ *
338
+ * @param message - Human-readable usage failure detail.
339
+ * @remarks Always exits with process code `2` to distinguish usage failures from runtime errors.
340
+ */
260
341
  function usageError(message: string): never {
261
342
  process.stderr.write(JSON.stringify({ ok: false, error: message, code: ErrorCode.INVALID_ARGS }) + "\n");
262
343
  process.exit(2);
@@ -265,8 +346,45 @@ function usageError(message: string): never {
265
346
  export { usageError };
266
347
 
267
348
  /** 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"]);
349
+ const VALUE_FLAGS = new Set([
350
+ "data",
351
+ "agent",
352
+ "url",
353
+ "max-age",
354
+ "filter",
355
+ "folder",
356
+ "search-facts",
357
+ "config-dir",
358
+ "max-tokens",
359
+ "seed",
360
+ "depth",
361
+ ]);
362
+
363
+ function collectFlagValues(argv: string[], flagName: string): string[] {
364
+ const values: string[] = [];
365
+
366
+ for (let i = 0; i < argv.length; i += 1) {
367
+ if (argv[i] !== `--${flagName}`) {
368
+ continue;
369
+ }
370
+ const next = argv[i + 1];
371
+ if (next !== undefined) {
372
+ values.push(next);
373
+ i += 1;
374
+ }
375
+ }
376
+
377
+ return values;
378
+ }
269
379
 
380
+ /**
381
+ * Parses raw CLI argv tokens into positional args and flag key/value pairs.
382
+ *
383
+ * @param argv - Raw argument tokens (typically `process.argv.slice(2)`).
384
+ * @returns Parsed positional args and normalized flags map.
385
+ * @remarks Flags listed in `VALUE_FLAGS` consume the following token as their value;
386
+ * all other `--flag` tokens are treated as boolean flags.
387
+ */
270
388
  export function parseArgs(argv: string[]): { args: string[]; flags: Record<string, string | boolean> } {
271
389
  const positional: string[] = [];
272
390
  const flags: Record<string, string | boolean> = {};
@@ -305,7 +423,7 @@ async function main(): Promise<void> {
305
423
 
306
424
  const command = args[0];
307
425
  if (!command) {
308
- usageError("Usage: cache-ctrl <command> [args]. Commands: list, inspect, flush, invalidate, touch, prune, check-freshness, check-files, search, write, install");
426
+ usageError("Usage: cache-ctrl <command> [args]. Commands: list, inspect, flush, invalidate, touch, prune, check-freshness, check-files, search, write-local, write-external, install, graph, map, watch, version");
309
427
  }
310
428
 
311
429
  switch (command) {
@@ -498,28 +616,54 @@ async function main(): Promise<void> {
498
616
  break;
499
617
  }
500
618
 
501
- case "write": {
502
- const agent = args[1];
503
- if (!agent) {
504
- usageError("Usage: cache-ctrl write <agent> [subject] --data '<json>'");
619
+ case "write-local": {
620
+ const dataStr = typeof flags.data === "string" ? flags.data : undefined;
621
+ if (!dataStr) {
622
+ usageError("Usage: cache-ctrl write-local --data '<json>'");
505
623
  }
506
- if (agent !== "external" && agent !== "local") {
507
- usageError(`Invalid agent: "${agent}". Must be external or local`);
624
+ let content: Record<string, unknown>;
625
+ try {
626
+ content = JSON.parse(dataStr) as Record<string, unknown>; // JSON.parse returns any; writeLocalCommand validates the payload shape via Zod before use.
627
+ } catch {
628
+ usageError("--data must be valid JSON");
629
+ }
630
+ if (typeof content !== "object" || content === null || Array.isArray(content)) {
631
+ usageError("--data must be a JSON object");
632
+ }
633
+ const result = await writeLocalCommand({
634
+ agent: "local",
635
+ content,
636
+ });
637
+ if (result.ok) {
638
+ printResult(result, pretty);
639
+ } else {
640
+ printError(result, pretty);
641
+ process.exit(1);
642
+ }
643
+ break;
644
+ }
645
+
646
+ case "write-external": {
647
+ const subject = args[1];
648
+ if (!subject) {
649
+ usageError("Usage: cache-ctrl write-external <subject> --data '<json>'");
508
650
  }
509
651
  const dataStr = typeof flags.data === "string" ? flags.data : undefined;
510
652
  if (!dataStr) {
511
- usageError("Usage: cache-ctrl write <agent> [subject] --data '<json>'");
653
+ usageError("Usage: cache-ctrl write-external <subject> --data '<json>'");
512
654
  }
513
655
  let content: Record<string, unknown>;
514
656
  try {
515
- content = JSON.parse(dataStr) as Record<string, unknown>;
657
+ content = JSON.parse(dataStr) as Record<string, unknown>; // JSON.parse returns any; writeExternalCommand validates the payload shape via Zod before use.
516
658
  } catch {
517
659
  usageError("--data must be valid JSON");
518
660
  }
519
- const subject = agent === "external" ? args[2] : undefined;
520
- const result = await writeCommand({
521
- agent,
522
- ...(subject !== undefined ? { subject } : {}),
661
+ if (typeof content !== "object" || content === null || Array.isArray(content)) {
662
+ usageError("--data must be a JSON object");
663
+ }
664
+ const result = await writeExternalCommand({
665
+ agent: "external",
666
+ subject,
523
667
  content,
524
668
  });
525
669
  if (result.ok) {
@@ -543,15 +687,96 @@ async function main(): Promise<void> {
543
687
  break;
544
688
  }
545
689
 
690
+ case "graph": {
691
+ if (flags["max-tokens"] === true) {
692
+ usageError("--max-tokens requires a numeric value");
693
+ }
694
+ const maxTokensRaw = typeof flags["max-tokens"] === "string" ? flags["max-tokens"] : undefined;
695
+ let maxTokensParsed: number | undefined;
696
+ if (maxTokensRaw !== undefined) {
697
+ const parsed = Number(maxTokensRaw);
698
+ if (!Number.isFinite(parsed) || parsed < 0) {
699
+ usageError(`Invalid --max-tokens value: "${maxTokensRaw}". Must be a non-negative number`);
700
+ }
701
+ maxTokensParsed = parsed;
702
+ }
703
+ if (flags.seed === true) {
704
+ usageError("--seed requires a value: --seed <path>[,<path>...]");
705
+ }
706
+ const seedFlagValues = collectFlagValues(rawArgs, "seed");
707
+ const seed = seedFlagValues
708
+ .flatMap((value) => value.split(","))
709
+ .map((value) => value.trim())
710
+ .filter((value) => value.length > 0);
711
+
712
+ const result = await graphCommand({
713
+ ...(maxTokensParsed !== undefined ? { maxTokens: maxTokensParsed } : {}),
714
+ ...(seed.length > 0 ? { seed } : {}),
715
+ });
716
+ if (result.ok) {
717
+ printResult(result, pretty);
718
+ } else {
719
+ printError(result, pretty);
720
+ process.exit(1);
721
+ }
722
+ break;
723
+ }
724
+
725
+ case "map": {
726
+ if (flags.depth === true) {
727
+ usageError("--depth requires a value: --depth overview|modules|full");
728
+ }
729
+ const depthRaw = typeof flags.depth === "string" ? flags.depth : undefined;
730
+ if (depthRaw !== undefined && depthRaw !== "overview" && depthRaw !== "modules" && depthRaw !== "full") {
731
+ usageError(`Invalid --depth value: "${depthRaw}". Must be overview, modules, or full`);
732
+ }
733
+ if (flags.folder === true) {
734
+ usageError("--folder requires a value: --folder <path-prefix>");
735
+ }
736
+ const folder = typeof flags.folder === "string" ? flags.folder : undefined;
737
+
738
+ const result = await mapCommand({
739
+ ...(depthRaw !== undefined ? { depth: depthRaw } : {}),
740
+ ...(folder !== undefined ? { folder } : {}),
741
+ });
742
+
743
+ if (result.ok) {
744
+ printResult(result, pretty);
745
+ } else {
746
+ printError(result, pretty);
747
+ process.exit(1);
748
+ }
749
+ break;
750
+ }
751
+
752
+ case "watch": {
753
+ const result = await watchCommand({ verbose: flags.verbose === true });
754
+ if (!result.ok) {
755
+ printError(result, pretty);
756
+ process.exit(1);
757
+ }
758
+ break;
759
+ }
760
+
761
+ case "version": {
762
+ const result = versionCommand({});
763
+ if (result.ok) {
764
+ printResult(result, pretty);
765
+ } else {
766
+ printError(result, pretty);
767
+ process.exit(1);
768
+ }
769
+ break;
770
+ }
771
+
546
772
  default:
547
- usageError(`Unknown command: "${command}". Commands: list, inspect, flush, invalidate, touch, prune, check-freshness, check-files, search, write, install`);
773
+ usageError(`Unknown command: "${command}". Commands: list, inspect, flush, invalidate, touch, prune, check-freshness, check-files, search, write-local, write-external, install, graph, map, watch, version`);
548
774
  }
549
775
  }
550
776
 
551
777
  if (import.meta.main) {
552
778
  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");
779
+ process.stderr.write(JSON.stringify(toUnknownResult(err)) + "\n");
555
780
  process.exit(1);
556
781
  });
557
782
  }
@@ -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;
@@ -21,9 +25,16 @@ const HeaderMetaSchema = z.object({
21
25
  etag: z.string().optional(),
22
26
  last_modified: z.string().optional(),
23
27
  checked_at: z.string(),
28
+ // "unchecked" = entry written without HTTP check
24
29
  status: z.enum(["fresh", "stale", "unchecked"]),
25
30
  });
26
31
 
32
+ /** Stored HTTP validator metadata for one source URL in an external cache entry. */
33
+ export type HeaderMeta = z.infer<typeof HeaderMetaSchema>;
34
+
35
+ /**
36
+ * Validates external context cache JSON files stored under `.ai/external-context-gatherer_cache/`.
37
+ */
27
38
  export const ExternalCacheFileSchema = z.looseObject({
28
39
  subject: z.string(),
29
40
  description: z.string(),
@@ -32,12 +43,22 @@ export const ExternalCacheFileSchema = z.looseObject({
32
43
  header_metadata: z.record(z.string(), HeaderMetaSchema),
33
44
  });
34
45
 
46
+ /** Validates one tracked file baseline used by local file-change detection. */
35
47
  export const TrackedFileSchema = z.object({
36
48
  path: z.string(),
37
49
  mtime: z.number(),
38
50
  hash: z.string().optional(),
39
51
  });
40
52
 
53
+ const FileFactsSchema = z.object({
54
+ summary: z.string().max(300).optional(),
55
+ role: z
56
+ .enum(["entry-point", "interface", "implementation", "test", "config"])
57
+ .optional(),
58
+ importance: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(),
59
+ facts: z.array(z.string().max(300)).max(10).optional(),
60
+ });
61
+
41
62
  /**
42
63
  * Zod schema for the local context-gatherer cache file (`context.json`).
43
64
  *
@@ -47,8 +68,8 @@ export const TrackedFileSchema = z.object({
47
68
  * Size constraints enforced at write time:
48
69
  * - `global_facts`: max 20 entries; each string ≤ 300 characters.
49
70
  * 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.
71
+ * - `facts`: per-file structured metadata with max 10 concise fact strings
72
+ * (each string 300 characters).
52
73
  */
53
74
  export const LocalCacheFileSchema = z.looseObject({
54
75
  timestamp: z.string(),
@@ -68,24 +89,27 @@ export const LocalCacheFileSchema = z.looseObject({
68
89
  "max 20 global facts — choose only cross-cutting structural observations",
69
90
  })
70
91
  .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(),
92
+ facts: z.record(z.string(), FileFactsSchema).optional(),
93
+ modules: z.record(z.string(), z.array(z.string())).optional(),
87
94
  });
88
95
 
89
96
  export type TrackedFile = z.infer<typeof TrackedFileSchema>;
97
+ export type FileFacts = z.infer<typeof FileFactsSchema>;
90
98
  export type ExternalCacheFile = z.infer<typeof ExternalCacheFileSchema>;
91
99
  export type LocalCacheFile = z.infer<typeof LocalCacheFileSchema>;
100
+
101
+ const GraphNodeSchema = z.object({
102
+ rank: z.number(),
103
+ deps: z.array(z.string()),
104
+ defs: z.array(z.string()),
105
+ });
106
+
107
+ /**
108
+ * Validates graph cache payloads written by `watch` and consumed by `graph`.
109
+ */
110
+ export const GraphCacheFileSchema = z.object({
111
+ files: z.record(z.string(), GraphNodeSchema),
112
+ computed_at: z.string(),
113
+ });
114
+
115
+ export type GraphCacheFile = z.infer<typeof GraphCacheFileSchema>;