@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.
- package/README.md +202 -28
- package/cache_ctrl.ts +125 -13
- package/package.json +2 -1
- package/skills/cache-ctrl-caller/SKILL.md +45 -31
- package/skills/cache-ctrl-external/SKILL.md +20 -45
- package/skills/cache-ctrl-local/SKILL.md +95 -86
- package/src/analysis/graphBuilder.ts +85 -0
- package/src/analysis/pageRank.ts +167 -0
- package/src/analysis/symbolExtractor.ts +240 -0
- package/src/cache/cacheManager.ts +52 -2
- package/src/cache/externalCache.ts +41 -64
- package/src/cache/graphCache.ts +12 -0
- package/src/cache/localCache.ts +2 -0
- package/src/commands/checkFiles.ts +7 -4
- package/src/commands/checkFreshness.ts +19 -19
- package/src/commands/flush.ts +9 -2
- package/src/commands/graph.ts +131 -0
- package/src/commands/inspect.ts +13 -181
- package/src/commands/inspectExternal.ts +79 -0
- package/src/commands/inspectLocal.ts +134 -0
- package/src/commands/install.ts +6 -0
- package/src/commands/invalidate.ts +19 -2
- package/src/commands/list.ts +11 -11
- package/src/commands/map.ts +87 -0
- package/src/commands/prune.ts +20 -8
- package/src/commands/search.ts +9 -2
- package/src/commands/touch.ts +9 -2
- package/src/commands/version.ts +14 -0
- package/src/commands/watch.ts +253 -0
- package/src/commands/writeExternal.ts +51 -0
- package/src/commands/writeLocal.ts +123 -0
- package/src/files/changeDetector.ts +15 -0
- package/src/files/gitFiles.ts +15 -0
- package/src/files/openCodeInstaller.ts +21 -2
- package/src/http/freshnessChecker.ts +23 -1
- package/src/index.ts +253 -28
- package/src/search/keywordSearch.ts +24 -0
- package/src/types/cache.ts +42 -18
- package/src/types/commands.ts +99 -1
- package/src/types/result.ts +27 -7
- package/src/utils/errors.ts +14 -0
- package/src/utils/traversal.ts +42 -0
- package/src/commands/write.ts +0 -170
package/src/files/gitFiles.ts
CHANGED
|
@@ -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
|
|
64
|
-
return {
|
|
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 {
|
|
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
|
-
| "
|
|
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
|
|
157
|
-
description: "Write a validated cache entry
|
|
166
|
+
"write-local": {
|
|
167
|
+
usage: "write-local --data '<json>'",
|
|
168
|
+
description: "Write a validated local cache entry",
|
|
158
169
|
details: [
|
|
159
170
|
" Arguments:",
|
|
160
|
-
"
|
|
161
|
-
"
|
|
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
|
|
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([
|
|
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
|
|
503
|
-
if (!
|
|
504
|
-
usageError("Usage: cache-ctrl write
|
|
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
|
-
|
|
507
|
-
|
|
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 <
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
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\-_./]+/);
|
package/src/types/cache.ts
CHANGED
|
@@ -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`:
|
|
51
|
-
*
|
|
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
|
-
|
|
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>;
|