decorated-pi 0.5.4 → 0.6.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/.codegraph/daemon.pid +6 -0
- package/AGENTS.md +92 -0
- package/README.md +53 -64
- package/commands/dp-model.ts +23 -0
- package/commands/dp-settings.ts +23 -0
- package/commands/mcp-status.ts +62 -0
- package/commands/retry.ts +19 -0
- package/commands/usage.ts +544 -0
- package/hooks/compaction.ts +204 -0
- package/hooks/externalize.ts +70 -0
- package/hooks/image-vision.ts +132 -0
- package/hooks/inject-agents-md.ts +164 -0
- package/hooks/mcp.ts +340 -0
- package/hooks/normalize-codeblocks.ts +88 -0
- package/hooks/pi-tool-filter.ts +28 -0
- package/{extensions/safety → hooks/redact}/types.ts +1 -8
- package/hooks/redact.ts +104 -0
- package/{extensions → hooks}/rtk.ts +92 -115
- package/hooks/session-title.ts +54 -0
- package/hooks/skeleton.ts +212 -0
- package/hooks/smart-at.ts +318 -0
- package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
- package/{extensions → hooks}/wakatime.ts +120 -122
- package/index.ts +155 -1
- package/package.json +5 -4
- package/{extensions/settings.ts → settings.ts} +32 -0
- package/{extensions → tools}/lsp/client.ts +0 -25
- package/{extensions → tools}/lsp/format.ts +2 -99
- package/{extensions → tools}/lsp/index.ts +1 -1
- package/{extensions → tools}/lsp/servers.ts +1 -1
- package/{extensions → tools}/lsp/tools.ts +1 -66
- package/{extensions → tools}/lsp/types.ts +0 -11
- package/tools/mcp/builtin/codegraph.ts +45 -0
- package/tools/mcp/builtin/context7.ts +10 -0
- package/tools/mcp/builtin/exa.ts +10 -0
- package/tools/mcp/builtin/index.ts +19 -0
- package/tools/mcp/cache.ts +124 -0
- package/{extensions → tools}/mcp/client.ts +2 -2
- package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
- package/tools/mcp/index.ts +44 -0
- package/tools/mcp/tool-definition.ts +112 -0
- package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
- package/{extensions/io.ts → tools/patch/index.ts} +29 -205
- package/tsconfig.json +10 -1
- package/ui/mcp-status.ts +202 -0
- package/ui/model-picker.ts +162 -0
- package/ui/module-settings.ts +83 -0
- package/ui/usage.ts +396 -0
- package/extensions/index.ts +0 -154
- package/extensions/io-tool-output.ts +0 -33
- package/extensions/mcp/index.ts +0 -435
- package/extensions/model-integration.ts +0 -531
- package/extensions/providers/ark-coding.ts +0 -75
- package/extensions/providers/index.ts +0 -9
- package/extensions/providers/ollama-cloud.ts +0 -101
- package/extensions/providers/qianfan-coding.ts +0 -71
- package/extensions/safety/index.ts +0 -102
- package/extensions/session-title.ts +0 -40
- package/extensions/slash.ts +0 -458
- package/extensions/smart-at.ts +0 -481
- package/extensions/subdir-agents.ts +0 -151
- /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
- /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
- /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
- /package/{extensions → tools}/lsp/env.ts +0 -0
- /package/{extensions → tools}/lsp/manager.ts +0 -0
- /package/{extensions → tools}/lsp/prompt.ts +0 -0
- /package/{extensions → tools}/lsp/protocol.ts +0 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* smart-at — high-speed file search autocomplete.
|
|
3
|
+
*
|
|
4
|
+
* Triggered on session_start by registering an autocomplete provider.
|
|
5
|
+
* Uses git ls-files (in git repos) or fd (elsewhere) for candidates,
|
|
6
|
+
* then fuzzy-matches with path penalties.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from "child_process";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { Module, Skeleton } from "./skeleton.js";
|
|
14
|
+
|
|
15
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
type PenaltyTier = 0 | 1 | 2 | 3 | 4;
|
|
18
|
+
|
|
19
|
+
interface FileCandidate {
|
|
20
|
+
path: string;
|
|
21
|
+
name: string;
|
|
22
|
+
isDir: boolean;
|
|
23
|
+
tier: PenaltyTier;
|
|
24
|
+
penalty: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ─── Hard-exclude & penalty rules ────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const HARD_EXCLUDE_DIRS = new Set(["node_modules", ".git", ".pnpm", ".svn"]);
|
|
30
|
+
|
|
31
|
+
const BAD_DIRS = new Set(["build", "dist", "coverage", "out", "target"]);
|
|
32
|
+
|
|
33
|
+
const EXT_PENALTY: Record<string, number> = {
|
|
34
|
+
o: -150, obj: -150, a: -150, so: -150, dll: -150, exe: -150,
|
|
35
|
+
wasm: -150, class: -120, pyc: -120,
|
|
36
|
+
bmp: -100, png: -100, jpg: -100, gif: -100, ico: -100, svg: -80,
|
|
37
|
+
mp3: -100, wav: -100, mp4: -100, avi: -100,
|
|
38
|
+
pdf: -100, zip: -100, tar: -100, gz: -100,
|
|
39
|
+
lock: -80,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ─── Penalty pre-compute ─────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function computePenaltyMeta(filePath: string, isDir: boolean, gitIgnored: boolean): { tier: PenaltyTier; penalty: number } {
|
|
45
|
+
const parts = filePath.replace(/\/$/, "").split("/");
|
|
46
|
+
const name = parts[parts.length - 1] || filePath;
|
|
47
|
+
const ext = (!isDir && name.includes(".")) ? (name.split(".").pop()?.toLowerCase() || "") : "";
|
|
48
|
+
const depth = parts.length;
|
|
49
|
+
const dirSegments = isDir ? parts : parts.slice(0, -1);
|
|
50
|
+
|
|
51
|
+
let tier: PenaltyTier = 0;
|
|
52
|
+
let tierPenalty = 0;
|
|
53
|
+
|
|
54
|
+
if (gitIgnored) { tier = 1; tierPenalty = -400; }
|
|
55
|
+
else if (dirSegments.some(d => d.startsWith(".") || d.startsWith("__"))) { tier = 2; tierPenalty = -300; }
|
|
56
|
+
else if (dirSegments.some(d => BAD_DIRS.has(d))) { tier = 3; tierPenalty = -200; }
|
|
57
|
+
else if (!isDir && (EXT_PENALTY[ext] ?? 0) < 0) { tier = 4; tierPenalty = EXT_PENALTY[ext]!; }
|
|
58
|
+
|
|
59
|
+
const basePenalty = -(depth * 30) - name.length;
|
|
60
|
+
return { tier, penalty: tierPenalty + basePenalty };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function computePenalty(filePath: string, isDir: boolean, gitIgnored: boolean): number {
|
|
64
|
+
return computePenaltyMeta(filePath, isDir, gitIgnored).penalty;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Candidate collection ────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
const SPAWN_OPTS = { timeout: 5000, encoding: "utf-8" as const, maxBuffer: 10 * 1024 * 1024 };
|
|
70
|
+
|
|
71
|
+
function isGitWorkTree(cwd: string): boolean {
|
|
72
|
+
return spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { ...SPAWN_OPTS, cwd }).status === 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function commandExists(command: string): boolean {
|
|
76
|
+
const result = spawnSync(
|
|
77
|
+
process.platform === "win32" ? "where" : (process.env.SHELL || "sh"),
|
|
78
|
+
process.platform === "win32" ? [command] : ["-lc", `command -v '${command.replace(/'/g, `'"'"'`)}'`],
|
|
79
|
+
SPAWN_OPTS,
|
|
80
|
+
);
|
|
81
|
+
return result.status === 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function collectCandidates(cwd: string): FileCandidate[] {
|
|
85
|
+
const candidates: FileCandidate[] = [];
|
|
86
|
+
const isChildOfHardExclude = (p: string): boolean => {
|
|
87
|
+
const parts = p.replace(/\/$/, "").split("/");
|
|
88
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
89
|
+
if (HARD_EXCLUDE_DIRS.has(parts[i]!)) return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const opts = { ...SPAWN_OPTS, cwd };
|
|
95
|
+
const isGit = isGitWorkTree(cwd);
|
|
96
|
+
|
|
97
|
+
if (isGit) {
|
|
98
|
+
const r1 = spawnSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], opts);
|
|
99
|
+
const visibleFiles = (r1.status === 0 && r1.stdout)
|
|
100
|
+
? r1.stdout.trim().split("\n").filter(Boolean) : [];
|
|
101
|
+
|
|
102
|
+
const r2 = spawnSync("git", ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--no-empty-directory"], opts);
|
|
103
|
+
const ignoredDirs = new Set<string>();
|
|
104
|
+
const ignoredFiles: string[] = [];
|
|
105
|
+
if (r2.status === 0 && r2.stdout) {
|
|
106
|
+
for (const raw of r2.stdout.trim().split("\n").filter(Boolean)) {
|
|
107
|
+
const entry = raw.replace(/^\.\//, "");
|
|
108
|
+
if (isChildOfHardExclude(entry)) continue;
|
|
109
|
+
if (entry.endsWith("/")) ignoredDirs.add(entry.replace(/\/$/, ""));
|
|
110
|
+
else ignoredFiles.push(entry);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const dirSet = new Set<string>();
|
|
115
|
+
for (const f of visibleFiles) {
|
|
116
|
+
if (isChildOfHardExclude(f)) continue;
|
|
117
|
+
const name = f.split("/").pop() || f;
|
|
118
|
+
const meta = computePenaltyMeta(f, false, false);
|
|
119
|
+
candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
|
|
120
|
+
const parts = f.split("/");
|
|
121
|
+
let current = "";
|
|
122
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
123
|
+
current = current ? `${current}/${parts[i]}` : parts[i]!;
|
|
124
|
+
dirSet.add(current);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const f of ignoredFiles) {
|
|
128
|
+
const name = f.split("/").pop() || f;
|
|
129
|
+
const meta = computePenaltyMeta(f, false, true);
|
|
130
|
+
candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
|
|
131
|
+
}
|
|
132
|
+
for (const d of dirSet) {
|
|
133
|
+
const name = d.split("/").pop() || d;
|
|
134
|
+
const meta = computePenaltyMeta(d, true, false);
|
|
135
|
+
candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
136
|
+
}
|
|
137
|
+
for (const d of ignoredDirs) {
|
|
138
|
+
const name = d.split("/").pop() || d;
|
|
139
|
+
const meta = computePenaltyMeta(d, true, true);
|
|
140
|
+
candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
141
|
+
}
|
|
142
|
+
for (const hd of HARD_EXCLUDE_DIRS) {
|
|
143
|
+
if (!existsSync(join(opts.cwd, hd))) continue;
|
|
144
|
+
const meta = computePenaltyMeta(hd, true, false);
|
|
145
|
+
candidates.push({ path: hd + "/", name: hd, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
const rel = (s: string) => {
|
|
149
|
+
let r = s.startsWith(cwd + "/") ? s.slice(cwd.length + 1) : s;
|
|
150
|
+
return r.startsWith("./") ? r.slice(2) : r;
|
|
151
|
+
};
|
|
152
|
+
const fdExcludes = [...HARD_EXCLUDE_DIRS].flatMap(d => ["--exclude", d]);
|
|
153
|
+
const fdOpts = { ...SPAWN_OPTS, cwd: undefined as string | undefined };
|
|
154
|
+
|
|
155
|
+
const r1 = spawnSync("fd", ["--type", "f", "--hidden", "--no-ignore", ...fdExcludes, ".", cwd], fdOpts);
|
|
156
|
+
if (r1.status === 0 && r1.stdout) {
|
|
157
|
+
for (const raw of r1.stdout.trim().split("\n").filter(Boolean)) {
|
|
158
|
+
const f = rel(raw);
|
|
159
|
+
const name = f.split("/").pop() || f;
|
|
160
|
+
const meta = computePenaltyMeta(f, false, false);
|
|
161
|
+
candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const r2 = spawnSync("fd", ["--type", "d", "--hidden", "--no-ignore", ...fdExcludes, ".", cwd], fdOpts);
|
|
165
|
+
if (r2.status === 0 && r2.stdout) {
|
|
166
|
+
for (const raw of r2.stdout.trim().split("\n").filter(Boolean)) {
|
|
167
|
+
const d = rel(raw).replace(/\/$/, "");
|
|
168
|
+
const name = d.split("/").pop() || d;
|
|
169
|
+
const meta = computePenaltyMeta(d, true, false);
|
|
170
|
+
candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const hd of HARD_EXCLUDE_DIRS) {
|
|
174
|
+
if (!existsSync(join(cwd, hd))) continue;
|
|
175
|
+
const meta = computePenaltyMeta(hd, true, false);
|
|
176
|
+
candidates.push({ path: hd + "/", name: hd, isDir: true, tier: meta.tier, penalty: meta.penalty });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return candidates;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Match scoring ───────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
function fuzzyScore(text: string, query: string): number {
|
|
185
|
+
let qi = 0, firstMatch = -1, lastMatch = -1;
|
|
186
|
+
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
|
|
187
|
+
if (text[ti] === query[qi]) {
|
|
188
|
+
if (firstMatch < 0) firstMatch = ti;
|
|
189
|
+
lastMatch = ti;
|
|
190
|
+
qi++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (qi < query.length) return 0;
|
|
194
|
+
const span = lastMatch - firstMatch + 1;
|
|
195
|
+
return Math.max(10, 200 - span * 3 - text.length);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function computeMatchScore(candidate: FileCandidate, query: string): number {
|
|
199
|
+
const { path: filePath, name, isDir } = candidate;
|
|
200
|
+
const stem = name.replace(/\.[^.]+$/, "");
|
|
201
|
+
const parts = filePath.replace(/\/$/, "").split("/");
|
|
202
|
+
const inDir = parts.slice(0, -1).some(d => d.includes(query));
|
|
203
|
+
|
|
204
|
+
let s = 0;
|
|
205
|
+
if (stem === query) s = isDir ? 1500 : 1050;
|
|
206
|
+
else if (name.startsWith(query + ".") || name.startsWith(query + "_")) s = 1000;
|
|
207
|
+
else if (name.startsWith(query)) s = 900;
|
|
208
|
+
else if (name.includes(query)) s = 600;
|
|
209
|
+
else if (filePath.includes(query)) s = 300;
|
|
210
|
+
else s = fuzzyScore(name, query);
|
|
211
|
+
|
|
212
|
+
if (!s) return 0;
|
|
213
|
+
if (isDir) s += 100;
|
|
214
|
+
if (inDir) s += 50;
|
|
215
|
+
return s;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function smartSearch(candidates: FileCandidate[], query: string): string[] {
|
|
219
|
+
if (!query) {
|
|
220
|
+
const visible = candidates.filter(c => c.tier === 0 || c.tier === 3 || c.tier === 4);
|
|
221
|
+
return visible
|
|
222
|
+
.sort((a, b) => {
|
|
223
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
224
|
+
return b.penalty - a.penalty || a.path.localeCompare(b.path);
|
|
225
|
+
})
|
|
226
|
+
.slice(0, 20)
|
|
227
|
+
.map(c => c.path);
|
|
228
|
+
}
|
|
229
|
+
const tokens = query.split(/\s+/).filter(Boolean);
|
|
230
|
+
if (tokens.length === 1) {
|
|
231
|
+
const scored = candidates
|
|
232
|
+
.map(c => ({ path: c.path, total: computeMatchScore(c, tokens[0]!) + c.penalty, matchScore: computeMatchScore(c, tokens[0]!) }))
|
|
233
|
+
.filter(x => x.matchScore > 0);
|
|
234
|
+
return scored
|
|
235
|
+
.sort((a, b) => b.total - a.total || a.path.localeCompare(b.path))
|
|
236
|
+
.slice(0, 20)
|
|
237
|
+
.map(x => x.path);
|
|
238
|
+
}
|
|
239
|
+
const seen = new Set<string>();
|
|
240
|
+
for (const t of tokens) {
|
|
241
|
+
const scored = candidates
|
|
242
|
+
.map(c => ({ path: c.path, total: computeMatchScore(c, t) + c.penalty, matchScore: computeMatchScore(c, t) }))
|
|
243
|
+
.filter(x => x.matchScore > 0)
|
|
244
|
+
.sort((a, b) => b.total - a.total)
|
|
245
|
+
.slice(0, 20);
|
|
246
|
+
for (const { path } of scored) seen.add(path);
|
|
247
|
+
}
|
|
248
|
+
return [...seen].slice(0, 20);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function atPrefix(text: string): string | null {
|
|
252
|
+
for (let i = text.length - 1; i >= 0; i--) {
|
|
253
|
+
if (text[i] !== "@") continue;
|
|
254
|
+
const b = text[i - 1];
|
|
255
|
+
if (i === 0 || b === " " || b === "\t" || b === "(" || b === "[") return text.slice(i);
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export const __smartAtTest = {
|
|
262
|
+
computePenaltyMeta, computePenalty, fuzzyScore, computeMatchScore, smartSearch, atPrefix,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
export const smartAtModule: Module = {
|
|
266
|
+
name: "smart-at",
|
|
267
|
+
hooks: {
|
|
268
|
+
session_start: [
|
|
269
|
+
(_event: any, ctx: ExtensionContext) => {
|
|
270
|
+
const cwd = String(ctx.cwd || "").trim();
|
|
271
|
+
let cache: FileCandidate[] | null = null;
|
|
272
|
+
|
|
273
|
+
const getOrBuildCache = () => (cache ??= collectCandidates(cwd));
|
|
274
|
+
const clearCache = () => { cache = null; };
|
|
275
|
+
|
|
276
|
+
ctx.ui.addAutocompleteProvider((orig: any) => ({
|
|
277
|
+
getSuggestions: (lines: any, cl: any, cc: any, opts: any) => {
|
|
278
|
+
const prefix = atPrefix((lines[cl] || "").slice(0, cc));
|
|
279
|
+
if (!prefix) {
|
|
280
|
+
clearCache();
|
|
281
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
282
|
+
return orig.getSuggestions(lines, cl, cc, opts);
|
|
283
|
+
}
|
|
284
|
+
const candidates = getOrBuildCache();
|
|
285
|
+
const results = smartSearch(candidates, prefix.slice(1));
|
|
286
|
+
if (!results.length) {
|
|
287
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
ctx.ui.setWidget("smart-at", ["\x1b[2mpowered by decorated-pi\x1b[0m"]);
|
|
291
|
+
return Promise.resolve({
|
|
292
|
+
items: results.map((f: string) => ({
|
|
293
|
+
value: "@" + f,
|
|
294
|
+
label: f.replace(/\/$/, "").split("/").pop() || f,
|
|
295
|
+
description: f,
|
|
296
|
+
})),
|
|
297
|
+
prefix,
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
applyCompletion: (...args: any[]) => {
|
|
301
|
+
clearCache();
|
|
302
|
+
ctx.ui.setWidget("smart-at", undefined);
|
|
303
|
+
return orig.applyCompletion.apply(orig, args);
|
|
304
|
+
},
|
|
305
|
+
shouldTriggerFileCompletion: orig.shouldTriggerFileCompletion?.bind(orig),
|
|
306
|
+
}));
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
export function setupSmartAt(sk: Skeleton): void {
|
|
313
|
+
sk.declareDependency({
|
|
314
|
+
label: "fd",
|
|
315
|
+
check: () => commandExists("fd") || isGitWorkTree(process.cwd()),
|
|
316
|
+
});
|
|
317
|
+
sk.register(smartAtModule);
|
|
318
|
+
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* track-mtime — file mtime tracking for stale-read protection.
|
|
3
3
|
*
|
|
4
4
|
* - `read` tool records mtime when the LLM reads a file
|
|
5
5
|
* - `patch` tool checks mtime before editing — rejects if file changed since last read
|
|
6
6
|
* - `patch` tool updates mtime after a successful write
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
*
|
|
8
|
+
* Markers are persisted via pi.appendEntry and restored from the current branch
|
|
9
|
+
* after the last compaction boundary.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import * as fs from "node:fs";
|
|
12
13
|
import * as path from "node:path";
|
|
14
|
+
import type { Module, Skeleton } from "./skeleton.js";
|
|
13
15
|
|
|
14
16
|
export const FILE_TIMES_CUSTOM_TYPE = "decorated-pi.file-times";
|
|
15
17
|
|
|
@@ -24,48 +26,27 @@ interface SessionLikeEntry {
|
|
|
24
26
|
data?: unknown;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
/** Last-known mtime for each absolute file path (ms since epoch). */
|
|
28
29
|
const readMarkers = new Map<string, number>();
|
|
29
30
|
|
|
30
|
-
/** Get current file mtime in ms. Throws if file doesn't exist. */
|
|
31
31
|
function getFileMtime(absPath: string): number {
|
|
32
|
-
|
|
33
|
-
return stat.mtimeMs;
|
|
32
|
+
return fs.statSync(absPath).mtimeMs;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
/** Record that the LLM has seen the current version of a file.
|
|
37
|
-
* Called after `read` tool completes and after `patch` writes. */
|
|
38
35
|
export function recordReadTime(absPath: string): void {
|
|
39
36
|
if (!fs.existsSync(absPath)) return;
|
|
40
37
|
readMarkers.set(absPath, getFileMtime(absPath));
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
/** Check if file has been modified since last read.
|
|
44
|
-
* Returns an error message if stale, or undefined if ok to edit. */
|
|
45
40
|
export function checkStaleFile(absPath: string, displayPath: string): string | undefined {
|
|
46
|
-
|
|
47
|
-
// doesn't require reading it first, and recreating a deleted file is safe.
|
|
48
|
-
if (!fs.existsSync(absPath)) {
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
|
|
41
|
+
if (!fs.existsSync(absPath)) return undefined;
|
|
52
42
|
const lastRead = readMarkers.get(absPath);
|
|
53
43
|
if (lastRead === undefined) {
|
|
54
|
-
|
|
55
|
-
return (
|
|
56
|
-
`File not read yet: ${displayPath}. ` +
|
|
57
|
-
`Please read the file with the read tool before editing.`
|
|
58
|
-
);
|
|
44
|
+
return `Please read the file with the read tool before editing. File not read yet: ${displayPath}.`;
|
|
59
45
|
}
|
|
60
|
-
|
|
61
46
|
const currentMtime = getFileMtime(absPath);
|
|
62
47
|
if (currentMtime > lastRead) {
|
|
63
|
-
return
|
|
64
|
-
`File modified since last read: ${displayPath}. ` +
|
|
65
|
-
`Please re-read the file with the read tool before editing.`
|
|
66
|
-
);
|
|
48
|
+
return `Please re-read the file with the read tool before editing. File modified since last read: ${displayPath}.`;
|
|
67
49
|
}
|
|
68
|
-
|
|
69
50
|
return undefined;
|
|
70
51
|
}
|
|
71
52
|
|
|
@@ -85,22 +66,16 @@ function lastCompactionIndex(entries: SessionLikeEntry[]): number {
|
|
|
85
66
|
}
|
|
86
67
|
|
|
87
68
|
function isFileTimeMarkerData(value: unknown): value is FileTimeMarkerData {
|
|
88
|
-
return !!value
|
|
89
|
-
&& typeof value === "object"
|
|
69
|
+
return !!value && typeof value === "object"
|
|
90
70
|
&& typeof (value as any).path === "string"
|
|
91
71
|
&& typeof (value as any).mtimeMs === "number";
|
|
92
72
|
}
|
|
93
73
|
|
|
94
|
-
/** Build a session-persisted marker payload for the current file version. */
|
|
95
74
|
export function createFileTimeMarkerData(cwd: string, absPath: string): FileTimeMarkerData | undefined {
|
|
96
75
|
if (!fs.existsSync(absPath)) return undefined;
|
|
97
|
-
return {
|
|
98
|
-
path: toStoredPath(cwd, absPath),
|
|
99
|
-
mtimeMs: getFileMtime(absPath),
|
|
100
|
-
};
|
|
76
|
+
return { path: toStoredPath(cwd, absPath), mtimeMs: getFileMtime(absPath) };
|
|
101
77
|
}
|
|
102
78
|
|
|
103
|
-
/** Restore markers from the current branch, ignoring anything before the last compaction. */
|
|
104
79
|
export function restoreReadMarkersFromBranch(entries: SessionLikeEntry[], cwd: string): void {
|
|
105
80
|
clearReadMarkers();
|
|
106
81
|
const start = lastCompactionIndex(entries) + 1;
|
|
@@ -112,13 +87,40 @@ export function restoreReadMarkersFromBranch(entries: SessionLikeEntry[], cwd: s
|
|
|
112
87
|
}
|
|
113
88
|
}
|
|
114
89
|
|
|
115
|
-
/** Clear all tracked file times (e.g., on session start or compaction). */
|
|
116
90
|
export function clearReadMarkers(): void {
|
|
117
91
|
readMarkers.clear();
|
|
118
92
|
}
|
|
119
93
|
|
|
120
|
-
/** Resolve a relative path to absolute (for consistent map keys). */
|
|
121
94
|
export function resolveAbsolutePath(cwd: string, filePath: string): string {
|
|
122
95
|
if (path.isAbsolute(filePath)) return path.normalize(filePath);
|
|
123
96
|
return path.normalize(path.resolve(cwd, filePath));
|
|
124
97
|
}
|
|
98
|
+
|
|
99
|
+
const READ_WRITE_EDIT_PATCH = new Set(["read", "write", "edit", "patch"]);
|
|
100
|
+
|
|
101
|
+
export const trackMtimeModule: Module = {
|
|
102
|
+
name: "track-mtime",
|
|
103
|
+
hooks: {
|
|
104
|
+
session_compact: [
|
|
105
|
+
() => clearReadMarkers(),
|
|
106
|
+
],
|
|
107
|
+
tool_result: [
|
|
108
|
+
(event, ctx, pi) => {
|
|
109
|
+
if (!READ_WRITE_EDIT_PATCH.has(event.toolName)) return;
|
|
110
|
+
const filePath = (event.input as any)?.path;
|
|
111
|
+
if (typeof filePath !== "string" || !filePath.trim()) return;
|
|
112
|
+
// For read: always record. For write/edit/patch: only on success.
|
|
113
|
+
if (event.toolName !== "read" && event.isError) return;
|
|
114
|
+
const cwd: string = ctx.cwd ?? process.cwd();
|
|
115
|
+
const absPath = resolveAbsolutePath(cwd, filePath);
|
|
116
|
+
recordReadTime(absPath);
|
|
117
|
+
const marker = createFileTimeMarkerData(cwd, absPath);
|
|
118
|
+
if (marker) pi.appendEntry(FILE_TIMES_CUSTOM_TYPE, marker);
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function setupTrackMtime(sk: Skeleton): void {
|
|
125
|
+
sk.register(trackMtimeModule);
|
|
126
|
+
}
|