@jmylchreest/aide-plugin 0.0.54 → 0.0.55

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.
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * aide-wrapper.ts - Ensures aide binary exists before executing
4
+ *
5
+ * Cross-platform TypeScript replacement for aide-wrapper.sh.
6
+ * Called by an assistant's MCP server configuration.
7
+ * Finds the aide binary, downloads it if missing, then delegates to it.
8
+ *
9
+ * Plugin root resolution order:
10
+ * 1. AIDE_PLUGIN_ROOT (canonical, platform-agnostic)
11
+ * 2. CLAUDE_PLUGIN_ROOT (set by Claude Code)
12
+ * 3. SCRIPT_DIR/.. (fallback: infer from wrapper location)
13
+ *
14
+ * Lives at: <plugin-root>/bin/aide-wrapper.ts
15
+ * Binary at: <plugin-root>/bin/aide[.exe]
16
+ *
17
+ * Logs written to: .aide/_logs/wrapper.log
18
+ */
19
+
20
+ import { execFileSync, spawnSync } from "child_process";
21
+ import {
22
+ existsSync,
23
+ mkdirSync,
24
+ readFileSync,
25
+ realpathSync,
26
+ appendFileSync,
27
+ unlinkSync,
28
+ writeFileSync,
29
+ rmdirSync,
30
+ } from "fs";
31
+ import { dirname, join, resolve } from "path";
32
+ import { fileURLToPath } from "url";
33
+
34
+ const isWindows = process.platform === "win32";
35
+ const EXT = isWindows ? ".exe" : "";
36
+
37
+ // Resolve symlinks so that invoking via node_modules/.bin/aide-wrapper
38
+ // (which is a symlink to the real package) gives us the real package dir.
39
+ const __filename = fileURLToPath(import.meta.url);
40
+ let scriptDir: string;
41
+ try {
42
+ scriptDir = dirname(realpathSync(__filename));
43
+ } catch {
44
+ scriptDir = dirname(__filename);
45
+ }
46
+
47
+ const PLUGIN_ROOT =
48
+ process.env.AIDE_PLUGIN_ROOT ||
49
+ process.env.CLAUDE_PLUGIN_ROOT ||
50
+ resolve(scriptDir, "..");
51
+
52
+ const BINARY = join(PLUGIN_ROOT, "bin", `aide${EXT}`);
53
+ const BIN_DIR = join(PLUGIN_ROOT, "bin");
54
+
55
+ // Setup logging
56
+ const LOG_DIR = join(PLUGIN_ROOT, ".aide", "_logs");
57
+ const LOG_FILE = join(LOG_DIR, "wrapper.log");
58
+ try {
59
+ mkdirSync(LOG_DIR, { recursive: true });
60
+ } catch {
61
+ // ignore
62
+ }
63
+
64
+ function log(msg: string): void {
65
+ const timestamp = new Date().toISOString().replace("T", " ").replace(/\..+/, "");
66
+ const line = `[${timestamp}] [aide-wrapper] ${msg}`;
67
+ process.stderr.write(line + "\n");
68
+ try {
69
+ appendFileSync(LOG_FILE, line + "\n");
70
+ } catch {
71
+ // ignore log write failures
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Compare semantic versions: returns true if a >= b
77
+ */
78
+ function versionGte(a: string, b: string): boolean {
79
+ const pa = a.split(".").map(Number);
80
+ const pb = b.split(".").map(Number);
81
+ for (let i = 0; i < 3; i++) {
82
+ const va = pa[i] || 0;
83
+ const vb = pb[i] || 0;
84
+ if (va > vb) return true;
85
+ if (va < vb) return false;
86
+ }
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Get the version string from the aide binary
92
+ */
93
+ function getBinaryVersion(binary: string): string | null {
94
+ try {
95
+ const output = execFileSync(binary, ["version"], {
96
+ stdio: "pipe",
97
+ timeout: 5000,
98
+ })
99
+ .toString()
100
+ .trim();
101
+ const match = output.match(
102
+ /(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.+]+)?)/,
103
+ );
104
+ return match ? match[1] : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Read the plugin version from plugin.json or package.json
112
+ */
113
+ function getPluginVersion(): string | null {
114
+ for (const relPath of [
115
+ ".claude-plugin/plugin.json",
116
+ "package.json",
117
+ ]) {
118
+ try {
119
+ const content = readFileSync(join(PLUGIN_ROOT, relPath), "utf-8");
120
+ const match = content.match(/"version"\s*:\s*"(\d+\.\d+\.\d+)/);
121
+ if (match) return match[1];
122
+ } catch {
123
+ // skip
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Check if the binary exists and is executable
131
+ */
132
+ function binaryExists(): boolean {
133
+ if (!existsSync(BINARY)) return false;
134
+ // On Windows, existence is sufficient (no execute bit)
135
+ if (isWindows) return true;
136
+ try {
137
+ execFileSync(BINARY, ["version"], { stdio: "pipe", timeout: 5000 });
138
+ return true;
139
+ } catch {
140
+ // Binary exists but can't execute — might need re-download
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Simple cross-platform file lock.
147
+ * Uses mkdir as an atomic operation (works on all platforms).
148
+ * Returns a cleanup function to release the lock.
149
+ */
150
+ function acquireLock(lockPath: string, timeoutMs: number = 60000): () => void {
151
+ const start = Date.now();
152
+ const pollMs = 200;
153
+
154
+ while (true) {
155
+ try {
156
+ mkdirSync(lockPath);
157
+ // Write our PID for debugging
158
+ try {
159
+ writeFileSync(join(lockPath, "pid"), String(process.pid));
160
+ } catch {
161
+ // ignore
162
+ }
163
+ return () => {
164
+ try {
165
+ unlinkSync(join(lockPath, "pid"));
166
+ } catch { /* ignore */ }
167
+ try {
168
+ // rmdir only works on empty dirs — that's what we want
169
+ rmdirSync(lockPath);
170
+ } catch { /* ignore */ }
171
+ };
172
+ } catch (err: unknown) {
173
+ if (
174
+ err &&
175
+ typeof err === "object" &&
176
+ "code" in err &&
177
+ (err as { code: string }).code === "EEXIST"
178
+ ) {
179
+ // Lock held by another process
180
+ if (Date.now() - start > timeoutMs) {
181
+ log("ERROR: Timed out waiting for download lock");
182
+ // Force-remove stale lock and try once more
183
+ try {
184
+ unlinkSync(join(lockPath, "pid"));
185
+ } catch { /* ignore */ }
186
+ try {
187
+ rmdirSync(lockPath);
188
+ } catch { /* ignore */ }
189
+ throw new Error("Timed out waiting for download lock");
190
+ }
191
+ // Poll — use Bun.sleepSync for cross-platform millisecond sleep
192
+ Bun.sleepSync(pollMs);
193
+ continue;
194
+ }
195
+ throw err;
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Download the aide binary using the TypeScript downloader
202
+ */
203
+ function downloadBinary(): void {
204
+ const LOCKDIR = join(BIN_DIR, ".aide-download.lock");
205
+
206
+ // Resolve the downloader script — prefer src/ (dev) but fall back to dist/ (npm install)
207
+ let downloader: string;
208
+ if (existsSync(join(PLUGIN_ROOT, "src", "lib", "aide-downloader.ts"))) {
209
+ downloader = join(PLUGIN_ROOT, "src", "lib", "aide-downloader.ts");
210
+ } else if (existsSync(join(PLUGIN_ROOT, "dist", "lib", "aide-downloader.js"))) {
211
+ downloader = join(PLUGIN_ROOT, "dist", "lib", "aide-downloader.js");
212
+ } else {
213
+ log(`ERROR: Cannot find aide-downloader in src/ or dist/ under ${PLUGIN_ROOT}`);
214
+ process.exit(1);
215
+ }
216
+
217
+ // Remove stale/outdated binary before locking
218
+ if (existsSync(BINARY)) {
219
+ log("Removing outdated binary before download");
220
+ try {
221
+ unlinkSync(BINARY);
222
+ } catch {
223
+ // ignore
224
+ }
225
+ }
226
+
227
+ const releaseLock = acquireLock(LOCKDIR);
228
+ try {
229
+ // Re-check after acquiring lock — another process may have finished the download
230
+ if (existsSync(BINARY)) {
231
+ log("Binary appeared while waiting for lock (downloaded by another process)");
232
+ return;
233
+ }
234
+
235
+ log("Downloading binary...");
236
+ log(`Using downloader: ${downloader}`);
237
+
238
+ // Determine runner based on file extension
239
+ let runner: string;
240
+ let runnerArgs: string[];
241
+
242
+ if (downloader.endsWith(".ts")) {
243
+ runner = "bun";
244
+ runnerArgs = [downloader, "--dest", BIN_DIR];
245
+ } else {
246
+ runner = "node";
247
+ runnerArgs = [downloader, "--dest", BIN_DIR];
248
+ }
249
+
250
+ // Use cross-spawn for cross-platform compatibility (lazy import to
251
+ // survive missing node_modules during bootstrap).
252
+ const crossSpawn = require("cross-spawn");
253
+ const result = crossSpawn.sync(runner, runnerArgs, {
254
+ stdio: ["ignore", "inherit", "inherit"],
255
+ timeout: 120000,
256
+ });
257
+
258
+ if (result.status !== 0) {
259
+ log("ERROR: Downloader failed");
260
+ process.exit(1);
261
+ }
262
+
263
+ if (!existsSync(BINARY)) {
264
+ log("ERROR: Binary not found after download");
265
+ process.exit(1);
266
+ }
267
+ } finally {
268
+ releaseLock();
269
+ }
270
+
271
+ log(`Binary ready at ${BINARY}`);
272
+ }
273
+
274
+ // --- Ensure dependencies ---
275
+
276
+ // After a Claude Code marketplace autoUpdate (git pull), node_modules/
277
+ // may be missing since it's gitignored. Detect and self-heal before any
278
+ // imports that depend on npm packages (e.g. 'which', 'cross-spawn').
279
+ function ensureDependencies(): void {
280
+ const nodeModules = join(PLUGIN_ROOT, "node_modules");
281
+ if (!existsSync(nodeModules)) {
282
+ log("node_modules missing — running bun install to restore dependencies");
283
+ try {
284
+ const result = spawnSync("bun", ["install", "--frozen-lockfile"], {
285
+ cwd: PLUGIN_ROOT,
286
+ stdio: ["ignore", "pipe", "pipe"],
287
+ timeout: 60000,
288
+ });
289
+ if (result.status === 0) {
290
+ log("bun install completed successfully");
291
+ } else {
292
+ const stderr = result.stderr?.toString().trim();
293
+ log(`WARNING: bun install failed (status ${result.status}): ${stderr}`);
294
+ }
295
+ } catch (err) {
296
+ log(`WARNING: bun install error: ${err}`);
297
+ }
298
+ }
299
+ }
300
+
301
+ ensureDependencies();
302
+
303
+ // --- Main ---
304
+
305
+ log(
306
+ `Starting wrapper (pid=${process.pid}, args=${process.argv.slice(2).join(" ")})`,
307
+ );
308
+ log(`PLUGIN_ROOT=${PLUGIN_ROOT}`);
309
+ log(`BINARY=${BINARY}`);
310
+
311
+ let needsDownload = false;
312
+
313
+ if (!binaryExists()) {
314
+ needsDownload = true;
315
+ log("Binary not found or not executable");
316
+ } else {
317
+ const binaryVersion = getBinaryVersion(BINARY);
318
+ log(`Binary version: ${binaryVersion ?? "unknown"}`);
319
+
320
+ if (binaryVersion && binaryVersion.includes("-dev.")) {
321
+ // Dev build — check base version against plugin version
322
+ const baseVersion = binaryVersion.split("-")[0];
323
+ const pluginVersion = getPluginVersion();
324
+
325
+ if (pluginVersion && versionGte(baseVersion, pluginVersion)) {
326
+ log(
327
+ `Dev build v${binaryVersion} (base ${baseVersion} >= plugin v${pluginVersion}), using local build`,
328
+ );
329
+ } else {
330
+ needsDownload = true;
331
+ log(
332
+ `Dev build v${binaryVersion} is older than plugin v${pluginVersion ?? "unknown"}, re-downloading`,
333
+ );
334
+ }
335
+ } else {
336
+ log(`Release binary v${binaryVersion ?? "unknown"}`);
337
+ }
338
+ }
339
+
340
+ if (needsDownload) {
341
+ downloadBinary();
342
+ }
343
+
344
+ // Execute the aide binary, replacing this process
345
+ log(`Executing: ${BINARY} ${process.argv.slice(2).join(" ")}`);
346
+
347
+ const result = spawnSync(BINARY, process.argv.slice(2), {
348
+ stdio: "inherit",
349
+ env: process.env,
350
+ });
351
+
352
+ // Forward the exit code
353
+ process.exit(result.status ?? 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmylchreest/aide-plugin",
3
- "version": "0.0.54",
3
+ "version": "0.0.55",
4
4
  "description": "aide plugin for OpenCode — multi-agent orchestration, memory, skills, and persistence",
5
5
  "type": "module",
6
6
  "main": "./src/opencode/index.ts",
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "bin": {
22
22
  "aide-plugin": "./src/cli/index.ts",
23
- "aide-wrapper": "./bin/aide-wrapper.sh"
23
+ "aide-wrapper": "./bin/aide-wrapper.ts"
24
24
  },
25
25
  "scripts": {
26
26
  "prebuild": "bun run copy-src",
@@ -48,5 +48,8 @@
48
48
  "engines": {
49
49
  "bun": ">=1.0.0"
50
50
  },
51
- "dependencies": {}
51
+ "dependencies": {
52
+ "cross-spawn": "^7.0.6",
53
+ "which": "^6.0.1"
54
+ }
52
55
  }
package/src/cli/mcp.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * MCP subcommand — delegates to aide-wrapper.sh to start the MCP server.
2
+ * MCP subcommand — delegates to aide-wrapper.ts to start the MCP server.
3
3
  *
4
4
  * This is the entry point used by OpenCode's MCP config:
5
5
  * "command": ["bunx", "-y", "@jmylchreest/aide-plugin", "mcp"]
@@ -7,27 +7,27 @@
7
7
  * The wrapper handles binary discovery/download, then exec's `aide mcp`.
8
8
  */
9
9
 
10
- import { execFileSync } from "child_process";
10
+ import spawn from "cross-spawn";
11
11
  import { existsSync } from "fs";
12
12
  import { dirname, join, resolve } from "path";
13
13
  import { fileURLToPath } from "url";
14
14
 
15
15
  /**
16
- * Find the aide-wrapper.sh script relative to this CLI module.
16
+ * Find the aide-wrapper.ts script relative to this CLI module.
17
17
  *
18
18
  * Resolution: this file lives at <plugin-root>/src/cli/mcp.ts,
19
- * so the wrapper is at <plugin-root>/bin/aide-wrapper.sh.
19
+ * so the wrapper is at <plugin-root>/bin/aide-wrapper.ts.
20
20
  */
21
21
  function findWrapper(): string {
22
22
  // __dirname equivalent for ESM
23
23
  const thisDir = dirname(fileURLToPath(import.meta.url));
24
24
  // src/cli -> src -> plugin-root
25
25
  const pluginRoot = resolve(thisDir, "..", "..");
26
- const wrapper = join(pluginRoot, "bin", "aide-wrapper.sh");
26
+ const wrapper = join(pluginRoot, "bin", "aide-wrapper.ts");
27
27
 
28
28
  if (!existsSync(wrapper)) {
29
29
  throw new Error(
30
- `aide-wrapper.sh not found at ${wrapper}\n` +
30
+ `aide-wrapper.ts not found at ${wrapper}\n` +
31
31
  `Expected plugin root: ${pluginRoot}\n` +
32
32
  `Ensure the package is installed correctly.`,
33
33
  );
@@ -37,27 +37,22 @@ function findWrapper(): string {
37
37
  }
38
38
 
39
39
  /**
40
- * Start the MCP server by exec'ing aide-wrapper.sh with "mcp" + any extra args.
40
+ * Start the MCP server by exec'ing aide-wrapper.ts with "mcp" + any extra args.
41
41
  * This replaces the current process so stdio is inherited directly.
42
42
  */
43
43
  export async function mcp(extraArgs: string[]): Promise<void> {
44
44
  const wrapper = findWrapper();
45
- const args = ["mcp", ...extraArgs];
45
+ const args = [wrapper, "mcp", ...extraArgs];
46
46
 
47
- // Use execFileSync with stdio inherit so the MCP JSON-RPC protocol
47
+ // Use bun (via cross-spawn for Windows .cmd resolution) to run the
48
+ // TypeScript wrapper. stdio is inherited so the MCP JSON-RPC protocol
48
49
  // flows directly between OpenCode and the aide binary.
49
- // The wrapper will exec() into the aide binary, replacing itself.
50
- try {
51
- execFileSync(wrapper, args, {
52
- stdio: "inherit",
53
- env: process.env,
54
- });
55
- } catch (err: unknown) {
56
- // execFileSync throws on non-zero exit. If the process was killed
57
- // by a signal (e.g. OpenCode shutting down), exit cleanly.
58
- if (err && typeof err === "object" && "status" in err) {
59
- process.exit((err as { status: number }).status ?? 1);
60
- }
61
- throw err;
50
+ const result = spawn.sync("bun", args, {
51
+ stdio: "inherit",
52
+ env: process.env,
53
+ });
54
+
55
+ if (result.status !== 0) {
56
+ process.exit(result.status ?? 1);
62
57
  }
63
58
  }
@@ -10,11 +10,14 @@
10
10
 
11
11
  import { execFileSync } from "child_process";
12
12
  import { existsSync, realpathSync } from "fs";
13
- import { join } from "path";
13
+ import { dirname, join } from "path";
14
+ import which from "which";
14
15
  import type { FindBinaryOptions } from "./types.js";
15
16
  import { debug } from "../lib/logger.js";
16
17
 
17
18
  const SOURCE = "aide-client";
19
+ const IS_WINDOWS = process.platform === "win32";
20
+ const BINARY_NAME = IS_WINDOWS ? "aide.exe" : "aide";
18
21
 
19
22
  /**
20
23
  * Find the aide binary — platform-agnostic implementation.
@@ -40,44 +43,51 @@ export function findAideBinary(opts: FindBinaryOptions = {}): string | null {
40
43
 
41
44
  // 1. Plugin root bin/
42
45
  if (pluginRoot) {
43
- const pluginBinary = join(pluginRoot, "bin", "aide");
46
+ const pluginBinary = join(pluginRoot, "bin", BINARY_NAME);
44
47
  if (existsSync(pluginBinary)) {
48
+ ensureBinInPath(dirname(pluginBinary));
45
49
  return pluginBinary;
46
50
  }
47
51
  }
48
52
 
49
53
  // 2. Project-local .aide/bin/
50
54
  if (cwd) {
51
- const projectBinary = join(cwd, ".aide", "bin", "aide");
55
+ const projectBinary = join(cwd, ".aide", "bin", BINARY_NAME);
52
56
  if (existsSync(projectBinary)) {
57
+ ensureBinInPath(dirname(projectBinary));
53
58
  return projectBinary;
54
59
  }
55
60
  }
56
61
 
57
62
  // 3. Additional paths
58
63
  for (const searchPath of additionalPaths) {
59
- const binary = join(searchPath, "aide");
64
+ const binary = join(searchPath, BINARY_NAME);
60
65
  if (existsSync(binary)) {
66
+ ensureBinInPath(dirname(binary));
61
67
  return binary;
62
68
  }
63
69
  }
64
70
 
65
- // 4. PATH fallback
66
- try {
67
- const result = execFileSync("which", ["aide"], {
68
- stdio: "pipe",
69
- timeout: 2000,
70
- })
71
- .toString()
72
- .trim();
73
- if (result) return result;
74
- } catch (err) {
75
- debug(SOURCE, `aide not found in PATH: ${err}`);
76
- }
71
+ // 4. PATH fallback (already on PATH, no injection needed)
72
+ const fromPath = which.sync("aide", { nothrow: true });
73
+ if (fromPath) return fromPath;
77
74
 
75
+ debug(SOURCE, `aide binary not found (checked pluginRoot=${pluginRoot || "none"}, cwd=${cwd || "none"}, PATH)`);
78
76
  return null;
79
77
  }
80
78
 
79
+ /**
80
+ * Ensure a directory is on PATH so child processes can find the aide binary.
81
+ * Only prepends if not already present.
82
+ */
83
+ function ensureBinInPath(binDir: string): void {
84
+ const currentPath = process.env.PATH || "";
85
+ const sep = process.platform === "win32" ? ";" : ":";
86
+ if (!currentPath.split(sep).includes(binDir)) {
87
+ process.env.PATH = `${binDir}${sep}${currentPath}`;
88
+ }
89
+ }
90
+
81
91
  /**
82
92
  * Run an aide command and return stdout, or null on failure.
83
93
  */
@@ -15,10 +15,11 @@
15
15
  */
16
16
 
17
17
  import { statSync, readFileSync, writeFileSync, existsSync } from "fs";
18
- import { resolve, isAbsolute, normalize } from "path";
18
+ import { resolve, isAbsolute, normalize, extname } from "path";
19
19
  import { tmpdir } from "os";
20
20
  import { join } from "path";
21
21
  import { debug } from "../lib/logger.js";
22
+ import { getPreviousRead, checkFileReadFreshness } from "./read-tracking.js";
22
23
 
23
24
  const SOURCE = "context-guard";
24
25
 
@@ -212,3 +213,102 @@ export function checkContextGuard(
212
213
  debug(SOURCE, `Advisory for ${filePath}: ${estLines} lines, ${sizeKB}KB`);
213
214
  return { shouldAdvise: true, advisory };
214
215
  }
216
+
217
+ // ============================================================================
218
+ // Smart Read Hint — suggest code index tools over redundant file re-reads
219
+ // ============================================================================
220
+
221
+ export interface SmartReadHintResult {
222
+ /** Whether to inject a hint message */
223
+ shouldHint: boolean;
224
+ /** Hint message to inject */
225
+ hint?: string;
226
+ }
227
+
228
+ /**
229
+ * Check whether a Read call should receive a smart-read hint suggesting
230
+ * the agent use code_outline/code_symbols/code_references instead.
231
+ *
232
+ * Triggers when:
233
+ * 1. The file was already read this session (tracked via state store)
234
+ * 2. The file hasn't changed since last indexing (mtime comparison)
235
+ * 3. The file is indexed with symbols (code_outline would be useful)
236
+ *
237
+ * Gated behind AIDE_CODE_WATCH=1 and requires a valid aide binary.
238
+ */
239
+ export function checkSmartReadHint(
240
+ toolName: string,
241
+ toolInput: Record<string, unknown>,
242
+ cwd: string,
243
+ binary: string | null,
244
+ ): SmartReadHintResult {
245
+ // Only advise on Read tool calls (case-insensitive for OpenCode compat)
246
+ if (toolName.toLowerCase() !== "read") {
247
+ return { shouldHint: false };
248
+ }
249
+
250
+ // Require code watcher to be enabled
251
+ if (process.env.AIDE_CODE_WATCH !== "1") {
252
+ return { shouldHint: false };
253
+ }
254
+
255
+ // Require aide binary
256
+ if (!binary) {
257
+ return { shouldHint: false };
258
+ }
259
+
260
+ // Extract file path from tool input (check multiple variants)
261
+ // Precedence matches checkContextGuard and checkWriteGuard
262
+ const filePath =
263
+ (toolInput.filePath as string) ||
264
+ (toolInput.file_path as string) ||
265
+ (toolInput.path as string);
266
+
267
+ if (!filePath) {
268
+ return { shouldHint: false };
269
+ }
270
+
271
+ // Skip targeted reads (agent already using offset/limit)
272
+ const offset = toolInput.offset as number | undefined;
273
+ const limit = toolInput.limit as number | undefined;
274
+ if (offset !== undefined && offset > 1) {
275
+ return { shouldHint: false };
276
+ }
277
+ if (limit !== undefined && limit < 100) {
278
+ return { shouldHint: false };
279
+ }
280
+
281
+ // Skip non-source-code files
282
+ const ext = extname(filePath).toLowerCase();
283
+ if (SKIP_EXTENSIONS.has(ext)) {
284
+ return { shouldHint: false };
285
+ }
286
+
287
+ // Check if this file was already read this session
288
+ const previousRead = getPreviousRead(binary, cwd, filePath);
289
+ if (!previousRead) {
290
+ // First read — no hint needed
291
+ return { shouldHint: false };
292
+ }
293
+
294
+ // Check if the file is indexed and fresh
295
+ const readCheck = checkFileReadFreshness(binary, cwd, filePath);
296
+ if (!readCheck) {
297
+ return { shouldHint: false };
298
+ }
299
+
300
+ if (readCheck.indexed && readCheck.fresh && readCheck.outline_available) {
301
+ const tokens = readCheck.estimated_tokens;
302
+ const tokenInfo = tokens > 0 ? ` (~${tokens} tokens)` : "";
303
+ const hint =
304
+ `[aide:smart-read] This file was already read this session and hasn't changed${tokenInfo}. ` +
305
+ `Consider using code_outline (for structure, ~5-15% of full tokens), ` +
306
+ `code_symbols (for API surface), or code_references (for call sites) ` +
307
+ `to avoid re-reading the full file.`;
308
+
309
+ debug(SOURCE, `Smart read hint for: ${filePath} (${tokens} tokens)`);
310
+ return { shouldHint: true, hint };
311
+ }
312
+
313
+ return { shouldHint: false };
314
+ }