@jaybeeuu/agent-cortex 2.7.0 → 2.8.0
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/CHANGELOG.md +239 -0
- package/README.md +61 -5
- package/agents/README.md +2 -2
- package/agents/ralph/agent.md +17 -16
- package/agents/ralph/claude/wake.md +4 -4
- package/agents/ralph/pi/wake.md +15 -20
- package/bin/agent-cortex.mjs +20 -1
- package/extensions/post-merge-watch/README.md +133 -0
- package/extensions/post-merge-watch/index.test.ts +247 -0
- package/extensions/post-merge-watch/index.ts +123 -0
- package/extensions/post-merge-watch/package.json +16 -0
- package/extensions/post-merge-watch/watch.test.ts +469 -0
- package/extensions/post-merge-watch/watch.ts +431 -0
- package/extensions/session-clear/README.md +74 -0
- package/extensions/session-clear/index.test.ts +524 -0
- package/extensions/session-clear/index.ts +159 -0
- package/extensions/session-clear/package.json +16 -0
- package/lib/cli.mjs +42 -1
- package/lib/self-update.mjs +553 -0
- package/package.json +1 -1
- package/skills/engineering/implement-task/SKILL.md +124 -0
- package/skills/engineering/tdd/SKILL.md +2 -0
- package/skills/planning/create-task/REFERENCE.md +15 -6
- package/skills/planning/create-task/SKILL.md +1 -1
- package/skills/planning/create-task/pipeline.json +8 -0
- package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
- package/skills/planning/create-task/templates/code.md +8 -3
- package/skills/planning/create-task/templates/document.md +8 -3
- package/skills/planning/create-task/templates/fix.md +8 -3
- package/skills/planning/create-task/templates/housekeeping.md +8 -4
- package/skills/planning/create-task/templates/post-merge.md +67 -0
- package/skills/planning/create-task/templates/review.md +8 -3
- package/skills/planning/create-task/templates/verify.md +8 -3
- package/skills/productivity/bd-tool/SKILL.md +55 -0
- package/skills/review/review-defects/EXAMPLES.md +122 -0
- package/skills/review/review-defects/SKILL.md +91 -0
- package/skills/style/style-tests/EXAMPLES.md +30 -0
- package/skills/style/style-tests/SKILL.md +10 -6
- package/skills/workflow/plan/SKILL.md +14 -3
- package/skills/workflow/ralph/REFERENCE.md +124 -27
- package/skills/workflow/ralph/SKILL.md +24 -15
- package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
- package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +34 -3
- package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
- package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
- package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
- package/skills/workflow/using-agent-skills/SKILL.md +15 -7
- package/token-map.README.md +27 -0
- package/token-map.json +10 -4
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type {
|
|
6
|
+
ExtensionAPI,
|
|
7
|
+
ExtensionContext,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
import { createTempRoot } from "../../test/helpers/temp-root.mjs";
|
|
11
|
+
import sessionClearExtension, { PROVENANCE_ENTRY } from "./index.ts";
|
|
12
|
+
|
|
13
|
+
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
type SessionModel = NonNullable<ExtensionContext["model"]>;
|
|
16
|
+
type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
17
|
+
|
|
18
|
+
const tempRoot = createTempRoot("session-clear-");
|
|
19
|
+
|
|
20
|
+
/** A handler recorded from `pi.on` or `pi.registerCommand`, invoked via
|
|
21
|
+
* Reflect.apply so its exact signature does not leak into the fake. */
|
|
22
|
+
type RecordedHandler = (...args: never[]) => unknown;
|
|
23
|
+
|
|
24
|
+
/** The state a live PI session exposes to an extension. */
|
|
25
|
+
interface FakeSession {
|
|
26
|
+
sessionFile: string | undefined;
|
|
27
|
+
name: string | undefined;
|
|
28
|
+
model: SessionModel | undefined;
|
|
29
|
+
thinkingLevel: ThinkingLevel;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface FakeUi {
|
|
33
|
+
notifications: Array<{ message: string; level: string | undefined }>;
|
|
34
|
+
/** Dialog-capable UI members the command must never reach. */
|
|
35
|
+
prompts: string[];
|
|
36
|
+
notify(message: string, level?: string): void;
|
|
37
|
+
select(title: string): Promise<undefined>;
|
|
38
|
+
confirm(title: string): Promise<boolean>;
|
|
39
|
+
editor(title: string): Promise<undefined>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface FakeContext {
|
|
43
|
+
ui: FakeUi;
|
|
44
|
+
cwd: string;
|
|
45
|
+
readonly model: SessionModel | undefined;
|
|
46
|
+
sessionManager: { getSessionFile(): string | undefined };
|
|
47
|
+
newSession(options?: { parentSession?: string }): Promise<{ cancelled: boolean }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Calls {
|
|
51
|
+
commands: Array<{ name: string; description: string | undefined }>;
|
|
52
|
+
sessionNames: string[];
|
|
53
|
+
models: SessionModel[];
|
|
54
|
+
thinkingLevels: ThinkingLevel[];
|
|
55
|
+
entries: Array<{ customType: string; data: unknown }>;
|
|
56
|
+
/** Anything sent to the model. `/clear` must send none. */
|
|
57
|
+
sent: unknown[];
|
|
58
|
+
newSessionOptions: Array<{ parentSession?: string }>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface FakeInstance {
|
|
62
|
+
pi: ExtensionAPI;
|
|
63
|
+
session: FakeSession;
|
|
64
|
+
calls: Calls;
|
|
65
|
+
/** Reproduce pi's teardown: every action method throws once the session is gone. */
|
|
66
|
+
invalidate(): void;
|
|
67
|
+
context(overrides?: Partial<Pick<FakeContext, "newSession">>): FakeContext;
|
|
68
|
+
runCommand(ctx: FakeContext): Promise<void>;
|
|
69
|
+
fireSessionStart(event: FakeSessionStart, ctx: FakeContext): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface FakeSessionStart {
|
|
73
|
+
type: "session_start";
|
|
74
|
+
reason: "startup" | "reload" | "new" | "resume" | "fork";
|
|
75
|
+
previousSessionFile?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sessionModel(provider: string, id: string): SessionModel {
|
|
79
|
+
return {
|
|
80
|
+
id,
|
|
81
|
+
name: id,
|
|
82
|
+
api: "anthropic-messages",
|
|
83
|
+
provider,
|
|
84
|
+
baseUrl: "https://pi.test",
|
|
85
|
+
input: ["text"],
|
|
86
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
87
|
+
reasoning: false,
|
|
88
|
+
contextWindow: 200_000,
|
|
89
|
+
maxTokens: 8_000,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build a full `ExtensionAPI` test double for one extension instance.
|
|
95
|
+
*
|
|
96
|
+
* The literal is complete so `tsc` fails when a pi release grows the interface,
|
|
97
|
+
* and every action method throws after `invalidate()` exactly as pi's own
|
|
98
|
+
* `assertActive()` does, so a use of a stale `pi` fails the test.
|
|
99
|
+
*/
|
|
100
|
+
function createFakeInstance(session: FakeSession): FakeInstance {
|
|
101
|
+
const calls: Calls = {
|
|
102
|
+
commands: [],
|
|
103
|
+
sessionNames: [],
|
|
104
|
+
models: [],
|
|
105
|
+
thinkingLevels: [],
|
|
106
|
+
entries: [],
|
|
107
|
+
sent: [],
|
|
108
|
+
newSessionOptions: [],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const state: FakeSession = { ...session };
|
|
112
|
+
const handlers = new Map<string, RecordedHandler[]>();
|
|
113
|
+
let commandHandler: RecordedHandler | undefined;
|
|
114
|
+
let stale = false;
|
|
115
|
+
|
|
116
|
+
const assertLive = (): void => {
|
|
117
|
+
if (stale) throw new Error("This extension ctx is stale after session replacement");
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const pi: ExtensionAPI = {
|
|
121
|
+
on: (event: string, handler: RecordedHandler) => {
|
|
122
|
+
const list = handlers.get(event) ?? [];
|
|
123
|
+
list.push(handler);
|
|
124
|
+
handlers.set(event, list);
|
|
125
|
+
return () => {};
|
|
126
|
+
},
|
|
127
|
+
registerFlag: () => {},
|
|
128
|
+
getFlag: () => {
|
|
129
|
+
assertLive();
|
|
130
|
+
return undefined;
|
|
131
|
+
},
|
|
132
|
+
getActiveTools: () => {
|
|
133
|
+
assertLive();
|
|
134
|
+
return [];
|
|
135
|
+
},
|
|
136
|
+
setActiveTools: () => {
|
|
137
|
+
assertLive();
|
|
138
|
+
},
|
|
139
|
+
registerCommand: (name, options) => {
|
|
140
|
+
assertLive();
|
|
141
|
+
calls.commands.push({ name, description: options.description });
|
|
142
|
+
commandHandler = options.handler;
|
|
143
|
+
},
|
|
144
|
+
registerShortcut: () => {},
|
|
145
|
+
registerTool: () => {},
|
|
146
|
+
registerMessageRenderer: () => {},
|
|
147
|
+
registerMarkdownTransformer: () => {},
|
|
148
|
+
registerEntryRenderer: () => {},
|
|
149
|
+
registerToolRenderer: () => {},
|
|
150
|
+
registerProvider: () => {},
|
|
151
|
+
unregisterProvider: () => {},
|
|
152
|
+
registerMcpServer: () => {},
|
|
153
|
+
unregisterMcpServer: () => {},
|
|
154
|
+
unregisterVirtualModel: () => {},
|
|
155
|
+
registerVirtualModel: () => {},
|
|
156
|
+
getMcpServers: () => [],
|
|
157
|
+
sendMessage: (message) => {
|
|
158
|
+
assertLive();
|
|
159
|
+
calls.sent.push(message);
|
|
160
|
+
},
|
|
161
|
+
sendUserMessage: (content) => {
|
|
162
|
+
assertLive();
|
|
163
|
+
calls.sent.push(content);
|
|
164
|
+
},
|
|
165
|
+
appendEntry: (customType, data) => {
|
|
166
|
+
assertLive();
|
|
167
|
+
calls.entries.push({ customType, data });
|
|
168
|
+
},
|
|
169
|
+
setSessionName: (name) => {
|
|
170
|
+
assertLive();
|
|
171
|
+
state.name = name;
|
|
172
|
+
calls.sessionNames.push(name);
|
|
173
|
+
},
|
|
174
|
+
getSessionName: () => {
|
|
175
|
+
assertLive();
|
|
176
|
+
return state.name;
|
|
177
|
+
},
|
|
178
|
+
setLabel: () => {
|
|
179
|
+
assertLive();
|
|
180
|
+
},
|
|
181
|
+
exec: async () => {
|
|
182
|
+
throw new Error("not implemented");
|
|
183
|
+
},
|
|
184
|
+
getAllTools: () => [],
|
|
185
|
+
getSettings: () => {
|
|
186
|
+
throw new Error("not implemented");
|
|
187
|
+
},
|
|
188
|
+
getCommands: () => [],
|
|
189
|
+
setModel: async (model) => {
|
|
190
|
+
assertLive();
|
|
191
|
+
state.model = model;
|
|
192
|
+
calls.models.push(model);
|
|
193
|
+
return true;
|
|
194
|
+
},
|
|
195
|
+
getThinkingLevel: () => {
|
|
196
|
+
assertLive();
|
|
197
|
+
return state.thinkingLevel;
|
|
198
|
+
},
|
|
199
|
+
setThinkingLevel: (level) => {
|
|
200
|
+
assertLive();
|
|
201
|
+
state.thinkingLevel = level;
|
|
202
|
+
calls.thinkingLevels.push(level);
|
|
203
|
+
},
|
|
204
|
+
events: { on: () => () => {}, emit: () => {} },
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const ui: FakeUi = {
|
|
208
|
+
notifications: [],
|
|
209
|
+
prompts: [],
|
|
210
|
+
notify(message, level) {
|
|
211
|
+
this.notifications.push({ message, level });
|
|
212
|
+
},
|
|
213
|
+
async select(title) {
|
|
214
|
+
this.prompts.push(title);
|
|
215
|
+
return undefined;
|
|
216
|
+
},
|
|
217
|
+
async confirm(title) {
|
|
218
|
+
this.prompts.push(title);
|
|
219
|
+
return true;
|
|
220
|
+
},
|
|
221
|
+
async editor(title) {
|
|
222
|
+
this.prompts.push(title);
|
|
223
|
+
return undefined;
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
pi,
|
|
229
|
+
session: state,
|
|
230
|
+
calls,
|
|
231
|
+
invalidate() {
|
|
232
|
+
stale = true;
|
|
233
|
+
},
|
|
234
|
+
context(overrides = {}) {
|
|
235
|
+
const ctx: FakeContext = {
|
|
236
|
+
ui,
|
|
237
|
+
cwd: "/workspace",
|
|
238
|
+
get model() {
|
|
239
|
+
return state.model;
|
|
240
|
+
},
|
|
241
|
+
sessionManager: { getSessionFile: () => state.sessionFile },
|
|
242
|
+
newSession: async (options) => {
|
|
243
|
+
calls.newSessionOptions.push(options ?? {});
|
|
244
|
+
return { cancelled: false };
|
|
245
|
+
},
|
|
246
|
+
...overrides,
|
|
247
|
+
};
|
|
248
|
+
return ctx;
|
|
249
|
+
},
|
|
250
|
+
async runCommand(ctx) {
|
|
251
|
+
assert.ok(commandHandler, "the extension must register /clear while loading");
|
|
252
|
+
await Reflect.apply(commandHandler, undefined, ["", ctx]);
|
|
253
|
+
},
|
|
254
|
+
async fireSessionStart(event, ctx) {
|
|
255
|
+
for (const handler of handlers.get("session_start") ?? []) {
|
|
256
|
+
await Reflect.apply(handler, undefined, [event, ctx]);
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Drive `/clear` the way pi does: the handler asks for a replacement, pi tears
|
|
264
|
+
* the old session down, loads a fresh extension instance, and starts the new
|
|
265
|
+
* session before returning. Returns the replacement instance, if one was made.
|
|
266
|
+
*/
|
|
267
|
+
async function runClear(
|
|
268
|
+
old: FakeInstance,
|
|
269
|
+
replacement: FakeSession,
|
|
270
|
+
options: { cancel?: boolean } = {},
|
|
271
|
+
): Promise<FakeInstance | undefined> {
|
|
272
|
+
const previousSessionFile = old.session.sessionFile;
|
|
273
|
+
let created: FakeInstance | undefined;
|
|
274
|
+
|
|
275
|
+
const ctx = old.context({
|
|
276
|
+
newSession: async (newSessionOptions) => {
|
|
277
|
+
old.calls.newSessionOptions.push(newSessionOptions ?? {});
|
|
278
|
+
if (options.cancel === true) return { cancelled: true };
|
|
279
|
+
|
|
280
|
+
old.invalidate();
|
|
281
|
+
created = createFakeInstance(replacement);
|
|
282
|
+
sessionClearExtension(created.pi);
|
|
283
|
+
await created.fireSessionStart(
|
|
284
|
+
{ type: "session_start", reason: "new", previousSessionFile },
|
|
285
|
+
created.context(),
|
|
286
|
+
);
|
|
287
|
+
return { cancelled: false };
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await old.runCommand(ctx);
|
|
292
|
+
return created;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const OLD_MODEL = sessionModel("anthropic", "claude-sonnet-4-5");
|
|
296
|
+
const OTHER_MODEL = sessionModel("anthropic", "claude-haiku-4-5");
|
|
297
|
+
|
|
298
|
+
function oldSession(overrides: Partial<FakeSession> = {}): FakeSession {
|
|
299
|
+
return {
|
|
300
|
+
sessionFile: "/sessions/old.jsonl",
|
|
301
|
+
name: "Refactor auth module",
|
|
302
|
+
model: OLD_MODEL,
|
|
303
|
+
thinkingLevel: "high",
|
|
304
|
+
...overrides,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function replacementSession(overrides: Partial<FakeSession> = {}): FakeSession {
|
|
309
|
+
return {
|
|
310
|
+
sessionFile: "/sessions/new.jsonl",
|
|
311
|
+
name: undefined,
|
|
312
|
+
model: OLD_MODEL,
|
|
313
|
+
thinkingLevel: "high",
|
|
314
|
+
...overrides,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ─── Registration ────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
describe("sessionClearExtension", () => {
|
|
321
|
+
it("registers /clear from the factory, before any session event fires", () => {
|
|
322
|
+
const instance = createFakeInstance(oldSession());
|
|
323
|
+
|
|
324
|
+
sessionClearExtension(instance.pi);
|
|
325
|
+
|
|
326
|
+
assert.deepEqual(
|
|
327
|
+
instance.calls.commands.map((command) => command.name),
|
|
328
|
+
["clear"],
|
|
329
|
+
);
|
|
330
|
+
assert.equal(
|
|
331
|
+
instance.calls.commands[0].description,
|
|
332
|
+
"Clear the context, keeping the session name, model and thinking level",
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("registers the command from the factory only, not from session_start", async () => {
|
|
337
|
+
const instance = createFakeInstance(oldSession());
|
|
338
|
+
sessionClearExtension(instance.pi);
|
|
339
|
+
|
|
340
|
+
await instance.fireSessionStart(
|
|
341
|
+
{ type: "session_start", reason: "startup" },
|
|
342
|
+
instance.context(),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
assert.equal(instance.calls.commands.length, 1);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ─── Replacement ─────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
describe("/clear replacement", () => {
|
|
352
|
+
it("links the cleared session as the replacement's parent", async () => {
|
|
353
|
+
const old = createFakeInstance(oldSession());
|
|
354
|
+
sessionClearExtension(old.pi);
|
|
355
|
+
|
|
356
|
+
await runClear(old, replacementSession());
|
|
357
|
+
|
|
358
|
+
assert.deepEqual(old.calls.newSessionOptions, [{ parentSession: "/sessions/old.jsonl" }]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("keeps the old session file on disk", async () => {
|
|
362
|
+
const workspace = await tempRoot.createWorkspace();
|
|
363
|
+
const sessionFile = join(workspace, "old.jsonl");
|
|
364
|
+
await writeFile(sessionFile, '{"type":"session"}\n', "utf-8");
|
|
365
|
+
|
|
366
|
+
const old = createFakeInstance(oldSession({ sessionFile }));
|
|
367
|
+
sessionClearExtension(old.pi);
|
|
368
|
+
|
|
369
|
+
await runClear(old, replacementSession({ sessionFile: join(workspace, "new.jsonl") }));
|
|
370
|
+
|
|
371
|
+
assert.equal(await readFile(sessionFile, "utf-8"), '{"type":"session"}\n');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("reports the clear without prompting or sending a model message", async () => {
|
|
375
|
+
const old = createFakeInstance(oldSession());
|
|
376
|
+
sessionClearExtension(old.pi);
|
|
377
|
+
const ctx = old.context();
|
|
378
|
+
|
|
379
|
+
await runClear(old, replacementSession());
|
|
380
|
+
|
|
381
|
+
assert.deepEqual(ctx.ui.prompts, [], "no confirmation, summary, or selection may be shown");
|
|
382
|
+
assert.deepEqual(old.calls.sent, [], "nothing may be sent to the model");
|
|
383
|
+
assert.deepEqual(ctx.ui.notifications, [], "the old instance must not report the clear");
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ─── Identity carry-over ─────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
describe("/clear identity", () => {
|
|
390
|
+
it("re-applies the session name even when the replacement already carries it", async () => {
|
|
391
|
+
const old = createFakeInstance(oldSession({ name: "Refactor auth module" }));
|
|
392
|
+
sessionClearExtension(old.pi);
|
|
393
|
+
|
|
394
|
+
const replacement = await runClear(
|
|
395
|
+
old,
|
|
396
|
+
replacementSession({ name: "Refactor auth module" }),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
assert.deepEqual(replacement?.calls.sessionNames, ["Refactor auth module"]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("restores the model when the replacement fell back to a different one", async () => {
|
|
403
|
+
const old = createFakeInstance(oldSession({ model: OLD_MODEL }));
|
|
404
|
+
sessionClearExtension(old.pi);
|
|
405
|
+
|
|
406
|
+
const replacement = await runClear(old, replacementSession({ model: OTHER_MODEL }));
|
|
407
|
+
|
|
408
|
+
assert.deepEqual(replacement?.calls.models, [OLD_MODEL]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("leaves the model alone when the replacement already uses it", async () => {
|
|
412
|
+
const old = createFakeInstance(oldSession({ model: OLD_MODEL }));
|
|
413
|
+
sessionClearExtension(old.pi);
|
|
414
|
+
|
|
415
|
+
const replacement = await runClear(old, replacementSession({ model: OLD_MODEL }));
|
|
416
|
+
|
|
417
|
+
assert.deepEqual(replacement?.calls.models, []);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("restores the thinking level when the replacement fell back to a different one", async () => {
|
|
421
|
+
const old = createFakeInstance(oldSession({ thinkingLevel: "high" }));
|
|
422
|
+
sessionClearExtension(old.pi);
|
|
423
|
+
|
|
424
|
+
const replacement = await runClear(
|
|
425
|
+
old,
|
|
426
|
+
replacementSession({ thinkingLevel: "low" }),
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
assert.deepEqual(replacement?.calls.thinkingLevels, ["high"]);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("leaves the thinking level alone when the replacement already uses it", async () => {
|
|
433
|
+
const old = createFakeInstance(oldSession({ thinkingLevel: "high" }));
|
|
434
|
+
sessionClearExtension(old.pi);
|
|
435
|
+
|
|
436
|
+
const replacement = await runClear(
|
|
437
|
+
old,
|
|
438
|
+
replacementSession({ thinkingLevel: "high" }),
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
assert.deepEqual(replacement?.calls.thinkingLevels, []);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("records the cleared session as a non-model provenance entry and reports it", async () => {
|
|
445
|
+
const old = createFakeInstance(oldSession({ sessionFile: "/sessions/old.jsonl" }));
|
|
446
|
+
sessionClearExtension(old.pi);
|
|
447
|
+
|
|
448
|
+
const replacement = await runClear(old, replacementSession());
|
|
449
|
+
|
|
450
|
+
assert.equal(replacement?.calls.entries.length, 1);
|
|
451
|
+
const [entry] = replacement?.calls.entries ?? [];
|
|
452
|
+
assert.equal(entry.customType, PROVENANCE_ENTRY);
|
|
453
|
+
const data = entry.data as { clearedFrom?: string; at?: string };
|
|
454
|
+
assert.equal(data.clearedFrom, "/sessions/old.jsonl");
|
|
455
|
+
assert.ok(
|
|
456
|
+
typeof data.at === "string" && !Number.isNaN(Date.parse(data.at)),
|
|
457
|
+
"the entry should record when the clear happened",
|
|
458
|
+
);
|
|
459
|
+
assert.deepEqual(replacement?.calls.sent, []);
|
|
460
|
+
const ctx = replacement?.context();
|
|
461
|
+
assert.match(
|
|
462
|
+
ctx?.ui.notifications[0]?.message ?? "",
|
|
463
|
+
/\/sessions\/old\.jsonl/,
|
|
464
|
+
"the report should name the cleared session",
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("applies the identity once, so later session starts are untouched", async () => {
|
|
469
|
+
const old = createFakeInstance(oldSession());
|
|
470
|
+
sessionClearExtension(old.pi);
|
|
471
|
+
|
|
472
|
+
const replacement = await runClear(old, replacementSession());
|
|
473
|
+
assert.ok(replacement);
|
|
474
|
+
await replacement.fireSessionStart(
|
|
475
|
+
{
|
|
476
|
+
type: "session_start",
|
|
477
|
+
reason: "new",
|
|
478
|
+
previousSessionFile: "/sessions/old.jsonl",
|
|
479
|
+
},
|
|
480
|
+
replacement.context(),
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
assert.deepEqual(replacement.calls.sessionNames, ["Refactor auth module"]);
|
|
484
|
+
assert.equal(replacement.calls.entries.length, 1);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("drops the identity when the replacement is cancelled", async () => {
|
|
488
|
+
const old = createFakeInstance(oldSession());
|
|
489
|
+
sessionClearExtension(old.pi);
|
|
490
|
+
|
|
491
|
+
const replacement = await runClear(old, replacementSession(), { cancel: true });
|
|
492
|
+
assert.equal(replacement, undefined);
|
|
493
|
+
|
|
494
|
+
// A later, unrelated session start must not pick up the abandoned identity.
|
|
495
|
+
const unrelated = createFakeInstance(replacementSession());
|
|
496
|
+
sessionClearExtension(unrelated.pi);
|
|
497
|
+
await unrelated.fireSessionStart(
|
|
498
|
+
{
|
|
499
|
+
type: "session_start",
|
|
500
|
+
reason: "new",
|
|
501
|
+
previousSessionFile: "/sessions/old.jsonl",
|
|
502
|
+
},
|
|
503
|
+
unrelated.context(),
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
assert.deepEqual(unrelated.calls.sessionNames, []);
|
|
507
|
+
assert.deepEqual(unrelated.calls.entries, []);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("ignores an identity on a session start that is not a replacement", async () => {
|
|
511
|
+
const old = createFakeInstance(oldSession());
|
|
512
|
+
sessionClearExtension(old.pi);
|
|
513
|
+
await runClear(old, replacementSession());
|
|
514
|
+
|
|
515
|
+
const startup = createFakeInstance(oldSession({ sessionFile: undefined }));
|
|
516
|
+
sessionClearExtension(startup.pi);
|
|
517
|
+
await startup.fireSessionStart(
|
|
518
|
+
{ type: "session_start", reason: "startup" },
|
|
519
|
+
startup.context(),
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
assert.deepEqual(startup.calls.sessionNames, []);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Clear extension.
|
|
3
|
+
*
|
|
4
|
+
* Registers `/clear`: replace the session with an empty one while keeping its
|
|
5
|
+
* identity — display name, model, thinking level, and the active agent.
|
|
6
|
+
*
|
|
7
|
+
* PI already has `/new`, but the replacement starts from the defaults, so
|
|
8
|
+
* clearing the context after a tangent costs three follow-up commands. `/clear`
|
|
9
|
+
* carries the identity across the replacement instead.
|
|
10
|
+
*
|
|
11
|
+
* The identity is captured as plain data before teardown, because the captured
|
|
12
|
+
* `pi` and command `ctx` are invalidated when the old session is disposed and
|
|
13
|
+
* `withSession`'s context exposes no session mutator. PI re-evaluates every
|
|
14
|
+
* extension module for the replacement runtime (`moduleCache: false`), so a
|
|
15
|
+
* module-level variable cannot carry the data over; it crosses in one
|
|
16
|
+
* process-global slot, and the replacement instance applies it at its own
|
|
17
|
+
* `session_start`.
|
|
18
|
+
*
|
|
19
|
+
* The active agent needs no handling here: the replacement runtime is built from
|
|
20
|
+
* the same launch configuration, so an agent selected with `--agent` re-applies
|
|
21
|
+
* itself at `session_start` exactly as it did on startup.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type {
|
|
25
|
+
ExtensionAPI,
|
|
26
|
+
ExtensionCommandContext,
|
|
27
|
+
ExtensionContext,
|
|
28
|
+
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
|
|
30
|
+
/** Provenance entry written to the replacement session. Never sent to the model. */
|
|
31
|
+
export const PROVENANCE_ENTRY = "session-clear";
|
|
32
|
+
|
|
33
|
+
/** The model the session is using, as the extension context reports it. */
|
|
34
|
+
type SessionModel = NonNullable<ExtensionContext["model"]>;
|
|
35
|
+
|
|
36
|
+
/** The thinking level the session is using, as the extension API reports it. */
|
|
37
|
+
type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
38
|
+
|
|
39
|
+
/** What `/clear` carries across the replacement. Plain data only. */
|
|
40
|
+
export interface Identity {
|
|
41
|
+
/** Session file the identity came from; also the replacement's parent. */
|
|
42
|
+
sourceSessionFile: string | undefined;
|
|
43
|
+
name: string | undefined;
|
|
44
|
+
model: SessionModel | undefined;
|
|
45
|
+
thinkingLevel: ThinkingLevel;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The single process-global slot that carries an identity across replacement. */
|
|
49
|
+
const HANDOFF_KEY = "__agentCortexSessionClearIdentity";
|
|
50
|
+
|
|
51
|
+
interface HandoffSlot {
|
|
52
|
+
__agentCortexSessionClearIdentity?: Identity;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function handoff(): HandoffSlot {
|
|
56
|
+
return globalThis as unknown as HandoffSlot;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Capture the parts of the session identity that the replacement starts without. */
|
|
60
|
+
export function captureIdentity(pi: ExtensionAPI, ctx: ExtensionCommandContext): Identity {
|
|
61
|
+
return {
|
|
62
|
+
sourceSessionFile: ctx.sessionManager.getSessionFile(),
|
|
63
|
+
name: pi.getSessionName(),
|
|
64
|
+
model: ctx.model,
|
|
65
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function rememberIdentity(identity: Identity): void {
|
|
70
|
+
handoff()[HANDOFF_KEY] = identity;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function forgetIdentity(): void {
|
|
74
|
+
delete handoff()[HANDOFF_KEY];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Take the identity captured for this replacement, or undefined when this
|
|
79
|
+
* session start was not a `/clear`.
|
|
80
|
+
*/
|
|
81
|
+
function takeIdentity(previousSessionFile: string | undefined): Identity | undefined {
|
|
82
|
+
const slot = handoff();
|
|
83
|
+
const identity = slot[HANDOFF_KEY];
|
|
84
|
+
if (identity === undefined || identity.sourceSessionFile !== previousSessionFile) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
delete slot[HANDOFF_KEY];
|
|
88
|
+
return identity;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sameModel(left: SessionModel | undefined, right: SessionModel | undefined): boolean {
|
|
92
|
+
if (left === undefined || right === undefined) {
|
|
93
|
+
return left === right;
|
|
94
|
+
}
|
|
95
|
+
return left.provider === right.provider && left.id === right.id;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Re-apply the captured identity to the replacement session, then record where
|
|
100
|
+
* the session came from and report the clear.
|
|
101
|
+
*
|
|
102
|
+
* The model and the thinking level are restored only when they differ from the
|
|
103
|
+
* replacement's own values, so a default is never needlessly re-pinned.
|
|
104
|
+
*/
|
|
105
|
+
export async function reapplyIdentity(
|
|
106
|
+
pi: ExtensionAPI,
|
|
107
|
+
ctx: ExtensionContext,
|
|
108
|
+
identity: Identity,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
if (identity.name !== undefined) {
|
|
111
|
+
pi.setSessionName(identity.name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (identity.model !== undefined && !sameModel(identity.model, ctx.model)) {
|
|
115
|
+
await pi.setModel(identity.model);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Read the level after the model restore: switching model re-derives it.
|
|
119
|
+
if (pi.getThinkingLevel() !== identity.thinkingLevel) {
|
|
120
|
+
pi.setThinkingLevel(identity.thinkingLevel);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pi.appendEntry(PROVENANCE_ENTRY, {
|
|
124
|
+
clearedFrom: identity.sourceSessionFile,
|
|
125
|
+
at: new Date().toISOString(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
ctx.ui.notify(
|
|
129
|
+
`Context cleared. Previous session: ${identity.sourceSessionFile ?? "(unsaved)"}`,
|
|
130
|
+
"info",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export default function sessionClearExtension(pi: ExtensionAPI): void {
|
|
135
|
+
pi.registerCommand("clear", {
|
|
136
|
+
description: "Clear the context, keeping the session name, model and thinking level",
|
|
137
|
+
handler: async (_args, ctx) => {
|
|
138
|
+
const identity = captureIdentity(pi, ctx);
|
|
139
|
+
rememberIdentity(identity);
|
|
140
|
+
|
|
141
|
+
const result = await ctx.newSession({ parentSession: identity.sourceSessionFile });
|
|
142
|
+
|
|
143
|
+
// A cancelled switch leaves this session active, so nothing is coming to
|
|
144
|
+
// collect the identity.
|
|
145
|
+
if (result.cancelled) {
|
|
146
|
+
forgetIdentity();
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
pi.on("session_start", async (event, ctx) => {
|
|
152
|
+
if (event.reason !== "new") return;
|
|
153
|
+
|
|
154
|
+
const identity = takeIdentity(event.previousSessionFile);
|
|
155
|
+
if (identity === undefined) return;
|
|
156
|
+
|
|
157
|
+
await reapplyIdentity(pi, ctx, identity);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agent-cortex/session-clear",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test --import tsx/esm index.test.ts",
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@earendil-works/pi-coding-agent": "^1.5.2",
|
|
12
|
+
"@types/node": "^22.0.0",
|
|
13
|
+
"tsx": "^4.0.0",
|
|
14
|
+
"typescript": "^5.0.0"
|
|
15
|
+
}
|
|
16
|
+
}
|