@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 +1 -1
- package/skills/context-usage/SKILL.md +157 -0
- package/src/core/context-pruning/dedup.ts +173 -0
- package/src/core/context-pruning/index.ts +19 -0
- package/src/core/context-pruning/purge.ts +80 -0
- package/src/core/context-pruning/supersede.ts +67 -0
- package/src/core/context-pruning/tracker.ts +179 -0
- package/src/core/context-pruning/types.ts +63 -0
- package/src/core/session-init.ts +12 -6
- package/src/core/session-summary-logic.ts +16 -13
- package/src/core/skill-matcher.ts +28 -8
- package/src/core/types.ts +4 -0
- package/src/opencode/hooks.ts +116 -12
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/core/session-init.ts
CHANGED
|
@@ -194,12 +194,11 @@ export function initializeSession(
|
|
|
194
194
|
agentCount: 0,
|
|
195
195
|
};
|
|
196
196
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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=${
|
|
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(
|
|
174
|
-
|
|
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
|
|
package/src/opencode/hooks.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
911
|
-
|
|
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
|
|