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.
Files changed (68) hide show
  1. package/.codegraph/daemon.pid +6 -0
  2. package/AGENTS.md +92 -0
  3. package/README.md +53 -64
  4. package/commands/dp-model.ts +23 -0
  5. package/commands/dp-settings.ts +23 -0
  6. package/commands/mcp-status.ts +62 -0
  7. package/commands/retry.ts +19 -0
  8. package/commands/usage.ts +544 -0
  9. package/hooks/compaction.ts +204 -0
  10. package/hooks/externalize.ts +70 -0
  11. package/hooks/image-vision.ts +132 -0
  12. package/hooks/inject-agents-md.ts +164 -0
  13. package/hooks/mcp.ts +340 -0
  14. package/hooks/normalize-codeblocks.ts +88 -0
  15. package/hooks/pi-tool-filter.ts +28 -0
  16. package/{extensions/safety → hooks/redact}/types.ts +1 -8
  17. package/hooks/redact.ts +104 -0
  18. package/{extensions → hooks}/rtk.ts +92 -115
  19. package/hooks/session-title.ts +54 -0
  20. package/hooks/skeleton.ts +212 -0
  21. package/hooks/smart-at.ts +318 -0
  22. package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
  23. package/{extensions → hooks}/wakatime.ts +120 -122
  24. package/index.ts +155 -1
  25. package/package.json +5 -4
  26. package/{extensions/settings.ts → settings.ts} +32 -0
  27. package/{extensions → tools}/lsp/client.ts +0 -25
  28. package/{extensions → tools}/lsp/format.ts +2 -99
  29. package/{extensions → tools}/lsp/index.ts +1 -1
  30. package/{extensions → tools}/lsp/servers.ts +1 -1
  31. package/{extensions → tools}/lsp/tools.ts +1 -66
  32. package/{extensions → tools}/lsp/types.ts +0 -11
  33. package/tools/mcp/builtin/codegraph.ts +45 -0
  34. package/tools/mcp/builtin/context7.ts +10 -0
  35. package/tools/mcp/builtin/exa.ts +10 -0
  36. package/tools/mcp/builtin/index.ts +19 -0
  37. package/tools/mcp/cache.ts +124 -0
  38. package/{extensions → tools}/mcp/client.ts +2 -2
  39. package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
  40. package/tools/mcp/index.ts +44 -0
  41. package/tools/mcp/tool-definition.ts +112 -0
  42. package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
  43. package/{extensions/io.ts → tools/patch/index.ts} +29 -205
  44. package/tsconfig.json +10 -1
  45. package/ui/mcp-status.ts +202 -0
  46. package/ui/model-picker.ts +162 -0
  47. package/ui/module-settings.ts +83 -0
  48. package/ui/usage.ts +396 -0
  49. package/extensions/index.ts +0 -154
  50. package/extensions/io-tool-output.ts +0 -33
  51. package/extensions/mcp/index.ts +0 -435
  52. package/extensions/model-integration.ts +0 -531
  53. package/extensions/providers/ark-coding.ts +0 -75
  54. package/extensions/providers/index.ts +0 -9
  55. package/extensions/providers/ollama-cloud.ts +0 -101
  56. package/extensions/providers/qianfan-coding.ts +0 -71
  57. package/extensions/safety/index.ts +0 -102
  58. package/extensions/session-title.ts +0 -40
  59. package/extensions/slash.ts +0 -458
  60. package/extensions/smart-at.ts +0 -481
  61. package/extensions/subdir-agents.ts +0 -151
  62. /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
  63. /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
  64. /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
  65. /package/{extensions → tools}/lsp/env.ts +0 -0
  66. /package/{extensions → tools}/lsp/manager.ts +0 -0
  67. /package/{extensions → tools}/lsp/prompt.ts +0 -0
  68. /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
- * File mtime tracking for stale-read protection.
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
- * - Markers are persisted to session custom entries and restored from the
8
- * current branch after the last compaction boundary.
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
- const stat = fs.statSync(absPath);
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
- // If file doesn't exist on disk, always allow — creating a new file
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
- // File exists but never read must read first to avoid blind edits
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
+ }