@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,51 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { findRepoRoot, resolveCacheDir, writeCache } from "../cache/cacheManager.js";
|
|
4
|
+
import type { WriteArgs, WriteResult } 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
|
+
import { formatZodError, validateSubject } from "../utils/validate.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validates and writes one external cache entry.
|
|
12
|
+
*
|
|
13
|
+
* @param args - {@link WriteArgs} command arguments for the external agent.
|
|
14
|
+
* @returns Promise<Result<WriteResult["value"]>>; common failures include INVALID_ARGS,
|
|
15
|
+
* VALIDATION_ERROR, FILE_WRITE_ERROR, LOCK_TIMEOUT/LOCK_ERROR, and UNKNOWN.
|
|
16
|
+
*/
|
|
17
|
+
export async function writeExternalCommand(args: WriteArgs): Promise<Result<WriteResult["value"]>> {
|
|
18
|
+
try {
|
|
19
|
+
if (!args.subject) {
|
|
20
|
+
return { ok: false, error: "subject is required for external agent", code: ErrorCode.INVALID_ARGS };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const subjectValidation = validateSubject(args.subject);
|
|
24
|
+
if (!subjectValidation.ok) return subjectValidation;
|
|
25
|
+
|
|
26
|
+
if (args.content["subject"] !== undefined && args.content["subject"] !== args.subject) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: `content.subject "${String(args.content["subject"])}" does not match subject argument "${args.subject}"`,
|
|
30
|
+
code: ErrorCode.VALIDATION_ERROR,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const contentWithSubject = { ...args.content, subject: args.subject };
|
|
35
|
+
|
|
36
|
+
const parsed = ExternalCacheFileSchema.safeParse(contentWithSubject);
|
|
37
|
+
if (!parsed.success) {
|
|
38
|
+
const message = formatZodError(parsed.error);
|
|
39
|
+
return { ok: false, error: `Validation failed: ${message}`, code: ErrorCode.VALIDATION_ERROR };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const repoRoot = await findRepoRoot(process.cwd());
|
|
43
|
+
const cacheDir = resolveCacheDir("external", repoRoot);
|
|
44
|
+
const filePath = join(cacheDir, `${args.subject}.json`);
|
|
45
|
+
const writeResult = await writeCache(filePath, contentWithSubject);
|
|
46
|
+
if (!writeResult.ok) return writeResult;
|
|
47
|
+
return { ok: true, value: { file: filePath } };
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return toUnknownResult(err);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { findRepoRoot, readCache, resolveCacheDir, writeCache } from "../cache/cacheManager.js";
|
|
4
|
+
import { filterExistingFiles, resolveTrackedFileStats } from "../files/changeDetector.js";
|
|
5
|
+
import type { WriteArgs, WriteResult } from "../types/commands.js";
|
|
6
|
+
import { type FileFacts, LocalCacheFileSchema, type TrackedFile, TrackedFileSchema } from "../types/cache.js";
|
|
7
|
+
import { ErrorCode, type Result } from "../types/result.js";
|
|
8
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
9
|
+
import { formatZodError } from "../utils/validate.js";
|
|
10
|
+
|
|
11
|
+
function evictFactsForDeletedPaths(
|
|
12
|
+
facts: Record<string, unknown>,
|
|
13
|
+
survivingFiles: TrackedFile[],
|
|
14
|
+
): Record<string, unknown> {
|
|
15
|
+
const survivingPaths = new Set(survivingFiles.map((file) => file.path));
|
|
16
|
+
return Object.fromEntries(Object.entries(facts).filter(([path]) => survivingPaths.has(path)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hasStringPath(entry: unknown): entry is { path: string } {
|
|
20
|
+
if (typeof entry !== "object" || entry === null) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const pathValue = Reflect.get(entry, "path");
|
|
24
|
+
return typeof pathValue === "string";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getSubmittedTrackedPaths(rawTrackedFiles: unknown): string[] {
|
|
28
|
+
if (!Array.isArray(rawTrackedFiles)) return [];
|
|
29
|
+
|
|
30
|
+
return rawTrackedFiles.filter(hasStringPath).map((entry) => entry.path);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validates and writes local context cache content with per-path merge semantics.
|
|
35
|
+
*
|
|
36
|
+
* @param args - {@link WriteArgs} command arguments for the local agent.
|
|
37
|
+
* @returns Promise<Result<WriteResult["value"]>>; common failures include VALIDATION_ERROR,
|
|
38
|
+
* FILE_READ_ERROR/FILE_WRITE_ERROR, LOCK_TIMEOUT/LOCK_ERROR, and UNKNOWN.
|
|
39
|
+
*/
|
|
40
|
+
export async function writeLocalCommand(args: WriteArgs): Promise<Result<WriteResult["value"]>> {
|
|
41
|
+
try {
|
|
42
|
+
const repoRoot = await findRepoRoot(process.cwd());
|
|
43
|
+
|
|
44
|
+
const contentWithTimestamp: Record<string, unknown> = {
|
|
45
|
+
...args.content,
|
|
46
|
+
timestamp: new Date().toISOString(),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const submittedPaths = getSubmittedTrackedPaths(contentWithTimestamp["tracked_files"]);
|
|
50
|
+
const guardedPaths = new Set(submittedPaths);
|
|
51
|
+
const resolvedTrackedFiles = await resolveTrackedFileStats(submittedPaths.map((path) => ({ path })), repoRoot);
|
|
52
|
+
const survivingSubmitted = resolvedTrackedFiles.filter((trackedFile) => trackedFile.mtime !== 0);
|
|
53
|
+
|
|
54
|
+
const submittedFacts =
|
|
55
|
+
typeof contentWithTimestamp["facts"] === "object" &&
|
|
56
|
+
contentWithTimestamp["facts"] !== null &&
|
|
57
|
+
!Array.isArray(contentWithTimestamp["facts"])
|
|
58
|
+
? (contentWithTimestamp["facts"] as Record<string, unknown>)
|
|
59
|
+
: {};
|
|
60
|
+
const rawSubmittedFacts = contentWithTimestamp["facts"];
|
|
61
|
+
if (typeof rawSubmittedFacts === "object" && rawSubmittedFacts !== null && !Array.isArray(rawSubmittedFacts)) {
|
|
62
|
+
const violatingPaths = Object.keys(submittedFacts).filter((path) => !guardedPaths.has(path));
|
|
63
|
+
if (violatingPaths.length > 0) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
error: `facts contains paths not in submitted tracked_files: ${violatingPaths.join(", ")}`,
|
|
67
|
+
code: ErrorCode.VALIDATION_ERROR,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const localCacheDir = resolveCacheDir("local", repoRoot);
|
|
73
|
+
const filePath = join(localCacheDir, "context.json");
|
|
74
|
+
|
|
75
|
+
const readResult = await readCache(filePath);
|
|
76
|
+
let existingContent: Record<string, unknown> = {};
|
|
77
|
+
let existingTrackedFiles: TrackedFile[] = [];
|
|
78
|
+
let existingFacts: Record<string, FileFacts> = {};
|
|
79
|
+
|
|
80
|
+
if (readResult.ok) {
|
|
81
|
+
existingContent = readResult.value;
|
|
82
|
+
const localParseResult = LocalCacheFileSchema.safeParse(existingContent);
|
|
83
|
+
if (localParseResult.success) {
|
|
84
|
+
existingTrackedFiles = localParseResult.data.tracked_files;
|
|
85
|
+
existingFacts = localParseResult.data.facts ?? {};
|
|
86
|
+
} else {
|
|
87
|
+
const trackedFilesResult = TrackedFileSchema.array().safeParse(existingContent["tracked_files"]);
|
|
88
|
+
existingTrackedFiles = trackedFilesResult.success ? trackedFilesResult.data : [];
|
|
89
|
+
}
|
|
90
|
+
} else if (readResult.code !== ErrorCode.FILE_NOT_FOUND) {
|
|
91
|
+
return { ok: false, error: readResult.error, code: readResult.code };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const submittedTrackedPaths = new Set(survivingSubmitted.map((trackedFile) => trackedFile.path));
|
|
95
|
+
const existingNotSubmitted = existingTrackedFiles.filter((trackedFile) => !submittedTrackedPaths.has(trackedFile.path));
|
|
96
|
+
const survivingExisting = await filterExistingFiles(existingNotSubmitted, repoRoot);
|
|
97
|
+
const mergedTrackedFiles = [...survivingExisting, ...survivingSubmitted];
|
|
98
|
+
|
|
99
|
+
const rawMergedFacts = { ...existingFacts, ...submittedFacts };
|
|
100
|
+
const mergedFacts = evictFactsForDeletedPaths(rawMergedFacts, mergedTrackedFiles);
|
|
101
|
+
|
|
102
|
+
const processedContent: Record<string, unknown> = {
|
|
103
|
+
...existingContent,
|
|
104
|
+
...contentWithTimestamp,
|
|
105
|
+
tracked_files: mergedTrackedFiles,
|
|
106
|
+
facts: mergedFacts,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const parsed = LocalCacheFileSchema.safeParse(processedContent);
|
|
110
|
+
if (!parsed.success) {
|
|
111
|
+
const message = formatZodError(parsed.error);
|
|
112
|
+
return { ok: false, error: `Validation failed: ${message}`, code: ErrorCode.VALIDATION_ERROR };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const writeResult = await writeCache(filePath, processedContent, "replace");
|
|
116
|
+
if (!writeResult.ok) return writeResult;
|
|
117
|
+
return { ok: true, value: { file: filePath } };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return toUnknownResult(err);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -3,12 +3,26 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { resolve, isAbsolute } from "node:path";
|
|
4
4
|
import type { TrackedFile } from "../types/cache.js";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Comparison outcome for one tracked file baseline.
|
|
8
|
+
*
|
|
9
|
+
* `status` and `reason` are coupled: `changed` uses `mtime`/`hash`, `missing` uses `missing`,
|
|
10
|
+
* and `unchanged` omits `reason`. `missing` covers both deleted files and traversal-rejected
|
|
11
|
+
* paths blocked by path-safety guards.
|
|
12
|
+
*/
|
|
6
13
|
export interface FileComparisonResult {
|
|
7
14
|
path: string;
|
|
8
15
|
status: "changed" | "unchanged" | "missing";
|
|
9
16
|
reason?: "mtime" | "hash" | "missing";
|
|
10
17
|
}
|
|
11
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Compares one tracked file against current filesystem state.
|
|
21
|
+
*
|
|
22
|
+
* Uses `mtime` as a fast path and falls back to SHA-256 hash comparison when stored hash is
|
|
23
|
+
* available and `mtime` changed. Paths that escape `repoRoot` are rejected by traversal guard
|
|
24
|
+
* and reported as `missing`. `ENOENT` is also reported as `missing`.
|
|
25
|
+
*/
|
|
12
26
|
export async function compareTrackedFile(file: TrackedFile, repoRoot: string): Promise<FileComparisonResult> {
|
|
13
27
|
const absolutePath = resolveTrackedFilePath(file.path, repoRoot);
|
|
14
28
|
|
|
@@ -47,6 +61,7 @@ export async function compareTrackedFile(file: TrackedFile, repoRoot: string): P
|
|
|
47
61
|
}
|
|
48
62
|
}
|
|
49
63
|
|
|
64
|
+
/** Computes SHA-256 hex digest for a file's current bytes. */
|
|
50
65
|
export async function computeFileHash(filePath: string): Promise<string> {
|
|
51
66
|
const content = await readFile(filePath);
|
|
52
67
|
return createHash("sha256").update(content).digest("hex");
|
package/src/files/gitFiles.ts
CHANGED
|
@@ -10,6 +10,11 @@ function parseGitOutput(stdout: string): string[] {
|
|
|
10
10
|
.filter((l) => l.length > 0);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Returns git-tracked file paths for a repository.
|
|
15
|
+
*
|
|
16
|
+
* Falls back to `[]` when git is unavailable, command execution fails, or directory is not a git repo.
|
|
17
|
+
*/
|
|
13
18
|
export async function getGitTrackedFiles(repoRoot: string): Promise<string[]> {
|
|
14
19
|
try {
|
|
15
20
|
const result = await execFileAsync("git", ["ls-files"], { cwd: repoRoot, maxBuffer: 10 * 1024 * 1024 });
|
|
@@ -19,6 +24,11 @@ export async function getGitTrackedFiles(repoRoot: string): Promise<string[]> {
|
|
|
19
24
|
}
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Returns git-tracked files deleted from the working tree.
|
|
29
|
+
*
|
|
30
|
+
* Falls back to `[]` when git is unavailable, command execution fails, or directory is not a git repo.
|
|
31
|
+
*/
|
|
22
32
|
export async function getGitDeletedFiles(repoRoot: string): Promise<string[]> {
|
|
23
33
|
try {
|
|
24
34
|
const result = await execFileAsync("git", ["ls-files", "--deleted"], { cwd: repoRoot, maxBuffer: 10 * 1024 * 1024 });
|
|
@@ -28,6 +38,11 @@ export async function getGitDeletedFiles(repoRoot: string): Promise<string[]> {
|
|
|
28
38
|
}
|
|
29
39
|
}
|
|
30
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Returns untracked files that are not ignored by git.
|
|
43
|
+
*
|
|
44
|
+
* Falls back to `[]` when git is unavailable, command execution fails, or directory is not a git repo.
|
|
45
|
+
*/
|
|
31
46
|
export async function getUntrackedNonIgnoredFiles(repoRoot: string): Promise<string[]> {
|
|
32
47
|
try {
|
|
33
48
|
const result = await execFileAsync("git", ["ls-files", "--others", "--exclude-standard"], {
|
|
@@ -4,9 +4,18 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
import type { InstallResult } from "../types/commands.js";
|
|
6
6
|
import { ErrorCode, type Result } from "../types/result.js";
|
|
7
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
7
8
|
|
|
8
9
|
const SKILL_NAMES = ["cache-ctrl-external", "cache-ctrl-local", "cache-ctrl-caller"] as const;
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the OpenCode configuration directory.
|
|
13
|
+
*
|
|
14
|
+
* @param overrideDir - Explicit CLI override path.
|
|
15
|
+
* @returns Absolute config directory path used for tool and skill installation.
|
|
16
|
+
* @remarks Resolution order: explicit override → `%APPDATA%/opencode` on Windows →
|
|
17
|
+
* `$XDG_CONFIG_HOME/opencode` on Unix-like systems → `~/.config/opencode` fallback.
|
|
18
|
+
*/
|
|
10
19
|
export function resolveOpenCodeConfigDir(overrideDir?: string): string {
|
|
11
20
|
if (overrideDir !== undefined) {
|
|
12
21
|
return overrideDir;
|
|
@@ -21,6 +30,7 @@ export function resolveOpenCodeConfigDir(overrideDir?: string): string {
|
|
|
21
30
|
return path.join(xdgConfigHome, "opencode");
|
|
22
31
|
}
|
|
23
32
|
|
|
33
|
+
/** Builds the generated OpenCode tool wrapper file content. */
|
|
24
34
|
export function buildToolWrapperContent(packageRoot: string): string {
|
|
25
35
|
const normalizedPackageRoot = packageRoot.replace(/\\/g, "/");
|
|
26
36
|
|
|
@@ -31,6 +41,15 @@ export function buildToolWrapperContent(packageRoot: string): string {
|
|
|
31
41
|
].join("\n");
|
|
32
42
|
}
|
|
33
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Installs or refreshes OpenCode tool + skill integration files.
|
|
46
|
+
*
|
|
47
|
+
* @param configDir - Target OpenCode config directory.
|
|
48
|
+
* @param packageRoot - Installed package root used to resolve bundled assets.
|
|
49
|
+
* @returns Written tool path and copied skill file paths.
|
|
50
|
+
* @remarks Operation is idempotent: reruns overwrite the wrapper and recopy skill files
|
|
51
|
+
* to align the config directory with the currently installed package version.
|
|
52
|
+
*/
|
|
34
53
|
export async function installOpenCodeIntegration(configDir: string, packageRoot: string): Promise<Result<InstallResult>> {
|
|
35
54
|
try {
|
|
36
55
|
const toolDir = path.join(configDir, "tools");
|
|
@@ -60,7 +79,7 @@ export async function installOpenCodeIntegration(configDir: string, packageRoot:
|
|
|
60
79
|
},
|
|
61
80
|
};
|
|
62
81
|
} catch (err) {
|
|
63
|
-
const
|
|
64
|
-
return {
|
|
82
|
+
const unknownError = toUnknownResult(err);
|
|
83
|
+
return { ...unknownError, code: ErrorCode.FILE_WRITE_ERROR };
|
|
65
84
|
}
|
|
66
85
|
}
|