pi-btw-cc 0.1.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/src/thread.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Persisted side-thread state for the /btw extension.
3
+ *
4
+ * A side thread belongs to exactly one session. It is stored in the session
5
+ * file as `btw-thread` custom entries (an append-only ledger), which pi keeps
6
+ * out of the main agent's LLM context. Each entry is one small record:
7
+ *
8
+ * { kind: "exchange", exchange } append one finished question/answer pair
9
+ * { kind: "promoted", id } mark one exchange as promoted into main
10
+ * { kind: "delete", id } drop one exchange from the thread
11
+ *
12
+ * Replaying records in branch order reconstructs the thread, so a promotion or
13
+ * a deletion survives restarts without rewriting earlier entries.
14
+ *
15
+ * This module intentionally has no imports: the ledger rules are unit tested
16
+ * outside the pi runtime.
17
+ */
18
+
19
+ /** One finished side question/answer pair. */
20
+ export interface BtwExchange {
21
+ /** Stable id, referenced by promoted and delete records. */
22
+ id: string;
23
+ /** The user's side question, verbatim. */
24
+ question: string;
25
+ /** Answer text returned by the no-tools side request. */
26
+ answer: string;
27
+ /** Epoch ms when the answer was produced. */
28
+ answeredAt: number;
29
+ /** `provider/modelId` of the model that answered, for display and provenance. */
30
+ model: string;
31
+ /** Number of earlier exchanges replayed into this request. */
32
+ replayed: number;
33
+ /**
34
+ * Set once the exchange was promoted into the main conversation with `f`.
35
+ * Promoted exchanges stay listed in the overlay but leave the replay
36
+ * window: the main session context already carries them, so replaying them
37
+ * again would duplicate the Q&A inside the side request.
38
+ */
39
+ promoted?: boolean;
40
+ }
41
+
42
+ /** Custom entry type used for every thread ledger record. */
43
+ export const BTW_ENTRY_TYPE = "btw-thread";
44
+ /** Custom message type used when an exchange is promoted into the main session. */
45
+ export const BTW_MESSAGE_TYPE = "btw";
46
+
47
+ /** Oldest exchanges are dropped once the thread exceeds this length. */
48
+ export const MAX_STORED_EXCHANGES = 50;
49
+ /** Replay window for a new side request; Claude Code replays its newest 20. */
50
+ export const MAX_REPLAY_EXCHANGES = 20;
51
+
52
+ /** Structural view of a session entry, so this module stays dependency free. */
53
+ export interface ThreadEntrySource {
54
+ type: string;
55
+ customType?: string;
56
+ data?: unknown;
57
+ }
58
+
59
+ export type BtwThreadRecord =
60
+ | { kind: "exchange"; exchange: BtwExchange }
61
+ | { kind: "promoted"; id: string }
62
+ | { kind: "delete"; id: string };
63
+
64
+ /** Append one exchange, dropping the oldest ones beyond the storage cap. */
65
+ export function appendExchange(thread: readonly BtwExchange[], exchange: BtwExchange): BtwExchange[] {
66
+ const next = [...thread, exchange];
67
+ return next.length <= MAX_STORED_EXCHANGES ? next : next.slice(next.length - MAX_STORED_EXCHANGES);
68
+ }
69
+
70
+ /**
71
+ * Exchanges that must be replayed into the next side request, oldest first.
72
+ *
73
+ * Promotion is recorded before the promoted copy reaches the main context:
74
+ * while the main agent streams, pi defers that append to the end of the turn.
75
+ * A side question asked inside that window therefore does not see the
76
+ * exchange, because it already left this window and is not in the main
77
+ * context yet.
78
+ */
79
+ export function replayWindow(thread: readonly BtwExchange[]): BtwExchange[] {
80
+ return thread.filter((exchange) => exchange.promoted !== true).slice(-MAX_REPLAY_EXCHANGES);
81
+ }
82
+
83
+ /** Mark one exchange as promoted without touching the others. */
84
+ export function markPromoted(thread: readonly BtwExchange[], id: string): BtwExchange[] {
85
+ return thread.map((exchange) => (exchange.id === id ? { ...exchange, promoted: true } : exchange));
86
+ }
87
+
88
+ /**
89
+ * Drop one exchange.
90
+ *
91
+ * An id that is no longer present is not an error: the storage cap in
92
+ * `appendExchange` evicts the oldest exchanges, so a delete record can refer to
93
+ * an exchange that a later replay has already dropped.
94
+ */
95
+ export function removeExchange(thread: readonly BtwExchange[], id: string): BtwExchange[] {
96
+ return thread.filter((exchange) => exchange.id !== id);
97
+ }
98
+
99
+ /**
100
+ * Reconstruct the side thread of a branch by replaying its ledger in order.
101
+ *
102
+ * Replaying applies the storage cap through `appendExchange`, so the result can
103
+ * never exceed MAX_STORED_EXCHANGES without a second clamp here.
104
+ */
105
+ export function findThread(entries: readonly ThreadEntrySource[]): BtwExchange[] {
106
+ let thread: BtwExchange[] = [];
107
+ for (const entry of entries) {
108
+ if (entry.type !== "custom" || entry.customType !== BTW_ENTRY_TYPE) continue;
109
+ thread = applyRecord(thread, parseRecord(entry.data));
110
+ }
111
+ return thread;
112
+ }
113
+
114
+ function applyRecord(thread: BtwExchange[], record: BtwThreadRecord): BtwExchange[] {
115
+ switch (record.kind) {
116
+ case "exchange":
117
+ return appendExchange(thread, record.exchange);
118
+ case "promoted":
119
+ return markPromoted(thread, record.id);
120
+ case "delete":
121
+ return removeExchange(thread, record.id);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Parse one ledger record.
127
+ *
128
+ * Records are written by this extension, so a shape mismatch means the session
129
+ * file was edited or corrupted. Throw instead of silently resetting the
130
+ * thread: a dropped side thread is worse than a visible error.
131
+ */
132
+ function parseRecord(data: unknown): BtwThreadRecord {
133
+ if (typeof data !== "object" || data === null) {
134
+ throw new Error(`Invalid ${BTW_ENTRY_TYPE} entry: expected an object, got ${JSON.stringify(data)}`);
135
+ }
136
+ const record = data as { kind?: unknown; exchange?: unknown; id?: unknown };
137
+ switch (record.kind) {
138
+ case "exchange":
139
+ return { kind: "exchange", exchange: parseExchange(record.exchange) };
140
+ case "promoted":
141
+ case "delete": {
142
+ if (typeof record.id !== "string") {
143
+ throw new Error(`Invalid ${BTW_ENTRY_TYPE} ${record.kind} record: id must be a string`);
144
+ }
145
+ return { kind: record.kind, id: record.id };
146
+ }
147
+ default:
148
+ throw new Error(`Invalid ${BTW_ENTRY_TYPE} entry: unknown kind ${JSON.stringify(record.kind)}`);
149
+ }
150
+ }
151
+
152
+ function parseExchange(value: unknown): BtwExchange {
153
+ if (typeof value !== "object" || value === null) {
154
+ throw new Error(`Invalid ${BTW_ENTRY_TYPE} exchange: expected an object`);
155
+ }
156
+ const exchange = value as Partial<BtwExchange>;
157
+ if (typeof exchange.id !== "string" || exchange.id === "") {
158
+ throw new Error(`Invalid ${BTW_ENTRY_TYPE} exchange: id must be a non-empty string`);
159
+ }
160
+ if (typeof exchange.question !== "string" || typeof exchange.answer !== "string") {
161
+ throw new Error(`Invalid ${BTW_ENTRY_TYPE} exchange ${exchange.id}: question and answer must be strings`);
162
+ }
163
+ if (typeof exchange.answeredAt !== "number" || !Number.isFinite(exchange.answeredAt)) {
164
+ throw new Error(`Invalid ${BTW_ENTRY_TYPE} exchange ${exchange.id}: answeredAt must be a number`);
165
+ }
166
+ if (typeof exchange.model !== "string" || typeof exchange.replayed !== "number") {
167
+ throw new Error(`Invalid ${BTW_ENTRY_TYPE} exchange ${exchange.id}: model and replayed are required`);
168
+ }
169
+ return {
170
+ id: exchange.id,
171
+ question: exchange.question,
172
+ answer: exchange.answer,
173
+ answeredAt: exchange.answeredAt,
174
+ model: exchange.model,
175
+ replayed: exchange.replayed,
176
+ ...(exchange.promoted === true ? { promoted: true } : {}),
177
+ };
178
+ }