@jmylchreest/aide-plugin 0.1.1 → 0.1.3

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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/skills/reflect/SKILL.md +24 -8
  3. package/src/cli/codex-config.ts +15 -0
  4. package/src/core/context-pruning/dedup.ts +7 -2
  5. package/src/core/context-pruning/tracker.ts +0 -23
  6. package/src/core/memory-query.ts +63 -0
  7. package/src/core/partial-memory.ts +23 -105
  8. package/src/core/session-checkpoint-logic.ts +170 -0
  9. package/src/core/session-resume-logic.ts +104 -0
  10. package/src/core/session-summary-logic.ts +16 -41
  11. package/src/core/session-text.ts +60 -0
  12. package/src/core/tool-observe.ts +53 -14
  13. package/src/hooks/agent-cleanup.ts +11 -28
  14. package/src/hooks/agent-signals.ts +11 -18
  15. package/src/hooks/comment-checker.ts +14 -31
  16. package/src/hooks/context-guard.ts +17 -31
  17. package/src/hooks/context-pruning.ts +13 -30
  18. package/src/hooks/hud-updater.ts +11 -29
  19. package/src/hooks/permission-handler.ts +14 -25
  20. package/src/hooks/persistence.ts +15 -31
  21. package/src/hooks/pre-compact.ts +52 -38
  22. package/src/hooks/pre-tool-enforcer.ts +14 -30
  23. package/src/hooks/reflect.ts +15 -8
  24. package/src/hooks/search-enrichment.ts +12 -29
  25. package/src/hooks/session-end.ts +60 -11
  26. package/src/hooks/session-start.ts +68 -30
  27. package/src/hooks/session-summary.ts +11 -27
  28. package/src/hooks/skill-injector.ts +15 -30
  29. package/src/hooks/subagent-tracker.ts +44 -39
  30. package/src/hooks/task-completed.ts +6 -9
  31. package/src/hooks/tool-observe.ts +27 -32
  32. package/src/hooks/tool-tracker.ts +11 -28
  33. package/src/hooks/write-guard.ts +12 -29
  34. package/src/lib/hook-utils.ts +48 -0
  35. package/src/lib/project-root.ts +30 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmylchreest/aide-plugin",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "aide plugin for OpenCode and Codex CLI — multi-agent orchestration, memory, skills, and persistence",
5
5
  "type": "module",
6
6
  "main": "./src/opencode/index.ts",
@@ -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
- ### 4. (Deterministic fallback) Run without classifications
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
- If step 1 returned no candidates, just run deterministically:
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
- Detectors declare a `RequiresLLM` capability. The CLI Stop hook
288
- (`aide reflect run`) runs only `RequiresLLM=false` detectors automatically;
289
- LLM-required detectors are skipped unless this skill provides classifications.
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.
@@ -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
- if (!SAFE_DEDUP_TOOLS.has(normalized)) {
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
- * Runs `aide memory list --tags=partial --format=json`, filters to the
166
- * given session, and maps each match through `mapFn`.
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: PartialMemoryEntry) => T,
173
- label: string,
167
+ mapFn: (m: MemoryEntry) => T,
174
168
  ): T[] {
175
- try {
176
- const sessionTag = `session:${sessionId}`;
177
-
178
- const output = execFileSync(
179
- binary,
180
- [
181
- "memory",
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 summaryParts: string[] = [];
291
-
292
- // Deduplicate and categorise partials
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
- for (const p of partials) {
299
- if (p.startsWith("Created file: ") || p.startsWith("Edited file: ")) {
300
- fileChanges.add(p.replace(/^(Created|Edited) file: /, ""));
301
- } else if (p.startsWith("Ran command: ")) {
302
- commands.push(p.replace("Ran command: ", ""));
303
- } else if (p.startsWith("Completed task: ")) {
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
+ }