@thecat69/cache-ctrl 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +558 -0
- package/cache_ctrl.ts +153 -0
- package/package.json +35 -0
- package/skills/cache-ctrl-caller/SKILL.md +154 -0
- package/skills/cache-ctrl-external/SKILL.md +130 -0
- package/skills/cache-ctrl-local/SKILL.md +213 -0
- package/src/cache/cacheManager.ts +241 -0
- package/src/cache/externalCache.ts +127 -0
- package/src/cache/localCache.ts +9 -0
- package/src/commands/checkFiles.ts +83 -0
- package/src/commands/checkFreshness.ts +123 -0
- package/src/commands/flush.ts +55 -0
- package/src/commands/inspect.ts +184 -0
- package/src/commands/install.ts +13 -0
- package/src/commands/invalidate.ts +53 -0
- package/src/commands/list.ts +83 -0
- package/src/commands/prune.ts +110 -0
- package/src/commands/search.ts +57 -0
- package/src/commands/touch.ts +47 -0
- package/src/commands/write.ts +170 -0
- package/src/files/changeDetector.ts +122 -0
- package/src/files/gitFiles.ts +41 -0
- package/src/files/openCodeInstaller.ts +66 -0
- package/src/http/freshnessChecker.ts +116 -0
- package/src/index.ts +557 -0
- package/src/search/keywordSearch.ts +59 -0
- package/src/types/cache.ts +91 -0
- package/src/types/commands.ts +192 -0
- package/src/types/result.ts +36 -0
- package/src/utils/fileStem.ts +7 -0
- package/src/utils/validate.ts +50 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cache-ctrl-local
|
|
3
|
+
description: How to use cache-ctrl to detect file changes and manage the local context cache
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# cache-ctrl — Local Cache Usage
|
|
7
|
+
|
|
8
|
+
Manage `.ai/local-context-gatherer_cache/context.json` to avoid redundant full-repo scans.
|
|
9
|
+
Three tiers of access — use the best one available.
|
|
10
|
+
|
|
11
|
+
## Availability Detection (run once at startup)
|
|
12
|
+
|
|
13
|
+
1. Call `cache_ctrl_check_files` (built-in tool).
|
|
14
|
+
- Success → **use Tier 1** for all operations below.
|
|
15
|
+
- Failure (tool not found / permission denied) → continue to step 2.
|
|
16
|
+
2. Run `bash: "which cache-ctrl"`.
|
|
17
|
+
- Exit 0 → **use Tier 2** for all operations below.
|
|
18
|
+
- Not found → **use Tier 3** for all operations below.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Fact-Writing Rules
|
|
23
|
+
|
|
24
|
+
Facts must be **concise observations** about a file — not reproductions of its content.
|
|
25
|
+
|
|
26
|
+
- **Each fact string must be ≤ 300 characters** (schema hard limit: 800). If an observation needs more, split it into two facts or summarize.
|
|
27
|
+
- **Max 30 facts per file.** Choose only the most architecturally meaningful observations.
|
|
28
|
+
- **Never write**: raw import lines, function bodies, code snippets, or verbatim text from the file.
|
|
29
|
+
- **Do write**: what the file exports, what pattern it uses, what dependencies it has, what its responsibility is.
|
|
30
|
+
|
|
31
|
+
**Good fact** ✅:
|
|
32
|
+
> `"Exports writeCommand — validates subject, merges per-path facts atomically, returns Result<WriteResult>"`
|
|
33
|
+
|
|
34
|
+
**Bad fact** ❌:
|
|
35
|
+
> `"import { ExternalCacheFileSchema, LocalCacheFileSchema } from '../types/cache.js'; import { ErrorCode, Result } from '../types/result.js'; import { WriteArgs, WriteResult } from '../types/commands.js'"` ← this is raw file content
|
|
36
|
+
|
|
37
|
+
**Global facts** are for cross-cutting structural observations only (e.g. CLI entry pattern, installation steps). Max 20, each ≤ 300 chars. Only update global_facts when you re-read a structural file (AGENTS.md, install.sh, package.json, *.toml, opencode.json).
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Mandatory: Write Before Return
|
|
42
|
+
|
|
43
|
+
**Every invocation must call `cache_ctrl_write` before returning.** Returning without writing is a failure — the orchestrator will detect the missing write and re-invoke you.
|
|
44
|
+
|
|
45
|
+
Sequential checklist (do not skip any step):
|
|
46
|
+
|
|
47
|
+
1. Call `cache_ctrl_check_files` — identify changed/new files
|
|
48
|
+
2. Read only the changed/new files (skip unchanged ones)
|
|
49
|
+
3. Extract concise facts per file (follow Fact-Writing Rules above)
|
|
50
|
+
4. **Call `cache_ctrl_write` — MANDATORY** (even if only 1 file changed, even if only global_facts changed)
|
|
51
|
+
5. Return your summary
|
|
52
|
+
|
|
53
|
+
If there are no changed files, the cache already exists and is non-empty, **and you were not invoked after a cache invalidation**, you may skip the write — but only in this case.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Startup Workflow
|
|
58
|
+
|
|
59
|
+
### 1. Check if tracked files changed
|
|
60
|
+
|
|
61
|
+
**Tier 1:** Call `cache_ctrl_check_files` (no parameters).
|
|
62
|
+
**Tier 2:** `cache-ctrl check-files`
|
|
63
|
+
**Tier 3:** `read` `.ai/local-context-gatherer_cache/context.json`.
|
|
64
|
+
- File absent → cold start, proceed to scan.
|
|
65
|
+
- File present → check `timestamp`. If older than 1 hour, treat as stale and re-scan. Otherwise treat as fresh.
|
|
66
|
+
|
|
67
|
+
Result interpretation (Tier 1 & 2):
|
|
68
|
+
- `status: "unchanged"` → tracked files are content-stable; skip re-scan and return cached context.
|
|
69
|
+
- `status: "changed"` → at least one tracked file changed; proceed to **delta scan** (read content of `changed_files` + `new_files` only — do not re-read unchanged files).
|
|
70
|
+
- `status: "unchanged"` with empty `tracked_files` → cold start, proceed to scan.
|
|
71
|
+
|
|
72
|
+
The response also reports:
|
|
73
|
+
- `new_files` — untracked non-ignored files absent from cache, plus git-tracked files absent from cache when the cache is non-empty (blank-slate caches skip git-tracked files to avoid false positives on cold start)
|
|
74
|
+
- `deleted_git_files` — git-tracked files deleted from the working tree (reported by `git ls-files --deleted`)
|
|
75
|
+
|
|
76
|
+
> **⚠ Cache is non-exhaustive**: `status: "unchanged"` only confirms that previously-tracked files are content-stable — it does not mean the file set is complete. Always check `new_files` and `deleted_git_files` in the response; if either is non-empty, include those paths in the next write to keep the cache up to date.
|
|
77
|
+
|
|
78
|
+
### 2. Invalidate before writing (optional)
|
|
79
|
+
|
|
80
|
+
> Do this only if cache is really outdated and a full rescan is needed. Otherwise just proceed with next step (writing).
|
|
81
|
+
|
|
82
|
+
**Tier 1:** Call `cache_ctrl_invalidate` with `agent: "local"`.
|
|
83
|
+
**Tier 2:** `cache-ctrl invalidate local`
|
|
84
|
+
**Tier 3:** Skip — overwriting the file in step 3 is sufficient.
|
|
85
|
+
|
|
86
|
+
### 3. Write cache after scanning
|
|
87
|
+
|
|
88
|
+
**Always use the write tool/command — never edit the file directly.** Direct writes bypass schema validation and can silently corrupt the cache format.
|
|
89
|
+
|
|
90
|
+
> **Write is per-path merge**: Submitted `tracked_files` entries replace existing entries for the same paths. Paths not in the submission are preserved. Entries for files deleted from disk are evicted automatically (no agent action needed).
|
|
91
|
+
|
|
92
|
+
#### Input fields (`content` object)
|
|
93
|
+
|
|
94
|
+
| Field | Type | Required | Notes |
|
|
95
|
+
|---|---|---|---|
|
|
96
|
+
| `topic` | `string` | ✅ | Human description of what was scanned |
|
|
97
|
+
| `description` | `string` | ✅ | One-liner for keyword search |
|
|
98
|
+
| `tracked_files` | `Array<{ path: string }>` | ✅ | Paths to track; `mtime` and `hash` are auto-computed by the tool |
|
|
99
|
+
| `global_facts` | `string[]` | optional | Repo-level facts; last-write-wins; see trigger rule below |
|
|
100
|
+
| `facts` | `Record<string, string[]>` | optional | Per-file facts keyed by path; per-path merge |
|
|
101
|
+
| `cache_miss_reason` | `string` | optional | Why the previous cache was discarded |
|
|
102
|
+
|
|
103
|
+
> **Cold start vs incremental**: On first run (no existing cache), submit all relevant files. On subsequent runs, submit only new and changed files — the tool merges them in.
|
|
104
|
+
|
|
105
|
+
> **Auto-set by the tool — do not include**: `timestamp` (current UTC), `mtime` (filesystem `lstat()`), and `hash` (SHA-256) per `tracked_files` entry.
|
|
106
|
+
|
|
107
|
+
### Scope rule for `facts`
|
|
108
|
+
|
|
109
|
+
Submit `facts` ONLY for files you actually read in this session (i.e., files present in
|
|
110
|
+
your submitted `tracked_files`). Never reconstruct or re-submit facts for unchanged files —
|
|
111
|
+
the tool preserves them automatically via per-path merge.
|
|
112
|
+
|
|
113
|
+
Submitting a facts key for a path absent from submitted `tracked_files` is a
|
|
114
|
+
VALIDATION_ERROR and the entire write is rejected.
|
|
115
|
+
|
|
116
|
+
### Fact completeness
|
|
117
|
+
|
|
118
|
+
When a file appears in `changed_files` or `new_files`, read the **whole file** before writing
|
|
119
|
+
facts — not just the diff. A 2-line change does not support a complete re-description of the
|
|
120
|
+
file, and submitting partial facts for a re-read path **permanently replaces** whatever was
|
|
121
|
+
cached before.
|
|
122
|
+
|
|
123
|
+
Write facts as **enumerable observations** — one entry per notable characteristic (purpose,
|
|
124
|
+
structure, key dependencies, patterns, constraints, entry points). Do not bundle multiple
|
|
125
|
+
distinct properties into a single string. A file should have as many fact entries as it has
|
|
126
|
+
distinct notable properties, not a prose summary compressed into one or two lines.
|
|
127
|
+
|
|
128
|
+
### When to submit `global_facts`
|
|
129
|
+
|
|
130
|
+
Submit `global_facts` only when you re-read at least one structural file in this session:
|
|
131
|
+
AGENTS.md, install.sh, opencode.json, package.json, *.toml config files.
|
|
132
|
+
|
|
133
|
+
If none of those are in `changed_files` or `new_files`, omit `global_facts` from the write.
|
|
134
|
+
The existing value is preserved automatically.
|
|
135
|
+
|
|
136
|
+
### Eviction
|
|
137
|
+
|
|
138
|
+
Facts for files deleted from disk are evicted automatically on the next write — no agent
|
|
139
|
+
action needed. `global_facts` is never evicted.
|
|
140
|
+
|
|
141
|
+
#### Tier 1 — `cache_ctrl_write`
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"agent": "local",
|
|
146
|
+
"content": {
|
|
147
|
+
"topic": "neovim plugin configuration scan",
|
|
148
|
+
"description": "Full scan of lua/plugins tree for neovim lazy.nvim setup",
|
|
149
|
+
"tracked_files": [
|
|
150
|
+
{ "path": "lua/plugins/ui/bufferline.lua" },
|
|
151
|
+
{ "path": "lua/plugins/lsp/nvim-lspconfig.lua" }
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
#### Tier 2 — CLI
|
|
158
|
+
|
|
159
|
+
`cache-ctrl write local --data '<json>'` — pass the same `content` object as JSON string.
|
|
160
|
+
|
|
161
|
+
#### Tier 3
|
|
162
|
+
|
|
163
|
+
Not available — there is no direct-file fallback for writes. If neither Tier 1 nor Tier 2 is accessible, request access to one of them.
|
|
164
|
+
|
|
165
|
+
### 4. Confirm cache (optional)
|
|
166
|
+
|
|
167
|
+
**Tier 1:** Call `cache_ctrl_list` with `agent: "local"` to confirm the entry was written.
|
|
168
|
+
**Tier 2:** `cache-ctrl list --agent local`
|
|
169
|
+
**Tier 3:** `read` `.ai/local-context-gatherer_cache/context.json` and verify `timestamp` is current.
|
|
170
|
+
|
|
171
|
+
Note: local entries show `is_stale: true` only when `cache_ctrl_check_files` detects actual changes (changed files, new non-ignored files, or deleted files). A freshly-written cache with no subsequent file changes will show `is_stale: false`.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Tool / Command Reference
|
|
176
|
+
|
|
177
|
+
| Operation | Tier 1 (built-in) | Tier 2 (CLI) | Tier 3 (manual) |
|
|
178
|
+
|---|---|---|---|
|
|
179
|
+
| Detect file changes | `cache_ctrl_check_files` | `cache-ctrl check-files` | read `context.json`, check `timestamp` |
|
|
180
|
+
| Invalidate cache | `cache_ctrl_invalidate` | `cache-ctrl invalidate local` | overwrite file in next step |
|
|
181
|
+
| Confirm written | `cache_ctrl_list` | `cache-ctrl list --agent local` | `read` file, check `timestamp` |
|
|
182
|
+
| Read facts (filtered) | `cache_ctrl_inspect` with `filter`, `folder`, or `searchFacts` | `cache-ctrl inspect local context --filter <kw>[,<kw>...]` / `--folder <path>` / `--search-facts <kw>[,<kw>...]` | `read` file, extract `facts`/`global_facts` |
|
|
183
|
+
| Read all facts (rare) | `cache_ctrl_inspect` (no filter) | `cache-ctrl inspect local context` | `read` file directly |
|
|
184
|
+
| Write cache | `cache_ctrl_write` | `cache-ctrl write local --data '<json>'` | ❌ not available |
|
|
185
|
+
|
|
186
|
+
> **⚠ Always use at least one filter when reading facts for a specific task.** Three targeting options are available — use the most specific one that fits your task:
|
|
187
|
+
>
|
|
188
|
+
> | Flag | What it matches | Best for |
|
|
189
|
+
> |---|---|---|
|
|
190
|
+
> | `--filter <kw>` | File path contains keyword | When you know which files by name/path segment |
|
|
191
|
+
> | `--folder <path>` | File path starts with folder prefix (recursive) | When you need all files in a directory subtree |
|
|
192
|
+
> | `--search-facts <kw>` | Any fact string contains keyword | When you need files related to a concept, pattern, or API |
|
|
193
|
+
>
|
|
194
|
+
> The flags are AND-ed when combined. Omit all filters only when you genuinely need facts for the entire repository (rare — e.g. building a full index; only appropriate for ≤ ~20 tracked files). An unfiltered `inspect` on a large repo can return thousands of fact strings.
|
|
195
|
+
|
|
196
|
+
> **`tracked_files` is never returned by `inspect` for the local agent.** It is internal operational metadata consumed by `check-files`. It will not appear in any inspect response.
|
|
197
|
+
|
|
198
|
+
## server_time in Responses
|
|
199
|
+
|
|
200
|
+
Every `cache_ctrl_*` tool call returns a `server_time` field at the outer JSON level:
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{ "ok": true, "value": { ... }, "server_time": "2026-04-05T12:34:56.789Z" }
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Use this to assess how stale stored timestamps are — you do not need `bash` or system access to know the current time.
|
|
207
|
+
|
|
208
|
+
## Cache Location
|
|
209
|
+
|
|
210
|
+
`.ai/local-context-gatherer_cache/context.json` — single file, no per-subject splitting.
|
|
211
|
+
|
|
212
|
+
No time-based TTL for Tier 1/2. Freshness determined by `cache_ctrl_check_files`.
|
|
213
|
+
Tier 3 uses a 1-hour `timestamp` TTL as a rough proxy.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { readFile, writeFile, rename, stat, unlink, readdir, mkdir } from "node:fs/promises";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import type { AgentType, CacheEntry, ExternalCacheFile, LocalCacheFile } from "../types/cache.js";
|
|
6
|
+
import { ExternalCacheFileSchema } from "../types/cache.js";
|
|
7
|
+
import { ErrorCode, type Result } from "../types/result.js";
|
|
8
|
+
import { getFileStem } from "../utils/fileStem.js";
|
|
9
|
+
|
|
10
|
+
const LOCK_RETRY_INTERVAL_MS = 50;
|
|
11
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
12
|
+
const LOCK_STALE_AGE_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
export async function findRepoRoot(startDir: string): Promise<string> {
|
|
15
|
+
let current = startDir;
|
|
16
|
+
while (true) {
|
|
17
|
+
try {
|
|
18
|
+
await stat(join(current, ".git"));
|
|
19
|
+
return current;
|
|
20
|
+
} catch {
|
|
21
|
+
const parent = dirname(current);
|
|
22
|
+
if (parent === current) {
|
|
23
|
+
// Reached filesystem root — fall back to startDir
|
|
24
|
+
return startDir;
|
|
25
|
+
}
|
|
26
|
+
current = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveCacheDir(agent: AgentType, repoRoot: string): string {
|
|
32
|
+
if (agent === "external") {
|
|
33
|
+
return join(repoRoot, ".ai", "external-context-gatherer_cache");
|
|
34
|
+
}
|
|
35
|
+
return join(repoRoot, ".ai", "local-context-gatherer_cache");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readCache(filePath: string): Promise<Result<Record<string, unknown>>> {
|
|
39
|
+
try {
|
|
40
|
+
const content = await readFile(filePath, "utf-8");
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
43
|
+
return { ok: true, value: parsed };
|
|
44
|
+
} catch {
|
|
45
|
+
return { ok: false, error: `Failed to parse JSON: ${filePath}`, code: ErrorCode.PARSE_ERROR };
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const error = err as NodeJS.ErrnoException;
|
|
49
|
+
if (error.code === "ENOENT") {
|
|
50
|
+
return { ok: false, error: `Cache file not found: ${filePath}`, code: ErrorCode.FILE_NOT_FOUND };
|
|
51
|
+
}
|
|
52
|
+
return { ok: false, error: `Failed to read file: ${filePath}: ${error.message}`, code: ErrorCode.FILE_READ_ERROR };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function writeCache(
|
|
57
|
+
filePath: string,
|
|
58
|
+
updates: Partial<ExternalCacheFile> | Partial<LocalCacheFile> | Record<string, unknown>,
|
|
59
|
+
mode: "merge" | "replace" = "merge",
|
|
60
|
+
): Promise<Result<void>> {
|
|
61
|
+
// Ensure parent directory exists before acquiring the lock
|
|
62
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
63
|
+
|
|
64
|
+
const lockResult = await acquireLock(filePath);
|
|
65
|
+
if (!lockResult.ok) return lockResult;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
let merged: Record<string, unknown>;
|
|
69
|
+
|
|
70
|
+
if (mode === "replace") {
|
|
71
|
+
merged = updates as Record<string, unknown>;
|
|
72
|
+
} else {
|
|
73
|
+
// Read existing content if file exists
|
|
74
|
+
let existing: Record<string, unknown> = {};
|
|
75
|
+
const readResult = await readCache(filePath);
|
|
76
|
+
if (readResult.ok) {
|
|
77
|
+
existing = readResult.value;
|
|
78
|
+
} else if (readResult.code !== ErrorCode.FILE_NOT_FOUND) {
|
|
79
|
+
return { ok: false, error: readResult.error, code: readResult.code };
|
|
80
|
+
}
|
|
81
|
+
merged = { ...existing, ...updates };
|
|
82
|
+
}
|
|
83
|
+
const tmpPath = `${filePath}.tmp.${process.pid}.${randomBytes(6).toString("hex")}`;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await writeFile(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
87
|
+
await rename(tmpPath, filePath);
|
|
88
|
+
return { ok: true, value: undefined };
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const error = err as NodeJS.ErrnoException;
|
|
91
|
+
// Clean up tmp file on failure
|
|
92
|
+
try {
|
|
93
|
+
await unlink(tmpPath);
|
|
94
|
+
} catch {
|
|
95
|
+
// Ignore cleanup failure
|
|
96
|
+
}
|
|
97
|
+
return { ok: false, error: `Failed to write cache: ${error.message}`, code: ErrorCode.FILE_WRITE_ERROR };
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
await releaseLock(filePath);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function listCacheFiles(agent: AgentType, repoRoot: string): Promise<Result<string[]>> {
|
|
105
|
+
const cacheDir = resolveCacheDir(agent, repoRoot);
|
|
106
|
+
try {
|
|
107
|
+
const entries = await readdir(cacheDir);
|
|
108
|
+
const jsonFiles = entries
|
|
109
|
+
.filter((name) => name.endsWith(".json") && !name.endsWith(".lock"))
|
|
110
|
+
.map((name) => join(cacheDir, name));
|
|
111
|
+
return { ok: true, value: jsonFiles };
|
|
112
|
+
} catch (err) {
|
|
113
|
+
const error = err as NodeJS.ErrnoException;
|
|
114
|
+
if (error.code === "ENOENT") {
|
|
115
|
+
return { ok: true, value: [] };
|
|
116
|
+
}
|
|
117
|
+
return { ok: false, error: `Failed to list cache directory: ${error.message}`, code: ErrorCode.FILE_READ_ERROR };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Loads all valid external cache entries for a repo, returning a `CacheEntry` array.
|
|
123
|
+
* Files that cannot be read or fail schema validation are skipped with a warning to stderr.
|
|
124
|
+
* Returns `ok: false` only when the cache directory itself cannot be listed.
|
|
125
|
+
*/
|
|
126
|
+
export async function loadExternalCacheEntries(repoRoot: string): Promise<Result<CacheEntry[]>> {
|
|
127
|
+
const filesResult = await listCacheFiles("external", repoRoot);
|
|
128
|
+
if (!filesResult.ok) return filesResult;
|
|
129
|
+
|
|
130
|
+
const entries: CacheEntry[] = [];
|
|
131
|
+
for (const filePath of filesResult.value) {
|
|
132
|
+
const readResult = await readCache(filePath);
|
|
133
|
+
if (!readResult.ok) {
|
|
134
|
+
process.stderr.write(`[cache-ctrl] Warning: skipping invalid JSON file: ${filePath}\n`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
|
|
138
|
+
if (!parseResult.success) {
|
|
139
|
+
process.stderr.write(`[cache-ctrl] Warning: skipping malformed external cache file: ${filePath}\n`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const data: ExternalCacheFile = parseResult.data;
|
|
143
|
+
const stem = getFileStem(filePath);
|
|
144
|
+
const subject = data.subject ?? stem;
|
|
145
|
+
if (subject !== stem) {
|
|
146
|
+
process.stderr.write(`[cache-ctrl] Warning: subject "${subject}" does not match file stem "${stem}" in ${filePath}\n`);
|
|
147
|
+
}
|
|
148
|
+
entries.push({
|
|
149
|
+
file: filePath,
|
|
150
|
+
agent: "external",
|
|
151
|
+
subject,
|
|
152
|
+
description: data.description,
|
|
153
|
+
fetched_at: data.fetched_at ?? "",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { ok: true, value: entries };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function acquireLock(filePath: string): Promise<Result<void>> {
|
|
161
|
+
const lockPath = `${filePath}.lock`;
|
|
162
|
+
const start = Date.now();
|
|
163
|
+
|
|
164
|
+
while (true) {
|
|
165
|
+
try {
|
|
166
|
+
// O_EXCL: atomic create, fails if exists
|
|
167
|
+
const fh = await open(lockPath, "wx");
|
|
168
|
+
await fh.write(`${process.pid}\n`);
|
|
169
|
+
await fh.close();
|
|
170
|
+
return { ok: true, value: undefined };
|
|
171
|
+
} catch (err) {
|
|
172
|
+
const error = err as NodeJS.ErrnoException;
|
|
173
|
+
if (error.code !== "EEXIST") {
|
|
174
|
+
return { ok: false, error: `Lock error: ${error.message}`, code: ErrorCode.LOCK_ERROR };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Lock exists — check if stale
|
|
178
|
+
const staleResult = await isLockStale(lockPath);
|
|
179
|
+
if (staleResult) {
|
|
180
|
+
// Remove stale lock and retry immediately
|
|
181
|
+
try {
|
|
182
|
+
await unlink(lockPath);
|
|
183
|
+
} catch {
|
|
184
|
+
// Another process may have removed it already
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check timeout
|
|
190
|
+
if (Date.now() - start >= LOCK_TIMEOUT_MS) {
|
|
191
|
+
return { ok: false, error: "Lock timeout: could not acquire lock within 5 seconds", code: ErrorCode.LOCK_TIMEOUT };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Wait and retry
|
|
195
|
+
await sleep(LOCK_RETRY_INTERVAL_MS);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function releaseLock(filePath: string): Promise<void> {
|
|
201
|
+
const lockPath = `${filePath}.lock`;
|
|
202
|
+
try {
|
|
203
|
+
await unlink(lockPath);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
const error = err as NodeJS.ErrnoException;
|
|
206
|
+
if (error.code !== "ENOENT") {
|
|
207
|
+
console.warn(`[cache-ctrl] Warning: failed to release lock ${lockPath}: ${error.message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function isLockStale(lockPath: string): Promise<boolean> {
|
|
213
|
+
try {
|
|
214
|
+
const lockStat = await stat(lockPath);
|
|
215
|
+
const ageMs = Date.now() - lockStat.mtimeMs;
|
|
216
|
+
if (ageMs > LOCK_STALE_AGE_MS) {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const content = await readFile(lockPath, "utf-8");
|
|
221
|
+
const pidStr = content.trim();
|
|
222
|
+
const pid = parseInt(pidStr, 10);
|
|
223
|
+
if (isNaN(pid) || pid <= 0 || pid >= 4_194_304) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
process.kill(pid, 0);
|
|
229
|
+
return false; // PID is alive
|
|
230
|
+
} catch {
|
|
231
|
+
return true; // PID is dead
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
// Cannot read lock — treat as stale
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function sleep(ms: number): Promise<void> {
|
|
240
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
241
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ExternalCacheFile, CacheEntry } from "../types/cache.js";
|
|
4
|
+
import { ExternalCacheFileSchema } from "../types/cache.js";
|
|
5
|
+
import { ErrorCode, type Result } from "../types/result.js";
|
|
6
|
+
import { readCache, listCacheFiles } from "./cacheManager.js";
|
|
7
|
+
import { scoreEntry } from "../search/keywordSearch.js";
|
|
8
|
+
import { getFileStem } from "../utils/fileStem.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
export function resolveExternalCacheDir(repoRoot: string): string {
|
|
13
|
+
return join(repoRoot, ".ai", "external-context-gatherer_cache");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function resolveExternalFiles(repoRoot: string): Promise<Result<string[]>> {
|
|
17
|
+
const cacheDir = resolveExternalCacheDir(repoRoot);
|
|
18
|
+
try {
|
|
19
|
+
const entries = await readdir(cacheDir);
|
|
20
|
+
return {
|
|
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;
|
|
37
|
+
const threshold = maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
|
38
|
+
const age = Date.now() - new Date(entry.fetched_at).getTime();
|
|
39
|
+
return age > threshold;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HeaderMeta {
|
|
43
|
+
etag?: string;
|
|
44
|
+
last_modified?: string;
|
|
45
|
+
checked_at: string;
|
|
46
|
+
status: "fresh" | "stale" | "unchecked";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function mergeHeaderMetadata(
|
|
50
|
+
existing: ExternalCacheFile,
|
|
51
|
+
updates: Record<string, HeaderMeta>,
|
|
52
|
+
): ExternalCacheFile {
|
|
53
|
+
return {
|
|
54
|
+
...existing,
|
|
55
|
+
header_metadata: {
|
|
56
|
+
...existing.header_metadata,
|
|
57
|
+
...updates,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getAgeHuman(fetchedAt: string): string {
|
|
63
|
+
if (!fetchedAt) return "invalidated";
|
|
64
|
+
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const fetched = new Date(fetchedAt).getTime();
|
|
67
|
+
const diffMs = now - fetched;
|
|
68
|
+
|
|
69
|
+
if (diffMs < 0) return "just now";
|
|
70
|
+
|
|
71
|
+
const minutes = Math.floor(diffMs / 60_000);
|
|
72
|
+
const hours = Math.floor(diffMs / 3_600_000);
|
|
73
|
+
const days = Math.floor(diffMs / 86_400_000);
|
|
74
|
+
|
|
75
|
+
if (days >= 1) {
|
|
76
|
+
return days === 1 ? "1 day ago" : `${days} days ago`;
|
|
77
|
+
}
|
|
78
|
+
if (hours >= 1) {
|
|
79
|
+
return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
|
|
80
|
+
}
|
|
81
|
+
if (minutes >= 1) {
|
|
82
|
+
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
|
|
83
|
+
}
|
|
84
|
+
return "just now";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves the file path of the best-scoring external cache entry for a given subject keyword.
|
|
89
|
+
* Returns NO_MATCH if no entry scores above zero.
|
|
90
|
+
*/
|
|
91
|
+
export async function resolveTopExternalMatch(repoRoot: string, subject: string): Promise<Result<string>> {
|
|
92
|
+
const filesResult = await listCacheFiles("external", repoRoot);
|
|
93
|
+
if (!filesResult.ok) return filesResult;
|
|
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
|
+
}
|
|
115
|
+
|
|
116
|
+
const keywords = [subject];
|
|
117
|
+
const scored = candidates
|
|
118
|
+
.map((c) => ({ ...c, score: scoreEntry(c.entry, keywords) }))
|
|
119
|
+
.filter((c) => c.score > 0)
|
|
120
|
+
.sort((a, b) => b.score - a.score);
|
|
121
|
+
|
|
122
|
+
if (scored.length === 0) {
|
|
123
|
+
return { ok: false, error: `No cache entry matched keyword "${subject}"`, code: ErrorCode.NO_MATCH };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { ok: true, value: scored[0]!.filePath };
|
|
127
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
export function resolveLocalCacheDir(repoRoot: string): string {
|
|
4
|
+
return join(repoRoot, ".ai", "local-context-gatherer_cache");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function resolveLocalCachePath(repoRoot: string): string {
|
|
8
|
+
return join(resolveLocalCacheDir(repoRoot), "context.json");
|
|
9
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { isAbsolute, posix, relative, sep } from "node:path";
|
|
2
|
+
import { findRepoRoot, readCache } from "../cache/cacheManager.js";
|
|
3
|
+
import { resolveLocalCachePath } from "../cache/localCache.js";
|
|
4
|
+
import { compareTrackedFile } from "../files/changeDetector.js";
|
|
5
|
+
import { getGitTrackedFiles, getGitDeletedFiles, getUntrackedNonIgnoredFiles } from "../files/gitFiles.js";
|
|
6
|
+
import { LocalCacheFileSchema } from "../types/cache.js";
|
|
7
|
+
import { ErrorCode, type Result } from "../types/result.js";
|
|
8
|
+
import type { CheckFilesResult } from "../types/commands.js";
|
|
9
|
+
|
|
10
|
+
const toPosix = (p: string) => p.split(sep).join(posix.sep);
|
|
11
|
+
|
|
12
|
+
export async function checkFilesCommand(): Promise<Result<CheckFilesResult["value"]>> {
|
|
13
|
+
try {
|
|
14
|
+
const repoRoot = await findRepoRoot(process.cwd());
|
|
15
|
+
const localPath = resolveLocalCachePath(repoRoot);
|
|
16
|
+
|
|
17
|
+
const readResult = await readCache(localPath);
|
|
18
|
+
if (!readResult.ok) return readResult;
|
|
19
|
+
|
|
20
|
+
const parseResult = LocalCacheFileSchema.safeParse(readResult.value);
|
|
21
|
+
if (!parseResult.success) {
|
|
22
|
+
return { ok: false, error: `Malformed local cache file: ${localPath}`, code: ErrorCode.PARSE_ERROR };
|
|
23
|
+
}
|
|
24
|
+
const data = parseResult.data;
|
|
25
|
+
const trackedFiles = data.tracked_files;
|
|
26
|
+
|
|
27
|
+
const changedFiles: Array<{ path: string; reason: "mtime" | "hash" | "missing" }> = [];
|
|
28
|
+
const unchangedFiles: string[] = [];
|
|
29
|
+
const missingFiles: string[] = [];
|
|
30
|
+
|
|
31
|
+
for (const trackedFile of trackedFiles) {
|
|
32
|
+
const result = await compareTrackedFile(trackedFile, repoRoot);
|
|
33
|
+
if (result.status === "unchanged") {
|
|
34
|
+
unchangedFiles.push(trackedFile.path);
|
|
35
|
+
} else if (result.status === "missing") {
|
|
36
|
+
missingFiles.push(trackedFile.path);
|
|
37
|
+
changedFiles.push({ path: trackedFile.path, reason: "missing" });
|
|
38
|
+
} else {
|
|
39
|
+
changedFiles.push({ path: trackedFile.path, reason: result.reason ?? "mtime" });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const [gitTrackedFiles, deletedGitFiles, untrackedNonIgnoredFiles] = await Promise.all([
|
|
44
|
+
getGitTrackedFiles(repoRoot),
|
|
45
|
+
getGitDeletedFiles(repoRoot),
|
|
46
|
+
getUntrackedNonIgnoredFiles(repoRoot),
|
|
47
|
+
]);
|
|
48
|
+
const toRepoRelativePosix = (filePath: string): string => {
|
|
49
|
+
const rel = isAbsolute(filePath) ? relative(repoRoot, filePath) : filePath;
|
|
50
|
+
return rel.split(sep).join(posix.sep);
|
|
51
|
+
};
|
|
52
|
+
const cachedPaths = new Set(trackedFiles.map((f) => toRepoRelativePosix(f.path)));
|
|
53
|
+
// When tracked_files is empty (blank-slate), skip git-tracked files from new_files
|
|
54
|
+
// because those were already present before this cache was written.
|
|
55
|
+
// Untracked non-ignored files are always reported as new — they represent newly
|
|
56
|
+
// created files that the user added to the working tree.
|
|
57
|
+
const baseFiles = trackedFiles.length > 0 ? gitTrackedFiles : [];
|
|
58
|
+
const newFiles = [...new Set([...baseFiles, ...untrackedNonIgnoredFiles])].filter(
|
|
59
|
+
(p) => !cachedPaths.has(toRepoRelativePosix(p)),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
value: {
|
|
65
|
+
status:
|
|
66
|
+
changedFiles.length > 0 ||
|
|
67
|
+
missingFiles.length > 0 ||
|
|
68
|
+
newFiles.length > 0 ||
|
|
69
|
+
deletedGitFiles.length > 0
|
|
70
|
+
? "changed"
|
|
71
|
+
: "unchanged",
|
|
72
|
+
changed_files: changedFiles,
|
|
73
|
+
unchanged_files: unchangedFiles,
|
|
74
|
+
missing_files: missingFiles,
|
|
75
|
+
new_files: newFiles,
|
|
76
|
+
deleted_git_files: deletedGitFiles,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
} catch (err) {
|
|
80
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
81
|
+
return { ok: false, error: msg, code: ErrorCode.UNKNOWN };
|
|
82
|
+
}
|
|
83
|
+
}
|