pi-fold 0.0.1 → 0.1.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,479 @@
1
+ import {
2
+ denseOwnArrayValues,
3
+ evidenceRef,
4
+ evidenceValue,
5
+ isPlainRecord,
6
+ sha256Text,
7
+ stableStringify,
8
+ } from "../json.ts";
9
+ import {
10
+ bytes,
11
+ clone,
12
+ messageRole,
13
+ ownValue,
14
+ sessionEntryMessages,
15
+ } from "./canonical.ts";
16
+ import {
17
+ ACTIVE_CONTEXT_POLICY,
18
+ BYTES_PER_TOKEN_FLOOR,
19
+ DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
20
+ DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX,
21
+ DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
22
+ DEFAULT_CONTEXT_WINDOW,
23
+ READ_ONLY_TOOLS_DEFAULT,
24
+ } from "./policy.ts";
25
+ import type {
26
+ ActiveContextSnapshot,
27
+ BranchObject,
28
+ CompleteTurn,
29
+ MappedMessage,
30
+ } from "./policy.ts";
31
+
32
+ export function terminalAssistant(message: unknown): boolean {
33
+ if (messageRole(message) !== "assistant") return false;
34
+ const stop = ownValue(message, "stopReason");
35
+ return stop === "stop" || stop === "length";
36
+ }
37
+
38
+ export function completeTurns(messages: unknown[]): CompleteTurn[] {
39
+ const starts: number[] = [];
40
+ for (let index = 0; index < messages.length; index += 1) if (messageRole(messages[index]) === "user") starts.push(index);
41
+ const turns: CompleteTurn[] = [];
42
+ for (let cursor = 0; cursor < starts.length; cursor += 1) {
43
+ const start = starts[cursor];
44
+ const limit = starts[cursor + 1] ?? messages.length;
45
+ let end = limit;
46
+ while (end > start && (messageRole(messages[end - 1]) === "custom" ||
47
+ messageRole(messages[end - 1]) === "bashExecution")) end -= 1;
48
+ if (end > start && terminalAssistant(messages[end - 1])) turns.push({ start, end });
49
+ }
50
+ return turns;
51
+ }
52
+
53
+ /**
54
+ * Native compaction may resume the assistant after a summary without replaying
55
+ * the compacted user message. Once a later user message exists, that closed
56
+ * assistant/tool prefix is stale evidence, not part of the fresh user tail.
57
+ * Keep the native summary itself raw and require a terminal assistant boundary.
58
+ */
59
+ export function leadingCompactionContinuation(messages: unknown[]): CompleteTurn | null {
60
+ if (messageRole(messages[0]) !== "compactionSummary") return null;
61
+ let firstUser = -1;
62
+ for (let index = 0; index < messages.length; index += 1) {
63
+ if (messageRole(messages[index]) === "user") { firstUser = index; break; }
64
+ }
65
+ if (firstUser <= 1 || messageRole(messages[1]) !== "assistant") return null;
66
+ let end = firstUser;
67
+ while (end > 1 && (messageRole(messages[end - 1]) === "custom" ||
68
+ messageRole(messages[end - 1]) === "bashExecution")) end -= 1;
69
+ return end > 1 && terminalAssistant(messages[end - 1]) ? { start: 1, end } : null;
70
+ }
71
+
72
+ export function isReadOnlyContextTool(
73
+ name: string,
74
+ args?: unknown,
75
+ toolName = DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
76
+ readOnlyTools: ReadonlySet<string> = READ_ONLY_TOOLS_DEFAULT,
77
+ ): boolean {
78
+ if (readOnlyTools.has(name)) return true;
79
+ if (name !== toolName || !isPlainRecord(args) || ownValue(args, "action") !== "status") return false;
80
+ const allowed = new Set(["action", "offset", "limit"]);
81
+ const keys = Reflect.ownKeys(args);
82
+ for (let index = 0; index < keys.length; index += 1) {
83
+ if (typeof keys[index] !== "string" || !allowed.has(keys[index] as string)) return false;
84
+ }
85
+ return true;
86
+ }
87
+
88
+ export function exactToolCallParts(content: unknown): unknown[] | null {
89
+ const parts = denseOwnArrayValues(content);
90
+ if (!parts) return null;
91
+ const calls: unknown[] = [];
92
+ for (let index = 0; index < parts.length; index += 1) {
93
+ if (ownValue(parts[index], "type") === "toolCall") calls.push(parts[index]);
94
+ }
95
+ return calls;
96
+ }
97
+
98
+ export interface ValidatedToolBatch {
99
+ calls: Array<{ id: string; name: string; assistantIndex: number; resultIndex: number }>;
100
+ }
101
+
102
+ export interface ScannedToolBatches extends ValidatedToolBatch {
103
+ unsafeIndices: Set<number>;
104
+ }
105
+
106
+ /** Validate each same-assistant tool batch inside one complete turn; results never cross a batch. */
107
+ export function scanTurnToolBatches(
108
+ messages: unknown[],
109
+ turn: CompleteTurn,
110
+ allowIncomplete = false,
111
+ toolName = DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
112
+ readOnlyTools: ReadonlySet<string> = READ_ONLY_TOOLS_DEFAULT,
113
+ ): ScannedToolBatches | null {
114
+ if (!Number.isSafeInteger(turn.start) || !Number.isSafeInteger(turn.end) ||
115
+ turn.start < 0 || turn.end > messages.length || turn.start >= turn.end) return null;
116
+ const terminal = terminalAssistant(messages[turn.end - 1]);
117
+ if (!terminal && !allowIncomplete) return null;
118
+ const batches: Array<{
119
+ assistantIndex: number;
120
+ end: number;
121
+ safe: boolean;
122
+ calls: Array<{ id: string; name: string; assistantIndex: number; resultIndex: number }>;
123
+ }> = [];
124
+ const unsafeIndices = new Set<number>();
125
+ let index = turn.start;
126
+ while (index < turn.end) {
127
+ const message = messages[index];
128
+ const role = messageRole(message);
129
+ if (role === "assistant" && terminal && index === turn.end - 1) {
130
+ index += 1;
131
+ continue;
132
+ }
133
+ if (role !== "assistant" || ownValue(message, "stopReason") !== "toolUse") {
134
+ if (role === "assistant" || role === "toolResult") unsafeIndices.add(index);
135
+ index += 1;
136
+ continue;
137
+ }
138
+ let end = index + 1;
139
+ while (end < turn.end && messageRole(messages[end]) === "toolResult") end += 1;
140
+ const content = denseOwnArrayValues(ownValue(message, "content"));
141
+ const rawCalls = exactToolCallParts(ownValue(message, "content")) ?? [];
142
+ const calls = new Map<string, { id: string; name: string; assistantIndex: number }>();
143
+ const results = new Map<string, { name: string; resultIndex: number }>();
144
+ let safe = Boolean(content && rawCalls.length);
145
+ for (const part of rawCalls) {
146
+ const id = ownValue(part, "id");
147
+ const name = ownValue(part, "name");
148
+ if (typeof id !== "string" || !id || typeof name !== "string" || !name ||
149
+ calls.has(id) || !isReadOnlyContextTool(name, ownValue(part, "arguments"), toolName, readOnlyTools)) safe = false;
150
+ else calls.set(id, { id, name, assistantIndex: index });
151
+ }
152
+ for (let resultIndex = index + 1; resultIndex < end; resultIndex += 1) {
153
+ const result = messages[resultIndex];
154
+ const id = ownValue(result, "toolCallId");
155
+ const name = ownValue(result, "toolName");
156
+ if (typeof id !== "string" || !id || typeof name !== "string" || !name ||
157
+ ownValue(result, "isError") !== false || results.has(id)) safe = false;
158
+ else results.set(id, { name, resultIndex });
159
+ }
160
+ const validated: ScannedToolBatches["calls"] = [];
161
+ if (calls.size !== results.size) safe = false;
162
+ for (const call of calls.values()) {
163
+ const result = results.get(call.id);
164
+ if (!result || result.name !== call.name) safe = false;
165
+ else validated.push({ ...call, resultIndex: result.resultIndex });
166
+ }
167
+ batches.push({ assistantIndex: index, end, safe, calls: validated });
168
+ index = end;
169
+ }
170
+
171
+ const owners = new Map<string, number>();
172
+ for (let batchIndex = 0; batchIndex < batches.length; batchIndex += 1) {
173
+ for (const call of batches[batchIndex].calls) {
174
+ const owner = owners.get(call.id);
175
+ if (owner === undefined) owners.set(call.id, batchIndex);
176
+ else {
177
+ batches[owner].safe = false;
178
+ batches[batchIndex].safe = false;
179
+ }
180
+ }
181
+ }
182
+
183
+ const calls: ScannedToolBatches["calls"] = [];
184
+ for (const batch of batches) {
185
+ if (batch.safe) calls.push(...batch.calls);
186
+ else for (let unsafe = batch.assistantIndex; unsafe < batch.end; unsafe += 1) unsafeIndices.add(unsafe);
187
+ }
188
+ return { calls, unsafeIndices };
189
+ }
190
+
191
+ /** Validate a complete turn as wholly read-only. Granular callers may still use its safe batches. */
192
+ export function validateTurnToolBatch(
193
+ messages: unknown[],
194
+ turn: CompleteTurn,
195
+ toolName = DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
196
+ readOnlyTools: ReadonlySet<string> = READ_ONLY_TOOLS_DEFAULT,
197
+ ): ValidatedToolBatch | null {
198
+ const scanned = scanTurnToolBatches(messages, turn, false, toolName, readOnlyTools);
199
+ return scanned && scanned.unsafeIndices.size === 0 ? { calls: scanned.calls } : null;
200
+ }
201
+
202
+ export interface ChapterUnit {
203
+ start: number;
204
+ end: number;
205
+ turnStart: number;
206
+ }
207
+
208
+ /**
209
+ * Structural chapter segments are deliberately independent of terminal turns.
210
+ * A long-running user turn is one segment even while the coordinator continues
211
+ * issuing tools; a later user message closes the preceding segment without
212
+ * making that boundary either necessary or sufficient for folding.
213
+ */
214
+ export function chapterSegments(messages: unknown[]): CompleteTurn[] {
215
+ const starts: number[] = [];
216
+ for (let index = 0; index < messages.length; index += 1) {
217
+ if (messageRole(messages[index]) === "user") starts.push(index);
218
+ }
219
+ const segments: CompleteTurn[] = [];
220
+ if (messageRole(messages[0]) === "compactionSummary") {
221
+ const end = starts[0] ?? messages.length;
222
+ if (end > 1) segments.push({ start: 1, end });
223
+ }
224
+ for (let cursor = 0; cursor < starts.length; cursor += 1) {
225
+ const start = starts[cursor];
226
+ let end = starts[cursor + 1] ?? messages.length;
227
+ while (end > start && (messageRole(messages[end - 1]) === "custom" ||
228
+ messageRole(messages[end - 1]) === "bashExecution")) end -= 1;
229
+ if (end > start) segments.push({ start, end });
230
+ }
231
+ return segments;
232
+ }
233
+
234
+ /**
235
+ * Build indivisible structurally closed units. Chapters may summarize completed
236
+ * mutating and failed work, including an old prefix of the currently running
237
+ * turn. Every tool-use assistant must own one complete, unique result batch;
238
+ * any pending, unmatched, or unknown shape protects its entire segment.
239
+ */
240
+ export function structurallyClosedChapterUnits(messages: unknown[], segment: CompleteTurn): ChapterUnit[] {
241
+ const units: ChapterUnit[] = [];
242
+ const ownedCalls = new Set<string>();
243
+ for (let index = segment.start; index < segment.end;) {
244
+ const message = messages[index];
245
+ const role = messageRole(message);
246
+ if (role === "toolResult") {
247
+ index += 1;
248
+ continue;
249
+ }
250
+ if (role !== "assistant") {
251
+ units.push({ start: index, end: index + 1, turnStart: segment.start });
252
+ index += 1;
253
+ continue;
254
+ }
255
+ const stopReason = ownValue(message, "stopReason");
256
+ const content = denseOwnArrayValues(ownValue(message, "content"));
257
+ const rawCalls = exactToolCallParts(ownValue(message, "content")) ?? [];
258
+ const toolShaped = stopReason === "toolUse" || rawCalls.length > 0;
259
+ if (!toolShaped) {
260
+ if (stopReason === "stop" || stopReason === "length" ||
261
+ stopReason === "error" || stopReason === "aborted") {
262
+ units.push({ start: index, end: index + 1, turnStart: segment.start });
263
+ }
264
+ index += 1;
265
+ continue;
266
+ }
267
+ const calls = new Map<string, string>();
268
+ let safe = Boolean(content && rawCalls.length &&
269
+ (stopReason === "toolUse" || stopReason === "length" ||
270
+ stopReason === "error" || stopReason === "aborted"));
271
+ for (const part of rawCalls) {
272
+ const id = ownValue(part, "id");
273
+ const name = ownValue(part, "name");
274
+ if (typeof id !== "string" || !id || typeof name !== "string" || !name ||
275
+ calls.has(id) || ownedCalls.has(id)) safe = false;
276
+ else calls.set(id, name);
277
+ }
278
+ let end = index + 1;
279
+ const results = new Map<string, string>();
280
+ while (end < segment.end && messageRole(messages[end]) === "toolResult") {
281
+ const id = ownValue(messages[end], "toolCallId");
282
+ const name = ownValue(messages[end], "toolName");
283
+ if (typeof id !== "string" || !id || typeof name !== "string" || !name || results.has(id)) safe = false;
284
+ else results.set(id, name);
285
+ end += 1;
286
+ }
287
+ if (calls.size !== results.size) safe = false;
288
+ for (const [id, name] of calls) {
289
+ if (results.get(id) !== name) {
290
+ safe = false;
291
+ break;
292
+ }
293
+ }
294
+ if (safe) {
295
+ for (const id of calls.keys()) ownedCalls.add(id);
296
+ units.push({ start: index, end, turnStart: segment.start });
297
+ }
298
+ index = end;
299
+ }
300
+ return units;
301
+ }
302
+
303
+ export function unsafeChapterIndices(messages: unknown[]): Set<number> {
304
+ const eligible = new Set<number>();
305
+ const segments = chapterSegments(messages);
306
+ for (const segment of segments) {
307
+ const units = structurallyClosedChapterUnits(messages, segment);
308
+ for (const unit of units) for (let index = unit.start; index < unit.end; index += 1) eligible.add(index);
309
+ }
310
+ const unsafe = new Set<number>();
311
+ for (let index = 0; index < messages.length; index += 1) if (!eligible.has(index)) unsafe.add(index);
312
+ // The initiating request of the still-running segment remains raw even after
313
+ // its older completed work has moved beyond the byte tail. It carries the
314
+ // live marathon objective; subsequent structurally closed batches may fold.
315
+ const current = segments.length ? segments[segments.length - 1] : undefined;
316
+ if (current?.end === messages.length && !terminalAssistant(messages[current.end - 1]) &&
317
+ messageRole(messages[current.start]) === "user") unsafe.add(current.start);
318
+ return unsafe;
319
+ }
320
+
321
+ export function freshBoundary(messages: unknown[], turns: CompleteTurn[], policy: typeof ACTIVE_CONTEXT_POLICY): number {
322
+ if (!messages.length) return 0;
323
+ let unfinishedUser = messages.length;
324
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
325
+ if (messageRole(messages[index]) !== "user") continue;
326
+ let complete = false;
327
+ for (let turnIndex = 0; turnIndex < turns.length; turnIndex += 1) {
328
+ if (turns[turnIndex].start === index) { complete = true; break; }
329
+ }
330
+ unfinishedUser = complete ? messages.length : index;
331
+ break;
332
+ }
333
+ const leading = leadingCompactionContinuation(messages);
334
+ let boundary = turns.length >= policy.freshTurns
335
+ ? turns[turns.length - policy.freshTurns].start
336
+ : leading?.end ?? 0;
337
+ boundary = Math.min(boundary, unfinishedUser);
338
+ while (boundary > 0) {
339
+ const suffix: unknown[] = [];
340
+ for (let index = boundary; index < messages.length; index += 1) suffix.push(messages[index]);
341
+ if (bytes(suffix) >= policy.freshBytes) break;
342
+ let previous: CompleteTurn | null = null;
343
+ for (let turnIndex = 0; turnIndex < turns.length; turnIndex += 1) {
344
+ if (turns[turnIndex].start < boundary) previous = turns[turnIndex];
345
+ }
346
+ if (!previous) { boundary = 0; break; }
347
+ boundary = previous.start;
348
+ }
349
+ return boundary;
350
+ }
351
+
352
+ export function toolFreshIndices(
353
+ messages: unknown[],
354
+ turns: CompleteTurn[],
355
+ policy: typeof ACTIVE_CONTEXT_POLICY,
356
+ ): Set<number> {
357
+ const protectedIndices = new Set<number>();
358
+ const firstFreshTurn = Math.max(0, turns.length - policy.freshTurns);
359
+ for (let turnIndex = firstFreshTurn; turnIndex < turns.length; turnIndex += 1) {
360
+ const turn = turns[turnIndex];
361
+ for (let index = turn.start; index < turn.end; index += 1) protectedIndices.add(index);
362
+ }
363
+ let tailBytes = 0;
364
+ let boundary = messages.length;
365
+ while (boundary > 0 && tailBytes < policy.freshBytes) {
366
+ boundary -= 1;
367
+ tailBytes += bytes(messages[boundary]);
368
+ }
369
+ for (let index = boundary; index < messages.length; index += 1) protectedIndices.add(index);
370
+ return protectedIndices;
371
+ }
372
+
373
+ export function mapActiveContext(input: {
374
+ sessionId: string;
375
+ eventMessages: unknown[];
376
+ contextEntries: Array<Record<string, unknown>>;
377
+ projectEntry?: (entry: Record<string, unknown>) => unknown[];
378
+ policy?: Partial<typeof ACTIVE_CONTEXT_POLICY>;
379
+ toolName?: string;
380
+ brandNoun?: string;
381
+ entryTypePrefix?: string;
382
+ readOnlyTools?: ReadonlySet<string>;
383
+ contextWindow?: number;
384
+ }): ActiveContextSnapshot {
385
+ const policy = Object.freeze({ ...ACTIVE_CONTEXT_POLICY, ...(input.policy ?? {}) }) as typeof ACTIVE_CONTEXT_POLICY;
386
+ const projectEntry = input.projectEntry ?? sessionEntryMessages;
387
+ const branchObjects: BranchObject[] = [];
388
+ for (let entryIndex = 0; entryIndex < input.contextEntries.length; entryIndex += 1) {
389
+ const entry = input.contextEntries[entryIndex];
390
+ const entryId = typeof entry?.id === "string" ? entry.id : "";
391
+ if (!entryId) continue;
392
+ const projected = denseOwnArrayValues(projectEntry(entry));
393
+ if (!projected) continue;
394
+ for (let messageIndex = 0; messageIndex < projected.length; messageIndex += 1) {
395
+ const message = projected[messageIndex];
396
+ try {
397
+ branchObjects.push({
398
+ branchIndex: branchObjects.length,
399
+ message,
400
+ ref: evidenceRef(input.sessionId, entryId, message),
401
+ });
402
+ } catch {
403
+ // Unknown persisted shapes stay unavailable rather than weakening exact refs.
404
+ }
405
+ }
406
+ }
407
+
408
+ const mapped: MappedMessage[] = [];
409
+ let cursor = 0;
410
+ for (let index = 0; index < input.eventMessages.length; index += 1) {
411
+ const message = input.eventMessages[index];
412
+ let wire: string | null = null;
413
+ let digest: string | null = null;
414
+ try {
415
+ wire = stableStringify(evidenceValue(message));
416
+ digest = sha256Text(wire);
417
+ } catch {
418
+ mapped.push({ index, message, ref: null });
419
+ continue;
420
+ }
421
+ let match = -1;
422
+ for (let branchIndex = cursor; branchIndex < branchObjects.length; branchIndex += 1) {
423
+ const candidate = branchObjects[branchIndex];
424
+ if (candidate.ref.sha256 === digest && stableStringify(evidenceValue(candidate.message)) === wire) {
425
+ match = branchIndex;
426
+ break;
427
+ }
428
+ }
429
+ if (match < 0) mapped.push({ index, message, ref: null });
430
+ else {
431
+ mapped.push({ index, message, ref: branchObjects[match].ref });
432
+ cursor = match + 1;
433
+ }
434
+ }
435
+
436
+ const reportedContextWindow = typeof input.contextWindow === "number" &&
437
+ Number.isFinite(input.contextWindow) && input.contextWindow > 0
438
+ ? input.contextWindow
439
+ : null;
440
+ // A fixed byte floor can dominate a small or shrunken window. Cap the protected
441
+ // tail at freshWindowShare of the window via a conservative bytes-per-token
442
+ // floor: this binds only on genuinely small windows and never widens the
443
+ // protected tail.
444
+ const tailPolicy = reportedContextWindow === null ? policy : Object.freeze({
445
+ ...policy,
446
+ freshBytes: Math.min(policy.freshBytes, Math.floor(
447
+ reportedContextWindow * policy.freshWindowShare * BYTES_PER_TOKEN_FLOOR)),
448
+ });
449
+ const turns = completeTurns(input.eventMessages);
450
+ const boundary = freshBoundary(input.eventMessages, turns, tailPolicy);
451
+ const protectedIndices = unsafeChapterIndices(input.eventMessages);
452
+ const toolProtectedIndices = toolFreshIndices(input.eventMessages, turns, tailPolicy);
453
+ // Chapter protection is set-shaped: preserve the newest complete turns and
454
+ // raw byte tail, while allowing an older closed prefix of the current turn.
455
+ for (const index of toolProtectedIndices) protectedIndices.add(index);
456
+ for (let mappedIndex = 0; mappedIndex < mapped.length; mappedIndex += 1) {
457
+ const item = mapped[mappedIndex];
458
+ if (item.ref) continue;
459
+ protectedIndices.add(item.index);
460
+ toolProtectedIndices.add(item.index);
461
+ }
462
+ return {
463
+ sessionId: input.sessionId,
464
+ messages: clone(input.eventMessages),
465
+ mapped,
466
+ branchObjects,
467
+ completeTurns: turns,
468
+ freshBoundary: boundary,
469
+ protectedIndices,
470
+ toolProtectedIndices,
471
+ policy: tailPolicy,
472
+ toolName: input.toolName ?? DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
473
+ brandNoun: input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
474
+ entryTypePrefix: input.entryTypePrefix ?? DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX,
475
+ readOnlyTools: input.readOnlyTools ?? READ_ONLY_TOOLS_DEFAULT,
476
+ contextWindow: reportedContextWindow ?? DEFAULT_CONTEXT_WINDOW,
477
+ windowSource: reportedContextWindow === null ? "fallback" : "reported",
478
+ };
479
+ }
@@ -0,0 +1,95 @@
1
+ const loadPiHost = () => import("@earendil-works/pi-coding-agent");
2
+
3
+ function normalizeSummarizer(summarizer) {
4
+ if (summarizer === "session" || summarizer === "deterministic") return summarizer;
5
+ if (!summarizer || typeof summarizer !== "object" || Array.isArray(summarizer) ||
6
+ typeof summarizer.provider !== "string" || !summarizer.provider ||
7
+ typeof summarizer.model !== "string" || !summarizer.model ||
8
+ (summarizer.effort !== undefined &&
9
+ (typeof summarizer.effort !== "string" || !summarizer.effort))) {
10
+ throw new Error(
11
+ 'summarizer must be "session", "deterministic", or an object with nonempty provider and model strings',
12
+ );
13
+ }
14
+ return {
15
+ provider: summarizer.provider,
16
+ model: summarizer.model,
17
+ ...(summarizer.effort === undefined ? {} : { effort: summarizer.effort }),
18
+ };
19
+ }
20
+
21
+ export function createSummarizeContextSpan(summarizer = "session", loadHostModule = loadPiHost) {
22
+ const configured = normalizeSummarizer(summarizer);
23
+ if (configured === "deterministic") return undefined;
24
+ if (typeof loadHostModule !== "function") {
25
+ throw new Error("Summarizer host-module loader must be a function");
26
+ }
27
+
28
+ let runtimePromise;
29
+ const getRuntime = () => {
30
+ runtimePromise ??= Promise.resolve().then(async () => {
31
+ const host = await loadHostModule();
32
+ if (typeof host?.ModelRuntime?.create !== "function") {
33
+ throw new Error("Pi host module does not export ModelRuntime.create()");
34
+ }
35
+ return host.ModelRuntime.create();
36
+ });
37
+ return runtimePromise;
38
+ };
39
+
40
+ return async (request, ctx) => {
41
+ if (typeof request?.sourceText !== "string" ||
42
+ !Number.isInteger(request?.maxBriefChars) || request.maxBriefChars < 1) {
43
+ throw new Error("Summarizer request requires sourceText and a positive maxBriefChars integer");
44
+ }
45
+
46
+ let model;
47
+ if (configured === "session") {
48
+ if (ctx?.model === undefined) {
49
+ throw new Error("Session summarizer requires an active session model");
50
+ }
51
+ model = ctx.model;
52
+ } else {
53
+ const runtime = await getRuntime();
54
+ model = runtime.getModel(configured.provider, configured.model);
55
+ if (!model) {
56
+ throw new Error(`Summarizer model '${configured.provider}/${configured.model}' was not found`);
57
+ }
58
+ }
59
+
60
+ const runtime = await getRuntime();
61
+ const effort = configured !== "session" && configured.effort !== undefined
62
+ ? configured.effort
63
+ : typeof ctx?.thinkingLevel === "string" && ctx.thinkingLevel
64
+ ? ctx.thinkingLevel
65
+ : "none";
66
+ const prompt = `Write a factual brief of at most ${request.maxBriefChars} characters. ` +
67
+ `Use no preamble and no Markdown headers.\n\n${request.sourceText}`;
68
+ const completionOptions = { maxTokens: 512, signal: request.signal };
69
+ if (model.reasoning && effort !== "none" && effort !== "off") {
70
+ completionOptions.reasoning = effort;
71
+ }
72
+ const response = await runtime.completeSimple(model, {
73
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
74
+ }, completionOptions);
75
+ const brief = Array.isArray(response?.content)
76
+ ? response.content
77
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
78
+ .map((part) => part.text)
79
+ .join("")
80
+ .trim()
81
+ : "";
82
+ if (!brief) throw new Error("Summarizer returned no text");
83
+ if (typeof model.provider !== "string" || !model.provider ||
84
+ typeof model.id !== "string" || !model.id) {
85
+ throw new Error("Resolved summarizer model lacks provider/id attribution");
86
+ }
87
+ return {
88
+ brief,
89
+ provider: model.provider,
90
+ model: model.id,
91
+ effort,
92
+ toolCalls: 0,
93
+ };
94
+ };
95
+ }
package/package.json CHANGED
@@ -1,16 +1,53 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "0.0.1",
4
- "description": "Lossless, agent-governed context folding for the Pi coding agent \u2014 only the oldest context changes; the fresh window stays untouched. Name reservation: first release imminent.",
5
- "main": "index.js",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/shaneconner/pi-fold.git"
3
+ "version": "0.1.1",
4
+ "description": "Lossless, agent-governed context folding for the Pi coding agent — only the oldest context changes; the fresh window stays untouched.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "context",
9
+ "context-management",
10
+ "folding",
11
+ "coding-agent"
12
+ ],
13
+ "type": "module",
14
+ "exports": {
15
+ ".": "./extensions/index.js",
16
+ "./package.json": "./package.json"
9
17
  },
