@jmylchreest/aide-plugin 0.1.0 → 0.1.2
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/reflect/SKILL.md +24 -8
- package/src/cli/codex-config.ts +15 -0
- package/src/core/context-pruning/dedup.ts +7 -2
- package/src/core/context-pruning/tracker.ts +0 -23
- package/src/core/memory-query.ts +63 -0
- package/src/core/partial-memory.ts +23 -105
- package/src/core/session-checkpoint-logic.ts +170 -0
- package/src/core/session-resume-logic.ts +104 -0
- package/src/core/session-summary-logic.ts +16 -41
- package/src/core/session-text.ts +60 -0
- package/src/core/tool-observe.ts +53 -14
- package/src/hooks/agent-cleanup.ts +11 -28
- package/src/hooks/agent-signals.ts +11 -18
- package/src/hooks/comment-checker.ts +14 -31
- package/src/hooks/context-guard.ts +17 -31
- package/src/hooks/context-pruning.ts +13 -30
- package/src/hooks/hud-updater.ts +11 -29
- package/src/hooks/permission-handler.ts +14 -25
- package/src/hooks/persistence.ts +15 -31
- package/src/hooks/pre-compact.ts +52 -38
- package/src/hooks/pre-tool-enforcer.ts +14 -30
- package/src/hooks/reflect.ts +15 -8
- package/src/hooks/search-enrichment.ts +12 -29
- package/src/hooks/session-end.ts +60 -11
- package/src/hooks/session-start.ts +68 -30
- package/src/hooks/session-summary.ts +11 -27
- package/src/hooks/skill-injector.ts +15 -30
- package/src/hooks/subagent-tracker.ts +44 -39
- package/src/hooks/task-completed.ts +6 -9
- package/src/hooks/tool-observe.ts +27 -32
- package/src/hooks/tool-tracker.ts +11 -28
- package/src/hooks/write-guard.ts +12 -29
- package/src/lib/hook-utils.ts +48 -0
- package/src/lib/project-root.ts +30 -15
package/package.json
CHANGED
package/skills/reflect/SKILL.md
CHANGED
|
@@ -139,18 +139,24 @@ Build a JSON array:
|
|
|
139
139
|
### 3. Run with classifications
|
|
140
140
|
|
|
141
141
|
```bash
|
|
142
|
-
./.aide/bin/aide reflect run \
|
|
142
|
+
./.aide/bin/aide reflect run --llm \
|
|
143
143
|
--classifications-json='[{"id":"01JF...A","intent":"corrective"}]'
|
|
144
144
|
```
|
|
145
145
|
|
|
146
146
|
Returns a JSON summary: `{"proposals_written": N, "shapes": {...}}`.
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
The `--llm` flag puts the runner in LLM mode, which runs the `RequiresLLM`
|
|
149
|
+
detectors (convergence, friction) in addition to repetition. The Stop hook
|
|
150
|
+
never passes it, so those detectors only ever surface in this reviewed pass.
|
|
149
151
|
|
|
150
|
-
|
|
152
|
+
### 4. Run in LLM mode without classifications
|
|
153
|
+
|
|
154
|
+
If step 1 returned no candidates, still run with `--llm` (not bare) so
|
|
155
|
+
friction and the marker-based convergence pass fire — only the LLM-graded
|
|
156
|
+
convergence intent is skipped:
|
|
151
157
|
|
|
152
158
|
```bash
|
|
153
|
-
./.aide/bin/aide reflect run
|
|
159
|
+
./.aide/bin/aide reflect run --llm
|
|
154
160
|
```
|
|
155
161
|
|
|
156
162
|
### 5. List proposals and summarise for the user
|
|
@@ -283,7 +289,17 @@ gated by the env var.
|
|
|
283
289
|
- **convergence** — `Edit A` → user corrective marker → `Edit B` on the same
|
|
284
290
|
file → optional positive signal. Marker-based by default, upgrades to
|
|
285
291
|
LLM-classified when intent labels are provided via step 3.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
292
|
+
- **friction** — the same tool failing repeatedly on the same target (a Bash
|
|
293
|
+
command that keeps erroring, an Edit that won't apply to a file). Gathered
|
|
294
|
+
from the observe `Error` field; the lesson worth keeping is usually the
|
|
295
|
+
*fix*, which you supply by reading the evidence. `RequiresLLM` — never
|
|
296
|
+
auto-fires in the Stop hook.
|
|
297
|
+
|
|
298
|
+
Detectors declare a `RequiresLLM` capability — two tiers:
|
|
299
|
+
|
|
300
|
+
- **Deterministic tier** (`RequiresLLM=false`: repetition) runs automatically
|
|
301
|
+
in the Stop hook. Must be high-precision; nothing reviews it before it lands
|
|
302
|
+
as a proposal.
|
|
303
|
+
- **LLM tier** (`RequiresLLM=true`: convergence, friction) runs only in this
|
|
304
|
+
reviewed pass, when the skill passes `--llm` (step 3/4). Higher recall is
|
|
305
|
+
fine — you judge each proposal before anything is promoted.
|
package/src/cli/codex-config.ts
CHANGED
|
@@ -218,6 +218,21 @@ function generateHooksJson(hookPrefix: string): CodexHooksJson {
|
|
|
218
218
|
],
|
|
219
219
|
},
|
|
220
220
|
],
|
|
221
|
+
// Failed tool calls arrive on a separate event (PostToolUse fires on
|
|
222
|
+
// success only); route them to tool-observe so the friction detector
|
|
223
|
+
// sees the failure. Same script — it reads the top-level error fields.
|
|
224
|
+
PostToolUseFailure: [
|
|
225
|
+
{
|
|
226
|
+
matcher: "*",
|
|
227
|
+
hooks: [
|
|
228
|
+
{
|
|
229
|
+
type: "command",
|
|
230
|
+
command: `${hookPrefix} tool-observe`,
|
|
231
|
+
timeout: 3,
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
],
|
|
221
236
|
Stop: [
|
|
222
237
|
{
|
|
223
238
|
matcher: "*",
|
|
@@ -128,8 +128,13 @@ export class DedupStrategy implements PruneStrategy {
|
|
|
128
128
|
): PruneResult {
|
|
129
129
|
const normalized = toolName.toLowerCase();
|
|
130
130
|
|
|
131
|
-
// Only apply to safe tools
|
|
132
|
-
|
|
131
|
+
// Only apply to safe tools. Try exact match first, then suffix fallback so
|
|
132
|
+
// harness-prefixed MCP names (mcp__plugin_aide_aide__code_outline,
|
|
133
|
+
// mcp__aide__code_outline) all resolve via the bare suffix entry.
|
|
134
|
+
const isSafe =
|
|
135
|
+
SAFE_DEDUP_TOOLS.has(normalized) ||
|
|
136
|
+
SAFE_DEDUP_TOOLS.has(normalized.split("__").pop() ?? "");
|
|
137
|
+
if (!isSafe) {
|
|
133
138
|
return { output, modified: false, bytesSaved: 0 };
|
|
134
139
|
}
|
|
135
140
|
|
|
@@ -143,29 +143,6 @@ export class ContextPruningTracker {
|
|
|
143
143
|
return { ...this.stats };
|
|
144
144
|
}
|
|
145
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
146
|
/** Clear history (e.g., on compaction). */
|
|
170
147
|
reset(): void {
|
|
171
148
|
this.history = [];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory query helper — platform-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Thin wrapper around `aide memory list … --format=json` so callers don't each
|
|
5
|
+
* re-implement the spawn + parse. Returns the parsed entries; filtering,
|
|
6
|
+
* sorting, and mapping are left to the caller (their needs differ — session
|
|
7
|
+
* partials filter by tag, the resume path sorts by createdAt).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "child_process";
|
|
11
|
+
import { debug } from "../lib/logger.js";
|
|
12
|
+
|
|
13
|
+
const SOURCE = "memory-query";
|
|
14
|
+
|
|
15
|
+
/** Shape of an entry from `aide memory list --format=json`. */
|
|
16
|
+
export interface MemoryEntry {
|
|
17
|
+
id: string;
|
|
18
|
+
category?: string;
|
|
19
|
+
content: string;
|
|
20
|
+
tags?: string[];
|
|
21
|
+
createdAt?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ListMemoriesOptions {
|
|
25
|
+
/** Comma-separated tag filter passed to `--tags=`. */
|
|
26
|
+
tags?: string;
|
|
27
|
+
/** Include memories tagged `forget` / `partial` (passes `--all`). */
|
|
28
|
+
all?: boolean;
|
|
29
|
+
/** Max rows to return (passes `--limit=`). Defaults to the CLI default. */
|
|
30
|
+
limit?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run `aide memory list` with the given filters and return parsed entries.
|
|
35
|
+
* Returns an empty array on any failure (missing binary, parse error, etc.).
|
|
36
|
+
*/
|
|
37
|
+
export function listMemoriesJson(
|
|
38
|
+
binary: string,
|
|
39
|
+
cwd: string,
|
|
40
|
+
opts: ListMemoriesOptions = {},
|
|
41
|
+
): MemoryEntry[] {
|
|
42
|
+
try {
|
|
43
|
+
const args = ["memory", "list", "--format=json"];
|
|
44
|
+
if (opts.tags) args.push(`--tags=${opts.tags}`);
|
|
45
|
+
if (opts.all) args.push("--all");
|
|
46
|
+
if (opts.limit !== undefined) args.push(`--limit=${opts.limit}`);
|
|
47
|
+
|
|
48
|
+
const output = execFileSync(binary, args, {
|
|
49
|
+
cwd,
|
|
50
|
+
encoding: "utf-8",
|
|
51
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
52
|
+
timeout: 5000,
|
|
53
|
+
}).trim();
|
|
54
|
+
|
|
55
|
+
if (!output || output === "[]" || output === "null") return [];
|
|
56
|
+
|
|
57
|
+
const parsed: MemoryEntry[] = JSON.parse(output);
|
|
58
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
59
|
+
} catch (err) {
|
|
60
|
+
debug(SOURCE, `memory list failed (non-fatal): ${err}`);
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
import { execFileSync } from "child_process";
|
|
18
18
|
import { debug } from "../lib/logger.js";
|
|
19
|
+
import { listMemoriesJson, type MemoryEntry } from "./memory-query.js";
|
|
20
|
+
import { categorizePartials, renderBulletSection } from "./session-text.js";
|
|
19
21
|
|
|
20
22
|
const SOURCE = "partial-memory";
|
|
21
23
|
|
|
@@ -152,55 +154,25 @@ export function storePartialMemory(
|
|
|
152
154
|
}
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
/** Shape returned by `aide memory list --format=json`. */
|
|
156
|
-
interface PartialMemoryEntry {
|
|
157
|
-
id: string;
|
|
158
|
-
tags: string[];
|
|
159
|
-
content: string;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
157
|
/**
|
|
163
158
|
* Query session partials and map to a caller-chosen type.
|
|
164
159
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
160
|
+
* Lists all `partial`-tagged memories, filters to the given session, and maps
|
|
161
|
+
* each match through `mapFn`.
|
|
167
162
|
*/
|
|
168
163
|
function querySessionPartials<T>(
|
|
169
164
|
binary: string,
|
|
170
165
|
cwd: string,
|
|
171
166
|
sessionId: string,
|
|
172
|
-
mapFn: (m:
|
|
173
|
-
label: string,
|
|
167
|
+
mapFn: (m: MemoryEntry) => T,
|
|
174
168
|
): T[] {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
"list",
|
|
183
|
-
"--tags=partial",
|
|
184
|
-
"--all", // Include even if tagged forget (shouldn't be, but defensive)
|
|
185
|
-
"--format=json",
|
|
186
|
-
"--limit=500",
|
|
187
|
-
],
|
|
188
|
-
{
|
|
189
|
-
cwd,
|
|
190
|
-
encoding: "utf-8",
|
|
191
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
192
|
-
timeout: 5000,
|
|
193
|
-
},
|
|
194
|
-
).trim();
|
|
195
|
-
|
|
196
|
-
if (!output || output === "[]") return [];
|
|
197
|
-
|
|
198
|
-
const memories: PartialMemoryEntry[] = JSON.parse(output);
|
|
199
|
-
return memories.filter((m) => m.tags?.includes(sessionTag)).map(mapFn);
|
|
200
|
-
} catch (err) {
|
|
201
|
-
debug(SOURCE, `Failed to gather ${label}: ${err}`);
|
|
202
|
-
return [];
|
|
203
|
-
}
|
|
169
|
+
const sessionTag = `session:${sessionId}`;
|
|
170
|
+
const memories = listMemoriesJson(binary, cwd, {
|
|
171
|
+
tags: "partial",
|
|
172
|
+
all: true, // Include even if tagged forget (shouldn't be, but defensive)
|
|
173
|
+
limit: 500,
|
|
174
|
+
});
|
|
175
|
+
return memories.filter((m) => m.tags?.includes(sessionTag)).map(mapFn);
|
|
204
176
|
}
|
|
205
177
|
|
|
206
178
|
/**
|
|
@@ -214,13 +186,7 @@ export function gatherPartials(
|
|
|
214
186
|
cwd: string,
|
|
215
187
|
sessionId: string,
|
|
216
188
|
): string[] {
|
|
217
|
-
return querySessionPartials(
|
|
218
|
-
binary,
|
|
219
|
-
cwd,
|
|
220
|
-
sessionId,
|
|
221
|
-
(m) => m.content,
|
|
222
|
-
"partials",
|
|
223
|
-
);
|
|
189
|
+
return querySessionPartials(binary, cwd, sessionId, (m) => m.content);
|
|
224
190
|
}
|
|
225
191
|
|
|
226
192
|
/**
|
|
@@ -231,13 +197,7 @@ export function gatherPartialIds(
|
|
|
231
197
|
cwd: string,
|
|
232
198
|
sessionId: string,
|
|
233
199
|
): string[] {
|
|
234
|
-
return querySessionPartials(
|
|
235
|
-
binary,
|
|
236
|
-
cwd,
|
|
237
|
-
sessionId,
|
|
238
|
-
(m) => m.id,
|
|
239
|
-
"partial IDs",
|
|
240
|
-
);
|
|
200
|
+
return querySessionPartials(binary, cwd, sessionId, (m) => m.id);
|
|
241
201
|
}
|
|
242
202
|
|
|
243
203
|
/**
|
|
@@ -287,58 +247,16 @@ export function buildSummaryFromPartials(
|
|
|
287
247
|
gitCommits: string[],
|
|
288
248
|
gitFiles: string[],
|
|
289
249
|
): string | null {
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const fileChanges = new Set<string>();
|
|
294
|
-
const commands: string[] = [];
|
|
295
|
-
const tasks: string[] = [];
|
|
296
|
-
const other: string[] = [];
|
|
250
|
+
const { files, commands, tasks } = categorizePartials(partials);
|
|
251
|
+
// Merge file changes from partials and git, preserving order, de-duped.
|
|
252
|
+
const allFiles = Array.from(new Set([...files, ...gitFiles]));
|
|
297
253
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
tasks.push(p.replace("Completed task: ", ""));
|
|
305
|
-
} else {
|
|
306
|
-
other.push(p);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (tasks.length > 0) {
|
|
311
|
-
summaryParts.push(
|
|
312
|
-
`## Tasks\n${tasks
|
|
313
|
-
.slice(0, 5)
|
|
314
|
-
.map((t) => `- ${t}`)
|
|
315
|
-
.join("\n")}`,
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (gitCommits.length > 0) {
|
|
320
|
-
summaryParts.push(
|
|
321
|
-
`## Commits\n${gitCommits.map((c) => `- ${c}`).join("\n")}`,
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Merge file changes from partials and git
|
|
326
|
-
const allFiles = new Set([...fileChanges, ...gitFiles]);
|
|
327
|
-
if (allFiles.size > 0) {
|
|
328
|
-
const files = Array.from(allFiles).slice(0, 15);
|
|
329
|
-
summaryParts.push(
|
|
330
|
-
`## Files Modified\n${files.map((f) => `- ${f}`).join("\n")}`,
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
if (commands.length > 0) {
|
|
335
|
-
summaryParts.push(
|
|
336
|
-
`## Commands\n${commands
|
|
337
|
-
.slice(0, 10)
|
|
338
|
-
.map((c) => `- ${c}`)
|
|
339
|
-
.join("\n")}`,
|
|
340
|
-
);
|
|
341
|
-
}
|
|
254
|
+
const summaryParts = [
|
|
255
|
+
renderBulletSection("Tasks", tasks, 5),
|
|
256
|
+
renderBulletSection("Commits", gitCommits),
|
|
257
|
+
renderBulletSection("Files Modified", allFiles, 15),
|
|
258
|
+
renderBulletSection("Commands", commands, 10),
|
|
259
|
+
].filter((s): s is string => s !== null);
|
|
342
260
|
|
|
343
261
|
const summary = summaryParts.join("\n\n");
|
|
344
262
|
return summary.length >= 50 ? summary : null;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session checkpoint logic — platform-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Builds a structured "session checkpoint" before context compaction, so the
|
|
5
|
+
* work-so-far is recoverable after the harness summarises the conversation.
|
|
6
|
+
*
|
|
7
|
+
* Why a checkpoint and not just a flat summary: aide has structured stores of
|
|
8
|
+
* its own (task tree, git state) that a mechanical roll-up can pull from
|
|
9
|
+
* without any LLM. The checkpoint shape mirrors the parts of MiMo-Code's
|
|
10
|
+
* checkpoint that are derivable without LLM judgment — task state, work
|
|
11
|
+
* accomplished, files touched, live resources — and is intentionally bounded
|
|
12
|
+
* (capped lists) rather than "compressed", since the hook has no model access.
|
|
13
|
+
*
|
|
14
|
+
* The sections that genuinely need LLM judgment (verbatim user intent,
|
|
15
|
+
* cross-task discovered knowledge) are deliberately omitted here — fabricating
|
|
16
|
+
* them mechanically would be worse than leaving them out.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from "child_process";
|
|
20
|
+
import { debug } from "../lib/logger.js";
|
|
21
|
+
import { categorizePartials, renderBulletSection } from "./session-text.js";
|
|
22
|
+
|
|
23
|
+
const SOURCE = "session-checkpoint";
|
|
24
|
+
|
|
25
|
+
/** Structured inputs for a checkpoint. All optional sections are omitted when empty. */
|
|
26
|
+
export interface CheckpointInput {
|
|
27
|
+
sessionId: string;
|
|
28
|
+
/** Raw partial-memory content strings for this session (from partial-memory). */
|
|
29
|
+
partials: string[];
|
|
30
|
+
/** Git oneline commit subjects made during the session. */
|
|
31
|
+
commits: string[];
|
|
32
|
+
/** Pre-rendered task-tree lines (icon + summary). See getTaskTree. */
|
|
33
|
+
taskTree?: string[];
|
|
34
|
+
/** One-line live runtime state, e.g. "branch main · 3 uncommitted file(s)". */
|
|
35
|
+
liveState?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TaskEntry {
|
|
39
|
+
id?: string;
|
|
40
|
+
title?: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
status?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Map a task status to a MiMo-style status icon. */
|
|
46
|
+
function statusIcon(status: string | undefined): string {
|
|
47
|
+
switch ((status ?? "").toLowerCase()) {
|
|
48
|
+
case "done":
|
|
49
|
+
case "complete":
|
|
50
|
+
case "completed":
|
|
51
|
+
return "✅";
|
|
52
|
+
case "claimed":
|
|
53
|
+
case "in_progress":
|
|
54
|
+
case "in-progress":
|
|
55
|
+
return "🔄";
|
|
56
|
+
case "blocked":
|
|
57
|
+
return "🟡";
|
|
58
|
+
case "abandoned":
|
|
59
|
+
return "❌";
|
|
60
|
+
default:
|
|
61
|
+
return "🔵"; // pending / open / unknown
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Render the current task tree from the swarm task store.
|
|
67
|
+
*
|
|
68
|
+
* Returns one line per task (`<icon> <summary> [status]`), capped. Empty array
|
|
69
|
+
* when no tasks exist (the common case for solo, non-swarm sessions) or when
|
|
70
|
+
* the task store can't be read — the checkpoint simply omits the section.
|
|
71
|
+
*/
|
|
72
|
+
export function getTaskTree(binary: string, cwd: string): string[] {
|
|
73
|
+
try {
|
|
74
|
+
const output = execFileSync(binary, ["task", "list", "--json"], {
|
|
75
|
+
cwd,
|
|
76
|
+
encoding: "utf-8",
|
|
77
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
78
|
+
timeout: 5000,
|
|
79
|
+
}).trim();
|
|
80
|
+
|
|
81
|
+
if (!output || output === "[]" || output === "null") return [];
|
|
82
|
+
|
|
83
|
+
const tasks: TaskEntry[] = JSON.parse(output);
|
|
84
|
+
if (!Array.isArray(tasks)) return [];
|
|
85
|
+
|
|
86
|
+
return tasks.slice(0, 15).map((t) => {
|
|
87
|
+
const label = t.title || t.description || t.id || "task";
|
|
88
|
+
const status = t.status ? ` [${t.status}]` : "";
|
|
89
|
+
return `${statusIcon(t.status)} ${label}${status}`;
|
|
90
|
+
});
|
|
91
|
+
} catch (err) {
|
|
92
|
+
debug(SOURCE, `Failed to read task tree (non-fatal): ${err}`);
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Describe volatile runtime state — current branch and uncommitted file count.
|
|
99
|
+
* Returns undefined when not a git repo or git is unavailable.
|
|
100
|
+
*/
|
|
101
|
+
export function getGitLiveState(cwd: string): string | undefined {
|
|
102
|
+
try {
|
|
103
|
+
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
104
|
+
cwd,
|
|
105
|
+
encoding: "utf-8",
|
|
106
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
107
|
+
timeout: 3000,
|
|
108
|
+
}).trim();
|
|
109
|
+
|
|
110
|
+
const porcelain = execFileSync("git", ["status", "--porcelain"], {
|
|
111
|
+
cwd,
|
|
112
|
+
encoding: "utf-8",
|
|
113
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
114
|
+
timeout: 3000,
|
|
115
|
+
}).trim();
|
|
116
|
+
|
|
117
|
+
const dirtyCount = porcelain
|
|
118
|
+
? porcelain.split("\n").filter((l) => l.trim()).length
|
|
119
|
+
: 0;
|
|
120
|
+
const parts: string[] = [];
|
|
121
|
+
if (branch) parts.push(`branch ${branch}`);
|
|
122
|
+
parts.push(`${dirtyCount} uncommitted file(s)`);
|
|
123
|
+
return parts.join(" · ");
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build a structured session checkpoint from already-gathered inputs.
|
|
131
|
+
*
|
|
132
|
+
* Pure function (no IO) so it is unit-testable. Sections with no content are
|
|
133
|
+
* omitted entirely. Returns null when the result carries no substantive
|
|
134
|
+
* content (mirrors the >=50-char guard used elsewhere).
|
|
135
|
+
*/
|
|
136
|
+
export function buildSessionCheckpoint(input: CheckpointInput): string | null {
|
|
137
|
+
const {
|
|
138
|
+
files,
|
|
139
|
+
commands,
|
|
140
|
+
tasks: completed,
|
|
141
|
+
other,
|
|
142
|
+
} = categorizePartials(input.partials);
|
|
143
|
+
|
|
144
|
+
const sections: string[] = [
|
|
145
|
+
"# Session checkpoint",
|
|
146
|
+
"_Structured snapshot written before context compaction. Use it to resume work; the verbatim conversation is the ground truth where they disagree._",
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
const contentSections = [
|
|
150
|
+
renderBulletSection("Task state", input.taskTree ?? []),
|
|
151
|
+
renderBulletSection("Work completed", completed, 10),
|
|
152
|
+
renderBulletSection("Commits", input.commits),
|
|
153
|
+
renderBulletSection("Files touched", files, 15),
|
|
154
|
+
renderBulletSection("Commands run", commands, 10),
|
|
155
|
+
renderBulletSection("Other", other, 10),
|
|
156
|
+
].filter((s): s is string => s !== null);
|
|
157
|
+
sections.push(...contentSections);
|
|
158
|
+
|
|
159
|
+
// Live resources is a single line, not a bullet list.
|
|
160
|
+
if (input.liveState) {
|
|
161
|
+
sections.push(`## Live resources\n${input.liveState}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// sections[0..1] are the header + instruction line; require at least one
|
|
165
|
+
// content section before we consider the checkpoint worth persisting.
|
|
166
|
+
if (sections.length <= 2) return null;
|
|
167
|
+
|
|
168
|
+
const checkpoint = sections.join("\n\n");
|
|
169
|
+
return checkpoint.length >= 50 ? checkpoint : null;
|
|
170
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session resume logic — platform-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* When a session resumes or restarts after a compaction, re-inject the most
|
|
5
|
+
* recent session checkpoint so the agent rebuilds working context instead of
|
|
6
|
+
* relearning it. This is aide's plugin-side equivalent of MiMo-Code's context
|
|
7
|
+
* reconstruction: a plugin can't rebuild the host's conversation, but it can
|
|
8
|
+
* inject the last checkpoint at the SessionStart boundary the host gives us.
|
|
9
|
+
*
|
|
10
|
+
* No LLM is involved — we surface the structured checkpoint verbatim and let
|
|
11
|
+
* the main agent reconcile it against the (authoritative) live conversation.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { listMemoriesJson, type MemoryEntry } from "./memory-query.js";
|
|
15
|
+
|
|
16
|
+
/** SessionStart `source` values for which a resume injection is warranted. */
|
|
17
|
+
const RESUME_SOURCES = new Set(["resume", "compact"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Query the newest checkpoint memory for this session.
|
|
21
|
+
*
|
|
22
|
+
* Prefers `checkpoint`-tagged memories (the structured snapshot written by the
|
|
23
|
+
* PreCompact hook); falls back to `session-summary`-tagged memories so this
|
|
24
|
+
* works even where the structured-checkpoint feature isn't present. Prefers an
|
|
25
|
+
* entry tagged for the current session, but falls back to the newest checkpoint
|
|
26
|
+
* overall (a resumed session may be assigned a fresh id by the host).
|
|
27
|
+
*/
|
|
28
|
+
export function getLatestCheckpoint(
|
|
29
|
+
binary: string,
|
|
30
|
+
cwd: string,
|
|
31
|
+
sessionId: string,
|
|
32
|
+
): string | null {
|
|
33
|
+
for (const tag of ["checkpoint", "session-summary"]) {
|
|
34
|
+
const found = queryNewest(binary, cwd, sessionId, tag);
|
|
35
|
+
if (found) return found;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function queryNewest(
|
|
41
|
+
binary: string,
|
|
42
|
+
cwd: string,
|
|
43
|
+
sessionId: string,
|
|
44
|
+
tag: string,
|
|
45
|
+
): string | null {
|
|
46
|
+
// checkpoints are tagged `partial`, so --all is required to see them.
|
|
47
|
+
const memories = listMemoriesJson(binary, cwd, {
|
|
48
|
+
tags: tag,
|
|
49
|
+
all: true,
|
|
50
|
+
limit: 200,
|
|
51
|
+
});
|
|
52
|
+
if (memories.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
const byTime = (a: MemoryEntry, b: MemoryEntry) =>
|
|
55
|
+
(b.createdAt ?? "").localeCompare(a.createdAt ?? "");
|
|
56
|
+
|
|
57
|
+
const sessionTag = `session:${sessionId}`;
|
|
58
|
+
const scoped = memories
|
|
59
|
+
.filter((m) => m.tags?.includes(sessionTag))
|
|
60
|
+
.sort(byTime);
|
|
61
|
+
const newest = (scoped.length > 0 ? scoped : [...memories].sort(byTime))[0];
|
|
62
|
+
|
|
63
|
+
return newest?.content?.trim() || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Render a resume block from checkpoint content. Pure (no IO), unit-testable.
|
|
68
|
+
*
|
|
69
|
+
* The verify-before-act reminder is load-bearing: the checkpoint is a snapshot
|
|
70
|
+
* that may be stale relative to the live conversation, so the agent must treat
|
|
71
|
+
* it as a starting point, not ground truth.
|
|
72
|
+
*/
|
|
73
|
+
export function renderResumeContext(content: string): string {
|
|
74
|
+
return [
|
|
75
|
+
"## Resuming session — last checkpoint",
|
|
76
|
+
"",
|
|
77
|
+
"<system-reminder>",
|
|
78
|
+
"This is a snapshot from before the session was compacted/resumed. Use it to " +
|
|
79
|
+
"rebuild your bearings, but VERIFY against the current conversation and the " +
|
|
80
|
+
"actual files before acting — re-read a file rather than trusting the snapshot " +
|
|
81
|
+
"where they could disagree.",
|
|
82
|
+
"</system-reminder>",
|
|
83
|
+
"",
|
|
84
|
+
content,
|
|
85
|
+
].join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the resume context block to append at SessionStart.
|
|
90
|
+
*
|
|
91
|
+
* Returns null when the source isn't a resume/compact, or when no checkpoint
|
|
92
|
+
* exists — callers append nothing in that case.
|
|
93
|
+
*/
|
|
94
|
+
export function buildResumeContext(
|
|
95
|
+
binary: string,
|
|
96
|
+
cwd: string,
|
|
97
|
+
sessionId: string,
|
|
98
|
+
source: string | undefined,
|
|
99
|
+
): string | null {
|
|
100
|
+
if (!source || !RESUME_SOURCES.has(source)) return null;
|
|
101
|
+
const content = getLatestCheckpoint(binary, cwd, sessionId);
|
|
102
|
+
if (!content) return null;
|
|
103
|
+
return renderResumeContext(content);
|
|
104
|
+
}
|