@thecat69/cache-ctrl 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +289 -78
- package/cache_ctrl.ts +107 -25
- package/package.json +2 -1
- package/skills/cache-ctrl-caller/SKILL.md +53 -114
- package/skills/cache-ctrl-external/SKILL.md +29 -89
- package/skills/cache-ctrl-local/SKILL.md +82 -164
- package/src/analysis/graphBuilder.ts +85 -0
- package/src/analysis/pageRank.ts +164 -0
- package/src/analysis/symbolExtractor.ts +240 -0
- package/src/cache/cacheManager.ts +53 -4
- package/src/cache/externalCache.ts +72 -77
- package/src/cache/graphCache.ts +12 -0
- package/src/cache/localCache.ts +2 -0
- package/src/commands/checkFiles.ts +9 -6
- 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 +24 -24
- 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 +15 -25
- package/src/commands/uninstall.ts +103 -0
- package/src/commands/update.ts +65 -0
- package/src/commands/version.ts +14 -0
- package/src/commands/watch.ts +270 -0
- package/src/commands/writeExternal.ts +51 -0
- package/src/commands/writeLocal.ts +121 -0
- package/src/files/changeDetector.ts +15 -0
- package/src/files/gitFiles.ts +15 -0
- package/src/files/openCodeInstaller.ts +21 -2
- package/src/index.ts +314 -58
- package/src/search/keywordSearch.ts +24 -0
- package/src/types/cache.ts +38 -26
- package/src/types/commands.ts +123 -22
- package/src/types/result.ts +26 -9
- package/src/utils/errors.ts +14 -0
- package/src/utils/traversal.ts +42 -0
- package/src/commands/checkFreshness.ts +0 -123
- package/src/commands/write.ts +0 -170
- package/src/http/freshnessChecker.ts +0 -116
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile, rename, stat, unlink, readdir, mkdir } from "node:fs/promises";
|
|
2
|
-
import { open } from "node:fs/promises";
|
|
1
|
+
import { readFile, writeFile, rename, stat, unlink, readdir, mkdir, open } from "node:fs/promises";
|
|
3
2
|
import { join, dirname } from "node:path";
|
|
4
3
|
import { randomBytes } from "node:crypto";
|
|
5
4
|
import type { AgentType, CacheEntry, ExternalCacheFile, LocalCacheFile } from "../types/cache.js";
|
|
@@ -11,6 +10,14 @@ const LOCK_RETRY_INTERVAL_MS = 50;
|
|
|
11
10
|
const LOCK_TIMEOUT_MS = 5000;
|
|
12
11
|
const LOCK_STALE_AGE_MS = 30_000;
|
|
13
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Resolves the repository root by walking upward until a `.git` directory is found.
|
|
15
|
+
*
|
|
16
|
+
* @param startDir - Directory where upward detection starts.
|
|
17
|
+
* @returns Absolute path to detected repo root, or `startDir` when no `.git` is found.
|
|
18
|
+
* @remarks Uses a parent-directory walk; on filesystem root fallback it intentionally
|
|
19
|
+
* returns `startDir` so CLI behavior remains deterministic outside git repositories.
|
|
20
|
+
*/
|
|
14
21
|
export async function findRepoRoot(startDir: string): Promise<string> {
|
|
15
22
|
let current = startDir;
|
|
16
23
|
while (true) {
|
|
@@ -28,6 +35,7 @@ export async function findRepoRoot(startDir: string): Promise<string> {
|
|
|
28
35
|
}
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
/** Resolves the on-disk cache directory for a given agent namespace. */
|
|
31
39
|
export function resolveCacheDir(agent: AgentType, repoRoot: string): string {
|
|
32
40
|
if (agent === "external") {
|
|
33
41
|
return join(repoRoot, ".ai", "external-context-gatherer_cache");
|
|
@@ -35,6 +43,14 @@ export function resolveCacheDir(agent: AgentType, repoRoot: string): string {
|
|
|
35
43
|
return join(repoRoot, ".ai", "local-context-gatherer_cache");
|
|
36
44
|
}
|
|
37
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Reads and JSON-parses one cache file.
|
|
48
|
+
*
|
|
49
|
+
* @param filePath - Absolute cache file path.
|
|
50
|
+
* @returns Parsed object on success, or typed read/parse failures.
|
|
51
|
+
* @remarks Returns `FILE_NOT_FOUND` when the file is absent, and `PARSE_ERROR` when the
|
|
52
|
+
* file exists but contains invalid JSON. Low-level I/O failures return `FILE_READ_ERROR`.
|
|
53
|
+
*/
|
|
38
54
|
export async function readCache(filePath: string): Promise<Result<Record<string, unknown>>> {
|
|
39
55
|
try {
|
|
40
56
|
const content = await readFile(filePath, "utf-8");
|
|
@@ -53,6 +69,17 @@ export async function readCache(filePath: string): Promise<Result<Record<string,
|
|
|
53
69
|
}
|
|
54
70
|
}
|
|
55
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Writes cache content using advisory locking and atomic rename.
|
|
74
|
+
*
|
|
75
|
+
* @param filePath - Absolute cache file path to write.
|
|
76
|
+
* @param updates - Partial updates (`merge`) or full replacement payload (`replace`).
|
|
77
|
+
* @param mode - `merge` overlays updates onto existing JSON; `replace` writes payload as-is.
|
|
78
|
+
* @remarks In `merge` mode the function performs read-modify-write preserving unknown fields.
|
|
79
|
+
* Writes use temp-file + `rename()` for atomic visibility. A per-file advisory lock is
|
|
80
|
+
* acquired before mutation and released in `finally`, preventing concurrent writers from
|
|
81
|
+
* interleaving updates.
|
|
82
|
+
*/
|
|
56
83
|
export async function writeCache(
|
|
57
84
|
filePath: string,
|
|
58
85
|
updates: Partial<ExternalCacheFile> | Partial<LocalCacheFile> | Record<string, unknown>,
|
|
@@ -101,6 +128,13 @@ export async function writeCache(
|
|
|
101
128
|
}
|
|
102
129
|
}
|
|
103
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Lists JSON cache files for an agent namespace.
|
|
133
|
+
*
|
|
134
|
+
* @param agent - Cache namespace to inspect.
|
|
135
|
+
* @param repoRoot - Repository root used to resolve cache directory paths.
|
|
136
|
+
* @returns Absolute `.json` file paths; returns an empty array when directory is absent.
|
|
137
|
+
*/
|
|
104
138
|
export async function listCacheFiles(agent: AgentType, repoRoot: string): Promise<Result<string[]>> {
|
|
105
139
|
const cacheDir = resolveCacheDir(agent, repoRoot);
|
|
106
140
|
try {
|
|
@@ -157,6 +191,15 @@ export async function loadExternalCacheEntries(repoRoot: string): Promise<Result
|
|
|
157
191
|
return { ok: true, value: entries };
|
|
158
192
|
}
|
|
159
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Acquires an advisory lock file for a cache path.
|
|
196
|
+
*
|
|
197
|
+
* @param filePath - Cache file path whose lock (`.lock`) should be acquired.
|
|
198
|
+
* @returns `ok: true` when lock is acquired, otherwise typed lock failure.
|
|
199
|
+
* @remarks Uses atomic `O_EXCL` create semantics to guarantee single-writer lock acquisition.
|
|
200
|
+
* Existing locks are checked for staleness via lock age and PID liveness (`process.kill(pid, 0)`).
|
|
201
|
+
* Retries every 50ms and fails with `LOCK_TIMEOUT` after 5 seconds.
|
|
202
|
+
*/
|
|
160
203
|
export async function acquireLock(filePath: string): Promise<Result<void>> {
|
|
161
204
|
const lockPath = `${filePath}.lock`;
|
|
162
205
|
const start = Date.now();
|
|
@@ -197,6 +240,12 @@ export async function acquireLock(filePath: string): Promise<Result<void>> {
|
|
|
197
240
|
}
|
|
198
241
|
}
|
|
199
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Releases a previously acquired advisory lock file.
|
|
245
|
+
*
|
|
246
|
+
* @param filePath - Cache file path whose `.lock` file should be removed.
|
|
247
|
+
* @remarks `ENOENT` is intentionally ignored to keep release fire-and-forget safe.
|
|
248
|
+
*/
|
|
200
249
|
export async function releaseLock(filePath: string): Promise<void> {
|
|
201
250
|
const lockPath = `${filePath}.lock`;
|
|
202
251
|
try {
|
|
@@ -204,7 +253,7 @@ export async function releaseLock(filePath: string): Promise<void> {
|
|
|
204
253
|
} catch (err) {
|
|
205
254
|
const error = err as NodeJS.ErrnoException;
|
|
206
255
|
if (error.code !== "ENOENT") {
|
|
207
|
-
|
|
256
|
+
process.stderr.write(`[cache-ctrl] Warning: failed to release lock ${lockPath}: ${error.message}\n`);
|
|
208
257
|
}
|
|
209
258
|
}
|
|
210
259
|
}
|
|
@@ -220,7 +269,7 @@ async function isLockStale(lockPath: string): Promise<boolean> {
|
|
|
220
269
|
const content = await readFile(lockPath, "utf-8");
|
|
221
270
|
const pidStr = content.trim();
|
|
222
271
|
const pid = parseInt(pidStr, 10);
|
|
223
|
-
if (isNaN(pid) || pid <= 0 || pid >= 4_194_304) {
|
|
272
|
+
if (Number.isNaN(pid) || pid <= 0 || pid >= 4_194_304) {
|
|
224
273
|
return true;
|
|
225
274
|
}
|
|
226
275
|
|
|
@@ -1,64 +1,43 @@
|
|
|
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 } from "../types/cache.js";
|
|
5
2
|
import { ErrorCode, type Result } from "../types/result.js";
|
|
6
|
-
import {
|
|
3
|
+
import { listCacheFiles, loadExternalCacheEntries, writeCache } from "./cacheManager.js";
|
|
7
4
|
import { scoreEntry } from "../search/keywordSearch.js";
|
|
8
|
-
import {
|
|
5
|
+
import { validateSubject } from "../utils/validate.js";
|
|
9
6
|
|
|
10
7
|
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
11
8
|
|
|
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;
|
|
9
|
+
/**
|
|
10
|
+
* Checks whether an external `fetched_at` timestamp exceeds staleness threshold.
|
|
11
|
+
*
|
|
12
|
+
* @param fetchedAt - ISO timestamp string stored in cache entry.
|
|
13
|
+
* @param maxAgeMs - Optional max age override in milliseconds.
|
|
14
|
+
* @returns `true` when timestamp is empty or older than the threshold.
|
|
15
|
+
*/
|
|
16
|
+
export function isFetchedAtStale(fetchedAt: string, maxAgeMs?: number): boolean {
|
|
17
|
+
if (!fetchedAt) return true;
|
|
37
18
|
const threshold = maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
38
|
-
const age = Date.now() - new Date(
|
|
19
|
+
const age = Date.now() - new Date(fetchedAt).getTime();
|
|
39
20
|
return age > threshold;
|
|
40
21
|
}
|
|
41
22
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
export function
|
|
50
|
-
|
|
51
|
-
updates: Record<string, HeaderMeta>,
|
|
52
|
-
): ExternalCacheFile {
|
|
53
|
-
return {
|
|
54
|
-
...existing,
|
|
55
|
-
header_metadata: {
|
|
56
|
-
...existing.header_metadata,
|
|
57
|
-
...updates,
|
|
58
|
-
},
|
|
59
|
-
};
|
|
23
|
+
/**
|
|
24
|
+
* Evaluates staleness for a full external cache entry.
|
|
25
|
+
*
|
|
26
|
+
* @param entry - External cache entry.
|
|
27
|
+
* @param maxAgeMs - Optional max age override in milliseconds.
|
|
28
|
+
* @returns `true` when entry should be considered stale.
|
|
29
|
+
*/
|
|
30
|
+
export function isExternalStale(entry: ExternalCacheFile, maxAgeMs?: number): boolean {
|
|
31
|
+
return isFetchedAtStale(entry.fetched_at ?? "", maxAgeMs);
|
|
60
32
|
}
|
|
61
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Formats human-readable age text from an external `fetched_at` timestamp.
|
|
36
|
+
*
|
|
37
|
+
* @param fetchedAt - ISO timestamp string from cache entry.
|
|
38
|
+
* @returns Relative age string such as `"just now"`, `"2 hours ago"`, or `"invalidated"`.
|
|
39
|
+
* @remarks Returns `"invalidated"` sentinel when `fetchedAt` is empty.
|
|
40
|
+
*/
|
|
62
41
|
export function getAgeHuman(fetchedAt: string): string {
|
|
63
42
|
if (!fetchedAt) return "invalidated";
|
|
64
43
|
|
|
@@ -89,39 +68,55 @@ export function getAgeHuman(fetchedAt: string): string {
|
|
|
89
68
|
* Returns NO_MATCH if no entry scores above zero.
|
|
90
69
|
*/
|
|
91
70
|
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
|
-
}
|
|
71
|
+
const entriesResult = await loadExternalCacheEntries(repoRoot);
|
|
72
|
+
if (!entriesResult.ok) return entriesResult;
|
|
115
73
|
|
|
116
74
|
const keywords = [subject];
|
|
117
|
-
const scored =
|
|
118
|
-
.map((
|
|
119
|
-
.filter((
|
|
75
|
+
const scored = entriesResult.value
|
|
76
|
+
.map((entry) => ({ entry, score: scoreEntry(entry, keywords) }))
|
|
77
|
+
.filter((candidate) => candidate.score > 0)
|
|
120
78
|
.sort((a, b) => b.score - a.score);
|
|
121
79
|
|
|
122
80
|
if (scored.length === 0) {
|
|
123
81
|
return { ok: false, error: `No cache entry matched keyword "${subject}"`, code: ErrorCode.NO_MATCH };
|
|
124
82
|
}
|
|
125
83
|
|
|
126
|
-
return { ok: true, value: scored[0]!.
|
|
84
|
+
return { ok: true, value: scored[0]!.entry.file };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Updates `fetched_at` for one external entry (best subject match) or all entries.
|
|
89
|
+
*
|
|
90
|
+
* @param repoRoot - Repository root.
|
|
91
|
+
* @param subject - Optional subject keyword; when provided, only top match is updated.
|
|
92
|
+
* @param fetchedAt - New ISO timestamp value (or empty string to invalidate).
|
|
93
|
+
* @returns Updated file paths.
|
|
94
|
+
*/
|
|
95
|
+
export async function updateExternalFetchedAt(
|
|
96
|
+
repoRoot: string,
|
|
97
|
+
subject: string | undefined,
|
|
98
|
+
fetchedAt: string,
|
|
99
|
+
): Promise<Result<string[]>> {
|
|
100
|
+
let filesToUpdate: string[];
|
|
101
|
+
|
|
102
|
+
if (subject) {
|
|
103
|
+
const subjectCheck = validateSubject(subject);
|
|
104
|
+
if (!subjectCheck.ok) return subjectCheck;
|
|
105
|
+
const matchResult = await resolveTopExternalMatch(repoRoot, subject);
|
|
106
|
+
if (!matchResult.ok) return matchResult;
|
|
107
|
+
filesToUpdate = [matchResult.value];
|
|
108
|
+
} else {
|
|
109
|
+
const filesResult = await listCacheFiles("external", repoRoot);
|
|
110
|
+
if (!filesResult.ok) return filesResult;
|
|
111
|
+
filesToUpdate = filesResult.value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const updated: string[] = [];
|
|
115
|
+
for (const filePath of filesToUpdate) {
|
|
116
|
+
const writeResult = await writeCache(filePath, { fetched_at: fetchedAt });
|
|
117
|
+
if (!writeResult.ok) return writeResult;
|
|
118
|
+
updated.push(filePath);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { ok: true, value: updated };
|
|
127
122
|
}
|
|
@@ -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());
|
|
@@ -26,20 +30,20 @@ export async function checkFilesCommand(): Promise<Result<CheckFilesResult["valu
|
|
|
26
30
|
|
|
27
31
|
const changedFiles: Array<{ path: string; reason: "mtime" | "hash" | "missing" }> = [];
|
|
28
32
|
const unchangedFiles: string[] = [];
|
|
29
|
-
const missingFiles: string[] = [];
|
|
30
33
|
|
|
31
34
|
for (const trackedFile of trackedFiles) {
|
|
32
35
|
const result = await compareTrackedFile(trackedFile, repoRoot);
|
|
33
36
|
if (result.status === "unchanged") {
|
|
34
37
|
unchangedFiles.push(trackedFile.path);
|
|
35
38
|
} else if (result.status === "missing") {
|
|
36
|
-
missingFiles.push(trackedFile.path);
|
|
37
39
|
changedFiles.push({ path: trackedFile.path, reason: "missing" });
|
|
38
40
|
} else {
|
|
39
41
|
changedFiles.push({ path: trackedFile.path, reason: result.reason ?? "mtime" });
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
const missingFiles = changedFiles.filter((file) => file.reason === "missing").map((file) => file.path);
|
|
46
|
+
|
|
43
47
|
const [gitTrackedFiles, deletedGitFiles, untrackedNonIgnoredFiles] = await Promise.all([
|
|
44
48
|
getGitTrackedFiles(repoRoot),
|
|
45
49
|
getGitDeletedFiles(repoRoot),
|
|
@@ -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
|
}
|
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
|
}
|