@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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, extname, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { parse } from "@typescript-eslint/typescript-estree";
|
|
5
|
+
|
|
6
|
+
export interface FileSymbols {
|
|
7
|
+
/** Resolved file paths this file imports from (relative imports only, resolved to absolute) */
|
|
8
|
+
deps: string[];
|
|
9
|
+
/** Exported symbol names declared in this file */
|
|
10
|
+
defs: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface AstNode {
|
|
14
|
+
type: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SUPPORTED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
19
|
+
|
|
20
|
+
function isAstNode(value: unknown): value is AstNode {
|
|
21
|
+
return typeof value === "object" && value !== null && "type" in value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function walkAst(value: unknown, visitor: (node: AstNode) => void): void {
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
for (const item of value) {
|
|
27
|
+
walkAst(item, visitor);
|
|
28
|
+
}
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (typeof value !== "object" || value === null) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (isAstNode(value)) {
|
|
37
|
+
visitor(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const child of Object.values(value)) {
|
|
41
|
+
walkAst(child, visitor);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function collectPatternIdentifiers(pattern: unknown, defs: Set<string>): void {
|
|
46
|
+
if (!isAstNode(pattern)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (pattern.type === "Identifier") {
|
|
51
|
+
const name = pattern.name;
|
|
52
|
+
if (typeof name === "string") {
|
|
53
|
+
defs.add(name);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (pattern.type === "RestElement") {
|
|
59
|
+
collectPatternIdentifiers(pattern.argument, defs);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (pattern.type === "AssignmentPattern") {
|
|
64
|
+
collectPatternIdentifiers(pattern.left, defs);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (pattern.type === "ArrayPattern") {
|
|
69
|
+
const elements = pattern.elements;
|
|
70
|
+
if (Array.isArray(elements)) {
|
|
71
|
+
for (const element of elements) {
|
|
72
|
+
collectPatternIdentifiers(element, defs);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (pattern.type === "ObjectPattern") {
|
|
79
|
+
const properties = pattern.properties;
|
|
80
|
+
if (!Array.isArray(properties)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const property of properties) {
|
|
85
|
+
if (!isAstNode(property)) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (property.type === "Property") {
|
|
90
|
+
collectPatternIdentifiers(property.value, defs);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (property.type === "RestElement") {
|
|
94
|
+
collectPatternIdentifiers(property.argument, defs);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function collectNamesFromDeclaration(declaration: unknown, defs: Set<string>): void {
|
|
101
|
+
if (!isAstNode(declaration)) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") {
|
|
106
|
+
const id = declaration.id;
|
|
107
|
+
if (isAstNode(id) && id.type === "Identifier") {
|
|
108
|
+
const name = id.name;
|
|
109
|
+
if (typeof name === "string") {
|
|
110
|
+
defs.add(name);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (declaration.type === "VariableDeclaration") {
|
|
117
|
+
const declarations = declaration.declarations;
|
|
118
|
+
if (!Array.isArray(declarations)) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const variableDeclarator of declarations) {
|
|
123
|
+
if (!isAstNode(variableDeclarator) || variableDeclarator.type !== "VariableDeclarator") {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
collectPatternIdentifiers(variableDeclarator.id, defs);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const id = declaration.id;
|
|
132
|
+
if (isAstNode(id) && id.type === "Identifier") {
|
|
133
|
+
const name = id.name;
|
|
134
|
+
if (typeof name === "string") {
|
|
135
|
+
defs.add(name);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function collectExportSpecifierName(specifier: unknown): string | null {
|
|
141
|
+
if (!isAstNode(specifier)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const exported = specifier.exported;
|
|
146
|
+
if (!isAstNode(exported)) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (exported.type === "Identifier") {
|
|
151
|
+
return typeof exported.name === "string" ? exported.name : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (exported.type === "Literal") {
|
|
155
|
+
return typeof exported.value === "string" ? exported.value : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Extract import dependencies and export definitions from a source file.
|
|
163
|
+
* Returns an empty FileSymbols if the file cannot be parsed.
|
|
164
|
+
* Never throws.
|
|
165
|
+
*/
|
|
166
|
+
export async function extractSymbols(filePath: string, repoRoot: string): Promise<FileSymbols> {
|
|
167
|
+
try {
|
|
168
|
+
const extension = extname(filePath).toLowerCase();
|
|
169
|
+
if (!SUPPORTED_EXTENSIONS.has(extension)) {
|
|
170
|
+
return { deps: [], defs: [] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const sourceCode = await readFile(filePath, "utf8");
|
|
174
|
+
const ast = parse(sourceCode, {
|
|
175
|
+
jsx: true,
|
|
176
|
+
range: false,
|
|
177
|
+
loc: false,
|
|
178
|
+
sourceType: "module",
|
|
179
|
+
errorOnUnknownASTType: false,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const normalizedRepoRoot = resolve(repoRoot);
|
|
183
|
+
const repoPrefix = `${normalizedRepoRoot}${sep}`;
|
|
184
|
+
const dependencies = new Set<string>();
|
|
185
|
+
const definitions = new Set<string>();
|
|
186
|
+
|
|
187
|
+
walkAst(ast, (node) => {
|
|
188
|
+
if (node.type === "ImportDeclaration") {
|
|
189
|
+
const source = node.source;
|
|
190
|
+
if (!isAstNode(source) || source.type !== "Literal") {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const importValue = source.value;
|
|
195
|
+
if (typeof importValue !== "string") {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!importValue.startsWith(".") && !importValue.startsWith("/")) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const resolvedDependency = resolve(dirname(filePath), importValue);
|
|
204
|
+
if (resolvedDependency === normalizedRepoRoot || resolvedDependency.startsWith(repoPrefix)) {
|
|
205
|
+
dependencies.add(resolvedDependency);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (node.type === "ExportNamedDeclaration") {
|
|
211
|
+
collectNamesFromDeclaration(node.declaration, definitions);
|
|
212
|
+
|
|
213
|
+
const specifiers = node.specifiers;
|
|
214
|
+
if (!Array.isArray(specifiers)) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const specifier of specifiers) {
|
|
219
|
+
const name = collectExportSpecifierName(specifier);
|
|
220
|
+
if (name !== null) {
|
|
221
|
+
definitions.add(name);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (node.type === "ExportDefaultDeclaration") {
|
|
228
|
+
definitions.add("default");
|
|
229
|
+
collectNamesFromDeclaration(node.declaration, definitions);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
deps: [...dependencies],
|
|
235
|
+
defs: [...definitions],
|
|
236
|
+
};
|
|
237
|
+
} catch {
|
|
238
|
+
return { deps: [], defs: [] };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -11,6 +11,14 @@ const LOCK_RETRY_INTERVAL_MS = 50;
|
|
|
11
11
|
const LOCK_TIMEOUT_MS = 5000;
|
|
12
12
|
const LOCK_STALE_AGE_MS = 30_000;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the repository root by walking upward until a `.git` directory is found.
|
|
16
|
+
*
|
|
17
|
+
* @param startDir - Directory where upward detection starts.
|
|
18
|
+
* @returns Absolute path to detected repo root, or `startDir` when no `.git` is found.
|
|
19
|
+
* @remarks Uses a parent-directory walk; on filesystem root fallback it intentionally
|
|
20
|
+
* returns `startDir` so CLI behavior remains deterministic outside git repositories.
|
|
21
|
+
*/
|
|
14
22
|
export async function findRepoRoot(startDir: string): Promise<string> {
|
|
15
23
|
let current = startDir;
|
|
16
24
|
while (true) {
|
|
@@ -28,6 +36,7 @@ export async function findRepoRoot(startDir: string): Promise<string> {
|
|
|
28
36
|
}
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
/** Resolves the on-disk cache directory for a given agent namespace. */
|
|
31
40
|
export function resolveCacheDir(agent: AgentType, repoRoot: string): string {
|
|
32
41
|
if (agent === "external") {
|
|
33
42
|
return join(repoRoot, ".ai", "external-context-gatherer_cache");
|
|
@@ -35,6 +44,14 @@ export function resolveCacheDir(agent: AgentType, repoRoot: string): string {
|
|
|
35
44
|
return join(repoRoot, ".ai", "local-context-gatherer_cache");
|
|
36
45
|
}
|
|
37
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Reads and JSON-parses one cache file.
|
|
49
|
+
*
|
|
50
|
+
* @param filePath - Absolute cache file path.
|
|
51
|
+
* @returns Parsed object on success, or typed read/parse failures.
|
|
52
|
+
* @remarks Returns `FILE_NOT_FOUND` when the file is absent, and `PARSE_ERROR` when the
|
|
53
|
+
* file exists but contains invalid JSON. Low-level I/O failures return `FILE_READ_ERROR`.
|
|
54
|
+
*/
|
|
38
55
|
export async function readCache(filePath: string): Promise<Result<Record<string, unknown>>> {
|
|
39
56
|
try {
|
|
40
57
|
const content = await readFile(filePath, "utf-8");
|
|
@@ -53,6 +70,17 @@ export async function readCache(filePath: string): Promise<Result<Record<string,
|
|
|
53
70
|
}
|
|
54
71
|
}
|
|
55
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Writes cache content using advisory locking and atomic rename.
|
|
75
|
+
*
|
|
76
|
+
* @param filePath - Absolute cache file path to write.
|
|
77
|
+
* @param updates - Partial updates (`merge`) or full replacement payload (`replace`).
|
|
78
|
+
* @param mode - `merge` overlays updates onto existing JSON; `replace` writes payload as-is.
|
|
79
|
+
* @remarks In `merge` mode the function performs read-modify-write preserving unknown fields.
|
|
80
|
+
* Writes use temp-file + `rename()` for atomic visibility. A per-file advisory lock is
|
|
81
|
+
* acquired before mutation and released in `finally`, preventing concurrent writers from
|
|
82
|
+
* interleaving updates.
|
|
83
|
+
*/
|
|
56
84
|
export async function writeCache(
|
|
57
85
|
filePath: string,
|
|
58
86
|
updates: Partial<ExternalCacheFile> | Partial<LocalCacheFile> | Record<string, unknown>,
|
|
@@ -101,6 +129,13 @@ export async function writeCache(
|
|
|
101
129
|
}
|
|
102
130
|
}
|
|
103
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Lists JSON cache files for an agent namespace.
|
|
134
|
+
*
|
|
135
|
+
* @param agent - Cache namespace to inspect.
|
|
136
|
+
* @param repoRoot - Repository root used to resolve cache directory paths.
|
|
137
|
+
* @returns Absolute `.json` file paths; returns an empty array when directory is absent.
|
|
138
|
+
*/
|
|
104
139
|
export async function listCacheFiles(agent: AgentType, repoRoot: string): Promise<Result<string[]>> {
|
|
105
140
|
const cacheDir = resolveCacheDir(agent, repoRoot);
|
|
106
141
|
try {
|
|
@@ -157,6 +192,15 @@ export async function loadExternalCacheEntries(repoRoot: string): Promise<Result
|
|
|
157
192
|
return { ok: true, value: entries };
|
|
158
193
|
}
|
|
159
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Acquires an advisory lock file for a cache path.
|
|
197
|
+
*
|
|
198
|
+
* @param filePath - Cache file path whose lock (`.lock`) should be acquired.
|
|
199
|
+
* @returns `ok: true` when lock is acquired, otherwise typed lock failure.
|
|
200
|
+
* @remarks Uses atomic `O_EXCL` create semantics to guarantee single-writer lock acquisition.
|
|
201
|
+
* Existing locks are checked for staleness via lock age and PID liveness (`process.kill(pid, 0)`).
|
|
202
|
+
* Retries every 50ms and fails with `LOCK_TIMEOUT` after 5 seconds.
|
|
203
|
+
*/
|
|
160
204
|
export async function acquireLock(filePath: string): Promise<Result<void>> {
|
|
161
205
|
const lockPath = `${filePath}.lock`;
|
|
162
206
|
const start = Date.now();
|
|
@@ -197,6 +241,12 @@ export async function acquireLock(filePath: string): Promise<Result<void>> {
|
|
|
197
241
|
}
|
|
198
242
|
}
|
|
199
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Releases a previously acquired advisory lock file.
|
|
246
|
+
*
|
|
247
|
+
* @param filePath - Cache file path whose `.lock` file should be removed.
|
|
248
|
+
* @remarks `ENOENT` is intentionally ignored to keep release fire-and-forget safe.
|
|
249
|
+
*/
|
|
200
250
|
export async function releaseLock(filePath: string): Promise<void> {
|
|
201
251
|
const lockPath = `${filePath}.lock`;
|
|
202
252
|
try {
|
|
@@ -204,7 +254,7 @@ export async function releaseLock(filePath: string): Promise<void> {
|
|
|
204
254
|
} catch (err) {
|
|
205
255
|
const error = err as NodeJS.ErrnoException;
|
|
206
256
|
if (error.code !== "ENOENT") {
|
|
207
|
-
|
|
257
|
+
process.stderr.write(`[cache-ctrl] Warning: failed to release lock ${lockPath}: ${error.message}\n`);
|
|
208
258
|
}
|
|
209
259
|
}
|
|
210
260
|
}
|
|
@@ -220,7 +270,7 @@ async function isLockStale(lockPath: string): Promise<boolean> {
|
|
|
220
270
|
const content = await readFile(lockPath, "utf-8");
|
|
221
271
|
const pidStr = content.trim();
|
|
222
272
|
const pid = parseInt(pidStr, 10);
|
|
223
|
-
if (isNaN(pid) || pid <= 0 || pid >= 4_194_304) {
|
|
273
|
+
if (Number.isNaN(pid) || pid <= 0 || pid >= 4_194_304) {
|
|
224
274
|
return true;
|
|
225
275
|
}
|
|
226
276
|
|
|
@@ -1,51 +1,42 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import type { ExternalCacheFile, CacheEntry } from "../types/cache.js";
|
|
4
|
-
import { ExternalCacheFileSchema } from "../types/cache.js";
|
|
1
|
+
import type { ExternalCacheFile, HeaderMeta } from "../types/cache.js";
|
|
5
2
|
import { ErrorCode, type Result } from "../types/result.js";
|
|
6
|
-
import {
|
|
3
|
+
import { loadExternalCacheEntries } from "./cacheManager.js";
|
|
7
4
|
import { scoreEntry } from "../search/keywordSearch.js";
|
|
8
|
-
import { getFileStem } from "../utils/fileStem.js";
|
|
9
5
|
|
|
10
6
|
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
11
7
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
ok: true,
|
|
22
|
-
value: entries
|
|
23
|
-
.filter((name) => name.endsWith(".json") && !name.endsWith(".lock"))
|
|
24
|
-
.map((name) => join(cacheDir, name)),
|
|
25
|
-
};
|
|
26
|
-
} catch (err) {
|
|
27
|
-
const error = err as NodeJS.ErrnoException;
|
|
28
|
-
if (error.code === "ENOENT") {
|
|
29
|
-
return { ok: true, value: [] };
|
|
30
|
-
}
|
|
31
|
-
return { ok: false, error: `Failed to list external cache directory: ${error.message}`, code: ErrorCode.FILE_READ_ERROR };
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function isExternalStale(entry: ExternalCacheFile, maxAgeMs?: number): boolean {
|
|
36
|
-
if (!entry.fetched_at) return true;
|
|
8
|
+
/**
|
|
9
|
+
* Checks whether an external `fetched_at` timestamp exceeds staleness threshold.
|
|
10
|
+
*
|
|
11
|
+
* @param fetchedAt - ISO timestamp string stored in cache entry.
|
|
12
|
+
* @param maxAgeMs - Optional max age override in milliseconds.
|
|
13
|
+
* @returns `true` when timestamp is empty or older than the threshold.
|
|
14
|
+
*/
|
|
15
|
+
export function isFetchedAtStale(fetchedAt: string, maxAgeMs?: number): boolean {
|
|
16
|
+
if (!fetchedAt) return true;
|
|
37
17
|
const threshold = maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
38
|
-
const age = Date.now() - new Date(
|
|
18
|
+
const age = Date.now() - new Date(fetchedAt).getTime();
|
|
39
19
|
return age > threshold;
|
|
40
20
|
}
|
|
41
21
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Evaluates staleness for a full external cache entry.
|
|
24
|
+
*
|
|
25
|
+
* @param entry - External cache entry.
|
|
26
|
+
* @param maxAgeMs - Optional max age override in milliseconds.
|
|
27
|
+
* @returns `true` when entry should be considered stale.
|
|
28
|
+
*/
|
|
29
|
+
export function isExternalStale(entry: ExternalCacheFile, maxAgeMs?: number): boolean {
|
|
30
|
+
return isFetchedAtStale(entry.fetched_at ?? "", maxAgeMs);
|
|
47
31
|
}
|
|
48
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Merges newly fetched header metadata into an external cache entry.
|
|
35
|
+
*
|
|
36
|
+
* @param existing - Existing external cache entry.
|
|
37
|
+
* @param updates - Per-URL header metadata updates.
|
|
38
|
+
* @returns New entry with merged `header_metadata`.
|
|
39
|
+
*/
|
|
49
40
|
export function mergeHeaderMetadata(
|
|
50
41
|
existing: ExternalCacheFile,
|
|
51
42
|
updates: Record<string, HeaderMeta>,
|
|
@@ -59,6 +50,13 @@ export function mergeHeaderMetadata(
|
|
|
59
50
|
};
|
|
60
51
|
}
|
|
61
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Formats human-readable age text from an external `fetched_at` timestamp.
|
|
55
|
+
*
|
|
56
|
+
* @param fetchedAt - ISO timestamp string from cache entry.
|
|
57
|
+
* @returns Relative age string such as `"just now"`, `"2 hours ago"`, or `"invalidated"`.
|
|
58
|
+
* @remarks Returns `"invalidated"` sentinel when `fetchedAt` is empty.
|
|
59
|
+
*/
|
|
62
60
|
export function getAgeHuman(fetchedAt: string): string {
|
|
63
61
|
if (!fetchedAt) return "invalidated";
|
|
64
62
|
|
|
@@ -89,39 +87,18 @@ export function getAgeHuman(fetchedAt: string): string {
|
|
|
89
87
|
* Returns NO_MATCH if no entry scores above zero.
|
|
90
88
|
*/
|
|
91
89
|
export async function resolveTopExternalMatch(repoRoot: string, subject: string): Promise<Result<string>> {
|
|
92
|
-
const
|
|
93
|
-
if (!
|
|
94
|
-
|
|
95
|
-
const candidates: Array<{ filePath: string; entry: CacheEntry }> = [];
|
|
96
|
-
for (const filePath of filesResult.value) {
|
|
97
|
-
const readResult = await readCache(filePath);
|
|
98
|
-
if (!readResult.ok) continue;
|
|
99
|
-
const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
|
|
100
|
-
if (!parseResult.success) continue;
|
|
101
|
-
const data = parseResult.data;
|
|
102
|
-
const stem = getFileStem(filePath);
|
|
103
|
-
const entrySubject = data.subject ?? stem;
|
|
104
|
-
candidates.push({
|
|
105
|
-
filePath,
|
|
106
|
-
entry: {
|
|
107
|
-
file: filePath,
|
|
108
|
-
agent: "external",
|
|
109
|
-
subject: entrySubject,
|
|
110
|
-
description: data.description,
|
|
111
|
-
fetched_at: data.fetched_at ?? "",
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
}
|
|
90
|
+
const entriesResult = await loadExternalCacheEntries(repoRoot);
|
|
91
|
+
if (!entriesResult.ok) return entriesResult;
|
|
115
92
|
|
|
116
93
|
const keywords = [subject];
|
|
117
|
-
const scored =
|
|
118
|
-
.map((
|
|
119
|
-
.filter((
|
|
94
|
+
const scored = entriesResult.value
|
|
95
|
+
.map((entry) => ({ entry, score: scoreEntry(entry, keywords) }))
|
|
96
|
+
.filter((candidate) => candidate.score > 0)
|
|
120
97
|
.sort((a, b) => b.score - a.score);
|
|
121
98
|
|
|
122
99
|
if (scored.length === 0) {
|
|
123
100
|
return { ok: false, error: `No cache entry matched keyword "${subject}"`, code: ErrorCode.NO_MATCH };
|
|
124
101
|
}
|
|
125
102
|
|
|
126
|
-
return { ok: true, value: scored[0]!.
|
|
103
|
+
return { ok: true, value: scored[0]!.entry.file };
|
|
127
104
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { resolveLocalCacheDir } from "./localCache.js";
|
|
4
|
+
|
|
5
|
+
export type { GraphCacheFile } from "../types/cache.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the absolute path to graph.json.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveGraphCachePath(repoRoot: string): string {
|
|
11
|
+
return join(resolveLocalCacheDir(repoRoot), "graph.json");
|
|
12
|
+
}
|
package/src/cache/localCache.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
|
|
3
|
+
/** Resolves the local cache directory path under the repository root. */
|
|
3
4
|
export function resolveLocalCacheDir(repoRoot: string): string {
|
|
4
5
|
return join(repoRoot, ".ai", "local-context-gatherer_cache");
|
|
5
6
|
}
|
|
6
7
|
|
|
8
|
+
/** Resolves the local context cache file path (`context.json`). */
|
|
7
9
|
export function resolveLocalCachePath(repoRoot: string): string {
|
|
8
10
|
return join(resolveLocalCacheDir(repoRoot), "context.json");
|
|
9
11
|
}
|
|
@@ -6,9 +6,13 @@ import { getGitTrackedFiles, getGitDeletedFiles, getUntrackedNonIgnoredFiles } f
|
|
|
6
6
|
import { LocalCacheFileSchema } from "../types/cache.js";
|
|
7
7
|
import { ErrorCode, type Result } from "../types/result.js";
|
|
8
8
|
import type { CheckFilesResult } from "../types/commands.js";
|
|
9
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Compares local tracked files against stored baselines and git file-set deltas.
|
|
13
|
+
* @returns Promise<Result<CheckFilesResult["value"]>>; common failures include FILE_NOT_FOUND,
|
|
14
|
+
* PARSE_ERROR, FILE_READ_ERROR, and UNKNOWN.
|
|
15
|
+
*/
|
|
12
16
|
export async function checkFilesCommand(): Promise<Result<CheckFilesResult["value"]>> {
|
|
13
17
|
try {
|
|
14
18
|
const repoRoot = await findRepoRoot(process.cwd());
|
|
@@ -77,7 +81,6 @@ export async function checkFilesCommand(): Promise<Result<CheckFilesResult["valu
|
|
|
77
81
|
},
|
|
78
82
|
};
|
|
79
83
|
} catch (err) {
|
|
80
|
-
|
|
81
|
-
return { ok: false, error: msg, code: ErrorCode.UNKNOWN };
|
|
84
|
+
return toUnknownResult(err);
|
|
82
85
|
}
|
|
83
86
|
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { findRepoRoot, readCache, writeCache } from "../cache/cacheManager.js";
|
|
2
2
|
import { isExternalStale, mergeHeaderMetadata, resolveTopExternalMatch } from "../cache/externalCache.js";
|
|
3
3
|
import { checkFreshness } from "../http/freshnessChecker.js";
|
|
4
|
-
import type { ExternalCacheFile } from "../types/cache.js";
|
|
4
|
+
import type { ExternalCacheFile, HeaderMeta } from "../types/cache.js";
|
|
5
5
|
import { ExternalCacheFileSchema } from "../types/cache.js";
|
|
6
6
|
import { ErrorCode, type Result } from "../types/result.js";
|
|
7
7
|
import type { CheckFreshnessArgs, CheckFreshnessResult } from "../types/commands.js";
|
|
8
|
-
import type { HeaderMeta } from "../cache/externalCache.js";
|
|
9
8
|
import { getFileStem } from "../utils/fileStem.js";
|
|
10
|
-
|
|
9
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Performs HTTP freshness checks for the matched external cache entry sources.
|
|
13
|
+
*
|
|
14
|
+
* @param args - {@link CheckFreshnessArgs} command arguments.
|
|
15
|
+
* @returns Promise<Result<CheckFreshnessResult["value"]>>; common failures include
|
|
16
|
+
* NO_MATCH, URL_NOT_FOUND, PARSE_ERROR, FILE_READ_ERROR/FILE_WRITE_ERROR, and UNKNOWN.
|
|
17
|
+
*/
|
|
11
18
|
export async function checkFreshnessCommand(args: CheckFreshnessArgs): Promise<Result<CheckFreshnessResult["value"]>> {
|
|
12
19
|
try {
|
|
13
20
|
const repoRoot = await findRepoRoot(process.cwd());
|
|
@@ -33,21 +40,15 @@ export async function checkFreshnessCommand(args: CheckFreshnessArgs): Promise<R
|
|
|
33
40
|
|
|
34
41
|
// Determine which URLs to check
|
|
35
42
|
const sources = cacheEntry.sources ?? [];
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
error: `URL not found in sources for subject '${subject}'`,
|
|
44
|
-
code: ErrorCode.URL_NOT_FOUND,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
urlsToCheck = [found];
|
|
48
|
-
} else {
|
|
49
|
-
urlsToCheck = sources;
|
|
43
|
+
const specificSource = args.url ? sources.find((source) => source.url === args.url) : undefined;
|
|
44
|
+
if (args.url && !specificSource) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
error: `URL not found in sources for subject '${subject}'`,
|
|
48
|
+
code: ErrorCode.URL_NOT_FOUND,
|
|
49
|
+
};
|
|
50
50
|
}
|
|
51
|
+
const urlsToCheck = specificSource ? [specificSource] : sources;
|
|
51
52
|
|
|
52
53
|
// Check freshness for each URL
|
|
53
54
|
const sourceResults: Array<{
|
|
@@ -117,7 +118,6 @@ export async function checkFreshnessCommand(args: CheckFreshnessArgs): Promise<R
|
|
|
117
118
|
},
|
|
118
119
|
};
|
|
119
120
|
} catch (err) {
|
|
120
|
-
|
|
121
|
-
return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
|
|
121
|
+
return toUnknownResult(err);
|
|
122
122
|
}
|
|
123
123
|
}
|
package/src/commands/flush.ts
CHANGED
|
@@ -3,7 +3,15 @@ import { findRepoRoot, listCacheFiles } from "../cache/cacheManager.js";
|
|
|
3
3
|
import { resolveLocalCachePath } from "../cache/localCache.js";
|
|
4
4
|
import { ErrorCode, type Result } from "../types/result.js";
|
|
5
5
|
import type { FlushArgs, FlushResult } from "../types/commands.js";
|
|
6
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Deletes cache files for one or both agent namespaces.
|
|
10
|
+
*
|
|
11
|
+
* @param args - {@link FlushArgs} command arguments.
|
|
12
|
+
* @returns Promise<Result<FlushResult["value"]>>; common failures include
|
|
13
|
+
* CONFIRMATION_REQUIRED, FILE_WRITE_ERROR, FILE_READ_ERROR, and UNKNOWN.
|
|
14
|
+
*/
|
|
7
15
|
export async function flushCommand(args: FlushArgs): Promise<Result<FlushResult["value"]>> {
|
|
8
16
|
if (!args.confirm) {
|
|
9
17
|
return {
|
|
@@ -49,7 +57,6 @@ export async function flushCommand(args: FlushArgs): Promise<Result<FlushResult[
|
|
|
49
57
|
|
|
50
58
|
return { ok: true, value: { deleted, count: deleted.length } };
|
|
51
59
|
} catch (err) {
|
|
52
|
-
|
|
53
|
-
return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
|
|
60
|
+
return toUnknownResult(err);
|
|
54
61
|
}
|
|
55
62
|
}
|