18
+ "private": false,
19
+ "repository": "https://github.com/shaneconner/pi-fold",
20
+ "bugs": "https://github.com/shaneconner/pi-fold/issues",
21
+ "homepage": "https://github.com/shaneconner/pi-fold#readme",
10
22
  "author": "Shane Conner",
11
- "license": "UNLICENSED",
23
+ "license": "MIT",
12
24
  "files": [
13
- "index.js",
14
- "README.md"
15
- ]
16
- }
25
+ "extensions",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "scripts": {
33
+ "lint": "eslint . --max-warnings 0",
34
+ "test": "node tests/verify.mjs"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-coding-agent": ">=0.83.0 <1"
38
+ },
39
+ "devDependencies": {
40
+ "@eslint/js": "^10.0.1",
41
+ "@stylistic/eslint-plugin": "^5.10.0",
42
+ "@typescript-eslint/eslint-plugin": "^8.66.0",
43
+ "@typescript-eslint/parser": "^8.66.0",
44
+ "eslint": "^10.8.0",
45
+ "globals": "^17.9.0",
46
+ "jiti": "^2.7.0"
47
+ },
48
+ "pi": {
49
+ "extensions": [
50
+ "./extensions"
51
+ ]
52
+ }
53
+ }
package/index.js DELETED
@@ -1,4 +0,0 @@
1
- throw new Error(
2
- "pi-fold 0.0.1 is a name reservation, not a release. " +
3
- "The first working release lands at https://github.com/shaneconner/pi-fold shortly."
4
- );