@jmylchreest/aide-plugin 0.0.42 → 0.0.43

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmylchreest/aide-plugin",
3
- "version": "0.0.42",
3
+ "version": "0.0.43",
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",
@@ -0,0 +1,157 @@
1
+ ---
2
+ name: context-usage
3
+ description: Analyze current session context and token usage from OpenCode SQLite database
4
+ platforms:
5
+ - opencode
6
+ triggers:
7
+ - context usage
8
+ - token usage
9
+ - session stats
10
+ - how much context
11
+ - context budget
12
+ - how big is this session
13
+ - session size
14
+ ---
15
+
16
+ # Context Usage Analysis
17
+
18
+ **Recommended model tier:** balanced (sonnet) - straightforward SQL queries
19
+
20
+ Analyze the current session's context window consumption, tool usage breakdown,
21
+ and token costs by querying the OpenCode SQLite database directly.
22
+
23
+ ## Prerequisites
24
+
25
+ - This skill **only works on OpenCode**. Verify by checking the environment:
26
+ - `$OPENCODE=1` — set by the OpenCode runtime
27
+ - `$AIDE_PLATFORM=opencode` — set by aide when running under OpenCode
28
+ - `$AIDE_SESSION_ID` — the current session ID (injected by aide)
29
+ - The OpenCode database is at `~/.local/share/opencode/opencode.db`.
30
+ - `sqlite3` must be available on the system.
31
+
32
+ If `$OPENCODE` is not `1` or `$AIDE_PLATFORM` is not `opencode`, abort immediately
33
+ and inform the user that this skill is only supported on OpenCode.
34
+ Do **not** attempt to query other databases (e.g. Claude Code's storage) — the
35
+ schema is OpenCode-specific.
36
+
37
+ If `$AIDE_SESSION_ID` is not set, abort with a message explaining that the
38
+ session ID could not be determined.
39
+
40
+ ## Workflow
41
+
42
+ Run the following queries **sequentially** in a single Bash call (chain with `&&`).
43
+ Present results to the user in a formatted summary after all queries complete.
44
+
45
+ ### Step 1: Validate environment
46
+
47
+ ```bash
48
+ test "$OPENCODE" = "1" && echo "Platform: OpenCode" || echo "ERROR: Not running on OpenCode (OPENCODE=$OPENCODE)"
49
+ test "$AIDE_PLATFORM" = "opencode" && echo "AIDE Platform: opencode" || echo "WARNING: AIDE_PLATFORM=$AIDE_PLATFORM"
50
+ test -n "$AIDE_SESSION_ID" && echo "Session: $AIDE_SESSION_ID" || echo "ERROR: AIDE_SESSION_ID not set"
51
+ ```
52
+
53
+ If `OPENCODE` is not `1`, stop immediately — this skill cannot work outside OpenCode.
54
+ If `AIDE_SESSION_ID` is not set, stop and inform the user.
55
+
56
+ ### Step 2: Session overview
57
+
58
+ ```bash
59
+ sqlite3 ~/.local/share/opencode/opencode.db "
60
+ SELECT
61
+ s.title,
62
+ s.slug,
63
+ ROUND((julianday('now') - julianday(datetime(s.time_created/1000, 'unixepoch'))) * 24, 1) as hours_old,
64
+ (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) as messages,
65
+ CASE WHEN s.time_compacting IS NOT NULL THEN 'yes' ELSE 'no' END as compacted
66
+ FROM session s
67
+ WHERE s.id = '$AIDE_SESSION_ID';
68
+ "
69
+ ```
70
+
71
+ ### Step 3: Token totals
72
+
73
+ Sum tokens from `step-finish` parts (each represents one LLM turn):
74
+
75
+ ```bash
76
+ sqlite3 ~/.local/share/opencode/opencode.db "
77
+ SELECT
78
+ SUM(json_extract(data, '$.tokens.input')) as input_tokens,
79
+ SUM(json_extract(data, '$.tokens.output')) as output_tokens,
80
+ SUM(json_extract(data, '$.tokens.cache.read')) as cache_read_tokens,
81
+ SUM(json_extract(data, '$.tokens.cache.write')) as cache_write_tokens,
82
+ SUM(json_extract(data, '$.tokens.total')) as total_tokens,
83
+ COUNT(*) as llm_turns
84
+ FROM part
85
+ WHERE session_id = '$AIDE_SESSION_ID'
86
+ AND json_extract(data, '$.type') = 'step-finish';
87
+ "
88
+ ```
89
+
90
+ ### Step 4: Tool output breakdown
91
+
92
+ Show tool usage ranked by total output size:
93
+
94
+ ```bash
95
+ sqlite3 ~/.local/share/opencode/opencode.db "
96
+ SELECT
97
+ json_extract(data, '$.tool') as tool,
98
+ COUNT(*) as calls,
99
+ SUM(length(json_extract(data, '$.state.output'))) as total_output_bytes,
100
+ ROUND(AVG(length(json_extract(data, '$.state.output')))) as avg_bytes,
101
+ MAX(length(json_extract(data, '$.state.output'))) as max_bytes
102
+ FROM part
103
+ WHERE session_id = '$AIDE_SESSION_ID'
104
+ AND json_extract(data, '$.type') = 'tool'
105
+ GROUP BY tool
106
+ ORDER BY total_output_bytes DESC;
107
+ "
108
+ ```
109
+
110
+ ### Step 5: Total session size
111
+
112
+ ```bash
113
+ sqlite3 ~/.local/share/opencode/opencode.db "
114
+ SELECT
115
+ SUM(length(json_extract(data, '$.state.output'))) as tool_output_bytes,
116
+ SUM(length(json_extract(data, '$.state.input'))) as tool_input_bytes,
117
+ SUM(length(data)) as total_part_bytes
118
+ FROM part
119
+ WHERE session_id = '$AIDE_SESSION_ID'
120
+ AND json_extract(data, '$.type') = 'tool';
121
+ "
122
+ ```
123
+
124
+ ## Output Format
125
+
126
+ Present the results as a structured summary:
127
+
128
+ ```
129
+ ## Session Context Usage
130
+
131
+ **Session:** <title> (<slug>)
132
+ **Age:** <hours> hours | **Messages:** <count> | **Compacted:** yes/no
133
+
134
+ ### Token Usage
135
+ | Metric | Count |
136
+ |--------|-------|
137
+ | Input tokens | <n> |
138
+ | Output tokens | <n> |
139
+ | Cache read | <n> |
140
+ | Cache write | <n> |
141
+ | **Total tokens** | **<n>** |
142
+ | LLM turns | <n> |
143
+
144
+ ### Tool Output Breakdown (by total bytes)
145
+ | Tool | Calls | Total Output | Avg/call | Max |
146
+ |------|-------|-------------|----------|-----|
147
+ | ... | ... | ... | ... | ... |
148
+
149
+ ### Session Size
150
+ - Tool outputs: <n> KB
151
+ - Tool inputs: <n> KB
152
+ - Total part storage: <n> KB (includes JSON metadata overhead)
153
+ ```
154
+
155
+ Format byte values as KB (divide by 1024, round to 1 decimal).
156
+ Highlight the top 3 tools by total output as the biggest context consumers.
157
+ If any single tool call exceeds 20KB, flag it as a potential optimization target.
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Dedup strategy: replace repeated identical tool outputs with a short pointer.
3
+ *
4
+ * Safe-to-dedup tools: Read (with mtime check), Glob, Grep, and aide MCP tools
5
+ * like code_search, code_symbols, code_outline, code_references,
6
+ * findings_list, findings_search, memory_list, memory_search.
7
+ *
8
+ * NEVER dedup: Bash, Write, Edit, or any tool with side effects.
9
+ */
10
+
11
+ import type { PruneResult, PruneStrategy, ToolRecord } from "./types.js";
12
+ import { statSync } from "fs";
13
+ import { resolve, isAbsolute } from "path";
14
+
15
+ /** Tools that are safe to deduplicate. */
16
+ const SAFE_DEDUP_TOOLS = new Set([
17
+ // Host built-in read-only tools
18
+ "read",
19
+ "glob",
20
+ "grep",
21
+ // aide MCP tools (read-only)
22
+ "mcp__aide__code_search",
23
+ "mcp__aide__code_symbols",
24
+ "mcp__aide__code_outline",
25
+ "mcp__aide__code_references",
26
+ "mcp__aide__code_stats",
27
+ "mcp__aide__findings_list",
28
+ "mcp__aide__findings_search",
29
+ "mcp__aide__findings_stats",
30
+ "mcp__aide__memory_list",
31
+ "mcp__aide__memory_search",
32
+ "mcp__aide__decision_list",
33
+ "mcp__aide__decision_get",
34
+ "mcp__aide__decision_history",
35
+ "mcp__aide__state_get",
36
+ "mcp__aide__state_list",
37
+ "mcp__aide__task_list",
38
+ "mcp__aide__task_get",
39
+ "mcp__aide__message_list",
40
+ // Claude Code naming convention (no mcp__ prefix)
41
+ "code_search",
42
+ "code_symbols",
43
+ "code_outline",
44
+ "code_references",
45
+ "code_stats",
46
+ "findings_list",
47
+ "findings_search",
48
+ "findings_stats",
49
+ "memory_list",
50
+ "memory_search",
51
+ "decision_list",
52
+ "decision_get",
53
+ "decision_history",
54
+ "state_get",
55
+ "state_list",
56
+ "task_list",
57
+ "task_get",
58
+ "message_list",
59
+ ]);
60
+
61
+ /** Extract the dedup key from tool args (the args that define "same call"). */
62
+ function dedupKey(toolName: string, args: Record<string, unknown>): string {
63
+ const normalized = toolName.toLowerCase();
64
+ // For Read, the key is filePath + offset + limit
65
+ if (normalized === "read") {
66
+ return JSON.stringify({
67
+ tool: "read",
68
+ filePath: args.filePath ?? args.file_path ?? args.path,
69
+ offset: args.offset ?? 0,
70
+ limit: args.limit ?? 2000,
71
+ });
72
+ }
73
+ // For Glob, the key is pattern + path
74
+ if (normalized === "glob") {
75
+ return JSON.stringify({
76
+ tool: "glob",
77
+ pattern: args.pattern,
78
+ path: args.path,
79
+ });
80
+ }
81
+ // For Grep, the key is pattern + path + include
82
+ if (normalized === "grep") {
83
+ return JSON.stringify({
84
+ tool: "grep",
85
+ pattern: args.pattern,
86
+ path: args.path,
87
+ include: args.include,
88
+ });
89
+ }
90
+ // For MCP tools, use all args as the key
91
+ return JSON.stringify({ tool: normalized, ...args });
92
+ }
93
+
94
+ /** Check file mtime for Read dedup safety. */
95
+ function getFileMtime(
96
+ args: Record<string, unknown>,
97
+ cwd?: string,
98
+ ): number | undefined {
99
+ const filePath =
100
+ (args.filePath as string) ??
101
+ (args.file_path as string) ??
102
+ (args.path as string);
103
+ if (!filePath) return undefined;
104
+
105
+ try {
106
+ const resolved = isAbsolute(filePath)
107
+ ? filePath
108
+ : resolve(cwd || process.cwd(), filePath);
109
+ return statSync(resolved).mtimeMs;
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ export class DedupStrategy implements PruneStrategy {
116
+ name = "dedup" as const;
117
+ private cwd?: string;
118
+
119
+ constructor(cwd?: string) {
120
+ this.cwd = cwd;
121
+ }
122
+
123
+ apply(
124
+ toolName: string,
125
+ args: Record<string, unknown>,
126
+ output: string,
127
+ history: ToolRecord[],
128
+ ): PruneResult {
129
+ const normalized = toolName.toLowerCase();
130
+
131
+ // Only apply to safe tools
132
+ if (!SAFE_DEDUP_TOOLS.has(normalized)) {
133
+ return { output, modified: false, bytesSaved: 0 };
134
+ }
135
+
136
+ const key = dedupKey(toolName, args);
137
+
138
+ // Find the most recent matching call in history
139
+ for (let i = history.length - 1; i >= 0; i--) {
140
+ const prev = history[i];
141
+ const prevKey = dedupKey(prev.toolName, prev.args);
142
+
143
+ if (prevKey !== key) continue;
144
+
145
+ // For Read: check mtime hasn't changed (file might have been edited)
146
+ if (normalized === "read") {
147
+ const currentMtime = getFileMtime(args, this.cwd);
148
+ if (
149
+ currentMtime !== undefined &&
150
+ prev.fileMtime !== undefined &&
151
+ currentMtime !== prev.fileMtime
152
+ ) {
153
+ // File changed — don't dedup
154
+ continue;
155
+ }
156
+ }
157
+
158
+ // Check if output is identical
159
+ const prevOutput = prev.prunedOutput ?? prev.originalOutput;
160
+ if (output === prevOutput) {
161
+ const replacement = `[aide:dedup] Identical to previous ${toolName} call (callId: ${prev.callId}). Output unchanged.`;
162
+ return {
163
+ output: replacement,
164
+ modified: true,
165
+ strategy: "dedup",
166
+ bytesSaved: output.length - replacement.length,
167
+ };
168
+ }
169
+ }
170
+
171
+ return { output, modified: false, bytesSaved: 0 };
172
+ }
173
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Context Pruning — reduces context/token usage by deduplicating,
3
+ * superseding, and purging tool outputs.
4
+ *
5
+ * Platform adapters integrate via the ContextPruningTracker:
6
+ * - OpenCode: tool.execute.after hook modifies output.output
7
+ * - Claude Code: PostToolUse hook returns updatedMCPToolOutput
8
+ */
9
+
10
+ export { ContextPruningTracker } from "./tracker.js";
11
+ export { DedupStrategy } from "./dedup.js";
12
+ export { SupersedeStrategy } from "./supersede.js";
13
+ export { PurgeErrorsStrategy } from "./purge.js";
14
+ export type {
15
+ ToolRecord,
16
+ PruneResult,
17
+ PruneStrategy,
18
+ PruningStats,
19
+ } from "./types.js";
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Purge-errors strategy: replace large error outputs (stack traces, build
3
+ * failures) with a compact summary.
4
+ *
5
+ * When a Bash command fails and produces a large error output, most of the
6
+ * context is stack frames that aren't useful for the model. This strategy
7
+ * trims the output to the first meaningful error lines.
8
+ */
9
+
10
+ import type { PruneResult, PruneStrategy, ToolRecord } from "./types.js";
11
+
12
+ /** Minimum output size to trigger purging (2KB). */
13
+ const MIN_SIZE_FOR_PURGE = 2048;
14
+
15
+ /** Max lines to keep from an error output. */
16
+ const MAX_ERROR_LINES = 30;
17
+
18
+ /** Patterns that indicate an error output. */
19
+ const ERROR_PATTERNS = [
20
+ /^error/im,
21
+ /^ERR!/im,
22
+ /exit code [1-9]/i,
23
+ /FAILED/i,
24
+ /panic:/i,
25
+ /Traceback/i,
26
+ /^Exception/im,
27
+ /compilation failed/i,
28
+ /build failed/i,
29
+ /TypeError:/,
30
+ /SyntaxError:/,
31
+ /ReferenceError:/,
32
+ ];
33
+
34
+ export class PurgeErrorsStrategy implements PruneStrategy {
35
+ name = "purge" as const;
36
+
37
+ apply(
38
+ toolName: string,
39
+ _args: Record<string, unknown>,
40
+ output: string,
41
+ _history: ToolRecord[],
42
+ ): PruneResult {
43
+ const normalized = toolName.toLowerCase();
44
+
45
+ // Only apply to Bash output
46
+ if (normalized !== "bash") {
47
+ return { output, modified: false, bytesSaved: 0 };
48
+ }
49
+
50
+ // Only purge if output is large enough to matter
51
+ if (output.length < MIN_SIZE_FOR_PURGE) {
52
+ return { output, modified: false, bytesSaved: 0 };
53
+ }
54
+
55
+ // Check if output looks like an error
56
+ const isError = ERROR_PATTERNS.some((p) => p.test(output));
57
+ if (!isError) {
58
+ return { output, modified: false, bytesSaved: 0 };
59
+ }
60
+
61
+ // Trim to first MAX_ERROR_LINES lines + a note
62
+ const lines = output.split("\n");
63
+ if (lines.length <= MAX_ERROR_LINES) {
64
+ return { output, modified: false, bytesSaved: 0 };
65
+ }
66
+
67
+ const kept = lines.slice(0, MAX_ERROR_LINES).join("\n");
68
+ const trimmedCount = lines.length - MAX_ERROR_LINES;
69
+ const replacement =
70
+ kept +
71
+ `\n\n[aide:purge] ... ${trimmedCount} additional error lines trimmed. Re-run the command to see full output.`;
72
+
73
+ return {
74
+ output: replacement,
75
+ modified: true,
76
+ strategy: "purge",
77
+ bytesSaved: output.length - replacement.length,
78
+ };
79
+ }
80
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Supersede strategy: when a Write or Edit completes, mark prior Read outputs
3
+ * of the same file as stale so the model doesn't rely on outdated content.
4
+ *
5
+ * This doesn't replace the current tool output — it annotates previous Read
6
+ * records so that if they're re-read (dedup check), the stale content is flagged.
7
+ *
8
+ * For now, this strategy only adds a note to the current Write/Edit output
9
+ * reminding the model that prior reads of this file are now stale.
10
+ */
11
+
12
+ import type { PruneResult, PruneStrategy, ToolRecord } from "./types.js";
13
+
14
+ /** Tools that supersede prior reads. */
15
+ const WRITE_TOOLS = new Set(["write", "edit"]);
16
+
17
+ /** Extract the file path from tool args. */
18
+ function getFilePath(args: Record<string, unknown>): string | undefined {
19
+ return (
20
+ (args.filePath as string) ??
21
+ (args.file_path as string) ??
22
+ (args.path as string) ??
23
+ undefined
24
+ );
25
+ }
26
+
27
+ export class SupersedeStrategy implements PruneStrategy {
28
+ name = "supersede" as const;
29
+
30
+ apply(
31
+ toolName: string,
32
+ args: Record<string, unknown>,
33
+ output: string,
34
+ history: ToolRecord[],
35
+ ): PruneResult {
36
+ const normalized = toolName.toLowerCase();
37
+
38
+ if (!WRITE_TOOLS.has(normalized)) {
39
+ return { output, modified: false, bytesSaved: 0 };
40
+ }
41
+
42
+ const filePath = getFilePath(args);
43
+ if (!filePath) {
44
+ return { output, modified: false, bytesSaved: 0 };
45
+ }
46
+
47
+ // Check if there are prior Read calls for this same file
48
+ const priorReads = history.filter((rec) => {
49
+ if (rec.toolName.toLowerCase() !== "read") return false;
50
+ const recPath = getFilePath(rec.args);
51
+ return recPath === filePath;
52
+ });
53
+
54
+ if (priorReads.length === 0) {
55
+ return { output, modified: false, bytesSaved: 0 };
56
+ }
57
+
58
+ // Annotate: prior reads of this file are now stale
59
+ const note = `\n[aide:supersede] Note: ${priorReads.length} prior Read(s) of "${filePath}" are now stale after this ${toolName}. Re-read if you need current content.`;
60
+ return {
61
+ output: output + note,
62
+ modified: true,
63
+ strategy: "supersede",
64
+ bytesSaved: 0, // We're adding, not saving bytes
65
+ };
66
+ }
67
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Context Pruning Tracker
3
+ *
4
+ * Orchestrates pruning strategies against tool outputs to reduce context usage.
5
+ * Platform adapters (OpenCode hooks, Claude Code PostToolUse) call into this
6
+ * tracker after each tool completes.
7
+ *
8
+ * The tracker maintains a history of tool invocations per session and applies
9
+ * strategies in order: dedup → supersede → purge-errors.
10
+ */
11
+
12
+ import type {
13
+ PruneResult,
14
+ PruneStrategy,
15
+ PruningStats,
16
+ ToolRecord,
17
+ } from "./types.js";
18
+ import { DedupStrategy } from "./dedup.js";
19
+ import { SupersedeStrategy } from "./supersede.js";
20
+ import { PurgeErrorsStrategy } from "./purge.js";
21
+
22
+ export class ContextPruningTracker {
23
+ private history: ToolRecord[] = [];
24
+ private strategies: PruneStrategy[];
25
+ private stats: PruningStats = {
26
+ totalCalls: 0,
27
+ prunedCalls: 0,
28
+ totalBytesSaved: 0,
29
+ estimatedContextBytes: 0,
30
+ };
31
+
32
+ /** Max history entries to keep (prevents unbounded growth). */
33
+ private maxHistory: number;
34
+
35
+ constructor(cwd?: string, maxHistory = 200) {
36
+ this.maxHistory = maxHistory;
37
+ this.strategies = [
38
+ new DedupStrategy(cwd),
39
+ new SupersedeStrategy(),
40
+ new PurgeErrorsStrategy(),
41
+ ];
42
+ }
43
+
44
+ /**
45
+ * Load existing history (for process-per-invocation environments like Claude Code).
46
+ * Merges with any existing in-memory history.
47
+ */
48
+ loadHistory(records: ToolRecord[]): void {
49
+ this.history = records.slice(-this.maxHistory);
50
+ // Recompute stats from loaded history
51
+ this.stats.totalCalls = this.history.length;
52
+ this.stats.prunedCalls = this.history.filter(
53
+ (r) => r.prunedOutput !== null,
54
+ ).length;
55
+ this.stats.estimatedContextBytes = this.history.reduce(
56
+ (sum, r) => sum + (r.prunedOutput ?? r.originalOutput).length,
57
+ 0,
58
+ );
59
+ this.stats.totalBytesSaved = this.history.reduce(
60
+ (sum, r) =>
61
+ sum +
62
+ (r.prunedOutput !== null
63
+ ? r.originalOutput.length - r.prunedOutput.length
64
+ : 0),
65
+ 0,
66
+ );
67
+ }
68
+
69
+ /** Get the current history (for persistence). */
70
+ getHistory(): ToolRecord[] {
71
+ return [...this.history];
72
+ }
73
+
74
+ /**
75
+ * Process a tool output through all pruning strategies.
76
+ * Returns the (possibly modified) output and metadata.
77
+ */
78
+ process(
79
+ callId: string,
80
+ toolName: string,
81
+ args: Record<string, unknown>,
82
+ output: string,
83
+ ): PruneResult {
84
+ this.stats.totalCalls++;
85
+
86
+ // Apply strategies in order — first match wins
87
+ let result: PruneResult = { output, modified: false, bytesSaved: 0 };
88
+
89
+ for (const strategy of this.strategies) {
90
+ result = strategy.apply(toolName, args, output, this.history);
91
+ if (result.modified) {
92
+ this.stats.prunedCalls++;
93
+ this.stats.totalBytesSaved += result.bytesSaved;
94
+ break;
95
+ }
96
+ }
97
+
98
+ // Track context size
99
+ this.stats.estimatedContextBytes += result.output.length;
100
+
101
+ // Record this call in history
102
+ const record: ToolRecord = {
103
+ callId,
104
+ toolName,
105
+ args,
106
+ originalOutput: output,
107
+ prunedOutput: result.modified ? result.output : null,
108
+ timestamp: Date.now(),
109
+ };
110
+
111
+ // For Read tools, record file mtime for dedup safety
112
+ if (toolName.toLowerCase() === "read") {
113
+ const filePath =
114
+ (args.filePath as string) ??
115
+ (args.file_path as string) ??
116
+ (args.path as string);
117
+ if (filePath) {
118
+ try {
119
+ const { statSync } = require("fs");
120
+ const { resolve, isAbsolute } = require("path");
121
+ const resolved = isAbsolute(filePath)
122
+ ? filePath
123
+ : resolve(process.cwd(), filePath);
124
+ record.fileMtime = statSync(resolved).mtimeMs;
125
+ } catch {
126
+ // File may not exist (e.g., directory read)
127
+ }
128
+ }
129
+ }
130
+
131
+ this.history.push(record);
132
+
133
+ // Trim history if needed
134
+ if (this.history.length > this.maxHistory) {
135
+ this.history = this.history.slice(-this.maxHistory);
136
+ }
137
+
138
+ return result;
139
+ }
140
+
141
+ /** Get current pruning stats (for context pressure signal). */
142
+ getStats(): PruningStats {
143
+ return { ...this.stats };
144
+ }
145
+
146
+ /**
147
+ * Get a context pressure value (0.0 - 1.0).
148
+ * This is a heuristic based on estimated context bytes and pruning ratio.
149
+ * Higher values mean more context pressure.
150
+ */
151
+ getContextPressure(): number {
152
+ // Use 128K tokens ≈ 512KB as a rough "full context" estimate
153
+ const estimatedCapacity = 512 * 1024;
154
+ const usageRatio = Math.min(
155
+ 1.0,
156
+ this.stats.estimatedContextBytes / estimatedCapacity,
157
+ );
158
+
159
+ // If we're pruning a lot, that's also a pressure signal
160
+ const pruneRatio =
161
+ this.stats.totalCalls > 0
162
+ ? this.stats.prunedCalls / this.stats.totalCalls
163
+ : 0;
164
+
165
+ // Weighted: 70% usage, 30% prune ratio
166
+ return Math.min(1.0, usageRatio * 0.7 + pruneRatio * 0.3);
167
+ }
168
+
169
+ /** Clear history (e.g., on compaction). */
170
+ reset(): void {
171
+ this.history = [];
172
+ this.stats = {
173
+ totalCalls: 0,
174
+ prunedCalls: 0,
175
+ totalBytesSaved: 0,
176
+ estimatedContextBytes: 0,
177
+ };
178
+ }
179
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Context pruning types.
3
+ *
4
+ * These types define the contract between the platform-agnostic pruning logic
5
+ * and the platform-specific adapters (OpenCode hooks, Claude Code PostToolUse).
6
+ */
7
+
8
+ /** A recorded tool use with its output, used for dedup tracking. */
9
+ export interface ToolRecord {
10
+ /** Unique ID for this tool invocation (callID from host). */
11
+ callId: string;
12
+ /** Tool name (e.g. "Read", "Glob", "mcp__aide__code_search"). */
13
+ toolName: string;
14
+ /** Key arguments that define the "identity" of this call. */
15
+ args: Record<string, unknown>;
16
+ /** The original tool output (before any pruning). */
17
+ originalOutput: string;
18
+ /** The pruned output (after dedup/supersede applied), or null if unchanged. */
19
+ prunedOutput: string | null;
20
+ /** Timestamp of the tool invocation. */
21
+ timestamp: number;
22
+ /** File mtime at time of call (for Read dedup safety). */
23
+ fileMtime?: number;
24
+ }
25
+
26
+ /** Result of applying pruning strategies to a tool output. */
27
+ export interface PruneResult {
28
+ /** The (possibly modified) output string. */
29
+ output: string;
30
+ /** Whether the output was modified. */
31
+ modified: boolean;
32
+ /** Which strategy modified it, if any. */
33
+ strategy?: "dedup" | "supersede" | "purge";
34
+ /** Bytes saved (original - pruned). */
35
+ bytesSaved: number;
36
+ }
37
+
38
+ /** Strategy interface — each strategy gets the current call + history. */
39
+ export interface PruneStrategy {
40
+ name: string;
41
+ /**
42
+ * Evaluate whether this strategy should prune the given tool output.
43
+ * Returns a PruneResult with the potentially modified output.
44
+ */
45
+ apply(
46
+ toolName: string,
47
+ args: Record<string, unknown>,
48
+ output: string,
49
+ history: ToolRecord[],
50
+ ): PruneResult;
51
+ }
52
+
53
+ /** Running stats for the context pressure signal. */
54
+ export interface PruningStats {
55
+ /** Total tool invocations tracked. */
56
+ totalCalls: number;
57
+ /** Total tool invocations that were pruned. */
58
+ prunedCalls: number;
59
+ /** Total bytes saved by pruning. */
60
+ totalBytesSaved: number;
61
+ /** Estimated total bytes of tool output in context. */
62
+ estimatedContextBytes: number;
63
+ }
@@ -194,12 +194,11 @@ export function initializeSession(
194
194
  agentCount: 0,
195
195
  };
196
196
 
197
- const statePath = join(cwd, ".aide", "state", "session.json");
198
- try {
199
- writeFileSync(statePath, JSON.stringify(state, null, 2));
200
- } catch {
201
- // Ignore
202
- }
197
+ // Session state is kept in-memory only (returned to the caller).
198
+ // We no longer write .aide/state/session.json — this eliminates a race
199
+ // condition where concurrent sessions would overwrite each other's file.
200
+ // Callers that need startedAt (e.g. getSessionCommits) receive it as a
201
+ // parameter from the in-memory SessionState or use a fallback default.
203
202
 
204
203
  return state;
205
204
  }
@@ -314,6 +313,7 @@ export function runSessionInit(
314
313
 
315
314
  result.static.global = data.global_memories.map((m) => m.content);
316
315
  result.static.project = data.project_memories.map((m) => m.content);
316
+ result.static.projectOverflow = data.project_memory_overflow ?? false;
317
317
  result.static.decisions = data.decisions.map(
318
318
  (d) =>
319
319
  `**${d.topic}**: ${d.value}${d.rationale ? ` (${d.rationale})` : ""}`,
@@ -426,6 +426,12 @@ export function buildWelcomeContext(
426
426
  for (const mem of memories.static.project) {
427
427
  lines.push(`- ${mem}`);
428
428
  }
429
+ if (memories.static.projectOverflow) {
430
+ lines.push("");
431
+ lines.push(
432
+ "_More project memories exist. Use `memory_search` to find specific context._",
433
+ );
434
+ }
429
435
  lines.push("");
430
436
  }
431
437
 
@@ -7,23 +7,22 @@
7
7
 
8
8
  import { execFileSync } from "child_process";
9
9
  import { readFileSync, existsSync } from "fs";
10
- import { join } from "path";
11
10
 
12
11
  /**
13
- * Get git commits made during this session
12
+ * Get git commits made during this session.
13
+ *
14
+ * @param cwd - Working directory
15
+ * @param startedAt - ISO timestamp of when the session started. When provided,
16
+ * scopes `git log --since` to this time. When omitted, falls back to "4 hours ago"
17
+ * as a reasonable default for a single coding session.
14
18
  */
15
- export function getSessionCommits(cwd: string): string[] {
19
+ export function getSessionCommits(cwd: string, startedAt?: string): string[] {
16
20
  try {
17
- const sessionPath = join(cwd, ".aide", "state", "session.json");
18
- if (!existsSync(sessionPath)) return [];
19
-
20
- const sessionData = JSON.parse(readFileSync(sessionPath, "utf-8"));
21
- const startedAt = sessionData.startedAt;
22
- if (!startedAt) return [];
21
+ const sinceArg = startedAt || "4 hours ago";
23
22
 
24
23
  const output = execFileSync(
25
24
  "git",
26
- ["log", "--oneline", `--since=${startedAt}`],
25
+ ["log", "--oneline", `--since=${sinceArg}`],
27
26
  {
28
27
  cwd,
29
28
  encoding: "utf-8",
@@ -52,6 +51,7 @@ export function getSessionCommits(cwd: string): string[] {
52
51
  export function buildSessionSummary(
53
52
  transcriptPath: string,
54
53
  cwd: string,
54
+ startedAt?: string,
55
55
  ): string | null {
56
56
  if (!existsSync(transcriptPath)) return null;
57
57
 
@@ -120,7 +120,7 @@ export function buildSessionSummary(
120
120
  }
121
121
  }
122
122
 
123
- const commits = getSessionCommits(cwd);
123
+ const commits = getSessionCommits(cwd, startedAt);
124
124
 
125
125
  if (
126
126
  filesModified.size === 0 &&
@@ -170,8 +170,11 @@ export function buildSessionSummary(
170
170
  *
171
171
  * Uses aide state and git history instead of transcript parsing.
172
172
  */
173
- export function buildSessionSummaryFromState(cwd: string): string | null {
174
- const commits = getSessionCommits(cwd);
173
+ export function buildSessionSummaryFromState(
174
+ cwd: string,
175
+ startedAt?: string,
176
+ ): string | null {
177
+ const commits = getSessionCommits(cwd, startedAt);
175
178
 
176
179
  const summaryParts: string[] = [];
177
180
 
@@ -11,14 +11,9 @@ import { homedir } from "os";
11
11
  import type { Skill, SkillMatchResult } from "./types.js";
12
12
 
13
13
  // Skill search locations relative to cwd
14
- const SKILL_LOCATIONS = [
15
- ".aide/skills",
16
- "skills",
17
- ];
14
+ const SKILL_LOCATIONS = [".aide/skills", "skills"];
18
15
 
19
- const GLOBAL_SKILL_LOCATIONS = [
20
- join(homedir(), ".aide", "skills"),
21
- ];
16
+ const GLOBAL_SKILL_LOCATIONS = [join(homedir(), ".aide", "skills")];
22
17
 
23
18
  /**
24
19
  * Calculate Levenshtein distance between two strings
@@ -120,6 +115,20 @@ export function parseSkillFrontmatter(
120
115
  }
121
116
  meta.triggers = triggers;
122
117
 
118
+ // Parse platforms array (e.g. "platforms:\n - opencode")
119
+ const platforms: string[] = [];
120
+ const platformMatch = yamlContent.match(/platforms:\s*\n((?:\s+-\s*.+\n?)*)/);
121
+ if (platformMatch) {
122
+ const plines = platformMatch[1].split("\n");
123
+ for (const line of plines) {
124
+ const itemMatch = line.match(/^\s+-\s*["']?([^"'\n]+)["']?\s*$/);
125
+ if (itemMatch) platforms.push(itemMatch[1].trim().toLowerCase());
126
+ }
127
+ }
128
+ if (platforms.length > 0) {
129
+ meta.platforms = platforms;
130
+ }
131
+
123
132
  return { meta, body };
124
133
  }
125
134
 
@@ -166,6 +175,7 @@ export function loadSkill(path: string): Skill | null {
166
175
  path,
167
176
  triggers,
168
177
  description: meta.description as string | undefined,
178
+ platforms: meta.platforms as string[] | undefined,
169
179
  content: body,
170
180
  };
171
181
  } catch {
@@ -222,17 +232,27 @@ export function discoverSkills(cwd: string, pluginRoot?: string): Skill[] {
222
232
  }
223
233
 
224
234
  /**
225
- * Find skills matching the prompt (supports typos via Levenshtein distance)
235
+ * Find skills matching the prompt (supports typos via Levenshtein distance).
236
+ *
237
+ * @param platform - If provided, skills with a `platforms` restriction are
238
+ * only included when the current platform is listed. Skills without the
239
+ * field are always eligible.
226
240
  */
227
241
  export function matchSkills(
228
242
  prompt: string,
229
243
  skills: Skill[],
230
244
  maxResults = 3,
245
+ platform?: string,
231
246
  ): Skill[] {
232
247
  const promptLower = prompt.toLowerCase();
233
248
  const matches: SkillMatchResult[] = [];
234
249
 
235
250
  for (const skill of skills) {
251
+ // Platform gate: skip skills restricted to a different platform
252
+ if (platform && skill.platforms && skill.platforms.length > 0) {
253
+ if (!skill.platforms.includes(platform)) continue;
254
+ }
255
+
236
256
  let score = 0;
237
257
 
238
258
  for (const trigger of skill.triggers) {
package/src/core/types.ts CHANGED
@@ -67,6 +67,7 @@ export interface SessionInitResult {
67
67
  category: string;
68
68
  tags: string[];
69
69
  }>;
70
+ project_memory_overflow?: boolean;
70
71
  decisions: Array<{ topic: string; value: string; rationale?: string }>;
71
72
  recent_sessions: Array<{
72
73
  session_id: string;
@@ -83,6 +84,7 @@ export interface MemoryInjection {
83
84
  static: {
84
85
  global: string[];
85
86
  project: string[];
87
+ projectOverflow?: boolean;
86
88
  decisions: string[];
87
89
  };
88
90
  dynamic: {
@@ -109,6 +111,8 @@ export interface Skill {
109
111
  path: string;
110
112
  triggers: string[];
111
113
  description?: string;
114
+ /** Optional platform restriction. If set, only matched on listed platforms ("opencode", "claude-code"). */
115
+ platforms?: string[];
112
116
  content: string;
113
117
  }
114
118
 
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { execFileSync } from "child_process";
29
+ import { join } from "path";
29
30
  import { findAideBinary } from "../core/aide-client.js";
30
31
  import {
31
32
  ensureDirectories,
@@ -66,6 +67,7 @@ import {
66
67
  buildSummaryFromPartials,
67
68
  cleanupPartials,
68
69
  } from "../core/partial-memory.js";
70
+ import { ContextPruningTracker } from "../core/context-pruning/index.js";
69
71
  import type { MemoryInjection, SessionState } from "../core/types.js";
70
72
  import type {
71
73
  Hooks,
@@ -114,6 +116,8 @@ interface AideState {
114
116
  /** Per-session metadata for agent-like tracking */
115
117
  sessionInfoMap: Map<string, SessionInfo>;
116
118
  client: OpenCodeClient;
119
+ /** Context pruning tracker for dedup/supersede/purge of tool outputs */
120
+ pruningTracker: ContextPruningTracker;
117
121
  }
118
122
 
119
123
  /**
@@ -142,6 +146,7 @@ export async function createHooks(
142
146
  lastUserPrompt: null,
143
147
  sessionInfoMap: new Map(),
144
148
  client,
149
+ pruningTracker: new ContextPruningTracker(cwd),
145
150
  };
146
151
 
147
152
  // Run one-time initialization (directories, binary, config)
@@ -184,16 +189,49 @@ function createConfigHandler(
184
189
  // Discover all skills and register them as OpenCode commands
185
190
  const skills = discoverSkills(state.cwd, state.pluginRoot ?? undefined);
186
191
 
192
+ // Register our skill directories with OpenCode's native skill discovery
193
+ // as a fallback, so the native `skill` tool can also find them.
194
+ const skillsConfig = (input as Record<string, unknown>).skills as
195
+ | { paths?: string[]; urls?: string[] }
196
+ | undefined;
197
+ const existingPaths = skillsConfig?.paths ?? [];
198
+ const aidePaths = [
199
+ join(state.cwd, ".aide", "skills"),
200
+ join(state.cwd, "skills"),
201
+ ];
202
+ if (state.pluginRoot) {
203
+ aidePaths.push(join(state.pluginRoot, "skills"));
204
+ }
205
+ // Only add paths that aren't already registered
206
+ const newPaths = aidePaths.filter((p) => !existingPaths.includes(p));
207
+ if (newPaths.length > 0) {
208
+ (input as Record<string, unknown>).skills = {
209
+ ...skillsConfig,
210
+ paths: [...existingPaths, ...newPaths],
211
+ };
212
+ }
213
+
187
214
  if (!input.command) {
188
215
  input.command = {};
189
216
  }
190
217
 
191
218
  for (const skill of skills) {
219
+ // Skip skills restricted to other platforms
220
+ if (
221
+ skill.platforms &&
222
+ skill.platforms.length > 0 &&
223
+ !skill.platforms.includes("opencode")
224
+ ) {
225
+ continue;
226
+ }
192
227
  const commandName = `aide:${skill.name}`;
193
228
  // Only register if not already defined (user config takes priority)
194
229
  if (!input.command[commandName]) {
195
230
  input.command[commandName] = {
196
- template: `Activate the aide "${skill.name}" skill. {{arguments}}`,
231
+ // IMPORTANT: Template wording must NOT trigger the native "skill" tool.
232
+ // The actual instructions are injected into the system prompt by
233
+ // createCommandHandler → pendingSkillsContext → createSystemTransformHandler.
234
+ template: `Follow the aide "${skill.name}" instructions that have been injected into your system prompt. Do NOT use the skill tool. {{arguments}}`,
197
235
  description: skill.description || `aide ${skill.name} skill`,
198
236
  };
199
237
  }
@@ -234,9 +272,18 @@ function createCommandHandler(state: AideState): (
234
272
  // Format the skill content for injection
235
273
  const context = formatSkillsContext([skill]);
236
274
 
237
- // Store for system transform injection
275
+ // Store for system transform injection (into system prompt)
238
276
  state.pendingSkillsContext = context;
239
277
 
278
+ // Also inject into the command output parts so the model sees the
279
+ // instructions directly in the user message. This avoids reliance
280
+ // on the system transform alone and prevents the model from trying
281
+ // to call the native "skill" tool to find the instructions.
282
+ output.parts.push({
283
+ type: "text",
284
+ text: `<aide-instructions>\n${context}\n</aide-instructions>`,
285
+ });
286
+
240
287
  // Also store the arguments as the user prompt for the transform
241
288
  if (args) {
242
289
  state.lastUserPrompt = args;
@@ -299,7 +346,7 @@ function initializeAide(state: AideState): void {
299
346
  state.binary,
300
347
  state.cwd,
301
348
  projectName,
302
- 3,
349
+ 2,
303
350
  config,
304
351
  );
305
352
  }
@@ -506,14 +553,20 @@ async function handleSessionIdle(
506
553
  let summary: string | null = null;
507
554
 
508
555
  if (partials.length > 0) {
509
- const commits = getSessionCommits(state.cwd);
556
+ const commits = getSessionCommits(
557
+ state.cwd,
558
+ state.sessionState?.startedAt,
559
+ );
510
560
  summary = buildSummaryFromPartials(partials, commits, []);
511
561
  debug(SOURCE, `Built summary from ${partials.length} partials`);
512
562
  }
513
563
 
514
564
  // Fall back to state-only summary if no partials
515
565
  if (!summary) {
516
- summary = buildSessionSummaryFromState(state.cwd);
566
+ summary = buildSessionSummaryFromState(
567
+ state.cwd,
568
+ state.sessionState?.startedAt,
569
+ );
517
570
  }
518
571
 
519
572
  if (summary) {
@@ -584,7 +637,7 @@ async function handleMessagePartUpdated(
584
637
  state.lastUserPrompt = prompt;
585
638
 
586
639
  const skills = discoverSkills(state.cwd, state.pluginRoot ?? undefined);
587
- const matched = matchSkills(prompt, skills, 3);
640
+ const matched = matchSkills(prompt, skills, 3, "opencode");
588
641
 
589
642
  if (matched.length > 0) {
590
643
  try {
@@ -704,6 +757,29 @@ function createToolAfterHandler(
704
757
  debug(SOURCE, `Partial memory write failed (non-fatal): ${err}`);
705
758
  }
706
759
 
760
+ // Context pruning: dedup/supersede/purge tool outputs
761
+ try {
762
+ const toolArgs = (_output.metadata?.args || {}) as Record<
763
+ string,
764
+ unknown
765
+ >;
766
+ const pruneResult = state.pruningTracker.process(
767
+ input.callID,
768
+ input.tool,
769
+ toolArgs,
770
+ _output.output,
771
+ );
772
+ if (pruneResult.modified) {
773
+ _output.output = pruneResult.output;
774
+ debug(
775
+ SOURCE,
776
+ `Context pruning [${pruneResult.strategy}]: saved ${pruneResult.bytesSaved} bytes for ${input.tool}`,
777
+ );
778
+ }
779
+ } catch (err) {
780
+ debug(SOURCE, `Context pruning failed (non-fatal): ${err}`);
781
+ }
782
+
707
783
  // Comment checker: detect excessive comments in Write/Edit output
708
784
  try {
709
785
  const toolArgs = (_output.metadata?.args || {}) as Record<
@@ -742,6 +818,10 @@ function createCompactionHandler(
742
818
  output: { context: string[]; prompt?: string },
743
819
  ) => Promise<void> {
744
820
  return async (input, output) => {
821
+ // Reset context pruning tracker — compaction clears the conversation
822
+ // history, so dedup/supersede references would be stale.
823
+ state.pruningTracker.reset();
824
+
745
825
  // Save state snapshot before compaction
746
826
  if (state.binary) {
747
827
  saveStateSnapshot(state.binary, state.cwd, input.sessionID);
@@ -758,7 +838,10 @@ function createCompactionHandler(
758
838
  let summary: string | null = null;
759
839
 
760
840
  if (partials.length > 0) {
761
- const commits = getSessionCommits(state.cwd);
841
+ const commits = getSessionCommits(
842
+ state.cwd,
843
+ state.sessionState?.startedAt,
844
+ );
762
845
  summary = buildSummaryFromPartials(partials, commits, []);
763
846
  debug(
764
847
  SOURCE,
@@ -768,7 +851,10 @@ function createCompactionHandler(
768
851
 
769
852
  // Fall back to state-only summary if no partials
770
853
  if (!summary) {
771
- summary = buildSessionSummaryFromState(state.cwd);
854
+ summary = buildSessionSummaryFromState(
855
+ state.cwd,
856
+ state.sessionState?.startedAt,
857
+ );
772
858
  }
773
859
 
774
860
  if (summary) {
@@ -802,7 +888,7 @@ function createCompactionHandler(
802
888
  state.binary,
803
889
  state.cwd,
804
890
  projectName,
805
- 3,
891
+ 2,
806
892
  );
807
893
  state.memories = freshMemories;
808
894
  state.welcomeContext = buildWelcomeContext(
@@ -844,6 +930,17 @@ function createSystemTransformHandler(
844
930
  output.system.push(state.welcomeContext);
845
931
  }
846
932
 
933
+ // Inject context pruning notes only after the first prune fires.
934
+ // Avoids adding ~280 bytes to every session that never triggers pruning.
935
+ if (state.pruningTracker.getStats().prunedCalls > 0) {
936
+ output.system.push(`<aide-context-pruning>
937
+ Tool outputs may contain these tags from aide's context optimization:
938
+ - [aide:dedup] — This output is identical to a previous call. Refer to the earlier result.
939
+ - [aide:supersede] — A prior Read of this file is now stale after a Write/Edit.
940
+ - [aide:purge] — Large error output was trimmed. Re-run the command for full output.
941
+ </aide-context-pruning>`);
942
+ }
943
+
847
944
  // Inject per-session context only when swarm mode is active and session
848
945
  // has been assigned a worktree/role (e.g., by swarm orchestration).
849
946
  // Without this guard, every normal session would incorrectly be told
@@ -894,7 +991,12 @@ function createSystemTransformHandler(
894
991
  } else if (state.lastUserPrompt) {
895
992
  try {
896
993
  const skills = discoverSkills(state.cwd, state.pluginRoot ?? undefined);
897
- const matched = matchSkills(state.lastUserPrompt, skills, 3);
994
+ const matched = matchSkills(
995
+ state.lastUserPrompt,
996
+ skills,
997
+ 3,
998
+ "opencode",
999
+ );
898
1000
  if (matched.length > 0) {
899
1001
  output.system.push(formatSkillsContext(matched));
900
1002
  debug(
@@ -907,8 +1009,9 @@ function createSystemTransformHandler(
907
1009
  }
908
1010
  }
909
1011
 
910
- // Inject messaging protocol for multi-instance coordination
911
- output.system.push(`<aide-messaging>
1012
+ // Inject messaging protocol only in swarm mode (saves ~450 bytes per turn otherwise)
1013
+ if (rawMode === "swarm") {
1014
+ output.system.push(`<aide-messaging>
912
1015
 
913
1016
  ## Agent Messaging
914
1017
 
@@ -927,6 +1030,7 @@ Use aide MCP tools to coordinate with other agents or sessions:
927
1030
  - Check messages periodically for requests from other agents
928
1031
 
929
1032
  </aide-messaging>`);
1033
+ }
930
1034
  };
931
1035
  }
932
1036