pi-midcompact 0.2.1
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/README.md +155 -0
- package/figures/agent-planning.svg +118 -0
- package/figures/context-projection.svg +64 -0
- package/figures/transaction-lifecycle.svg +117 -0
- package/package.json +57 -0
- package/skills/midcompact/SKILL.md +57 -0
- package/src/atoms.ts +152 -0
- package/src/index.ts +436 -0
- package/src/messages.ts +116 -0
- package/src/plan.ts +93 -0
- package/src/projection.ts +61 -0
- package/src/renderers.ts +67 -0
- package/src/review-ui.ts +198 -0
- package/src/state.ts +64 -0
- package/src/telemetry.ts +81 -0
- package/src/types.ts +152 -0
package/src/atoms.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Atom, LocateQuery, MessageLike, MessageRef, SessionEntryLike } from "./types.js";
|
|
2
|
+
import { approxTokens, mapEntryIds, messageKey, renderMessage, toolCalls, truncate } from "./messages.js";
|
|
3
|
+
|
|
4
|
+
export function buildAtoms(messages: MessageLike[], branch: readonly SessionEntryLike[]): Atom[] {
|
|
5
|
+
const entryIds = mapEntryIds(messages, branch);
|
|
6
|
+
const refs: MessageRef[] = messages.map((message, index) => ({
|
|
7
|
+
message,
|
|
8
|
+
key: messageKey(message),
|
|
9
|
+
entryId: entryIds[index],
|
|
10
|
+
}));
|
|
11
|
+
const atoms: Atom[] = [];
|
|
12
|
+
let i = 0;
|
|
13
|
+
while (i < refs.length) {
|
|
14
|
+
const current = refs[i]!;
|
|
15
|
+
const message = current.message;
|
|
16
|
+
|
|
17
|
+
if (message.role === "custom" && message.customType === "midcompact-summary") {
|
|
18
|
+
atoms.push(makeAtom(atoms.length, "compressed", [current], false, true, blockIdFromSummary(message)));
|
|
19
|
+
i += 1;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (message.role === "assistant") {
|
|
24
|
+
const calls = toolCalls(message);
|
|
25
|
+
if (calls.length > 0) {
|
|
26
|
+
const expected = new Set(calls.map((call) => call.id));
|
|
27
|
+
const chunk: MessageRef[] = [current];
|
|
28
|
+
const seen = new Set<string>();
|
|
29
|
+
let j = i + 1;
|
|
30
|
+
while (j < refs.length) {
|
|
31
|
+
const next = refs[j]!;
|
|
32
|
+
if (next.message.role !== "toolResult") break;
|
|
33
|
+
if (!next.message.toolCallId || !expected.has(next.message.toolCallId)) break;
|
|
34
|
+
chunk.push(next);
|
|
35
|
+
seen.add(next.message.toolCallId);
|
|
36
|
+
j += 1;
|
|
37
|
+
if (seen.size === expected.size) break;
|
|
38
|
+
}
|
|
39
|
+
const closed = seen.size === expected.size;
|
|
40
|
+
atoms.push(makeAtom(atoms.length, "tool_exchange", chunk, closed && chunk.every(hasEntry), closed));
|
|
41
|
+
i = j;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
atoms.push(makeAtom(atoms.length, "assistant", [current], hasEntry(current), true));
|
|
45
|
+
i += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (message.role === "toolResult") {
|
|
50
|
+
atoms.push(makeAtom(atoms.length, "orphan_tool_result", [current], false, false));
|
|
51
|
+
i += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (message.role === "user") {
|
|
56
|
+
atoms.push(makeAtom(atoms.length, "user", [current], hasEntry(current), true));
|
|
57
|
+
i += 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (message.role === "bashExecution") {
|
|
62
|
+
atoms.push(makeAtom(atoms.length, "bash", [current], hasEntry(current), true));
|
|
63
|
+
i += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
atoms.push(makeAtom(atoms.length, message.role === "custom" ? "custom" : "other", [current], false, true));
|
|
68
|
+
i += 1;
|
|
69
|
+
}
|
|
70
|
+
return atoms;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasEntry(ref: MessageRef): boolean {
|
|
74
|
+
return typeof ref.entryId === "string" && ref.entryId.length > 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function blockIdFromSummary(message: MessageLike): string | undefined {
|
|
78
|
+
if (!message.details || typeof message.details !== "object") return undefined;
|
|
79
|
+
const id = (message.details as Record<string, unknown>).blockId;
|
|
80
|
+
return typeof id === "string" ? id : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function makeAtom(
|
|
84
|
+
index: number,
|
|
85
|
+
kind: Atom["kind"],
|
|
86
|
+
messages: MessageRef[],
|
|
87
|
+
compressible: boolean,
|
|
88
|
+
protocolClosed: boolean,
|
|
89
|
+
compressedBlockId?: string,
|
|
90
|
+
): Atom {
|
|
91
|
+
const fullText = messages.map((ref) => renderMessage(ref.message)).join("\n\n");
|
|
92
|
+
const toolNames = new Set<string>();
|
|
93
|
+
const roles = new Set<string>();
|
|
94
|
+
for (const ref of messages) {
|
|
95
|
+
roles.add(ref.message.role);
|
|
96
|
+
for (const call of toolCalls(ref.message)) toolNames.add(call.name);
|
|
97
|
+
if (ref.message.role === "toolResult" && ref.message.toolName) toolNames.add(ref.message.toolName);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
ref: `a${String(index + 1).padStart(4, "0")}`,
|
|
101
|
+
index,
|
|
102
|
+
kind,
|
|
103
|
+
messages,
|
|
104
|
+
entryIds: messages.flatMap((ref) => (ref.entryId ? [ref.entryId] : [])),
|
|
105
|
+
messageKeys: messages.map((ref) => ref.key),
|
|
106
|
+
preview: truncate(fullText, 700),
|
|
107
|
+
fullText,
|
|
108
|
+
approxTokens: approxTokens(fullText),
|
|
109
|
+
compressible,
|
|
110
|
+
protocolClosed,
|
|
111
|
+
toolNames: [...toolNames],
|
|
112
|
+
roles: [...roles],
|
|
113
|
+
compressedBlockId,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function locateAtoms(atoms: Atom[], query: LocateQuery): Atom[] {
|
|
118
|
+
if (query.ref) {
|
|
119
|
+
const atom = atoms.find((candidate) => candidate.ref === query.ref);
|
|
120
|
+
return atom ? [atom] : [];
|
|
121
|
+
}
|
|
122
|
+
const pattern = query.pattern?.toLocaleLowerCase();
|
|
123
|
+
const toolName = query.toolName?.toLocaleLowerCase();
|
|
124
|
+
const source = query.source ?? "any";
|
|
125
|
+
let matches = atoms.filter((atom) => {
|
|
126
|
+
if (!matchesSource(atom, source)) return false;
|
|
127
|
+
if (toolName && !atom.toolNames.some((name) => name.toLocaleLowerCase() === toolName)) return false;
|
|
128
|
+
if (pattern && !atom.fullText.toLocaleLowerCase().includes(pattern)) return false;
|
|
129
|
+
return Boolean(pattern || toolName || source !== "any");
|
|
130
|
+
});
|
|
131
|
+
if ((query.direction ?? "oldest") === "newest") matches = matches.reverse();
|
|
132
|
+
return matches.slice(0, Math.max(1, Math.min(query.limit ?? 5, 20)));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function matchesSource(atom: Atom, source: NonNullable<LocateQuery["source"]>): boolean {
|
|
136
|
+
if (source === "any") return true;
|
|
137
|
+
if (source === "user") return atom.roles.includes("user");
|
|
138
|
+
if (source === "assistant") return atom.roles.includes("assistant");
|
|
139
|
+
if (source === "tool_result") return atom.roles.includes("toolResult");
|
|
140
|
+
if (source === "tool_call") return atom.toolNames.length > 0 && atom.roles.includes("assistant");
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function formatLocatedAtom(atom: Atom, detail: "brief" | "full" = "brief"): string {
|
|
145
|
+
const flags = [atom.kind, atom.compressible ? "compressible" : "protected", atom.protocolClosed ? "closed" : "open"].join(", ");
|
|
146
|
+
const text = detail === "full" ? truncate(atom.fullText, 12_000) : atom.preview;
|
|
147
|
+
return [
|
|
148
|
+
`${atom.ref} | position ${atom.index + 1} | ${flags}`,
|
|
149
|
+
atom.toolNames.length ? `tools: ${atom.toolNames.join(", ")}` : "",
|
|
150
|
+
text,
|
|
151
|
+
].filter(Boolean).join("\n");
|
|
152
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { StringEnum, Type, type Static } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
buildSessionContext,
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionCommandContext,
|
|
6
|
+
type ExtensionContext,
|
|
7
|
+
type SessionEntry,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
import { buildAtoms, formatLocatedAtom, locateAtoms } from "./atoms.js";
|
|
11
|
+
import { messageText } from "./messages.js";
|
|
12
|
+
import { addDraftRange, emptyDraft, formatDraft, removeDraftRange, updateDraftRange } from "./plan.js";
|
|
13
|
+
import { projectMessages } from "./projection.js";
|
|
14
|
+
import { registerStateRenderer, stateTreeLabel } from "./renderers.js";
|
|
15
|
+
import { showReviewUi } from "./review-ui.js";
|
|
16
|
+
import {
|
|
17
|
+
DRAFT_ENTRY,
|
|
18
|
+
STATE_ENTRY,
|
|
19
|
+
TXN_ENTRY,
|
|
20
|
+
emptyCompressionState,
|
|
21
|
+
restoreCompressionState,
|
|
22
|
+
restoreTransaction,
|
|
23
|
+
} from "./state.js";
|
|
24
|
+
import { draftTelemetry, formatPercent, formatTelemetry, formatTokenCount, snapshotContextUsage } from "./telemetry.js";
|
|
25
|
+
import type {
|
|
26
|
+
Atom,
|
|
27
|
+
CommitStats,
|
|
28
|
+
CompressionBlock,
|
|
29
|
+
CompressionState,
|
|
30
|
+
DraftPlan,
|
|
31
|
+
MessageLike,
|
|
32
|
+
TransactionState,
|
|
33
|
+
} from "./types.js";
|
|
34
|
+
|
|
35
|
+
const TOOL_NAME = "midcompact";
|
|
36
|
+
const TOOL_DESCRIPTION = "Locate, draft, or recall mid-context compression. Use the `midcompact` skill for workflow guidance.";
|
|
37
|
+
const STATUS_KEY = "midcompact";
|
|
38
|
+
|
|
39
|
+
const Params = Type.Object({
|
|
40
|
+
action: StringEnum(["locate", "plan", "recall"] as const),
|
|
41
|
+
ref: Type.Optional(Type.String()),
|
|
42
|
+
pattern: Type.Optional(Type.String()),
|
|
43
|
+
source: Type.Optional(StringEnum(["any", "user", "assistant", "tool_call", "tool_result"] as const)),
|
|
44
|
+
tool_name: Type.Optional(Type.String()),
|
|
45
|
+
direction: Type.Optional(StringEnum(["oldest", "newest"] as const)),
|
|
46
|
+
limit: Type.Optional(Type.Number()),
|
|
47
|
+
detail: Type.Optional(StringEnum(["brief", "full"] as const)),
|
|
48
|
+
op: Type.Optional(StringEnum(["show", "add", "update", "remove"] as const)),
|
|
49
|
+
start: Type.Optional(Type.String()),
|
|
50
|
+
end: Type.Optional(Type.String()),
|
|
51
|
+
draft_id: Type.Optional(Type.String()),
|
|
52
|
+
topic: Type.Optional(Type.String()),
|
|
53
|
+
summary: Type.Optional(Type.String()),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
type ParamsType = Static<typeof Params>;
|
|
57
|
+
|
|
58
|
+
type RuntimeSnapshot = { atoms: Atom[]; anchorState?: CompressionState };
|
|
59
|
+
|
|
60
|
+
export default function (pi: ExtensionAPI) {
|
|
61
|
+
let activeState: CompressionState | undefined;
|
|
62
|
+
let transaction: TransactionState | undefined;
|
|
63
|
+
let draft: DraftPlan | undefined;
|
|
64
|
+
|
|
65
|
+
registerStateRenderer(pi);
|
|
66
|
+
|
|
67
|
+
function restoreRuntime(ctx: ExtensionContext): void {
|
|
68
|
+
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
|
69
|
+
activeState = restoreCompressionState(branch) ?? undefined;
|
|
70
|
+
const restored = restoreTransaction(branch);
|
|
71
|
+
transaction = restored.transaction;
|
|
72
|
+
draft = restored.draft ?? (transaction ? emptyDraft(transaction.id) : undefined);
|
|
73
|
+
updateStatus(ctx, transaction, draft);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => restoreRuntime(ctx));
|
|
77
|
+
pi.on("session_tree", async (_event: unknown, ctx: ExtensionContext) => restoreRuntime(ctx));
|
|
78
|
+
pi.on("session_shutdown", async (_event: unknown, ctx: ExtensionContext) => {
|
|
79
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
80
|
+
activeState = undefined;
|
|
81
|
+
transaction = undefined;
|
|
82
|
+
draft = undefined;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
pi.on("context", async (event) => {
|
|
86
|
+
if (!activeState?.blocks.length) return;
|
|
87
|
+
return { messages: projectMessages(event.messages as MessageLike[], activeState) as typeof event.messages };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
pi.registerCommand("midcompact", {
|
|
91
|
+
description: "Start, review, commit, inspect, or abort a branch-isolated mid-context compression transaction",
|
|
92
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
93
|
+
await ctx.waitForIdle();
|
|
94
|
+
const sub = args.trim().toLowerCase();
|
|
95
|
+
|
|
96
|
+
if (sub === "abort") {
|
|
97
|
+
const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
|
|
98
|
+
const currentTx = restored.transaction ?? transaction;
|
|
99
|
+
if (!currentTx) {
|
|
100
|
+
ctx.ui.notify("No active midcompact transaction.", "info");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const result = await ctx.navigateTree(currentTx.anchorEntryId, { summarize: false });
|
|
104
|
+
if (result.cancelled) {
|
|
105
|
+
ctx.ui.notify("Midcompact abort cancelled by tree navigation.", "warning");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
transaction = undefined;
|
|
109
|
+
draft = undefined;
|
|
110
|
+
updateStatus(ctx, transaction, draft);
|
|
111
|
+
ctx.ui.notify("Midcompact transaction aborted; returned to anchor.", "info");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (sub === "commit") {
|
|
116
|
+
const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
|
|
117
|
+
const currentTx = restored.transaction ?? transaction;
|
|
118
|
+
const currentDraft = restored.draft ?? draft;
|
|
119
|
+
if (!currentTx) {
|
|
120
|
+
ctx.ui.notify("No active midcompact transaction.", "warning");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!currentDraft?.ranges.length) {
|
|
124
|
+
ctx.ui.notify("Draft is empty; nothing to commit.", "warning");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
|
|
128
|
+
const telemetry = draftTelemetry(currentTx, currentDraft);
|
|
129
|
+
const nextState = mergeDraftIntoState(snapshot.anchorState, currentDraft, currentTx, telemetry);
|
|
130
|
+
const result = await ctx.navigateTree(currentTx.anchorEntryId, { summarize: false });
|
|
131
|
+
if (result.cancelled) {
|
|
132
|
+
ctx.ui.notify("Midcompact commit cancelled by tree navigation.", "warning");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
pi.appendEntry(STATE_ENTRY, nextState);
|
|
136
|
+
const stateEntryId = ctx.sessionManager.getLeafId();
|
|
137
|
+
if (stateEntryId) pi.setLabel(stateEntryId, stateTreeLabel(nextState));
|
|
138
|
+
activeState = nextState;
|
|
139
|
+
transaction = undefined;
|
|
140
|
+
draft = undefined;
|
|
141
|
+
updateStatus(ctx, transaction, draft);
|
|
142
|
+
ctx.ui.notify(commitNotice(nextState), "info");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (sub === "review") {
|
|
147
|
+
await reviewTransaction(ctx);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (sub === "status") {
|
|
152
|
+
const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
|
|
153
|
+
const currentTx = restored.transaction ?? transaction;
|
|
154
|
+
const currentDraft = restored.draft ?? draft;
|
|
155
|
+
if (currentTx) {
|
|
156
|
+
ctx.ui.notify(formatDraft(currentDraft ?? emptyDraft(currentTx.id), draftTelemetry(currentTx, currentDraft)), "info");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!activeState?.blocks.length) {
|
|
160
|
+
ctx.ui.notify("No active transaction and no active midcompact blocks on this branch.", "info");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
ctx.ui.notify(activeStateStatus(activeState), "info");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (sub) {
|
|
168
|
+
ctx.ui.notify("Usage: /midcompact | /midcompact review | /midcompact commit | /midcompact status | /midcompact abort", "warning");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (transaction) {
|
|
173
|
+
ctx.ui.notify("A midcompact transaction is already active on this branch.", "warning");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const sm = ctx.sessionManager;
|
|
177
|
+
const anchorEntryId = sm.getLeafId();
|
|
178
|
+
if (!anchorEntryId) {
|
|
179
|
+
ctx.ui.notify("Cannot start midcompact without a session leaf.", "error");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
transaction = {
|
|
183
|
+
version: 1,
|
|
184
|
+
id: `tx-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
|
|
185
|
+
anchorEntryId,
|
|
186
|
+
startedAt: new Date().toISOString(),
|
|
187
|
+
anchorUsage: snapshotContextUsage(ctx.getContextUsage()),
|
|
188
|
+
};
|
|
189
|
+
draft = emptyDraft(transaction.id);
|
|
190
|
+
pi.appendEntry(TXN_ENTRY, transaction);
|
|
191
|
+
pi.appendEntry(DRAFT_ENTRY, draft);
|
|
192
|
+
updateStatus(ctx, transaction, draft);
|
|
193
|
+
const awareness = formatTelemetry(draftTelemetry(transaction, draft));
|
|
194
|
+
ctx.ui.notify(`Midcompact started at anchor ${anchorEntryId}. ${compactUsage(transaction)}`, "info");
|
|
195
|
+
pi.sendUserMessage([
|
|
196
|
+
"A mid-compaction transaction is active on a frozen anchor snapshot.",
|
|
197
|
+
awareness,
|
|
198
|
+
"These numbers are context awareness, not a target or optimization constraint. Use them to judge the scale of proposed compression while preserving semantic value.",
|
|
199
|
+
"Load the `midcompact` skill, use the `midcompact` tool to locate and draft compression ranges, and present the draft for user review.",
|
|
200
|
+
"The Agent cannot commit. The user can inspect `/midcompact review`, then explicitly run `/midcompact commit` when satisfied.",
|
|
201
|
+
].join("\n"));
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
pi.registerTool({
|
|
206
|
+
name: TOOL_NAME,
|
|
207
|
+
label: "Midcompact",
|
|
208
|
+
description: TOOL_DESCRIPTION,
|
|
209
|
+
parameters: Params,
|
|
210
|
+
async execute(_id: string, params: ParamsType, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
211
|
+
try {
|
|
212
|
+
if (params.action === "recall") return toolResult(handleRecall(params, ctx));
|
|
213
|
+
if (!transaction) return toolResult("No active midcompact transaction. Ask the user to run `/midcompact` first.");
|
|
214
|
+
const snapshot = buildAnchorSnapshot(ctx.sessionManager, transaction);
|
|
215
|
+
if (params.action === "locate") return toolResult(handleLocate(params, snapshot.atoms));
|
|
216
|
+
if (params.action === "plan") {
|
|
217
|
+
draft ??= emptyDraft(transaction.id);
|
|
218
|
+
draft = handlePlan(params, draft, snapshot.atoms);
|
|
219
|
+
pi.appendEntry(DRAFT_ENTRY, draft);
|
|
220
|
+
updateStatus(ctx, transaction, draft);
|
|
221
|
+
return toolResult(formatDraft(draft, draftTelemetry(transaction, draft)));
|
|
222
|
+
}
|
|
223
|
+
return toolResult("Unknown action.");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return toolResult(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
async function reviewTransaction(ctx: ExtensionCommandContext): Promise<void> {
|
|
231
|
+
while (true) {
|
|
232
|
+
const restored = restoreTransaction(ctx.sessionManager.getBranch() as SessionEntry[]);
|
|
233
|
+
const currentTx = restored.transaction ?? transaction;
|
|
234
|
+
const currentDraft = restored.draft ?? draft;
|
|
235
|
+
if (!currentTx) {
|
|
236
|
+
ctx.ui.notify("No active midcompact transaction.", "warning");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const currentPlan = currentDraft ?? emptyDraft(currentTx.id);
|
|
240
|
+
const snapshot = buildAnchorSnapshot(ctx.sessionManager, currentTx);
|
|
241
|
+
const telemetry = draftTelemetry(currentTx, currentPlan);
|
|
242
|
+
if (ctx.mode !== "tui") {
|
|
243
|
+
ctx.ui.notify(formatDraft(currentPlan, telemetry), "info");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const action = await showReviewUi(ctx, snapshot.atoms, currentPlan, telemetry);
|
|
247
|
+
if (action.action === "close") return;
|
|
248
|
+
const range = currentPlan.ranges.find((candidate) => candidate.id === action.draftId);
|
|
249
|
+
if (!range) continue;
|
|
250
|
+
|
|
251
|
+
if (action.action === "edit-summary") {
|
|
252
|
+
const value = await ctx.ui.editor(`Edit ${range.id} summary`, range.summary);
|
|
253
|
+
if (value === undefined || !value.trim()) continue;
|
|
254
|
+
draft = updateDraftRange(currentPlan, range.id, { summary: value.trim() });
|
|
255
|
+
pi.appendEntry(DRAFT_ENTRY, draft);
|
|
256
|
+
updateStatus(ctx, currentTx, draft);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (action.action === "edit-topic") {
|
|
260
|
+
const value = await ctx.ui.input(`Edit ${range.id} topic`, range.topic ?? "");
|
|
261
|
+
if (value === undefined) continue;
|
|
262
|
+
draft = updateDraftRange(currentPlan, range.id, { topic: value.trim() || undefined });
|
|
263
|
+
pi.appendEntry(DRAFT_ENTRY, draft);
|
|
264
|
+
updateStatus(ctx, currentTx, draft);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (action.action === "remove") {
|
|
268
|
+
const approved = await ctx.ui.confirm("Remove compression range?", `${range.id}: ${range.startRef} → ${range.endRef}`);
|
|
269
|
+
if (!approved) continue;
|
|
270
|
+
draft = removeDraftRange(currentPlan, range.id);
|
|
271
|
+
pi.appendEntry(DRAFT_ENTRY, draft);
|
|
272
|
+
updateStatus(ctx, currentTx, draft);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function handleRecall(params: ParamsType, ctx: ExtensionContext): string {
|
|
278
|
+
const sm = ctx.sessionManager;
|
|
279
|
+
const branchState = restoreCompressionState(sm.getBranch() as SessionEntry[]) ?? activeState;
|
|
280
|
+
if (!branchState?.blocks.length) return "No compressed blocks are active on this branch.";
|
|
281
|
+
if (!params.ref) {
|
|
282
|
+
const query = (params.pattern ?? "").trim().toLocaleLowerCase();
|
|
283
|
+
const matches = branchState.blocks.filter((block) => !query || `${block.id}\n${block.topic ?? ""}\n${block.summary}`.toLocaleLowerCase().includes(query));
|
|
284
|
+
if (!matches.length) return "No compressed blocks matched.";
|
|
285
|
+
return matches.slice(0, Math.max(1, Math.min(params.limit ?? 8, 20))).map((block) =>
|
|
286
|
+
`${block.id}${block.topic ? ` | ${block.topic}` : ""} | ~${block.originalApproxTokens} original tokens\n${block.summary}`
|
|
287
|
+
).join("\n\n");
|
|
288
|
+
}
|
|
289
|
+
const block = branchState.blocks.find((candidate) => candidate.id === params.ref);
|
|
290
|
+
if (!block) return `Unknown compressed block ${params.ref}.`;
|
|
291
|
+
const byId = new Map((sm.getEntries() as SessionEntry[]).map((entry) => [entry.id, entry]));
|
|
292
|
+
const parts: string[] = [];
|
|
293
|
+
for (const id of block.entryIds) {
|
|
294
|
+
const entry = byId.get(id);
|
|
295
|
+
if (entry?.type === "message") parts.push(`[${id}] ${messageText(entry.message as MessageLike)}`);
|
|
296
|
+
}
|
|
297
|
+
const full = parts.join("\n\n");
|
|
298
|
+
const limit = params.detail === "full" ? 40_000 : 12_000;
|
|
299
|
+
return full.length > limit ? `${full.slice(0, limit)}\n\n[truncated; refine the recall request or inspect the source session for more]` : full;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function buildAnchorSnapshot(sm: ExtensionContext["sessionManager"], tx: TransactionState): RuntimeSnapshot {
|
|
304
|
+
const entries = sm.getEntries() as SessionEntry[];
|
|
305
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
306
|
+
const built = buildSessionContext(entries, tx.anchorEntryId, byId);
|
|
307
|
+
const anchorBranch = sm.getBranch(tx.anchorEntryId) as SessionEntry[];
|
|
308
|
+
const anchorState = restoreCompressionState(anchorBranch);
|
|
309
|
+
const visibleMessages = projectMessages(built.messages as MessageLike[], anchorState);
|
|
310
|
+
return { atoms: buildAtoms(visibleMessages, anchorBranch as any), anchorState };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function handleLocate(params: ParamsType, atoms: Atom[]): string {
|
|
314
|
+
const matches = locateAtoms(atoms, {
|
|
315
|
+
ref: params.ref,
|
|
316
|
+
pattern: params.pattern,
|
|
317
|
+
source: params.source,
|
|
318
|
+
toolName: params.tool_name,
|
|
319
|
+
direction: params.direction,
|
|
320
|
+
limit: params.limit,
|
|
321
|
+
detail: params.detail,
|
|
322
|
+
});
|
|
323
|
+
if (!matches.length) return "No matching atoms in the frozen anchor snapshot.";
|
|
324
|
+
return matches.map((atom) => formatLocatedAtom(atom, params.detail ?? "brief")).join("\n\n---\n\n");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function handlePlan(params: ParamsType, current: DraftPlan, atoms: Atom[]): DraftPlan {
|
|
328
|
+
const op = params.op ?? "show";
|
|
329
|
+
if (op === "show") return current;
|
|
330
|
+
if (op === "remove") {
|
|
331
|
+
if (!params.draft_id) throw new Error("plan remove requires draft_id.");
|
|
332
|
+
return removeDraftRange(current, params.draft_id);
|
|
333
|
+
}
|
|
334
|
+
if (op === "update") {
|
|
335
|
+
if (!params.draft_id) throw new Error("plan update requires draft_id.");
|
|
336
|
+
if (params.summary === undefined && params.topic === undefined) throw new Error("plan update requires summary or topic.");
|
|
337
|
+
return updateDraftRange(current, params.draft_id, { summary: params.summary, topic: params.topic });
|
|
338
|
+
}
|
|
339
|
+
if (!params.start || !params.end || !params.summary) throw new Error("plan add requires start, end, and summary.");
|
|
340
|
+
return addDraftRange(current, atoms, { start: params.start, end: params.end, summary: params.summary, topic: params.topic });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function mergeDraftIntoState(
|
|
344
|
+
base: CompressionState | undefined,
|
|
345
|
+
draft: DraftPlan,
|
|
346
|
+
transaction: TransactionState,
|
|
347
|
+
telemetry: ReturnType<typeof draftTelemetry>,
|
|
348
|
+
): CompressionState {
|
|
349
|
+
const previous = base ?? emptyCompressionState();
|
|
350
|
+
const blocks = [...previous.blocks];
|
|
351
|
+
const addedBlockIds: string[] = [];
|
|
352
|
+
let next = nextBlockNumber(blocks);
|
|
353
|
+
for (const range of draft.ranges) {
|
|
354
|
+
const block: CompressionBlock = {
|
|
355
|
+
id: `c${String(next).padStart(4, "0")}`,
|
|
356
|
+
topic: range.topic,
|
|
357
|
+
summary: range.summary,
|
|
358
|
+
entryIds: [...range.entryIds],
|
|
359
|
+
messageKeys: [...range.messageKeys],
|
|
360
|
+
createdAt: new Date().toISOString(),
|
|
361
|
+
originalApproxTokens: range.originalApproxTokens,
|
|
362
|
+
compressedApproxTokens: range.compressedApproxTokens,
|
|
363
|
+
};
|
|
364
|
+
blocks.push(block);
|
|
365
|
+
addedBlockIds.push(block.id);
|
|
366
|
+
next += 1;
|
|
367
|
+
}
|
|
368
|
+
const committedAt = new Date().toISOString();
|
|
369
|
+
const lastCommit: CommitStats = {
|
|
370
|
+
transactionId: transaction.id,
|
|
371
|
+
committedAt,
|
|
372
|
+
addedBlockIds,
|
|
373
|
+
addedRangeCount: draft.ranges.length,
|
|
374
|
+
selectedOriginalApproxTokens: telemetry.selectedOriginalApproxTokens,
|
|
375
|
+
selectedCompressedApproxTokens: telemetry.selectedCompressedApproxTokens,
|
|
376
|
+
estimatedSavedTokens: telemetry.estimatedSavedTokens,
|
|
377
|
+
anchorUsage: transaction.anchorUsage,
|
|
378
|
+
projectedTokens: telemetry.projectedTokens,
|
|
379
|
+
projectedPercent: telemetry.projectedPercent,
|
|
380
|
+
};
|
|
381
|
+
return { version: 1, createdAt: committedAt, blocks, lastCommit };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function nextBlockNumber(blocks: CompressionBlock[]): number {
|
|
385
|
+
let max = 0;
|
|
386
|
+
for (const block of blocks) {
|
|
387
|
+
const match = /^c(\d+)$/.exec(block.id);
|
|
388
|
+
if (match) max = Math.max(max, Number(match[1]));
|
|
389
|
+
}
|
|
390
|
+
return max + 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function updateStatus(
|
|
394
|
+
ctx: ExtensionContext,
|
|
395
|
+
tx: TransactionState | undefined,
|
|
396
|
+
currentDraft: DraftPlan | undefined,
|
|
397
|
+
): void {
|
|
398
|
+
const theme = ctx.ui.theme;
|
|
399
|
+
if (tx) {
|
|
400
|
+
const telemetry = draftTelemetry(tx, currentDraft);
|
|
401
|
+
const projected = telemetry.projectedPercent === null ? "" : ` · projected ${formatPercent(telemetry.projectedPercent, true)}`;
|
|
402
|
+
ctx.ui.setStatus(
|
|
403
|
+
STATUS_KEY,
|
|
404
|
+
`${theme.fg("accent", "MC planning")} · ${currentDraft?.ranges.length ?? 0} ranges${projected}`,
|
|
405
|
+
);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function compactUsage(tx: TransactionState): string {
|
|
412
|
+
const usage = tx.anchorUsage;
|
|
413
|
+
if (!usage) return "Anchor context usage unavailable.";
|
|
414
|
+
return `Anchor context ${formatTokenCount(usage.tokens)}/${formatTokenCount(usage.contextWindow)} (${formatPercent(usage.percent)}).`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function activeStateStatus(state: CompressionState): string {
|
|
418
|
+
const saved = state.blocks.reduce(
|
|
419
|
+
(sum, block) => sum + Math.max(0, block.originalApproxTokens - block.compressedApproxTokens),
|
|
420
|
+
0,
|
|
421
|
+
);
|
|
422
|
+
return `Midcompact active on this branch: ${state.blocks.length} block(s), ~${formatTokenCount(saved)} estimated tokens saved. Original history remains recallable.`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function commitNotice(state: CompressionState): string {
|
|
426
|
+
const commit = state.lastCommit;
|
|
427
|
+
if (!commit) return `Midcompact committed: ${state.blocks.length} active compressed block(s).`;
|
|
428
|
+
const projection = commit.projectedPercent === null
|
|
429
|
+
? ""
|
|
430
|
+
: ` Projected anchor context ${formatPercent(commit.anchorUsage?.percent ?? null)} → ${formatPercent(commit.projectedPercent, true)}.`;
|
|
431
|
+
return `Midcompact committed: ${commit.addedRangeCount} new range(s), ~${formatTokenCount(commit.estimatedSavedTokens)} estimated tokens saved.${projection}`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function toolResult(text: string) {
|
|
435
|
+
return { content: [{ type: "text" as const, text }], details: {} };
|
|
436
|
+
}
|