@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,131 @@
|
|
|
1
|
+
import { computePageRank } from "../analysis/pageRank.js";
|
|
2
|
+
import { findRepoRoot, readCache } from "../cache/cacheManager.js";
|
|
3
|
+
import { resolveGraphCachePath } from "../cache/graphCache.js";
|
|
4
|
+
import { GraphCacheFileSchema } from "../types/cache.js";
|
|
5
|
+
import { ErrorCode, type Result } from "../types/result.js";
|
|
6
|
+
import type { DependencyGraph } from "../analysis/graphBuilder.js";
|
|
7
|
+
import type { GraphArgs, GraphResult } from "../types/commands.js";
|
|
8
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
9
|
+
|
|
10
|
+
interface RankedFileEntry {
|
|
11
|
+
path: string;
|
|
12
|
+
rank: number;
|
|
13
|
+
deps: string[];
|
|
14
|
+
defs: string[];
|
|
15
|
+
ref_count: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function estimateEntryTokens(entry: RankedFileEntry): number {
|
|
19
|
+
return Math.ceil(JSON.stringify(entry).length / 4);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function countReferences(graph: DependencyGraph): Map<string, number> {
|
|
23
|
+
const refCounts = new Map<string, number>();
|
|
24
|
+
|
|
25
|
+
for (const nodePath of graph.keys()) {
|
|
26
|
+
refCounts.set(nodePath, 0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const graphNode of graph.values()) {
|
|
30
|
+
const uniqueDeps = new Set(graphNode.deps);
|
|
31
|
+
for (const depPath of uniqueDeps) {
|
|
32
|
+
if (!refCounts.has(depPath)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const currentCount = refCounts.get(depPath) ?? 0;
|
|
36
|
+
refCounts.set(depPath, currentCount + 1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return refCounts;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns a token-budgeted, PageRank-ranked projection of the dependency graph cache.
|
|
45
|
+
*
|
|
46
|
+
* @param args - {@link GraphArgs} command arguments.
|
|
47
|
+
* @returns Promise<Result<GraphResult["value"]>>; common failures include FILE_NOT_FOUND,
|
|
48
|
+
* PARSE_ERROR, FILE_READ_ERROR, and UNKNOWN.
|
|
49
|
+
*/
|
|
50
|
+
export async function graphCommand(args: GraphArgs): Promise<Result<GraphResult["value"]>> {
|
|
51
|
+
try {
|
|
52
|
+
const repoRoot = await findRepoRoot(process.cwd());
|
|
53
|
+
const graphPath = resolveGraphCachePath(repoRoot);
|
|
54
|
+
|
|
55
|
+
const readResult = await readCache(graphPath);
|
|
56
|
+
if (!readResult.ok) {
|
|
57
|
+
if (readResult.code === ErrorCode.FILE_NOT_FOUND) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
error:
|
|
61
|
+
"graph.json not found — run 'cache-ctrl watch' or wait for the background daemon to compute the graph",
|
|
62
|
+
code: ErrorCode.FILE_NOT_FOUND,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return readResult;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const parseResult = GraphCacheFileSchema.safeParse(readResult.value);
|
|
69
|
+
if (!parseResult.success) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error: `Malformed graph cache file: ${graphPath}`,
|
|
73
|
+
code: ErrorCode.PARSE_ERROR,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parsed = parseResult.data;
|
|
78
|
+
const graph: DependencyGraph = new Map(
|
|
79
|
+
Object.entries(parsed.files).map(([nodePath, node]) => [
|
|
80
|
+
nodePath,
|
|
81
|
+
{
|
|
82
|
+
deps: node.deps,
|
|
83
|
+
defs: node.defs,
|
|
84
|
+
},
|
|
85
|
+
]),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const refCounts = countReferences(graph);
|
|
89
|
+
const ranks = computePageRank(graph, {
|
|
90
|
+
...(args.seed !== undefined ? { seedFiles: args.seed } : {}),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const rankedEntries = [...ranks.entries()]
|
|
94
|
+
.map(([nodePath, rank]): RankedFileEntry => {
|
|
95
|
+
const node = graph.get(nodePath);
|
|
96
|
+
return {
|
|
97
|
+
path: nodePath,
|
|
98
|
+
rank,
|
|
99
|
+
deps: node?.deps ?? [],
|
|
100
|
+
defs: node?.defs ?? [],
|
|
101
|
+
ref_count: refCounts.get(nodePath) ?? 0,
|
|
102
|
+
};
|
|
103
|
+
})
|
|
104
|
+
.sort((a, b) => b.rank - a.rank);
|
|
105
|
+
|
|
106
|
+
const tokenBudget = Math.max(64, Math.min(args.maxTokens ?? 1024, 128_000));
|
|
107
|
+
let tokenEstimate = 0;
|
|
108
|
+
const budgetedEntries: RankedFileEntry[] = [];
|
|
109
|
+
|
|
110
|
+
for (const entry of rankedEntries) {
|
|
111
|
+
const estimatedTokens = estimateEntryTokens(entry);
|
|
112
|
+
if (tokenEstimate + estimatedTokens > tokenBudget) {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
budgetedEntries.push(entry);
|
|
116
|
+
tokenEstimate += estimatedTokens;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
value: {
|
|
122
|
+
ranked_files: budgetedEntries,
|
|
123
|
+
total_files: graph.size,
|
|
124
|
+
computed_at: parsed.computed_at,
|
|
125
|
+
token_estimate: tokenEstimate,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return toUnknownResult(err);
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/commands/inspect.ts
CHANGED
|
@@ -1,184 +1,16 @@
|
|
|
1
|
-
import { normalize } from "node:path";
|
|
2
|
-
|
|
3
|
-
import { findRepoRoot, loadExternalCacheEntries, readCache } from "../cache/cacheManager.js";
|
|
4
|
-
import { resolveLocalCachePath } from "../cache/localCache.js";
|
|
5
|
-
import { scoreEntry } from "../search/keywordSearch.js";
|
|
6
|
-
import type { CacheEntry, ExternalCacheFile, LocalCacheFile } from "../types/cache.js";
|
|
7
|
-
import { ExternalCacheFileSchema, LocalCacheFileSchema } from "../types/cache.js";
|
|
8
|
-
import { ErrorCode, type Result } from "../types/result.js";
|
|
9
1
|
import type { InspectArgs, InspectResult } from "../types/commands.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
2
|
+
import { inspectExternalCommand } from "./inspectExternal.js";
|
|
3
|
+
import { inspectLocalCommand } from "./inspectLocal.js";
|
|
4
|
+
import type { Result } from "../types/result.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Routes inspect requests to the agent-specific implementation.
|
|
8
|
+
*
|
|
9
|
+
* @param args - {@link InspectArgs} command arguments.
|
|
10
|
+
* @returns Promise<Result<InspectResult["value"]>>; may return INVALID_ARGS,
|
|
11
|
+
* FILE_NOT_FOUND, AMBIGUOUS_MATCH, PARSE_ERROR, or UNKNOWN.
|
|
12
|
+
*/
|
|
22
13
|
export async function inspectCommand(args: InspectArgs): Promise<Result<InspectResult["value"]>> {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (args.folder !== undefined) {
|
|
26
|
-
if (args.agent === "external") {
|
|
27
|
-
return {
|
|
28
|
-
ok: false,
|
|
29
|
-
error: "--folder is only supported for local cache",
|
|
30
|
-
code: ErrorCode.INVALID_ARGS,
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const normalizedFolder = args.folder.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
35
|
-
|
|
36
|
-
if (normalizedFolder.length === 0) {
|
|
37
|
-
return {
|
|
38
|
-
ok: false,
|
|
39
|
-
error: "--folder must not be an empty string",
|
|
40
|
-
code: ErrorCode.INVALID_ARGS,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (normalize(normalizedFolder).split("/").includes("..")) {
|
|
45
|
-
return {
|
|
46
|
-
ok: false,
|
|
47
|
-
error: "--folder must not contain '..' path segments",
|
|
48
|
-
code: ErrorCode.INVALID_ARGS,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const repoRoot = await findRepoRoot(process.cwd());
|
|
54
|
-
|
|
55
|
-
const candidates: Array<{ entry: CacheEntry; content: ExternalCacheFile | LocalCacheFile; file: string }> = [];
|
|
56
|
-
|
|
57
|
-
if (args.agent === "external") {
|
|
58
|
-
const entriesResult = await loadExternalCacheEntries(repoRoot);
|
|
59
|
-
if (!entriesResult.ok) return entriesResult;
|
|
60
|
-
|
|
61
|
-
for (const entry of entriesResult.value) {
|
|
62
|
-
const readResult = await readCache(entry.file);
|
|
63
|
-
if (!readResult.ok) continue;
|
|
64
|
-
const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
|
|
65
|
-
if (!parseResult.success) continue;
|
|
66
|
-
candidates.push({ entry, content: parseResult.data, file: entry.file });
|
|
67
|
-
}
|
|
68
|
-
} else {
|
|
69
|
-
const localPath = resolveLocalCachePath(repoRoot);
|
|
70
|
-
const readResult = await readCache(localPath);
|
|
71
|
-
if (!readResult.ok) return readResult;
|
|
72
|
-
|
|
73
|
-
const parseResult = LocalCacheFileSchema.safeParse(readResult.value);
|
|
74
|
-
if (!parseResult.success) {
|
|
75
|
-
return { ok: false, error: `Malformed local cache file: ${localPath}`, code: ErrorCode.PARSE_ERROR };
|
|
76
|
-
}
|
|
77
|
-
const data = parseResult.data;
|
|
78
|
-
const entry: CacheEntry = {
|
|
79
|
-
file: localPath,
|
|
80
|
-
agent: "local",
|
|
81
|
-
subject: data.topic ?? "local",
|
|
82
|
-
description: data.description,
|
|
83
|
-
fetched_at: data.timestamp ?? "",
|
|
84
|
-
};
|
|
85
|
-
candidates.push({ entry, content: data, file: localPath });
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (candidates.length === 0) {
|
|
89
|
-
return { ok: false, error: `No cache entries found for agent "${args.agent}"`, code: ErrorCode.FILE_NOT_FOUND };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Score all candidates
|
|
93
|
-
const keywords = [args.subject];
|
|
94
|
-
const scored = candidates.map((c) => ({
|
|
95
|
-
...c,
|
|
96
|
-
score: scoreEntry(c.entry, keywords),
|
|
97
|
-
}));
|
|
98
|
-
|
|
99
|
-
// Filter out zero-score entries
|
|
100
|
-
const matched = scored.filter((s) => s.score > 0);
|
|
101
|
-
|
|
102
|
-
if (matched.length === 0) {
|
|
103
|
-
return { ok: false, error: `No cache entry matched keyword "${args.subject}"`, code: ErrorCode.FILE_NOT_FOUND };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Sort by score descending
|
|
107
|
-
matched.sort((a, b) => b.score - a.score);
|
|
108
|
-
|
|
109
|
-
const top = matched[0]!;
|
|
110
|
-
const second = matched[1];
|
|
111
|
-
|
|
112
|
-
if (second && top.score === second.score) {
|
|
113
|
-
return {
|
|
114
|
-
ok: false,
|
|
115
|
-
error: `Ambiguous match: multiple entries scored equally for "${args.subject}"`,
|
|
116
|
-
code: ErrorCode.AMBIGUOUS_MATCH,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (args.agent === "local") {
|
|
121
|
-
// Destructure to strip tracked_files (internal operational metadata, never exposed
|
|
122
|
-
// to callers) and to extract facts for filtering. All other fields — including
|
|
123
|
-
// global_facts, topic, description, timestamp, cache_miss_reason — flow through
|
|
124
|
-
// via ...rest and are always included in the response.
|
|
125
|
-
const { tracked_files: _dropped, facts, ...rest } = top.content as LocalCacheFile;
|
|
126
|
-
|
|
127
|
-
// Step 1 — folder filter: keep only entries under the specified folder prefix.
|
|
128
|
-
let filteredFacts = facts;
|
|
129
|
-
if (filteredFacts !== undefined && args.folder !== undefined) {
|
|
130
|
-
const normalizedFolder = args.folder.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
131
|
-
filteredFacts = Object.fromEntries(
|
|
132
|
-
Object.entries(filteredFacts).filter(([key]) => {
|
|
133
|
-
const normalizedPath = key.replace(/\\/g, "/");
|
|
134
|
-
return (
|
|
135
|
-
normalizedPath === normalizedFolder ||
|
|
136
|
-
normalizedPath.startsWith(normalizedFolder + "/")
|
|
137
|
-
);
|
|
138
|
-
}),
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Step 2 — path keyword filter (existing --filter logic applied to already-folder-filtered set).
|
|
143
|
-
if (filteredFacts !== undefined) {
|
|
144
|
-
filteredFacts = filterFacts(filteredFacts, args.filter ?? []);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Step 3 — search-facts filter: keep entries where any fact string contains any keyword.
|
|
148
|
-
if (filteredFacts !== undefined && args.searchFacts !== undefined) {
|
|
149
|
-
const kwsLower = args.searchFacts.map((k) => k.toLowerCase());
|
|
150
|
-
filteredFacts = Object.fromEntries(
|
|
151
|
-
Object.entries(filteredFacts).filter(([, factStrings]) =>
|
|
152
|
-
factStrings.some((f) => kwsLower.some((kw) => f.toLowerCase().includes(kw))),
|
|
153
|
-
),
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
ok: true,
|
|
159
|
-
value: {
|
|
160
|
-
...rest,
|
|
161
|
-
...(filteredFacts !== undefined ? { facts: filteredFacts } : {}),
|
|
162
|
-
file: top.file,
|
|
163
|
-
agent: args.agent,
|
|
164
|
-
// The cast is safe: tracked_files is intentionally stripped from the local
|
|
165
|
-
// response. LocalCacheFile uses z.looseObject so the runtime shape is valid;
|
|
166
|
-
// the static type just cannot express the intentional omission without a
|
|
167
|
-
// separate type definition.
|
|
168
|
-
} as unknown as InspectResult["value"],
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return {
|
|
173
|
-
ok: true,
|
|
174
|
-
value: {
|
|
175
|
-
...top.content,
|
|
176
|
-
file: top.file,
|
|
177
|
-
agent: args.agent,
|
|
178
|
-
},
|
|
179
|
-
};
|
|
180
|
-
} catch (err) {
|
|
181
|
-
const error = err as Error;
|
|
182
|
-
return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
|
|
183
|
-
}
|
|
14
|
+
if (args.agent === "local") return inspectLocalCommand(args);
|
|
15
|
+
return inspectExternalCommand(args);
|
|
184
16
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { findRepoRoot, loadExternalCacheEntries, readCache } from "../cache/cacheManager.js";
|
|
2
|
+
import { scoreEntry } from "../search/keywordSearch.js";
|
|
3
|
+
import type { CacheEntry, ExternalCacheFile } from "../types/cache.js";
|
|
4
|
+
import type { InspectArgs, InspectResult } from "../types/commands.js";
|
|
5
|
+
import { ExternalCacheFileSchema } from "../types/cache.js";
|
|
6
|
+
import { ErrorCode, type Result } from "../types/result.js";
|
|
7
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Inspects the best-matching external cache entry by subject keyword.
|
|
11
|
+
*
|
|
12
|
+
* @param args - {@link InspectArgs} with `agent: "external"`.
|
|
13
|
+
* @returns Promise<Result<InspectResult["value"]>>; common failures include INVALID_ARGS,
|
|
14
|
+
* FILE_NOT_FOUND, AMBIGUOUS_MATCH, PARSE_ERROR, and UNKNOWN.
|
|
15
|
+
*/
|
|
16
|
+
export async function inspectExternalCommand(args: InspectArgs): Promise<Result<InspectResult["value"]>> {
|
|
17
|
+
try {
|
|
18
|
+
if (args.folder !== undefined) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
error: "--folder is only supported for local cache",
|
|
22
|
+
code: ErrorCode.INVALID_ARGS,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const repoRoot = await findRepoRoot(process.cwd());
|
|
27
|
+
const entriesResult = await loadExternalCacheEntries(repoRoot);
|
|
28
|
+
if (!entriesResult.ok) return entriesResult;
|
|
29
|
+
|
|
30
|
+
const candidates: Array<{ entry: CacheEntry; content: ExternalCacheFile; file: string }> = [];
|
|
31
|
+
|
|
32
|
+
for (const entry of entriesResult.value) {
|
|
33
|
+
const readResult = await readCache(entry.file);
|
|
34
|
+
if (!readResult.ok) continue;
|
|
35
|
+
const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
|
|
36
|
+
if (!parseResult.success) continue;
|
|
37
|
+
candidates.push({ entry, content: parseResult.data, file: entry.file });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (candidates.length === 0) {
|
|
41
|
+
return { ok: false, error: `No cache entries found for agent "${args.agent}"`, code: ErrorCode.FILE_NOT_FOUND };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const keywords = [args.subject];
|
|
45
|
+
const scored = candidates.map((candidate) => ({
|
|
46
|
+
...candidate,
|
|
47
|
+
score: scoreEntry(candidate.entry, keywords),
|
|
48
|
+
}));
|
|
49
|
+
const matched = scored.filter((candidate) => candidate.score > 0);
|
|
50
|
+
|
|
51
|
+
if (matched.length === 0) {
|
|
52
|
+
return { ok: false, error: `No cache entry matched keyword "${args.subject}"`, code: ErrorCode.FILE_NOT_FOUND };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
matched.sort((a, b) => b.score - a.score);
|
|
56
|
+
|
|
57
|
+
const top = matched[0]!;
|
|
58
|
+
const second = matched[1];
|
|
59
|
+
|
|
60
|
+
if (second && top.score === second.score) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: `Ambiguous match: multiple entries scored equally for "${args.subject}"`,
|
|
64
|
+
code: ErrorCode.AMBIGUOUS_MATCH,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
value: {
|
|
71
|
+
...top.content,
|
|
72
|
+
file: top.file,
|
|
73
|
+
agent: args.agent,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return toUnknownResult(err);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { normalize } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { findRepoRoot, readCache } from "../cache/cacheManager.js";
|
|
4
|
+
import { resolveLocalCachePath } from "../cache/localCache.js";
|
|
5
|
+
import { scoreEntry } from "../search/keywordSearch.js";
|
|
6
|
+
import type { CacheEntry, FileFacts } from "../types/cache.js";
|
|
7
|
+
import type { InspectArgs, InspectResult } from "../types/commands.js";
|
|
8
|
+
import { LocalCacheFileSchema } from "../types/cache.js";
|
|
9
|
+
import { ErrorCode, type Result } from "../types/result.js";
|
|
10
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
11
|
+
|
|
12
|
+
function filterFacts(
|
|
13
|
+
facts: Record<string, FileFacts>,
|
|
14
|
+
keywords: string[],
|
|
15
|
+
): Record<string, FileFacts> {
|
|
16
|
+
if (keywords.length === 0) return facts;
|
|
17
|
+
const lowerKeywords = keywords.map((keyword) => keyword.toLowerCase());
|
|
18
|
+
return Object.fromEntries(
|
|
19
|
+
Object.entries(facts).filter(([path]) => lowerKeywords.some((keyword) => path.toLowerCase().includes(keyword))),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeFolderArg(folder: string): Result<string> {
|
|
24
|
+
const normalizedFolder = folder.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
25
|
+
|
|
26
|
+
if (normalizedFolder.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: "--folder must not be an empty string",
|
|
30
|
+
code: ErrorCode.INVALID_ARGS,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (normalize(normalizedFolder).split("/").includes("..")) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
error: "--folder must not contain '..' path segments",
|
|
38
|
+
code: ErrorCode.INVALID_ARGS,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { ok: true, value: normalizedFolder };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Inspects local context cache content with optional path/fact filters.
|
|
47
|
+
*
|
|
48
|
+
* @param args - {@link InspectArgs} with `agent: "local"`.
|
|
49
|
+
* @returns Promise<Result<InspectResult["value"]>>; common failures include INVALID_ARGS,
|
|
50
|
+
* FILE_NOT_FOUND, PARSE_ERROR, and UNKNOWN.
|
|
51
|
+
*/
|
|
52
|
+
export async function inspectLocalCommand(args: InspectArgs): Promise<Result<InspectResult["value"]>> {
|
|
53
|
+
try {
|
|
54
|
+
let normalizedFolder: string | undefined;
|
|
55
|
+
if (args.folder !== undefined) {
|
|
56
|
+
const folderResult = normalizeFolderArg(args.folder);
|
|
57
|
+
if (!folderResult.ok) return folderResult;
|
|
58
|
+
normalizedFolder = folderResult.value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const repoRoot = await findRepoRoot(process.cwd());
|
|
62
|
+
const localPath = resolveLocalCachePath(repoRoot);
|
|
63
|
+
const readResult = await readCache(localPath);
|
|
64
|
+
if (!readResult.ok) return readResult;
|
|
65
|
+
|
|
66
|
+
const parseResult = LocalCacheFileSchema.safeParse(readResult.value);
|
|
67
|
+
if (!parseResult.success) {
|
|
68
|
+
return { ok: false, error: `Malformed local cache file: ${localPath}`, code: ErrorCode.PARSE_ERROR };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const data = parseResult.data;
|
|
72
|
+
const entry: CacheEntry = {
|
|
73
|
+
file: localPath,
|
|
74
|
+
agent: "local",
|
|
75
|
+
subject: data.topic ?? "local",
|
|
76
|
+
description: data.description,
|
|
77
|
+
fetched_at: data.timestamp ?? "",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const scored = [
|
|
81
|
+
{
|
|
82
|
+
entry,
|
|
83
|
+
content: data,
|
|
84
|
+
file: localPath,
|
|
85
|
+
score: scoreEntry(entry, [args.subject]),
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
const matched = scored.filter((candidate) => candidate.score > 0);
|
|
89
|
+
|
|
90
|
+
if (matched.length === 0) {
|
|
91
|
+
return { ok: false, error: `No cache entry matched keyword "${args.subject}"`, code: ErrorCode.FILE_NOT_FOUND };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const top = matched[0]!;
|
|
95
|
+
const { tracked_files: _dropped, facts, ...rest } = top.content;
|
|
96
|
+
|
|
97
|
+
let filteredFacts = facts;
|
|
98
|
+
if (filteredFacts !== undefined && normalizedFolder !== undefined) {
|
|
99
|
+
filteredFacts = Object.fromEntries(
|
|
100
|
+
Object.entries(filteredFacts).filter(([key]) => {
|
|
101
|
+
const normalizedPath = key.replace(/\\/g, "/");
|
|
102
|
+
return normalizedPath === normalizedFolder || normalizedPath.startsWith(normalizedFolder + "/");
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (filteredFacts !== undefined) {
|
|
108
|
+
filteredFacts = filterFacts(filteredFacts, args.filter ?? []);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (filteredFacts !== undefined && args.searchFacts !== undefined) {
|
|
112
|
+
const loweredKeywords = args.searchFacts.map((keyword) => keyword.toLowerCase());
|
|
113
|
+
filteredFacts = Object.fromEntries(
|
|
114
|
+
Object.entries(filteredFacts).filter(([, factEntry]) =>
|
|
115
|
+
(factEntry.facts ?? []).some((fact) => loweredKeywords.some((keyword) => fact.toLowerCase().includes(keyword))),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const resultValue: InspectResult["value"] = {
|
|
121
|
+
...rest,
|
|
122
|
+
...(filteredFacts !== undefined ? { facts: filteredFacts } : {}),
|
|
123
|
+
file: top.file,
|
|
124
|
+
agent: args.agent,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
value: resultValue,
|
|
130
|
+
};
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return toUnknownResult(err);
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/commands/install.ts
CHANGED
|
@@ -4,6 +4,12 @@ import { installOpenCodeIntegration, resolveOpenCodeConfigDir } from "../files/o
|
|
|
4
4
|
import type { InstallArgs, InstallResult } from "../types/commands.js";
|
|
5
5
|
import type { Result } from "../types/result.js";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Installs OpenCode tool wrapper and bundled skill files.
|
|
9
|
+
*
|
|
10
|
+
* @param args - {@link InstallArgs} command arguments.
|
|
11
|
+
* @returns Promise<Result<InstallResult>>; common failures include FILE_WRITE_ERROR and UNKNOWN.
|
|
12
|
+
*/
|
|
7
13
|
export async function installCommand(
|
|
8
14
|
args: InstallArgs,
|
|
9
15
|
packageRoot: string = path.resolve(import.meta.dir, "../.."),
|
|
@@ -1,35 +1,27 @@
|
|
|
1
|
-
import { findRepoRoot,
|
|
2
|
-
import {
|
|
1
|
+
import { findRepoRoot, writeCache, readCache } from "../cache/cacheManager.js";
|
|
2
|
+
import { updateExternalFetchedAt } from "../cache/externalCache.js";
|
|
3
|
+
import { resolveGraphCachePath } from "../cache/graphCache.js";
|
|
3
4
|
import { resolveLocalCachePath } from "../cache/localCache.js";
|
|
4
5
|
import { ErrorCode, type Result } from "../types/result.js";
|
|
5
6
|
import type { InvalidateArgs, InvalidateResult } from "../types/commands.js";
|
|
6
|
-
import {
|
|
7
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Marks cache entries stale by zeroing their freshness timestamps.
|
|
11
|
+
*
|
|
12
|
+
* @param args - {@link InvalidateArgs} command arguments.
|
|
13
|
+
* @returns Promise<Result<InvalidateResult["value"]>>; common failures include INVALID_ARGS,
|
|
14
|
+
* NO_MATCH, FILE_NOT_FOUND, FILE_WRITE_ERROR, and UNKNOWN.
|
|
15
|
+
*/
|
|
8
16
|
export async function invalidateCommand(args: InvalidateArgs): Promise<Result<InvalidateResult["value"]>> {
|
|
9
17
|
try {
|
|
10
18
|
const repoRoot = await findRepoRoot(process.cwd());
|
|
11
19
|
const invalidated: string[] = [];
|
|
12
20
|
|
|
13
21
|
if (args.agent === "external") {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const subjectCheck = validateSubject(args.subject);
|
|
18
|
-
if (!subjectCheck.ok) return subjectCheck;
|
|
19
|
-
const matchResult = await resolveTopExternalMatch(repoRoot, args.subject);
|
|
20
|
-
if (!matchResult.ok) return matchResult;
|
|
21
|
-
filesToInvalidate = [matchResult.value];
|
|
22
|
-
} else {
|
|
23
|
-
const filesResult = await listCacheFiles("external", repoRoot);
|
|
24
|
-
if (!filesResult.ok) return filesResult;
|
|
25
|
-
filesToInvalidate = filesResult.value;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
for (const filePath of filesToInvalidate) {
|
|
29
|
-
const writeResult = await writeCache(filePath, { fetched_at: "" });
|
|
30
|
-
if (!writeResult.ok) return writeResult;
|
|
31
|
-
invalidated.push(filePath);
|
|
32
|
-
}
|
|
22
|
+
const updateResult = await updateExternalFetchedAt(repoRoot, args.subject, "");
|
|
23
|
+
if (!updateResult.ok) return updateResult;
|
|
24
|
+
invalidated.push(...updateResult.value);
|
|
33
25
|
} else {
|
|
34
26
|
// local — only invalidate if the file already exists
|
|
35
27
|
const localPath = resolveLocalCachePath(repoRoot);
|
|
@@ -43,11 +35,19 @@ export async function invalidateCommand(args: InvalidateArgs): Promise<Result<In
|
|
|
43
35
|
const writeResult = await writeCache(localPath, { timestamp: "" });
|
|
44
36
|
if (!writeResult.ok) return writeResult;
|
|
45
37
|
invalidated.push(localPath);
|
|
38
|
+
|
|
39
|
+
const graphPath = resolveGraphCachePath(repoRoot);
|
|
40
|
+
const graphReadResult = await readCache(graphPath);
|
|
41
|
+
if (graphReadResult.ok) {
|
|
42
|
+
const graphWriteResult = await writeCache(graphPath, { computed_at: "" });
|
|
43
|
+
if (!graphWriteResult.ok) return graphWriteResult;
|
|
44
|
+
} else if (graphReadResult.code !== ErrorCode.FILE_NOT_FOUND) {
|
|
45
|
+
return graphReadResult;
|
|
46
|
+
}
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
return { ok: true, value: { invalidated } };
|
|
49
50
|
} catch (err) {
|
|
50
|
-
|
|
51
|
-
return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
|
|
51
|
+
return toUnknownResult(err);
|
|
52
52
|
}
|
|
53
53
|
}
|
package/src/commands/list.ts
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { findRepoRoot, loadExternalCacheEntries, readCache } from "../cache/cacheManager.js";
|
|
2
|
-
import { getAgeHuman } from "../cache/externalCache.js";
|
|
2
|
+
import { getAgeHuman, isFetchedAtStale } from "../cache/externalCache.js";
|
|
3
3
|
import { resolveLocalCachePath } from "../cache/localCache.js";
|
|
4
4
|
import { checkFilesCommand } from "./checkFiles.js";
|
|
5
5
|
import { LocalCacheFileSchema } from "../types/cache.js";
|
|
6
6
|
import { ErrorCode, type Result } from "../types/result.js";
|
|
7
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
7
8
|
import type { ListArgs, ListEntry, ListResult } from "../types/commands.js";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Lists cache entries for external and/or local namespaces.
|
|
12
|
+
*
|
|
13
|
+
* @param args - {@link ListArgs} command arguments.
|
|
14
|
+
* @returns Promise<Result<ListResult["value"]>>; common failures include FILE_READ_ERROR,
|
|
15
|
+
* FILE_NOT_FOUND, PARSE_ERROR, and UNKNOWN.
|
|
16
|
+
*/
|
|
16
17
|
export async function listCommand(args: ListArgs): Promise<Result<ListResult["value"]>> {
|
|
17
18
|
try {
|
|
18
19
|
const repoRoot = await findRepoRoot(process.cwd());
|
|
@@ -31,7 +32,7 @@ export async function listCommand(args: ListArgs): Promise<Result<ListResult["va
|
|
|
31
32
|
...(entry.description !== undefined ? { description: entry.description } : {}),
|
|
32
33
|
fetched_at: entry.fetched_at,
|
|
33
34
|
age_human: getAgeHuman(entry.fetched_at),
|
|
34
|
-
is_stale:
|
|
35
|
+
is_stale: isFetchedAtStale(entry.fetched_at),
|
|
35
36
|
});
|
|
36
37
|
}
|
|
37
38
|
}
|
|
@@ -77,7 +78,6 @@ export async function listCommand(args: ListArgs): Promise<Result<ListResult["va
|
|
|
77
78
|
|
|
78
79
|
return { ok: true, value: entries };
|
|
79
80
|
} catch (err) {
|
|
80
|
-
|
|
81
|
-
return { ok: false, error: msg, code: ErrorCode.UNKNOWN };
|
|
81
|
+
return toUnknownResult(err);
|
|
82
82
|
}
|
|
83
83
|
}
|