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.
@@ -0,0 +1,116 @@
1
+ import type { MessageLike, SessionEntryLike } from "./types.js";
2
+
3
+ export function stableStringify(value: unknown): string {
4
+ return JSON.stringify(sortValue(value));
5
+ }
6
+
7
+ function sortValue(value: unknown): unknown {
8
+ if (Array.isArray(value)) return value.map(sortValue);
9
+ if (!value || typeof value !== "object") return value;
10
+ const input = value as Record<string, unknown>;
11
+ const out: Record<string, unknown> = {};
12
+ for (const key of Object.keys(input).sort()) out[key] = sortValue(input[key]);
13
+ return out;
14
+ }
15
+
16
+ function fnv1a64(text: string): string {
17
+ let hash = 0xcbf29ce484222325n;
18
+ const prime = 0x100000001b3n;
19
+ const mask = 0xffffffffffffffffn;
20
+ const bytes = new TextEncoder().encode(text);
21
+ for (const byte of bytes) {
22
+ hash ^= BigInt(byte);
23
+ hash = (hash * prime) & mask;
24
+ }
25
+ return hash.toString(16).padStart(16, "0");
26
+ }
27
+
28
+ export function messageKey(message: MessageLike): string {
29
+ const timestamp = typeof message.timestamp === "number" ? message.timestamp : 0;
30
+ return `${message.role}:${timestamp}:${fnv1a64(stableStringify(message))}`;
31
+ }
32
+
33
+ export function contentText(content: unknown): string {
34
+ if (typeof content === "string") return content;
35
+ if (!Array.isArray(content)) return "";
36
+ const out: string[] = [];
37
+ for (const raw of content) {
38
+ if (!raw || typeof raw !== "object") continue;
39
+ const part = raw as Record<string, unknown>;
40
+ if (part.type === "text" && typeof part.text === "string") out.push(part.text);
41
+ else if (part.type === "thinking" && typeof part.thinking === "string") out.push(part.thinking);
42
+ else if (part.type === "toolCall") {
43
+ const name = typeof part.name === "string" ? part.name : "tool";
44
+ const args = "arguments" in part ? part.arguments : {};
45
+ out.push(`${name}(${safeJson(args)})`);
46
+ }
47
+ }
48
+ return out.join("\n");
49
+ }
50
+
51
+ function safeJson(value: unknown): string {
52
+ try {
53
+ return JSON.stringify(value) ?? "";
54
+ } catch {
55
+ return "[unserializable]";
56
+ }
57
+ }
58
+
59
+ export function messageText(message: MessageLike): string {
60
+ if (message.role === "toolResult") {
61
+ const name = message.toolName ?? "tool";
62
+ return `tool_result ${name}: ${contentText(message.content)}`;
63
+ }
64
+ const pieces = [contentText(message.content)];
65
+ if (typeof message.command === "string") pieces.push(`$ ${message.command}`);
66
+ if (typeof message.output === "string") pieces.push(message.output);
67
+ if (typeof message.summary === "string") pieces.push(message.summary);
68
+ return pieces.filter(Boolean).join("\n");
69
+ }
70
+
71
+ export function toolCalls(message: MessageLike): Array<{ id: string; name: string; arguments?: unknown }> {
72
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return [];
73
+ const calls: Array<{ id: string; name: string; arguments?: unknown }> = [];
74
+ for (const raw of message.content) {
75
+ if (!raw || typeof raw !== "object") continue;
76
+ const part = raw as Record<string, unknown>;
77
+ if (part.type !== "toolCall") continue;
78
+ if (typeof part.id !== "string" || typeof part.name !== "string") continue;
79
+ calls.push({ id: part.id, name: part.name, arguments: part.arguments });
80
+ }
81
+ return calls;
82
+ }
83
+
84
+ export function approxTokens(text: string): number {
85
+ return Math.max(1, Math.ceil(text.length / 4));
86
+ }
87
+
88
+ export function truncate(text: string, limit: number): string {
89
+ const normalized = text.replace(/\s+/g, " ").trim();
90
+ if (normalized.length <= limit) return normalized;
91
+ return `${normalized.slice(0, Math.max(0, limit - 1))}…`;
92
+ }
93
+
94
+ export function mapEntryIds(messages: MessageLike[], branch: readonly SessionEntryLike[]): Array<string | undefined> {
95
+ const queues = new Map<string, string[]>();
96
+ for (const entry of branch) {
97
+ if (entry.type !== "message" || !entry.message) continue;
98
+ const key = messageKey(entry.message);
99
+ const queue = queues.get(key) ?? [];
100
+ queue.push(entry.id);
101
+ queues.set(key, queue);
102
+ }
103
+ return messages.map((message) => {
104
+ const queue = queues.get(messageKey(message));
105
+ return queue?.shift();
106
+ });
107
+ }
108
+
109
+ export function renderMessage(message: MessageLike): string {
110
+ if (message.role === "user") return `User: ${messageText(message)}`;
111
+ if (message.role === "assistant") return `Assistant: ${messageText(message)}`;
112
+ if (message.role === "toolResult") return `Tool result (${message.toolName ?? "tool"}): ${contentText(message.content)}`;
113
+ if (message.role === "bashExecution") return `Bash: ${message.command ?? ""}\n${message.output ?? ""}`;
114
+ if (message.role === "custom") return `Custom: ${messageText(message)}`;
115
+ return `${message.role}: ${messageText(message)}`;
116
+ }
package/src/plan.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { Atom, DraftPlan, DraftRange, DraftTelemetry } from "./types.js";
2
+ import { estimateCompressedTokens } from "./projection.js";
3
+ import { formatTelemetry } from "./telemetry.js";
4
+
5
+ export function emptyDraft(transactionId: string): DraftPlan {
6
+ return { version: 1, transactionId, revision: 0, ranges: [] };
7
+ }
8
+
9
+ export function addDraftRange(
10
+ draft: DraftPlan,
11
+ atoms: Atom[],
12
+ input: { start: string; end: string; summary: string; topic?: string },
13
+ ): DraftPlan {
14
+ const start = atoms.find((atom) => atom.ref === input.start);
15
+ const end = atoms.find((atom) => atom.ref === input.end);
16
+ if (!start || !end) throw new Error("Unknown atom ref; run locate again against the current anchor snapshot.");
17
+ if (start.index > end.index) throw new Error("start must not occur after end.");
18
+ const selected = atoms.slice(start.index, end.index + 1);
19
+ if (selected.length === 0) throw new Error("Empty range.");
20
+ const unsafe = selected.find((atom) => !atom.compressible || !atom.protocolClosed || atom.kind === "compressed");
21
+ if (unsafe) throw new Error(`Range crosses protected atom ${unsafe.ref} (${unsafe.kind}). Split the plan around it.`);
22
+ const overlaps = draft.ranges.some((range) => !(end.index < range.startIndex || start.index > range.endIndex));
23
+ if (overlaps) throw new Error("Range overlaps an existing draft range.");
24
+ const id = nextDraftId(draft);
25
+ const range: DraftRange = {
26
+ id,
27
+ startRef: start.ref,
28
+ endRef: end.ref,
29
+ startIndex: start.index,
30
+ endIndex: end.index,
31
+ topic: input.topic,
32
+ summary: input.summary,
33
+ entryIds: selected.flatMap((atom) => atom.entryIds),
34
+ messageKeys: selected.flatMap((atom) => atom.messageKeys),
35
+ originalApproxTokens: selected.reduce((sum, atom) => sum + atom.approxTokens, 0),
36
+ compressedApproxTokens: estimateCompressedTokens(input.summary, input.topic),
37
+ startPreview: start.preview,
38
+ endPreview: end.preview,
39
+ };
40
+ return { ...draft, revision: draft.revision + 1, ranges: [...draft.ranges, range].sort((a, b) => a.startIndex - b.startIndex) };
41
+ }
42
+
43
+ export function updateDraftRange(
44
+ draft: DraftPlan,
45
+ draftId: string,
46
+ patch: { summary?: string; topic?: string },
47
+ ): DraftPlan {
48
+ const target = draft.ranges.find((range) => range.id === draftId);
49
+ if (!target) throw new Error(`Unknown draft range ${draftId}.`);
50
+ const summary = patch.summary ?? target.summary;
51
+ const topic = patch.topic ?? target.topic;
52
+ return {
53
+ ...draft,
54
+ revision: draft.revision + 1,
55
+ ranges: draft.ranges.map((range) => range.id === draftId
56
+ ? { ...range, summary, topic, compressedApproxTokens: estimateCompressedTokens(summary, topic) }
57
+ : range),
58
+ };
59
+ }
60
+
61
+ export function removeDraftRange(draft: DraftPlan, draftId: string): DraftPlan {
62
+ if (!draft.ranges.some((range) => range.id === draftId)) throw new Error(`Unknown draft range ${draftId}.`);
63
+ return { ...draft, revision: draft.revision + 1, ranges: draft.ranges.filter((range) => range.id !== draftId) };
64
+ }
65
+
66
+ function nextDraftId(draft: DraftPlan): string {
67
+ let max = 0;
68
+ for (const range of draft.ranges) {
69
+ const match = /^d(\d+)$/.exec(range.id);
70
+ if (match) max = Math.max(max, Number(match[1]));
71
+ }
72
+ return `d${max + 1}`;
73
+ }
74
+
75
+ export function formatDraft(draft: DraftPlan, telemetry?: DraftTelemetry): string {
76
+ const lines: string[] = [];
77
+ if (telemetry) lines.push(formatTelemetry(telemetry), "");
78
+ if (draft.ranges.length === 0) {
79
+ lines.push(`Draft v${draft.revision}: no compression ranges.`);
80
+ return lines.join("\n");
81
+ }
82
+ const before = draft.ranges.reduce((sum, range) => sum + range.originalApproxTokens, 0);
83
+ const after = draft.ranges.reduce((sum, range) => sum + range.compressedApproxTokens, 0);
84
+ lines.push(`Draft v${draft.revision}: ${draft.ranges.length} range(s), ~${before} → ~${after} tokens`);
85
+ for (const range of draft.ranges) {
86
+ lines.push(`\n${range.id}: ${range.startRef} → ${range.endRef}${range.topic ? ` | ${range.topic}` : ""}`);
87
+ lines.push(`~${range.originalApproxTokens} → ~${range.compressedApproxTokens} tokens`);
88
+ lines.push(`summary: ${range.summary}`);
89
+ lines.push(`start: ${range.startPreview}`);
90
+ if (range.endRef !== range.startRef) lines.push(`end: ${range.endPreview}`);
91
+ }
92
+ return lines.join("\n");
93
+ }
@@ -0,0 +1,61 @@
1
+ import type { CompressionBlock, CompressionState, MessageLike } from "./types.js";
2
+ import { approxTokens, messageKey } from "./messages.js";
3
+
4
+ export function summaryMessage(block: CompressionBlock, timestamp = Date.now()): MessageLike {
5
+ const topic = block.topic ? `: ${block.topic}` : "";
6
+ return {
7
+ role: "custom",
8
+ customType: "midcompact-summary",
9
+ content: [
10
+ `[Midcompact summary ${block.id}${topic}]`,
11
+ block.summary,
12
+ "",
13
+ `Original block: ${block.id}`,
14
+ `Use midcompact(action=\"recall\", ref=\"${block.id}\") if exact details are needed.`,
15
+ ].join("\n"),
16
+ display: true,
17
+ details: {
18
+ blockId: block.id,
19
+ originalApproxTokens: block.originalApproxTokens,
20
+ topic: block.topic,
21
+ },
22
+ timestamp,
23
+ } as MessageLike;
24
+ }
25
+
26
+ export function projectMessages(messages: MessageLike[], state?: CompressionState): MessageLike[] {
27
+ if (!state?.blocks.length) return messages;
28
+ const keys = messages.map(messageKey);
29
+ const replacements: Array<{ start: number; end: number; block: CompressionBlock }> = [];
30
+ for (const block of state.blocks) {
31
+ const found = findSubsequence(keys, block.messageKeys);
32
+ if (!found) continue; // fail open: never delete content if the persisted locator no longer resolves exactly
33
+ replacements.push({ start: found.start, end: found.end, block });
34
+ }
35
+ replacements.sort((a, b) => a.start - b.start);
36
+ for (let i = 1; i < replacements.length; i += 1) {
37
+ if (replacements[i]!.start <= replacements[i - 1]!.end) return messages; // corrupted/overlapping state: fail open
38
+ }
39
+ const out = [...messages];
40
+ for (const replacement of [...replacements].reverse()) {
41
+ const first = messages[replacement.start];
42
+ const timestamp = typeof first?.timestamp === "number" ? first.timestamp : Date.now();
43
+ out.splice(replacement.start, replacement.end - replacement.start + 1, summaryMessage(replacement.block, timestamp));
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function findSubsequence(haystack: string[], needle: string[]): { start: number; end: number } | undefined {
49
+ if (needle.length === 0 || needle.length > haystack.length) return undefined;
50
+ outer: for (let start = 0; start <= haystack.length - needle.length; start += 1) {
51
+ for (let offset = 0; offset < needle.length; offset += 1) {
52
+ if (haystack[start + offset] !== needle[offset]) continue outer;
53
+ }
54
+ return { start, end: start + needle.length - 1 };
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ export function estimateCompressedTokens(summary: string, topic?: string): number {
60
+ return approxTokens(`${topic ?? ""}\n${summary}`) + 40;
61
+ }
@@ -0,0 +1,67 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Text } from "@earendil-works/pi-tui";
3
+
4
+ import { STATE_ENTRY } from "./state.js";
5
+ import { formatPercent, formatTokenCount } from "./telemetry.js";
6
+ import type { CompressionState } from "./types.js";
7
+
8
+ export function registerStateRenderer(pi: ExtensionAPI): void {
9
+ pi.registerEntryRenderer<CompressionState>(STATE_ENTRY, (entry, { expanded }, theme) => {
10
+ const state = entry.data;
11
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
12
+ if (!state) {
13
+ box.addChild(new Text(theme.fg("dim", "[midcompact] state unavailable"), 0, 0));
14
+ return box;
15
+ }
16
+ const commit = state.lastCommit;
17
+ const saved = state.blocks.reduce(
18
+ (sum, block) => sum + Math.max(0, block.originalApproxTokens - block.compressedApproxTokens),
19
+ 0,
20
+ );
21
+ const added = commit?.addedRangeCount ?? 0;
22
+ const headline = [
23
+ theme.fg("success", "✓ MIDCOMPACT"),
24
+ added ? `+${added} range${added === 1 ? "" : "s"}` : `${state.blocks.length} active block${state.blocks.length === 1 ? "" : "s"}`,
25
+ `${state.blocks.length} active`,
26
+ `~${formatTokenCount(saved)} saved`,
27
+ ].join(" · ");
28
+ box.addChild(new Text(headline, 0, 0));
29
+
30
+ if (commit?.anchorUsage?.contextWindow && commit.projectedTokens !== null) {
31
+ box.addChild(new Text(
32
+ theme.fg(
33
+ "dim",
34
+ `anchor ${formatPercent(commit.anchorUsage.percent)} → projected ${formatPercent(commit.projectedPercent, true)} ` +
35
+ `(~${formatTokenCount(commit.projectedTokens)}/${formatTokenCount(commit.anchorUsage.contextWindow)})`,
36
+ ),
37
+ 0,
38
+ 0,
39
+ ));
40
+ }
41
+
42
+ if (expanded) {
43
+ const addedSet = new Set(commit?.addedBlockIds ?? []);
44
+ const blocks = addedSet.size ? state.blocks.filter((block) => addedSet.has(block.id)) : state.blocks;
45
+ for (const block of blocks) {
46
+ const title = `${block.id}${block.topic ? ` · ${block.topic}` : ""} · ~${formatTokenCount(block.originalApproxTokens)} → ~${formatTokenCount(block.compressedApproxTokens)}`;
47
+ box.addChild(new Text(theme.fg("accent", title), 0, 0));
48
+ box.addChild(new Text(theme.fg("dim", block.summary), 1, 0));
49
+ }
50
+ box.addChild(new Text(theme.fg("dim", "Original history retained; use midcompact recall when exact details are needed."), 0, 0));
51
+ }
52
+ return box;
53
+ });
54
+ }
55
+
56
+ export function stateTreeLabel(state: CompressionState): string {
57
+ const commit = state.lastCommit;
58
+ const added = commit?.addedRangeCount ?? 0;
59
+ const saved = commit?.estimatedSavedTokens ?? state.blocks.reduce(
60
+ (sum, block) => sum + Math.max(0, block.originalApproxTokens - block.compressedApproxTokens),
61
+ 0,
62
+ );
63
+ const projection = commit?.projectedPercent === null || commit?.projectedPercent === undefined
64
+ ? ""
65
+ : ` · →~${formatPercent(commit.projectedPercent, false)}`;
66
+ return `midcompact${added ? ` +${added}` : ""} · ~${formatTokenCount(saved)} saved${projection}`;
67
+ }
@@ -0,0 +1,198 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+
4
+ import { formatPercent, formatTokenCount } from "./telemetry.js";
5
+ import type { Atom, DraftPlan, DraftRange, DraftTelemetry, ReviewAction } from "./types.js";
6
+
7
+ export async function showReviewUi(
8
+ ctx: ExtensionCommandContext,
9
+ atoms: Atom[],
10
+ draft: DraftPlan,
11
+ telemetry: DraftTelemetry,
12
+ ): Promise<ReviewAction> {
13
+ if (ctx.mode !== "tui") return { action: "close" };
14
+
15
+ return ctx.ui.custom<ReviewAction>((tui, theme, _keybindings, done) => {
16
+ let scrollOffset = 0;
17
+ let selectedRange = draft.ranges.length ? 0 : -1;
18
+ let expandSelected = false;
19
+ let jumpToSelected = true;
20
+
21
+ const selected = (): DraftRange | undefined => selectedRange >= 0 ? draft.ranges[selectedRange] : undefined;
22
+
23
+ const component = {
24
+ render(width: number): string[] {
25
+ const w = Math.max(30, width);
26
+ const rows = Math.max(12, tui.terminal.rows);
27
+ const border = (text: string) => theme.fg("border", text);
28
+ const dim = (text: string) => theme.fg("dim", text);
29
+ const accent = (text: string) => theme.fg("accent", text);
30
+ const success = (text: string) => theme.fg("success", text);
31
+ const warning = (text: string) => theme.fg("warning", text);
32
+ const bodyWidth = Math.max(20, w - 4);
33
+ const framed = (text: string) => `${border("│")} ${truncateToWidth(text, bodyWidth, "…", true)} ${border("│")}`;
34
+ const horizontal = (left: string, mid = "─", right = "─") => border(`${left}${mid.repeat(Math.max(0, w - 2))}${right}`);
35
+
36
+ const header: string[] = [];
37
+ header.push(horizontal("╭", "─", "╮"));
38
+ header.push(framed(accent(theme.bold(`Midcompact Review · Draft v${draft.revision}`))));
39
+ header.push(framed(formatUsageLine(telemetry, theme)));
40
+ header.push(framed(dim("This is awareness, not a target. Review the linear anchor snapshot before /midcompact commit.")));
41
+ header.push(horizontal("├", "─", "┤"));
42
+
43
+ const range = selected();
44
+ const selectedInfo: string[] = [];
45
+ if (range) {
46
+ selectedInfo.push(framed(`${accent("Selected")} ${range.id} ${range.startRef} → ${range.endRef}${range.topic ? ` ${range.topic}` : ""}`));
47
+ selectedInfo.push(framed(dim(`~${formatTokenCount(range.originalApproxTokens)} → ~${formatTokenCount(range.compressedApproxTokens)} tokens`)));
48
+ for (const line of wrapTextWithAnsi(`${accent("Summary:")} ${range.summary}`, bodyWidth)) selectedInfo.push(framed(line));
49
+ selectedInfo.push(horizontal("├", "─", "┤"));
50
+ }
51
+
52
+ const body: string[] = [];
53
+ const atomLineStarts = new Map<number, number>();
54
+ for (const atom of atoms) {
55
+ atomLineStarts.set(atom.index, body.length);
56
+ const owner = owningRange(atom.index, draft.ranges);
57
+ const isSelected = Boolean(range && owner?.id === range.id);
58
+ const isRangeStart = owner?.startIndex === atom.index;
59
+ const isRangeEnd = owner?.endIndex === atom.index;
60
+ const rangeMark = owner ? (isRangeStart ? "┌" : isRangeEnd ? "└" : "│") : " ";
61
+ const selectionMark = isSelected ? "▶" : " ";
62
+ const policy = owner ? warning(owner.id) : success("KEEP");
63
+ const token = dim(`~${formatTokenCount(atom.approxTokens)}`);
64
+ const oneLine = firstLine(atom.preview);
65
+ body.push(framed(`${selectionMark}${rangeMark} ${policy} ${atom.ref} [${atom.kind}] ${token} ${oneLine}`));
66
+ if (expandSelected && isSelected) {
67
+ const detail = wrapTextWithAnsi(atom.preview, Math.max(10, bodyWidth - 4));
68
+ for (const detailLine of detail.slice(1, 8)) body.push(framed(dim(` ${detailLine}`)));
69
+ }
70
+ }
71
+ if (!body.length) body.push(framed(dim("(No atoms in anchor snapshot.)")));
72
+
73
+ const footerRows = 4;
74
+ const viewportHeight = Math.max(5, rows - header.length - selectedInfo.length - footerRows - 2);
75
+ if (jumpToSelected && range) {
76
+ scrollOffset = atomLineStarts.get(range.startIndex) ?? scrollOffset;
77
+ jumpToSelected = false;
78
+ }
79
+ const maxOffset = Math.max(0, body.length - viewportHeight);
80
+ scrollOffset = Math.max(0, Math.min(scrollOffset, maxOffset));
81
+ const visible = body.slice(scrollOffset, scrollOffset + viewportHeight);
82
+ while (visible.length < viewportHeight) visible.push(framed(""));
83
+
84
+ const top = body.length ? scrollOffset + 1 : 0;
85
+ const bottom = Math.min(body.length, scrollOffset + viewportHeight);
86
+ const footer = [
87
+ horizontal("├", "─", "┤"),
88
+ framed(dim(`Lines ${top}-${bottom} of ${body.length} · n/p select range · ↑↓/PgUp/PgDn scroll · x expand selected`)),
89
+ framed(dim("e edit summary · t edit topic · d remove selected range · Enter/Esc close")),
90
+ horizontal("╰", "─", "╯"),
91
+ ];
92
+ return [...header, ...selectedInfo, ...visible, ...footer];
93
+ },
94
+ invalidate(): void {},
95
+ handleInput(data: string): void {
96
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter) || data === "q") {
97
+ done({ action: "close" });
98
+ return;
99
+ }
100
+ if ((data === "n" || matchesKey(data, Key.right)) && draft.ranges.length) {
101
+ selectedRange = (selectedRange + 1 + draft.ranges.length) % draft.ranges.length;
102
+ jumpToSelected = true;
103
+ tui.requestRender();
104
+ return;
105
+ }
106
+ if ((data === "p" || matchesKey(data, Key.left)) && draft.ranges.length) {
107
+ selectedRange = (selectedRange - 1 + draft.ranges.length) % draft.ranges.length;
108
+ jumpToSelected = true;
109
+ tui.requestRender();
110
+ return;
111
+ }
112
+ if (matchesKey(data, Key.up) || data === "k") {
113
+ scrollOffset = Math.max(0, scrollOffset - 1);
114
+ tui.requestRender();
115
+ return;
116
+ }
117
+ if (matchesKey(data, Key.down) || data === "j") {
118
+ scrollOffset += 1;
119
+ tui.requestRender();
120
+ return;
121
+ }
122
+ if (matchesKey(data, Key.pageUp)) {
123
+ scrollOffset = Math.max(0, scrollOffset - 12);
124
+ tui.requestRender();
125
+ return;
126
+ }
127
+ if (matchesKey(data, Key.pageDown)) {
128
+ scrollOffset += 12;
129
+ tui.requestRender();
130
+ return;
131
+ }
132
+ if (matchesKey(data, Key.home)) {
133
+ scrollOffset = 0;
134
+ tui.requestRender();
135
+ return;
136
+ }
137
+ if (matchesKey(data, Key.end)) {
138
+ scrollOffset = Number.MAX_SAFE_INTEGER;
139
+ tui.requestRender();
140
+ return;
141
+ }
142
+ if (data === "x") {
143
+ expandSelected = !expandSelected;
144
+ jumpToSelected = true;
145
+ tui.requestRender();
146
+ return;
147
+ }
148
+ const range = selected();
149
+ if (!range) return;
150
+ if (data === "e") done({ action: "edit-summary", draftId: range.id });
151
+ else if (data === "t") done({ action: "edit-topic", draftId: range.id });
152
+ else if (data === "d") done({ action: "remove", draftId: range.id });
153
+ },
154
+ };
155
+ return component;
156
+ });
157
+ }
158
+
159
+ export function buildReviewText(atoms: Atom[], draft: DraftPlan, telemetry: DraftTelemetry): string {
160
+ const lines = [
161
+ `Midcompact Review · Draft v${draft.revision}`,
162
+ plainUsageLine(telemetry),
163
+ "This is awareness, not a target.",
164
+ "",
165
+ ];
166
+ for (const atom of atoms) {
167
+ const owner = owningRange(atom.index, draft.ranges);
168
+ lines.push(`${owner ? owner.id : "KEEP"} ${atom.ref} [${atom.kind}] ~${formatTokenCount(atom.approxTokens)} ${firstLine(atom.preview)}`);
169
+ }
170
+ if (draft.ranges.length) {
171
+ lines.push("", "Proposed summaries:");
172
+ for (const range of draft.ranges) lines.push(`${range.id} ${range.startRef} → ${range.endRef}: ${range.summary}`);
173
+ }
174
+ return lines.join("\n");
175
+ }
176
+
177
+ function owningRange(atomIndex: number, ranges: DraftRange[]): DraftRange | undefined {
178
+ return ranges.find((range) => atomIndex >= range.startIndex && atomIndex <= range.endIndex);
179
+ }
180
+
181
+ function firstLine(text: string): string {
182
+ return text.replace(/\s+/g, " ").trim();
183
+ }
184
+
185
+ function plainUsageLine(telemetry: DraftTelemetry): string {
186
+ const anchor = telemetry.contextWindow === null
187
+ ? "anchor usage unavailable"
188
+ : `${formatTokenCount(telemetry.anchorTokens)}/${formatTokenCount(telemetry.contextWindow)} (${formatPercent(telemetry.anchorPercent)})`;
189
+ const projected = telemetry.projectedTokens === null || telemetry.contextWindow === null
190
+ ? "projected unavailable"
191
+ : `~${formatTokenCount(telemetry.projectedTokens)}/${formatTokenCount(telemetry.contextWindow)} (${formatPercent(telemetry.projectedPercent, true)})`;
192
+ return `Anchor ${anchor} · Draft selected ~${formatTokenCount(telemetry.selectedOriginalApproxTokens)}→~${formatTokenCount(telemetry.selectedCompressedApproxTokens)} · Projected ${projected}`;
193
+ }
194
+
195
+ function formatUsageLine(telemetry: DraftTelemetry, theme: ExtensionCommandContext["ui"]["theme"]): string {
196
+ const label = theme.fg("accent", "Context");
197
+ return `${label}: ${plainUsageLine(telemetry)}`;
198
+ }
package/src/state.ts ADDED
@@ -0,0 +1,64 @@
1
+ import type { CompressionState, DraftPlan, SessionEntryLike, TransactionState } from "./types.js";
2
+
3
+ export const STATE_ENTRY = "midcompact-state";
4
+ export const TXN_ENTRY = "midcompact-transaction";
5
+ export const DRAFT_ENTRY = "midcompact-draft";
6
+
7
+ interface CustomEntryLike extends SessionEntryLike {
8
+ data?: unknown;
9
+ }
10
+
11
+ export function emptyCompressionState(): CompressionState {
12
+ return { version: 1, createdAt: new Date().toISOString(), blocks: [] };
13
+ }
14
+
15
+ export function restoreCompressionState(entries: readonly unknown[]): CompressionState | undefined {
16
+ let latest: CompressionState | undefined;
17
+ for (const raw of entries) {
18
+ const entry = raw as CustomEntryLike;
19
+ if (entry.type !== "custom" || entry.customType !== STATE_ENTRY) continue;
20
+ if (isCompressionState(entry.data)) latest = entry.data;
21
+ }
22
+ return latest;
23
+ }
24
+
25
+ export function restoreTransaction(entries: readonly unknown[]): { transaction?: TransactionState; draft?: DraftPlan } {
26
+ let transaction: TransactionState | undefined;
27
+ let draft: DraftPlan | undefined;
28
+ for (const raw of entries) {
29
+ const entry = raw as CustomEntryLike;
30
+ if (entry.type !== "custom") continue;
31
+ if (entry.customType === TXN_ENTRY && isTransaction(entry.data)) {
32
+ transaction = entry.data;
33
+ draft = undefined;
34
+ continue;
35
+ }
36
+ if (entry.customType === DRAFT_ENTRY && transaction && isDraft(entry.data) && entry.data.transactionId === transaction.id) {
37
+ draft = entry.data;
38
+ }
39
+ }
40
+ return { transaction, draft };
41
+ }
42
+
43
+ export function isCompressionState(value: unknown): value is CompressionState {
44
+ if (!value || typeof value !== "object") return false;
45
+ const state = value as Record<string, unknown>;
46
+ if (state.version !== 1 || typeof state.createdAt !== "string" || !Array.isArray(state.blocks)) return false;
47
+ return state.blocks.every((raw) => {
48
+ if (!raw || typeof raw !== "object") return false;
49
+ const block = raw as Record<string, unknown>;
50
+ return typeof block.id === "string" && typeof block.summary === "string" && Array.isArray(block.entryIds) && Array.isArray(block.messageKeys);
51
+ });
52
+ }
53
+
54
+ function isTransaction(value: unknown): value is TransactionState {
55
+ if (!value || typeof value !== "object") return false;
56
+ const tx = value as Record<string, unknown>;
57
+ return tx.version === 1 && typeof tx.id === "string" && typeof tx.anchorEntryId === "string" && typeof tx.startedAt === "string";
58
+ }
59
+
60
+ function isDraft(value: unknown): value is DraftPlan {
61
+ if (!value || typeof value !== "object") return false;
62
+ const plan = value as Record<string, unknown>;
63
+ return plan.version === 1 && typeof plan.transactionId === "string" && typeof plan.revision === "number" && Array.isArray(plan.ranges);
64
+ }