@jmylchreest/aide-plugin 0.0.54 → 0.0.56

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,151 @@
1
+ /**
2
+ * Read tracking — platform-agnostic core logic.
3
+ *
4
+ * Tracks file reads per session and checks file freshness against
5
+ * the aide code index. Used by both Claude Code hooks and OpenCode plugin
6
+ * to provide smart read hints (suggest code_outline/code_symbols over
7
+ * redundant file re-reads).
8
+ *
9
+ * Gated behind AIDE_CODE_WATCH=1 (file watcher must be enabled).
10
+ */
11
+
12
+ import { execFileSync } from "child_process";
13
+ import { isAbsolute, relative, resolve } from "path";
14
+ import { setState, getState } from "./aide-client.js";
15
+ import { debug } from "../lib/logger.js";
16
+
17
+ const SOURCE = "read-tracking";
18
+
19
+ /** Prefix for state keys tracking file reads */
20
+ const STATE_KEY_PREFIX = "file-read:";
21
+
22
+ /**
23
+ * Result from checking file freshness against the code index.
24
+ */
25
+ export interface ReadCheckResult {
26
+ indexed: boolean;
27
+ fresh: boolean;
28
+ symbols: number;
29
+ outline_available: boolean;
30
+ estimated_tokens: number;
31
+ }
32
+
33
+ /**
34
+ * Normalize a file path to a relative path from cwd.
35
+ * Ensures consistent state keys regardless of absolute/relative input.
36
+ */
37
+ function toRelativePath(cwd: string, filePath: string): string {
38
+ const abs = isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
39
+ return relative(cwd, abs);
40
+ }
41
+
42
+ /**
43
+ * Record that a file was read in this session.
44
+ * Sets a state key so subsequent reads can be detected.
45
+ *
46
+ * No-op if AIDE_CODE_WATCH is not enabled.
47
+ */
48
+ export function recordFileRead(
49
+ binary: string,
50
+ cwd: string,
51
+ filePath: string,
52
+ ): void {
53
+ if (process.env.AIDE_CODE_WATCH !== "1") return;
54
+
55
+ try {
56
+ const relPath = toRelativePath(cwd, filePath);
57
+ const key = STATE_KEY_PREFIX + relPath;
58
+ setState(binary, cwd, key, new Date().toISOString());
59
+ debug(SOURCE, `Recorded read: ${relPath}`);
60
+ } catch (err) {
61
+ debug(SOURCE, `Failed to record read: ${err}`);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Check if a file was previously read in this session.
67
+ * Returns the ISO timestamp of the last read, or null if not read.
68
+ *
69
+ * Returns null if AIDE_CODE_WATCH is not enabled.
70
+ */
71
+ export function getPreviousRead(
72
+ binary: string,
73
+ cwd: string,
74
+ filePath: string,
75
+ ): string | null {
76
+ if (process.env.AIDE_CODE_WATCH !== "1") return null;
77
+
78
+ try {
79
+ const relPath = toRelativePath(cwd, filePath);
80
+ const key = STATE_KEY_PREFIX + relPath;
81
+ return getState(binary, cwd, key);
82
+ } catch (err) {
83
+ debug(SOURCE, `Failed to check previous read: ${err}`);
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Check whether a file is indexed and whether its content is fresh
90
+ * (unchanged since last indexing) by calling `aide code read-check`.
91
+ *
92
+ * Returns null on any error (binary not found, command failed, etc.).
93
+ */
94
+ export function checkFileReadFreshness(
95
+ binary: string,
96
+ cwd: string,
97
+ filePath: string,
98
+ ): ReadCheckResult | null {
99
+ try {
100
+ const relPath = toRelativePath(cwd, filePath);
101
+ const output = execFileSync(binary, ["code", "read-check", relPath, "--json"], {
102
+ cwd,
103
+ encoding: "utf-8",
104
+ timeout: 5000,
105
+ stdio: ["pipe", "pipe", "pipe"],
106
+ });
107
+ const result = JSON.parse(output.trim()) as ReadCheckResult;
108
+ debug(SOURCE, `Read check ${relPath}: indexed=${result.indexed} fresh=${result.fresh} symbols=${result.symbols}`);
109
+ return result;
110
+ } catch (err) {
111
+ debug(SOURCE, `Read check failed: ${err}`);
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Record a token event via `aide token record`.
118
+ * Fire-and-forget — errors are logged but not propagated.
119
+ */
120
+ export function recordTokenEvent(
121
+ binary: string,
122
+ cwd: string,
123
+ eventType: string,
124
+ tool: string,
125
+ filePath: string,
126
+ tokens: number,
127
+ tokensSaved: number = 0,
128
+ ): void {
129
+ try {
130
+ const args = ["token", "record", eventType, tool, filePath, String(tokens)];
131
+ if (tokensSaved > 0) {
132
+ args.push(String(tokensSaved));
133
+ }
134
+ execFileSync(binary, args, {
135
+ cwd,
136
+ timeout: 3000,
137
+ stdio: ["pipe", "pipe", "pipe"],
138
+ });
139
+ debug(SOURCE, `Token event: ${eventType} ${tool} ${filePath} tokens=${tokens} saved=${tokensSaved}`);
140
+ } catch (err) {
141
+ debug(SOURCE, `Failed to record token event: ${err}`);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Estimate tokens for a file by its size, using the default ratio.
147
+ * This is a rough client-side estimate; the Go binary has per-language ratios.
148
+ */
149
+ export function estimateTokensFromSize(sizeBytes: number): number {
150
+ return Math.round(sizeBytes / 3.0);
151
+ }
@@ -15,7 +15,7 @@ import {
15
15
  unlinkSync,
16
16
  statSync,
17
17
  } from "fs";
18
- import { join } from "path";
18
+ import { basename, join } from "path";
19
19
  import { execFileSync } from "child_process";
20
20
  import { homedir } from "os";
21
21
  import type {
@@ -182,7 +182,7 @@ export function getProjectName(cwd: string): string {
182
182
  // Not a git repo or no remote
183
183
  }
184
184
 
185
- return cwd.split("/").pop() || "unknown";
185
+ return basename(cwd) || "unknown";
186
186
  }
187
187
 
188
188
  /**
@@ -41,6 +41,78 @@ export function getSessionCommits(cwd: string, startedAt?: string): string[] {
41
41
  }
42
42
  }
43
43
 
44
+ interface TranscriptEntry {
45
+ type?: string;
46
+ tool_name?: string;
47
+ tool_input?: { file_path?: string; [key: string]: unknown };
48
+ content?:
49
+ | string
50
+ | {
51
+ text?: string;
52
+ tool_use?: { name?: string; input?: { file_path?: string } };
53
+ };
54
+ }
55
+
56
+ interface TranscriptData {
57
+ filesModified: Set<string>;
58
+ toolsUsed: Set<string>;
59
+ userMessages: string[];
60
+ }
61
+
62
+ /**
63
+ * Parse raw JSONL transcript lines into structured data.
64
+ */
65
+ function parseTranscript(lines: string[]): TranscriptData {
66
+ const entries: TranscriptEntry[] = [];
67
+ for (const line of lines) {
68
+ try {
69
+ const parsed: unknown = JSON.parse(line);
70
+ if (
71
+ typeof parsed === "object" &&
72
+ parsed !== null &&
73
+ !Array.isArray(parsed)
74
+ ) {
75
+ entries.push(parsed as TranscriptEntry);
76
+ }
77
+ } catch {
78
+ // Skip malformed
79
+ }
80
+ }
81
+
82
+ const filesModified = new Set<string>();
83
+ const toolsUsed = new Set<string>();
84
+ const userMessages: string[] = [];
85
+
86
+ for (const entry of entries) {
87
+ const contentObj =
88
+ typeof entry.content === "object" ? entry.content : null;
89
+ if (
90
+ entry.type === "tool_use" ||
91
+ (entry.type === "assistant" && contentObj?.tool_use)
92
+ ) {
93
+ const toolName = entry.tool_name || contentObj?.tool_use?.name;
94
+ if (toolName) toolsUsed.add(toolName);
95
+
96
+ const toolInput = entry.tool_input || contentObj?.tool_use?.input;
97
+ if (toolInput?.file_path && toolName) {
98
+ if (["Write", "Edit"].includes(toolName)) {
99
+ filesModified.add(toolInput.file_path);
100
+ }
101
+ }
102
+ }
103
+
104
+ if (entry.type === "human" || entry.type === "user") {
105
+ const text =
106
+ typeof entry.content === "string" ? entry.content : contentObj?.text;
107
+ if (text && text.length > 10 && text.length < 500) {
108
+ userMessages.push(text.slice(0, 200));
109
+ }
110
+ }
111
+ }
112
+
113
+ return { filesModified, toolsUsed, userMessages };
114
+ }
115
+
44
116
  /**
45
117
  * Build a session summary from transcript data.
46
118
  *
@@ -61,64 +133,7 @@ export function buildSessionSummary(
61
133
 
62
134
  if (lines.length < 5) return null;
63
135
 
64
- interface TranscriptEntry {
65
- type?: string;
66
- tool_name?: string;
67
- tool_input?: { file_path?: string; [key: string]: unknown };
68
- content?:
69
- | string
70
- | {
71
- text?: string;
72
- tool_use?: { name?: string; input?: { file_path?: string } };
73
- };
74
- }
75
-
76
- const entries: TranscriptEntry[] = [];
77
- for (const line of lines) {
78
- try {
79
- const parsed: unknown = JSON.parse(line);
80
- if (
81
- typeof parsed === "object" &&
82
- parsed !== null &&
83
- !Array.isArray(parsed)
84
- ) {
85
- entries.push(parsed as TranscriptEntry);
86
- }
87
- } catch {
88
- // Skip malformed
89
- }
90
- }
91
-
92
- const filesModified = new Set<string>();
93
- const toolsUsed = new Set<string>();
94
- const userMessages: string[] = [];
95
-
96
- for (const entry of entries) {
97
- const contentObj =
98
- typeof entry.content === "object" ? entry.content : null;
99
- if (
100
- entry.type === "tool_use" ||
101
- (entry.type === "assistant" && contentObj?.tool_use)
102
- ) {
103
- const toolName = entry.tool_name || contentObj?.tool_use?.name;
104
- if (toolName) toolsUsed.add(toolName);
105
-
106
- const toolInput = entry.tool_input || contentObj?.tool_use?.input;
107
- if (toolInput?.file_path && toolName) {
108
- if (["Write", "Edit"].includes(toolName)) {
109
- filesModified.add(toolInput.file_path);
110
- }
111
- }
112
- }
113
-
114
- if (entry.type === "human" || entry.type === "user") {
115
- const text =
116
- typeof entry.content === "string" ? entry.content : contentObj?.text;
117
- if (text && text.length > 10 && text.length < 500) {
118
- userMessages.push(text.slice(0, 200));
119
- }
120
- }
121
- }
136
+ const { filesModified, toolsUsed, userMessages } = parseTranscript(lines);
122
137
 
123
138
  const commits = getSessionCommits(cwd, startedAt);
124
139
 
@@ -8,11 +8,11 @@
8
8
  import { existsSync, readFileSync, readdirSync } from "fs";
9
9
  import { join, basename, extname } from "path";
10
10
  import { homedir } from "os";
11
- import { execSync } from "child_process";
11
+ import which from "which";
12
12
  import type { Skill, SkillMatchResult } from "./types.js";
13
13
 
14
14
  /**
15
- * Cache of binary existence checks to avoid repeated shell invocations.
15
+ * Cache of binary existence checks to avoid repeated lookups.
16
16
  * Maps binary name to boolean (exists on PATH).
17
17
  */
18
18
  const binaryExistsCache = new Map<string, boolean>();
@@ -25,16 +25,9 @@ function binaryExists(name: string): boolean {
25
25
  const cached = binaryExistsCache.get(name);
26
26
  if (cached !== undefined) return cached;
27
27
 
28
- try {
29
- const cmd =
30
- process.platform === "win32" ? `where ${name}` : `command -v ${name}`;
31
- execSync(cmd, { stdio: "ignore", timeout: 2000 });
32
- binaryExistsCache.set(name, true);
33
- return true;
34
- } catch {
35
- binaryExistsCache.set(name, false);
36
- return false;
37
- }
28
+ const found = which.sync(name, { nothrow: true }) !== null;
29
+ binaryExistsCache.set(name, found);
30
+ return found;
38
31
  }
39
32
 
40
33
  // Skill search locations relative to cwd
@@ -5,6 +5,7 @@
5
5
  * Tracks tool usage per-agent and updates session statistics.
6
6
  */
7
7
 
8
+ import { basename } from "path";
8
9
  import { setState, getState } from "./aide-client.js";
9
10
  import type { ToolUseInfo } from "./types.js";
10
11
 
@@ -31,7 +32,7 @@ export function formatToolDescription(
31
32
  case "Read":
32
33
  if (toolInput.file_path) {
33
34
  const filename =
34
- toolInput.file_path.split("/").pop() || toolInput.file_path;
35
+ basename(toolInput.file_path);
35
36
  return `Read(${filename})`;
36
37
  }
37
38
  return toolName;
@@ -40,7 +41,7 @@ export function formatToolDescription(
40
41
  case "Write":
41
42
  if (toolInput.file_path) {
42
43
  const filename =
43
- toolInput.file_path.split("/").pop() || toolInput.file_path;
44
+ basename(toolInput.file_path);
44
45
  return `${toolName}(${filename})`;
45
46
  }
46
47
  return toolName;
package/src/lib/hud.ts CHANGED
@@ -290,6 +290,87 @@ async function refreshUsageCache(): Promise<void> {
290
290
  }
291
291
  }
292
292
 
293
+ type ElementFormatter = (
294
+ state: SessionState,
295
+ agents: AgentState[],
296
+ config: HudConfig,
297
+ cwd: string,
298
+ ) => string | null;
299
+
300
+ const elementFormatters: Record<string, ElementFormatter> = {
301
+ mode: (state, _agents, config) => {
302
+ const modeName = state.activeMode || "idle";
303
+ const toolSuffix = state.lastTool ? `(${state.lastTool})` : "";
304
+ if (config.format === "icons") {
305
+ const icon = ICONS.mode[state.activeMode || "none"] || ICONS.mode.none;
306
+ return `${icon} ${modeName}${toolSuffix}`;
307
+ }
308
+ return `mode:${modeName}${toolSuffix}`;
309
+ },
310
+
311
+ agents: (_state, agents, config) => {
312
+ const runningCount = agents.filter((a) => a.status === "running").length;
313
+ if (runningCount <= 0) return null;
314
+ if (config.format === "icons") {
315
+ return `${ICONS.agents} ${runningCount}`;
316
+ }
317
+ return `agents:${runningCount}`;
318
+ },
319
+
320
+ duration: (state, _agents, config) => {
321
+ const duration = formatDuration(state.startedAt);
322
+ if (config.format === "icons") {
323
+ return `${ICONS.time} ${duration}`;
324
+ }
325
+ return duration;
326
+ },
327
+
328
+ tools: (state, _agents, config) => {
329
+ if (state.toolCalls <= 0) return null;
330
+ if (config.format === "icons") {
331
+ return `🔧 ${state.toolCalls}`;
332
+ }
333
+ return `tools:${state.toolCalls}`;
334
+ },
335
+
336
+ usage: (_state, _agents, config, cwd) => {
337
+ const cacheTTL = config.usageCacheTTL
338
+ ? config.usageCacheTTL * 1000
339
+ : undefined;
340
+ const usage = getUsageSummary(cwd, cacheTTL);
341
+ if (!usage) return null;
342
+
343
+ const fmt = (n: number) =>
344
+ n >= 1000000
345
+ ? `${(n / 1000000).toFixed(1)}M`
346
+ : n >= 1000
347
+ ? `${(n / 1000).toFixed(0)}K`
348
+ : `${n}`;
349
+
350
+ let usageStr: string;
351
+ if (usage.fiveHourPercent !== null) {
352
+ // API-sourced percentages (accurate)
353
+ const remainStr =
354
+ usage.fiveHourRemain && usage.fiveHourRemain !== "expired"
355
+ ? ` ~${usage.fiveHourRemain}`
356
+ : "";
357
+ const weekStr =
358
+ usage.weeklyPercent !== null
359
+ ? ` wk:${Math.round(usage.weeklyPercent)}%`
360
+ : "";
361
+ usageStr = `5h:${Math.round(usage.fiveHourPercent)}%${remainStr}${weekStr}`;
362
+ } else {
363
+ // Fallback to weighted token counts
364
+ usageStr = `5h:${fmt(usage.window5hTokens)}`;
365
+ }
366
+
367
+ if (config.format === "icons") {
368
+ return `📊 ${usageStr}`;
369
+ }
370
+ return usageStr;
371
+ },
372
+ };
373
+
293
374
  /**
294
375
  * Format HUD output based on config
295
376
  */
@@ -304,92 +385,10 @@ export function formatHud(
304
385
  const parts: string[] = [];
305
386
 
306
387
  for (const element of config.elements) {
307
- switch (element) {
308
- case "mode": {
309
- const modeName = state.activeMode || "idle";
310
- const toolSuffix = state.lastTool ? `(${state.lastTool})` : "";
311
- if (config.format === "icons") {
312
- const icon =
313
- ICONS.mode[state.activeMode || "none"] || ICONS.mode.none;
314
- parts.push(`${icon} ${modeName}${toolSuffix}`);
315
- } else {
316
- parts.push(`mode:${modeName}${toolSuffix}`);
317
- }
318
- break;
319
- }
320
-
321
- case "agents": {
322
- const runningCount = agents.filter(
323
- (a) => a.status === "running",
324
- ).length;
325
- if (runningCount > 0) {
326
- if (config.format === "icons") {
327
- parts.push(`${ICONS.agents} ${runningCount}`);
328
- } else {
329
- parts.push(`agents:${runningCount}`);
330
- }
331
- }
332
- break;
333
- }
334
-
335
- case "duration": {
336
- const duration = formatDuration(state.startedAt);
337
- if (config.format === "icons") {
338
- parts.push(`${ICONS.time} ${duration}`);
339
- } else {
340
- parts.push(duration);
341
- }
342
- break;
343
- }
344
-
345
- case "tools":
346
- if (state.toolCalls > 0) {
347
- if (config.format === "icons") {
348
- parts.push(`🔧 ${state.toolCalls}`);
349
- } else {
350
- parts.push(`tools:${state.toolCalls}`);
351
- }
352
- }
353
- break;
354
-
355
- case "usage": {
356
- const cacheTTL = config.usageCacheTTL
357
- ? config.usageCacheTTL * 1000
358
- : undefined;
359
- const usage = getUsageSummary(cwd, cacheTTL);
360
- if (usage) {
361
- const fmt = (n: number) =>
362
- n >= 1000000
363
- ? `${(n / 1000000).toFixed(1)}M`
364
- : n >= 1000
365
- ? `${(n / 1000).toFixed(0)}K`
366
- : `${n}`;
367
-
368
- let usageStr: string;
369
- if (usage.fiveHourPercent !== null) {
370
- // API-sourced percentages (accurate)
371
- const remainStr =
372
- usage.fiveHourRemain && usage.fiveHourRemain !== "expired"
373
- ? ` ~${usage.fiveHourRemain}`
374
- : "";
375
- const weekStr =
376
- usage.weeklyPercent !== null
377
- ? ` wk:${Math.round(usage.weeklyPercent)}%`
378
- : "";
379
- usageStr = `5h:${Math.round(usage.fiveHourPercent)}%${remainStr}${weekStr}`;
380
- } else {
381
- // Fallback to weighted token counts
382
- usageStr = `5h:${fmt(usage.window5hTokens)}`;
383
- }
384
-
385
- if (config.format === "icons") {
386
- parts.push(`📊 ${usageStr}`);
387
- } else {
388
- parts.push(usageStr);
389
- }
390
- }
391
- break;
392
- }
388
+ const formatter = elementFormatters[element];
389
+ if (formatter) {
390
+ const part = formatter(state, agents, config, cwd);
391
+ if (part) parts.push(part);
393
392
  }
394
393
  }
395
394
 
package/src/lib/logger.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  * AIDE Debug Logger
3
3
  *
4
4
  * Provides timing and tracing for hooks and operations.
5
- * Disabled by default - enable with AIDE_DEBUG=1 environment variable.
5
+ * Disabled by default. Enable with either:
6
+ * 1. AIDE_DEBUG=1 environment variable
7
+ * 2. touch .aide/.debug sentinel file (no restart needed)
6
8
  *
7
9
  * Usage:
8
10
  * import { Logger } from '../lib/logger';
@@ -13,7 +15,8 @@
13
15
  * log.flush(); // Write all logs to file
14
16
  *
15
17
  * Enable logging:
16
- * AIDE_DEBUG=1 claude
18
+ * AIDE_DEBUG=1 claude # env var (requires restart)
19
+ * touch .aide/.debug # sentinel file (live toggle)
17
20
  */
18
21
 
19
22
  import { existsSync, mkdirSync, appendFileSync, writeFileSync } from "fs";
@@ -46,10 +49,11 @@ export class Logger {
46
49
  private sessionStart: number;
47
50
 
48
51
  constructor(source: string, cwd?: string) {
49
- const debugEnv = process.env.AIDE_DEBUG || "";
50
- this.enabled = debugEnv === "1" || debugEnv === "true";
51
52
  this.source = source;
52
53
  this.cwd = cwd || process.cwd();
54
+ // Set debugLogCwd so isDebugEnabled() can check the sentinel file
55
+ if (cwd) setDebugCwd(cwd);
56
+ this.enabled = isDebugEnabled();
53
57
  this.logDir = join(this.cwd, ".aide", "_logs");
54
58
  this.logFile = join(this.logDir, "startup.log");
55
59
  this.sessionStart = Date.now();
@@ -329,16 +333,39 @@ export function getLogger(source: string, cwd?: string): Logger {
329
333
  return defaultLogger;
330
334
  }
331
335
 
336
+ // Debug log state - tracks cwd for file-based logging
337
+ let debugLogCwd = process.cwd();
338
+
339
+ // Cache sentinel file check per cwd to avoid repeated stat calls
340
+ let debugSentinelCwd = "";
341
+ let debugSentinelResult = false;
342
+
332
343
  /**
333
- * Quick check if debug logging is enabled
344
+ * Quick check if debug logging is enabled.
345
+ *
346
+ * Checks (in order):
347
+ * 1. AIDE_DEBUG=1 environment variable
348
+ * 2. .aide/.debug sentinel file in the project root
349
+ *
350
+ * The sentinel file allows enabling debug without restarting Claude.
351
+ * Create it with: touch .aide/.debug
352
+ * Remove it with: rm .aide/.debug
334
353
  */
335
354
  export function isDebugEnabled(): boolean {
336
355
  const debugEnv = process.env.AIDE_DEBUG || "";
337
- return debugEnv === "1" || debugEnv === "true";
338
- }
356
+ if (debugEnv === "1" || debugEnv === "true") return true;
339
357
 
340
- // Debug log state - tracks cwd for file-based logging
341
- let debugLogCwd = process.cwd();
358
+ // Check sentinel file (cached per cwd)
359
+ if (debugLogCwd !== debugSentinelCwd) {
360
+ debugSentinelCwd = debugLogCwd;
361
+ try {
362
+ debugSentinelResult = existsSync(join(debugLogCwd, ".aide", ".debug"));
363
+ } catch {
364
+ debugSentinelResult = false;
365
+ }
366
+ }
367
+ return debugSentinelResult;
368
+ }
342
369
 
343
370
  /**
344
371
  * Set the working directory for debug logging.
@@ -346,6 +373,8 @@ let debugLogCwd = process.cwd();
346
373
  */
347
374
  export function setDebugCwd(cwd: string): void {
348
375
  debugLogCwd = cwd;
376
+ // Invalidate sentinel cache so next isDebugEnabled() re-checks
377
+ debugSentinelCwd = "";
349
378
  }
350
379
 
351
380
  /**