@thecat69/cache-ctrl 1.0.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.
- package/README.md +558 -0
- package/cache_ctrl.ts +153 -0
- package/package.json +35 -0
- package/skills/cache-ctrl-caller/SKILL.md +154 -0
- package/skills/cache-ctrl-external/SKILL.md +130 -0
- package/skills/cache-ctrl-local/SKILL.md +213 -0
- package/src/cache/cacheManager.ts +241 -0
- package/src/cache/externalCache.ts +127 -0
- package/src/cache/localCache.ts +9 -0
- package/src/commands/checkFiles.ts +83 -0
- package/src/commands/checkFreshness.ts +123 -0
- package/src/commands/flush.ts +55 -0
- package/src/commands/inspect.ts +184 -0
- package/src/commands/install.ts +13 -0
- package/src/commands/invalidate.ts +53 -0
- package/src/commands/list.ts +83 -0
- package/src/commands/prune.ts +110 -0
- package/src/commands/search.ts +57 -0
- package/src/commands/touch.ts +47 -0
- package/src/commands/write.ts +170 -0
- package/src/files/changeDetector.ts +122 -0
- package/src/files/gitFiles.ts +41 -0
- package/src/files/openCodeInstaller.ts +66 -0
- package/src/http/freshnessChecker.ts +116 -0
- package/src/index.ts +557 -0
- package/src/search/keywordSearch.ts +59 -0
- package/src/types/cache.ts +91 -0
- package/src/types/commands.ts +192 -0
- package/src/types/result.ts +36 -0
- package/src/utils/fileStem.ts +7 -0
- package/src/utils/validate.ts +50 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { listCommand } from "./commands/list.js";
|
|
3
|
+
import { inspectCommand } from "./commands/inspect.js";
|
|
4
|
+
import { flushCommand } from "./commands/flush.js";
|
|
5
|
+
import { invalidateCommand } from "./commands/invalidate.js";
|
|
6
|
+
import { touchCommand } from "./commands/touch.js";
|
|
7
|
+
import { pruneCommand } from "./commands/prune.js";
|
|
8
|
+
import { checkFreshnessCommand } from "./commands/checkFreshness.js";
|
|
9
|
+
import { checkFilesCommand } from "./commands/checkFiles.js";
|
|
10
|
+
import { searchCommand } from "./commands/search.js";
|
|
11
|
+
import { writeCommand } from "./commands/write.js";
|
|
12
|
+
import { installCommand } from "./commands/install.js";
|
|
13
|
+
import { ErrorCode } from "./types/result.js";
|
|
14
|
+
|
|
15
|
+
type CommandName =
|
|
16
|
+
| "list"
|
|
17
|
+
| "inspect"
|
|
18
|
+
| "flush"
|
|
19
|
+
| "invalidate"
|
|
20
|
+
| "touch"
|
|
21
|
+
| "prune"
|
|
22
|
+
| "check-freshness"
|
|
23
|
+
| "check-files"
|
|
24
|
+
| "search"
|
|
25
|
+
| "write"
|
|
26
|
+
| "install";
|
|
27
|
+
|
|
28
|
+
function isKnownCommand(cmd: string): cmd is CommandName {
|
|
29
|
+
return Object.hasOwn(COMMAND_HELP as Record<string, unknown>, cmd);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CommandHelp {
|
|
33
|
+
usage: string;
|
|
34
|
+
description: string;
|
|
35
|
+
details: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const COMMAND_HELP: Record<CommandName, CommandHelp> = {
|
|
39
|
+
list: {
|
|
40
|
+
usage: "list [--agent external|local|all]",
|
|
41
|
+
description: "List all cache entries with age and staleness",
|
|
42
|
+
details: [
|
|
43
|
+
" Arguments:",
|
|
44
|
+
" (none)",
|
|
45
|
+
"",
|
|
46
|
+
" Options:",
|
|
47
|
+
" --agent external|local|all Filter by agent type (default: all)",
|
|
48
|
+
"",
|
|
49
|
+
" Output: JSON array of cache entries with timestamps and staleness flags.",
|
|
50
|
+
].join("\n"),
|
|
51
|
+
},
|
|
52
|
+
inspect: {
|
|
53
|
+
usage: "inspect <agent> <subject-keyword> [--filter <kw>] [--folder <path>] [--search-facts <kw>]",
|
|
54
|
+
description: "Show full content of a cache entry",
|
|
55
|
+
details: [
|
|
56
|
+
" Arguments:",
|
|
57
|
+
" <agent> Agent type: external or local",
|
|
58
|
+
" <subject-keyword> Keyword used to locate the cache entry",
|
|
59
|
+
"",
|
|
60
|
+
" Options:",
|
|
61
|
+
" --filter <kw>[,<kw>...] Return only facts whose file path contains any keyword",
|
|
62
|
+
" (local agent only; comma-separated; case-insensitive OR match)",
|
|
63
|
+
" --folder <path> Return only facts whose file path starts with the given folder prefix",
|
|
64
|
+
" (local agent only; recursive; INVALID_ARGS if used with external agent)",
|
|
65
|
+
" --search-facts <kw>[,<kw>...] Return only facts where any fact string contains any keyword",
|
|
66
|
+
" (local agent only; comma-separated; case-insensitive OR match)",
|
|
67
|
+
"",
|
|
68
|
+
" Output: Full JSON content of the matched cache entry.",
|
|
69
|
+
" Note: tracked_files is never returned for local agent inspect.",
|
|
70
|
+
" Note: --filter, --folder, and --search-facts are AND-ed when combined.",
|
|
71
|
+
].join("\n"),
|
|
72
|
+
},
|
|
73
|
+
flush: {
|
|
74
|
+
usage: "flush <agent|all> --confirm",
|
|
75
|
+
description: "Delete all cache entries (destructive, requires --confirm)",
|
|
76
|
+
details: [
|
|
77
|
+
" Arguments:",
|
|
78
|
+
" <agent|all> Agent to flush: external, local, or all",
|
|
79
|
+
"",
|
|
80
|
+
" Options:",
|
|
81
|
+
" --confirm Required flag — confirms the destructive operation",
|
|
82
|
+
"",
|
|
83
|
+
" WARNING: This permanently deletes all matching cache entries.",
|
|
84
|
+
].join("\n"),
|
|
85
|
+
},
|
|
86
|
+
invalidate: {
|
|
87
|
+
usage: "invalidate <agent> [subject-keyword]",
|
|
88
|
+
description: "Mark cache entries as stale (content preserved)",
|
|
89
|
+
details: [
|
|
90
|
+
" Arguments:",
|
|
91
|
+
" <agent> Agent type: external or local",
|
|
92
|
+
" [subject-keyword] Optional keyword to target a specific entry",
|
|
93
|
+
"",
|
|
94
|
+
" Output: Number of entries marked as stale.",
|
|
95
|
+
].join("\n"),
|
|
96
|
+
},
|
|
97
|
+
touch: {
|
|
98
|
+
usage: "touch <agent> [subject-keyword]",
|
|
99
|
+
description: "Refresh timestamps on cache entries",
|
|
100
|
+
details: [
|
|
101
|
+
" Arguments:",
|
|
102
|
+
" <agent> Agent type: external or local",
|
|
103
|
+
" [subject-keyword] Optional keyword to target a specific entry",
|
|
104
|
+
"",
|
|
105
|
+
" Output: Number of entries whose timestamps were updated.",
|
|
106
|
+
].join("\n"),
|
|
107
|
+
},
|
|
108
|
+
prune: {
|
|
109
|
+
usage: "prune [--agent external|local|all] [--max-age <duration>] [--delete]",
|
|
110
|
+
description: "Find and optionally remove stale entries",
|
|
111
|
+
details: [
|
|
112
|
+
" Arguments:",
|
|
113
|
+
" (none)",
|
|
114
|
+
"",
|
|
115
|
+
" Options:",
|
|
116
|
+
" --agent external|local|all Filter by agent type (default: all)",
|
|
117
|
+
" --max-age <duration> Maximum age threshold (e.g. 24h, 7d)",
|
|
118
|
+
" --delete Actually delete the stale entries (dry-run if omitted)",
|
|
119
|
+
].join("\n"),
|
|
120
|
+
},
|
|
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
|
+
"check-files": {
|
|
135
|
+
usage: "check-files",
|
|
136
|
+
description: "Compare tracked local files against stored mtime/hash",
|
|
137
|
+
details: [
|
|
138
|
+
" Arguments:",
|
|
139
|
+
" (none)",
|
|
140
|
+
"",
|
|
141
|
+
" Output: List of files whose mtime or hash differs from the stored baseline.",
|
|
142
|
+
" Also reports new_files (files not excluded by .gitignore that are absent from cache — includes git-tracked and untracked-non-ignored files) and deleted_git_files.",
|
|
143
|
+
].join("\n"),
|
|
144
|
+
},
|
|
145
|
+
search: {
|
|
146
|
+
usage: "search <keyword> [<keyword>...]",
|
|
147
|
+
description: "Search cache entries by keyword (ranked results)",
|
|
148
|
+
details: [
|
|
149
|
+
" Arguments:",
|
|
150
|
+
" <keyword> [<keyword>...] One or more keywords to search for",
|
|
151
|
+
"",
|
|
152
|
+
" Output: Ranked list of matching cache entries.",
|
|
153
|
+
].join("\n"),
|
|
154
|
+
},
|
|
155
|
+
write: {
|
|
156
|
+
usage: "write <agent> [subject] --data '<json>'",
|
|
157
|
+
description: "Write a validated cache entry from JSON",
|
|
158
|
+
details: [
|
|
159
|
+
" Arguments:",
|
|
160
|
+
" <agent> Agent type: external or local",
|
|
161
|
+
" [subject] Optional subject identifier (required for external agent)",
|
|
162
|
+
"",
|
|
163
|
+
" Options:",
|
|
164
|
+
" --data '<json>' JSON string containing the cache entry payload",
|
|
165
|
+
"",
|
|
166
|
+
" Output: Confirmation with the written entry's key.",
|
|
167
|
+
].join("\n"),
|
|
168
|
+
},
|
|
169
|
+
install: {
|
|
170
|
+
usage: "install [--config-dir <path>]",
|
|
171
|
+
description: "Set up OpenCode tool and skills in the user config directory",
|
|
172
|
+
details: [
|
|
173
|
+
" Arguments:",
|
|
174
|
+
" (none)",
|
|
175
|
+
"",
|
|
176
|
+
" Options:",
|
|
177
|
+
" --config-dir <path> Override the OpenCode config directory (default: platform-specific)",
|
|
178
|
+
"",
|
|
179
|
+
" Output: JSON object describing installed tool/skill paths.",
|
|
180
|
+
].join("\n"),
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const GLOBAL_OPTIONS_SECTION = [
|
|
185
|
+
"Global options:",
|
|
186
|
+
" --help Show help (use 'help <command>' for command-specific help)",
|
|
187
|
+
" --pretty Pretty-print JSON output",
|
|
188
|
+
].join("\n");
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Writes plain-text usage information to stdout.
|
|
192
|
+
*
|
|
193
|
+
* @param command - If provided, prints help for that specific command.
|
|
194
|
+
* If omitted, prints the full command reference.
|
|
195
|
+
* Does NOT call process.exit — the caller handles exit.
|
|
196
|
+
*/
|
|
197
|
+
export function printHelp(command?: string): boolean {
|
|
198
|
+
if (command === undefined) {
|
|
199
|
+
const lines: string[] = [
|
|
200
|
+
"Usage: cache-ctrl <command> [args] [options]",
|
|
201
|
+
"",
|
|
202
|
+
"Commands:",
|
|
203
|
+
];
|
|
204
|
+
|
|
205
|
+
const maxUsageLen = Math.max(
|
|
206
|
+
...Object.values(COMMAND_HELP).map((h) => h.usage.length),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
for (const [, help] of Object.entries(COMMAND_HELP) as [CommandName, CommandHelp][]) {
|
|
210
|
+
const paddedUsage = help.usage.padEnd(maxUsageLen);
|
|
211
|
+
lines.push(` ${paddedUsage} ${help.description}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
lines.push("", GLOBAL_OPTIONS_SECTION, "", "Run 'cache-ctrl help <command>' for command-specific help.");
|
|
215
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const sanitized = command.replace(/[\x00-\x1F\x7F]/g, "");
|
|
220
|
+
|
|
221
|
+
if (command === "help") {
|
|
222
|
+
return printHelp();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!isKnownCommand(command)) {
|
|
226
|
+
process.stderr.write(`Unknown command: "${sanitized}". Run 'cache-ctrl help' for available commands.\n`);
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const help = COMMAND_HELP[command];
|
|
231
|
+
const lines: string[] = [
|
|
232
|
+
`Usage: cache-ctrl ${help.usage}`,
|
|
233
|
+
"",
|
|
234
|
+
`Description: ${help.description}`,
|
|
235
|
+
"",
|
|
236
|
+
help.details,
|
|
237
|
+
"",
|
|
238
|
+
GLOBAL_OPTIONS_SECTION,
|
|
239
|
+
];
|
|
240
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function printResult(value: unknown, pretty: boolean): void {
|
|
245
|
+
if (pretty) {
|
|
246
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
247
|
+
} else {
|
|
248
|
+
process.stdout.write(JSON.stringify(value) + "\n");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function printError(error: { ok: false; error: string; code: string }, pretty: boolean): void {
|
|
253
|
+
if (pretty) {
|
|
254
|
+
process.stderr.write(JSON.stringify(error, null, 2) + "\n");
|
|
255
|
+
} else {
|
|
256
|
+
process.stderr.write(JSON.stringify(error) + "\n");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function usageError(message: string): never {
|
|
261
|
+
process.stderr.write(JSON.stringify({ ok: false, error: message, code: ErrorCode.INVALID_ARGS }) + "\n");
|
|
262
|
+
process.exit(2);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export { usageError };
|
|
266
|
+
|
|
267
|
+
/** 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"]);
|
|
269
|
+
|
|
270
|
+
export function parseArgs(argv: string[]): { args: string[]; flags: Record<string, string | boolean> } {
|
|
271
|
+
const positional: string[] = [];
|
|
272
|
+
const flags: Record<string, string | boolean> = {};
|
|
273
|
+
|
|
274
|
+
let i = 0;
|
|
275
|
+
while (i < argv.length) {
|
|
276
|
+
const arg = argv[i]!;
|
|
277
|
+
if (arg.startsWith("--")) {
|
|
278
|
+
const key = arg.slice(2);
|
|
279
|
+
const next = argv[i + 1];
|
|
280
|
+
if (VALUE_FLAGS.has(key) && next !== undefined) {
|
|
281
|
+
flags[key] = next;
|
|
282
|
+
i += 2;
|
|
283
|
+
} else {
|
|
284
|
+
flags[key] = true;
|
|
285
|
+
i += 1;
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
positional.push(arg);
|
|
289
|
+
i += 1;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { args: positional, flags };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function main(): Promise<void> {
|
|
297
|
+
const rawArgs = process.argv.slice(2);
|
|
298
|
+
const { args, flags } = parseArgs(rawArgs);
|
|
299
|
+
const pretty = flags.pretty === true;
|
|
300
|
+
|
|
301
|
+
if (flags["help"] === true) {
|
|
302
|
+
printHelp();
|
|
303
|
+
process.exit(0);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const command = args[0];
|
|
307
|
+
if (!command) {
|
|
308
|
+
usageError("Usage: cache-ctrl <command> [args]. Commands: list, inspect, flush, invalidate, touch, prune, check-freshness, check-files, search, write, install");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
switch (command) {
|
|
312
|
+
case "help": {
|
|
313
|
+
const ok = printHelp(args[1]);
|
|
314
|
+
process.exit(ok ? 0 : 1);
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case "list": {
|
|
318
|
+
const agentArg = typeof flags.agent === "string" ? flags.agent : undefined;
|
|
319
|
+
const validAgents: (string | undefined)[] = ["external", "local", "all", undefined];
|
|
320
|
+
if (!validAgents.includes(agentArg)) {
|
|
321
|
+
usageError(`Invalid --agent value: "${agentArg}". Must be external, local, or all`);
|
|
322
|
+
}
|
|
323
|
+
const result = await listCommand({
|
|
324
|
+
...(agentArg !== undefined ? { agent: agentArg as "external" | "local" | "all" } : {}),
|
|
325
|
+
});
|
|
326
|
+
if (result.ok) {
|
|
327
|
+
printResult(result, pretty);
|
|
328
|
+
} else {
|
|
329
|
+
printError(result, pretty);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
case "inspect": {
|
|
336
|
+
const agent = args[1];
|
|
337
|
+
const subject = args[2];
|
|
338
|
+
if (!agent || !subject) {
|
|
339
|
+
usageError("Usage: cache-ctrl inspect <agent> <subject-keyword>");
|
|
340
|
+
}
|
|
341
|
+
if (agent !== "external" && agent !== "local") {
|
|
342
|
+
usageError(`Invalid agent: "${agent}". Must be external or local`);
|
|
343
|
+
}
|
|
344
|
+
if (flags.filter === true) {
|
|
345
|
+
usageError("--filter requires a value: --filter <kw>[,<kw>...]");
|
|
346
|
+
}
|
|
347
|
+
if (typeof flags.folder === "string" && flags.folder.trim() === "") {
|
|
348
|
+
usageError("--folder requires a non-empty value");
|
|
349
|
+
}
|
|
350
|
+
const filterRaw = typeof flags.filter === "string" ? flags.filter : undefined;
|
|
351
|
+
const filter = filterRaw
|
|
352
|
+
? filterRaw
|
|
353
|
+
.split(",")
|
|
354
|
+
.map((f) => f.trim())
|
|
355
|
+
.filter(Boolean)
|
|
356
|
+
: undefined;
|
|
357
|
+
const folder = typeof flags.folder === "string" ? flags.folder : undefined;
|
|
358
|
+
const searchFactsRaw = typeof flags["search-facts"] === "string" ? flags["search-facts"] : undefined;
|
|
359
|
+
const searchFacts = searchFactsRaw
|
|
360
|
+
? searchFactsRaw
|
|
361
|
+
.split(",")
|
|
362
|
+
.map((f) => f.trim())
|
|
363
|
+
.filter(Boolean)
|
|
364
|
+
: undefined;
|
|
365
|
+
const result = await inspectCommand({
|
|
366
|
+
agent,
|
|
367
|
+
subject,
|
|
368
|
+
...(filter !== undefined ? { filter } : {}),
|
|
369
|
+
...(folder !== undefined ? { folder } : {}),
|
|
370
|
+
...(searchFacts !== undefined ? { searchFacts } : {}),
|
|
371
|
+
});
|
|
372
|
+
if (result.ok) {
|
|
373
|
+
printResult(result, pretty);
|
|
374
|
+
} else {
|
|
375
|
+
printError(result, pretty);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
case "flush": {
|
|
382
|
+
const agent = args[1];
|
|
383
|
+
if (!agent) {
|
|
384
|
+
usageError("Usage: cache-ctrl flush <agent|all> --confirm");
|
|
385
|
+
}
|
|
386
|
+
if (agent !== "external" && agent !== "local" && agent !== "all") {
|
|
387
|
+
usageError(`Invalid agent: "${agent}". Must be external, local, or all`);
|
|
388
|
+
}
|
|
389
|
+
const confirm = flags.confirm === true;
|
|
390
|
+
const result = await flushCommand({ agent, confirm });
|
|
391
|
+
if (result.ok) {
|
|
392
|
+
printResult(result, pretty);
|
|
393
|
+
} else {
|
|
394
|
+
printError(result, pretty);
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
case "invalidate": {
|
|
401
|
+
const agent = args[1];
|
|
402
|
+
if (!agent) {
|
|
403
|
+
usageError("Usage: cache-ctrl invalidate <agent> [subject-keyword]");
|
|
404
|
+
}
|
|
405
|
+
if (agent !== "external" && agent !== "local") {
|
|
406
|
+
usageError(`Invalid agent: "${agent}". Must be external or local`);
|
|
407
|
+
}
|
|
408
|
+
const subject = args[2];
|
|
409
|
+
const result = await invalidateCommand({ agent, ...(subject !== undefined ? { subject } : {}) });
|
|
410
|
+
if (result.ok) {
|
|
411
|
+
printResult(result, pretty);
|
|
412
|
+
} else {
|
|
413
|
+
printError(result, pretty);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
case "touch": {
|
|
420
|
+
const agent = args[1];
|
|
421
|
+
if (!agent) {
|
|
422
|
+
usageError("Usage: cache-ctrl touch <agent> [subject-keyword]");
|
|
423
|
+
}
|
|
424
|
+
if (agent !== "external" && agent !== "local") {
|
|
425
|
+
usageError(`Invalid agent: "${agent}". Must be external or local`);
|
|
426
|
+
}
|
|
427
|
+
const subject = args[2];
|
|
428
|
+
const result = await touchCommand({ agent, ...(subject !== undefined ? { subject } : {}) });
|
|
429
|
+
if (result.ok) {
|
|
430
|
+
printResult(result, pretty);
|
|
431
|
+
} else {
|
|
432
|
+
printError(result, pretty);
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
case "prune": {
|
|
439
|
+
const agentArg = typeof flags.agent === "string" ? flags.agent : undefined;
|
|
440
|
+
if (agentArg && agentArg !== "external" && agentArg !== "local" && agentArg !== "all") {
|
|
441
|
+
usageError(`Invalid --agent value: "${agentArg}". Must be external, local, or all`);
|
|
442
|
+
}
|
|
443
|
+
const maxAge = typeof flags["max-age"] === "string" ? flags["max-age"] : undefined;
|
|
444
|
+
const doDelete = flags.delete === true;
|
|
445
|
+
const result = await pruneCommand({
|
|
446
|
+
...(agentArg !== undefined ? { agent: agentArg as "external" | "local" | "all" } : {}),
|
|
447
|
+
...(maxAge !== undefined ? { maxAge } : {}),
|
|
448
|
+
delete: doDelete,
|
|
449
|
+
});
|
|
450
|
+
if (result.ok) {
|
|
451
|
+
printResult(result, pretty);
|
|
452
|
+
} else {
|
|
453
|
+
printError(result, pretty);
|
|
454
|
+
process.exit(1);
|
|
455
|
+
}
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
|
|
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 } : {}) });
|
|
466
|
+
if (result.ok) {
|
|
467
|
+
printResult(result, pretty);
|
|
468
|
+
} else {
|
|
469
|
+
printError(result, pretty);
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
case "check-files": {
|
|
476
|
+
const result = await checkFilesCommand();
|
|
477
|
+
if (result.ok) {
|
|
478
|
+
printResult(result, pretty);
|
|
479
|
+
} else {
|
|
480
|
+
printError(result, pretty);
|
|
481
|
+
process.exit(1);
|
|
482
|
+
}
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
case "search": {
|
|
487
|
+
const keywords = args.slice(1);
|
|
488
|
+
if (keywords.length === 0) {
|
|
489
|
+
usageError("Usage: cache-ctrl search <keyword> [<keyword>...]");
|
|
490
|
+
}
|
|
491
|
+
const result = await searchCommand({ keywords });
|
|
492
|
+
if (result.ok) {
|
|
493
|
+
printResult(result, pretty);
|
|
494
|
+
} else {
|
|
495
|
+
printError(result, pretty);
|
|
496
|
+
process.exit(1);
|
|
497
|
+
}
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
|
|
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`);
|
|
508
|
+
}
|
|
509
|
+
const dataStr = typeof flags.data === "string" ? flags.data : undefined;
|
|
510
|
+
if (!dataStr) {
|
|
511
|
+
usageError("Usage: cache-ctrl write <agent> [subject] --data '<json>'");
|
|
512
|
+
}
|
|
513
|
+
let content: Record<string, unknown>;
|
|
514
|
+
try {
|
|
515
|
+
content = JSON.parse(dataStr) as Record<string, unknown>;
|
|
516
|
+
} catch {
|
|
517
|
+
usageError("--data must be valid JSON");
|
|
518
|
+
}
|
|
519
|
+
const subject = agent === "external" ? args[2] : undefined;
|
|
520
|
+
const result = await writeCommand({
|
|
521
|
+
agent,
|
|
522
|
+
...(subject !== undefined ? { subject } : {}),
|
|
523
|
+
content,
|
|
524
|
+
});
|
|
525
|
+
if (result.ok) {
|
|
526
|
+
printResult(result, pretty);
|
|
527
|
+
} else {
|
|
528
|
+
printError(result, pretty);
|
|
529
|
+
process.exit(1);
|
|
530
|
+
}
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
case "install": {
|
|
535
|
+
const configDir = typeof flags["config-dir"] === "string" ? flags["config-dir"] : undefined;
|
|
536
|
+
const result = await installCommand({ ...(configDir !== undefined ? { configDir } : {}) });
|
|
537
|
+
if (result.ok) {
|
|
538
|
+
printResult(result, pretty);
|
|
539
|
+
} else {
|
|
540
|
+
printError(result, pretty);
|
|
541
|
+
process.exit(1);
|
|
542
|
+
}
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
default:
|
|
547
|
+
usageError(`Unknown command: "${command}". Commands: list, inspect, flush, invalidate, touch, prune, check-freshness, check-files, search, write, install`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (import.meta.main) {
|
|
552
|
+
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");
|
|
555
|
+
process.exit(1);
|
|
556
|
+
});
|
|
557
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CacheEntry } from "../types/cache.js";
|
|
2
|
+
import { getFileStem } from "../utils/fileStem.js";
|
|
3
|
+
|
|
4
|
+
export function scoreEntry(entry: CacheEntry, keywords: string[]): number {
|
|
5
|
+
const stem = getFileStem(entry.file).toLowerCase();
|
|
6
|
+
const subject = entry.subject.toLowerCase();
|
|
7
|
+
const description = (entry.description ?? "").toLowerCase();
|
|
8
|
+
|
|
9
|
+
let total = 0;
|
|
10
|
+
for (const keyword of keywords) {
|
|
11
|
+
const kw = keyword.toLowerCase();
|
|
12
|
+
let score = 0;
|
|
13
|
+
|
|
14
|
+
// Exact file stem match
|
|
15
|
+
if (stem === kw) {
|
|
16
|
+
score = Math.max(score, 100);
|
|
17
|
+
} else if (stem.includes(kw)) {
|
|
18
|
+
// Substring file stem match
|
|
19
|
+
score = Math.max(score, 80);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Exact word match on subject/topic
|
|
23
|
+
if (isExactWordMatch(subject, kw)) {
|
|
24
|
+
score = Math.max(score, 70);
|
|
25
|
+
} else if (subject.includes(kw)) {
|
|
26
|
+
// Substring match on subject/topic
|
|
27
|
+
score = Math.max(score, 50);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Keyword match on description
|
|
31
|
+
if (description.includes(kw)) {
|
|
32
|
+
score = Math.max(score, 30);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
total += score;
|
|
36
|
+
}
|
|
37
|
+
return total;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function rankResults(entries: CacheEntry[], keywords: string[]): CacheEntry[] {
|
|
41
|
+
const scored = entries.map((entry) => ({
|
|
42
|
+
entry,
|
|
43
|
+
score: scoreEntry(entry, keywords),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Filter out zero-score entries
|
|
47
|
+
const matched = scored.filter((s) => s.score > 0);
|
|
48
|
+
|
|
49
|
+
// Sort by score descending; preserve order for ties
|
|
50
|
+
matched.sort((a, b) => b.score - a.score);
|
|
51
|
+
|
|
52
|
+
return matched.map((s) => ({ ...s.entry, score: s.score }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isExactWordMatch(text: string, keyword: string): boolean {
|
|
56
|
+
// Match whole words — split on non-alphanumeric chars
|
|
57
|
+
const words = text.split(/[\s\-_./]+/);
|
|
58
|
+
return words.some((word) => word === keyword);
|
|
59
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export type AgentType = "external" | "local";
|
|
4
|
+
|
|
5
|
+
export interface CacheEntry {
|
|
6
|
+
file: string;
|
|
7
|
+
agent: AgentType;
|
|
8
|
+
subject: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
fetched_at: string;
|
|
11
|
+
score?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SourceSchema = z.object({
|
|
15
|
+
type: z.string(),
|
|
16
|
+
url: z.string(),
|
|
17
|
+
version: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
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
|
+
|
|
27
|
+
export const ExternalCacheFileSchema = z.looseObject({
|
|
28
|
+
subject: z.string(),
|
|
29
|
+
description: z.string(),
|
|
30
|
+
fetched_at: z.string(),
|
|
31
|
+
sources: z.array(SourceSchema),
|
|
32
|
+
header_metadata: z.record(z.string(), HeaderMetaSchema),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const TrackedFileSchema = z.object({
|
|
36
|
+
path: z.string(),
|
|
37
|
+
mtime: z.number(),
|
|
38
|
+
hash: z.string().optional(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Zod schema for the local context-gatherer cache file (`context.json`).
|
|
43
|
+
*
|
|
44
|
+
* Uses `z.looseObject()` so that unknown fields written by agents are preserved
|
|
45
|
+
* unchanged through atomic read-modify-write merges.
|
|
46
|
+
*
|
|
47
|
+
* Size constraints enforced at write time:
|
|
48
|
+
* - `global_facts`: max 20 entries; each string ≤ 300 characters.
|
|
49
|
+
* 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.
|
|
52
|
+
*/
|
|
53
|
+
export const LocalCacheFileSchema = z.looseObject({
|
|
54
|
+
timestamp: z.string(),
|
|
55
|
+
topic: z.string(),
|
|
56
|
+
description: z.string(),
|
|
57
|
+
cache_miss_reason: z.string().optional(),
|
|
58
|
+
tracked_files: z.array(TrackedFileSchema),
|
|
59
|
+
global_facts: z
|
|
60
|
+
.array(
|
|
61
|
+
z.string().max(300, {
|
|
62
|
+
message:
|
|
63
|
+
"global facts must be concise cross-cutting observations (max 300 chars)",
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
.max(20, {
|
|
67
|
+
message:
|
|
68
|
+
"max 20 global facts — choose only cross-cutting structural observations",
|
|
69
|
+
})
|
|
70
|
+
.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(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export type TrackedFile = z.infer<typeof TrackedFileSchema>;
|
|
90
|
+
export type ExternalCacheFile = z.infer<typeof ExternalCacheFileSchema>;
|
|
91
|
+
export type LocalCacheFile = z.infer<typeof LocalCacheFileSchema>;
|