@matthewfl/pi-contemplator 0.1.7 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthewfl/pi-contemplator",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "A Pi extension that keeps long-running agentic sessions on track with background memory, contemplation, and structural review.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "typecheck": "tsc --noEmit",
42
42
  "test": "npm run test:unit && npm run test:e2e",
43
43
  "test:unit": "vitest run",
44
- "test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-summarizer.mjs && node tests/e2e/rpc-delivery.mjs && node tests/e2e/rpc-restore-review.mjs && node tests/e2e/rpc-compaction.mjs && node tests/e2e/rpc-compaction-resilience.mjs && node tests/e2e/rpc-memory-edges.mjs && node tests/e2e/rpc-observer-length.mjs && node tests/e2e/rpc-routing-isolation.mjs"
44
+ "test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-contemplator-history.mjs && node tests/e2e/rpc-summarizer.mjs && node tests/e2e/rpc-delivery.mjs && node tests/e2e/rpc-restore-review.mjs && node tests/e2e/rpc-compaction.mjs && node tests/e2e/rpc-compaction-resilience.mjs && node tests/e2e/rpc-memory-edges.mjs && node tests/e2e/rpc-observer-length.mjs && node tests/e2e/rpc-routing-isolation.mjs"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@earendil-works/pi-agent-core": "*",
@@ -2,7 +2,7 @@ import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage,
2
2
  import { Type, type Message, type Model } from "@earendil-works/pi-ai";
3
3
  import type { Static } from "typebox";
4
4
  import { streamSimple } from "@earendil-works/pi-ai/compat";
5
- import { generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
5
+ import { estimateTokens as estimateAgentMessageTokens, generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
6
6
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  import { Box, Text } from "@earendil-works/pi-tui";
8
8
  import { agentActiveTimeMs, assistantOutputTokens, assistantToolCallCount, fullProjection, isReviewRequestEntry, isReviewResultEntry, OM_AGENT_ACTIVITY, OM_REVIEWER_MESSAGE, OM_REVIEWER_NOTICE, OM_REVIEWER_STATE, OM_REVIEW_REQUEST, OM_REVIEW_RESULT, rawTokensSinceObservationCoverage, recallMemorySources, type Entry, type ReviewResult, type StructuralReviewRequest } from "../../session-ledger/index.js";
@@ -19,6 +19,66 @@ import { buildContemplatorSystemPrompt } from "./prompts.js";
19
19
  import { runStructuralReview } from "../reviewer/agent.js";
20
20
  import { createWorkerStallWatchdog } from "../../worker-watchdog.js";
21
21
 
22
+ const CONTEMPLATOR_HISTORY_FALLBACK_TRIGGER_TOKENS = 20_000;
23
+ const CONTEMPLATOR_HISTORY_MAX_TRIGGER_TOKENS = 40_000;
24
+ const CONTEMPLATOR_HISTORY_CONTEXT_FRACTION = 0.25;
25
+ const CONTEMPLATOR_HISTORY_KEEP_RECENT_TOKENS = 12_000;
26
+ const CONTEMPLATOR_HISTORY_SUMMARY_RESERVE_TOKENS = 16_000;
27
+ const CONTEMPLATOR_HISTORY_SUMMARY_INSTRUCTIONS = "This is an older prefix of a private contemplator transcript. Be concise. Preserve durable user intent, decisions, evidence, unresolved reasoning gaps, and review/probe outcomes. Newer transcript messages are retained verbatim after this checkpoint.";
28
+
29
+ function contemplatorMessageTokens(message: AgentMessage): number {
30
+ try {
31
+ return Math.max(1, estimateAgentMessageTokens(message));
32
+ } catch {
33
+ return Math.max(1, Math.ceil(JSON.stringify(message).length / 4));
34
+ }
35
+ }
36
+
37
+ function contemplatorHistoryTokens(history: readonly AgentMessage[]): number {
38
+ return history.reduce((total, message) => total + contemplatorMessageTokens(message), 0);
39
+ }
40
+
41
+ function contemplatorHistoryTriggerTokens(model: Model<any>): number {
42
+ const contextWindow = Number(model.contextWindow);
43
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return CONTEMPLATOR_HISTORY_FALLBACK_TRIGGER_TOKENS;
44
+ return Math.max(1, Math.min(CONTEMPLATOR_HISTORY_MAX_TRIGGER_TOKENS, Math.floor(contextWindow * CONTEMPLATOR_HISTORY_CONTEXT_FRACTION)));
45
+ }
46
+
47
+ /** Return a user-message boundary that leaves approximately targetTokens recent. */
48
+ function recentHistoryStart(history: readonly AgentMessage[], entryIds: readonly (string | undefined)[], targetTokens: number): number | undefined {
49
+ let tokens = 0;
50
+ let start = history.length;
51
+ while (start > 0 && tokens < targetTokens) {
52
+ start--;
53
+ tokens += contemplatorMessageTokens(history[start]);
54
+ }
55
+ while (start > 0 && history[start].role !== "user") start--;
56
+ // Retained messages are durable pointers, not copied payloads. If a legacy
57
+ // snapshot supplied messages without entry ids, retain only the newer complete
58
+ // updates whose original om.contemplator.message entries can be referenced.
59
+ let lastMissingId = -1;
60
+ for (let index = start; index < entryIds.length; index++) {
61
+ if (entryIds[index] === undefined) lastMissingId = index;
62
+ }
63
+ if (lastMissingId >= 0) {
64
+ start = lastMissingId + 1;
65
+ while (start < history.length && history[start].role !== "user") start++;
66
+ }
67
+ return start > 0 && start < history.length ? start : undefined;
68
+ }
69
+
70
+ /** Pick a smaller complete oldest prefix after a length-truncated first attempt. */
71
+ function smallerPrefixEnd(history: readonly AgentMessage[], initialEnd: number): number | undefined {
72
+ const target = Math.max(1, Math.floor(contemplatorHistoryTokens(history.slice(0, initialEnd)) / 2));
73
+ let tokens = 0;
74
+ for (let index = 0; index < initialEnd; index++) {
75
+ tokens += contemplatorMessageTokens(history[index]);
76
+ const next = index + 1;
77
+ if (tokens >= target && next < initialEnd && history[next].role === "user") return next;
78
+ }
79
+ return undefined;
80
+ }
81
+
22
82
  interface PendingUpdate {
23
83
  observations: string[];
24
84
  reviews: string[];
@@ -211,6 +271,8 @@ export function createRequestReviewTool(
211
271
 
212
272
  export class Contemplator {
213
273
  private history: AgentMessage[] = [];
274
+ /** Ledger entry id for each private-history message; used by compact checkpoints to retain a suffix without copying it. */
275
+ private historyEntryIds: Array<string | undefined> = [];
214
276
  private pending: PendingUpdate | undefined;
215
277
  private running = false;
216
278
  /** Invalidates stale/hard-timed-out flush finalizers across session changes. */
@@ -300,6 +362,7 @@ export class Contemplator {
300
362
  this.consecutiveFlushFailures = 0;
301
363
  this.agentActiveSince = undefined;
302
364
  this.history = [];
365
+ this.historyEntryIds = [];
303
366
  this.pending = undefined;
304
367
  this.seenObservationIds.clear();
305
368
  this.seenReviewIds.clear();
@@ -441,6 +504,8 @@ export class Contemplator {
441
504
  if (this.running && !resetTracking) return;
442
505
  if (tipId === this.restoredTipId && !resetTracking) return;
443
506
  this.history = [];
507
+ this.historyEntryIds = [];
508
+ const historyMessagesByEntryId = new Map<string, AgentMessage>();
444
509
  let resetProjection: ReturnType<typeof fullProjection> | undefined;
445
510
  if (resetTracking) {
446
511
  this.deliveredProbeIds.clear();
@@ -468,14 +533,27 @@ export class Contemplator {
468
533
  for (const entry of entries) {
469
534
  if (entry.customType === CONTEMPLATOR_STATE && entry.data && typeof entry.data === "object") {
470
535
  const state = entry.data as { history?: unknown };
471
- if (Array.isArray(state.history)) this.history = state.history.filter((message): message is AgentMessage => !!message && typeof message === "object");
536
+ if (Array.isArray(state.history)) {
537
+ this.history = state.history.filter((message): message is AgentMessage => !!message && typeof message === "object");
538
+ this.historyEntryIds = this.history.map(() => undefined);
539
+ }
472
540
  }
473
541
  if (entry.customType === CONTEMPLATOR_MESSAGE && entry.data && typeof entry.data === "object") {
474
- const data = entry.data as { message?: unknown; compacted?: unknown };
542
+ const data = entry.data as { message?: unknown; compacted?: unknown; retainedMessageEntryIds?: unknown };
475
543
  const message = data.message;
476
544
  if (message && typeof message === "object") {
477
- if (data.compacted === true) this.history = [message as AgentMessage];
478
- else this.history.push(message as AgentMessage);
545
+ const typedMessage = message as AgentMessage;
546
+ historyMessagesByEntryId.set(entry.id, typedMessage);
547
+ if (data.compacted === true) {
548
+ const retainedIds = Array.isArray(data.retainedMessageEntryIds)
549
+ ? data.retainedMessageEntryIds.filter((id): id is string => typeof id === "string" && historyMessagesByEntryId.has(id))
550
+ : [];
551
+ this.history = [typedMessage, ...retainedIds.map((id) => historyMessagesByEntryId.get(id)!)];
552
+ this.historyEntryIds = [entry.id, ...retainedIds];
553
+ } else {
554
+ this.history.push(typedMessage);
555
+ this.historyEntryIds.push(entry.id);
556
+ }
479
557
  }
480
558
  }
481
559
  if (entry.customType === OM_REVIEWER_STATE && entry.data && typeof entry.data === "object") {
@@ -862,7 +940,7 @@ export class Contemplator {
862
940
  for (const message of runMessages) {
863
941
  if (message.role !== "user" && message.role !== "assistant") continue;
864
942
  this.history.push(message);
865
- this.pi.appendEntry(CONTEMPLATOR_MESSAGE, { version: 1, message });
943
+ this.historyEntryIds.push(this.appendContemplatorHistoryEntry(ctx, { version: 1, message }));
866
944
  promptPersisted = true;
867
945
  this.markTipPersisted(ctx);
868
946
  }
@@ -892,7 +970,13 @@ export class Contemplator {
892
970
  }
893
971
  if (sessionGeneration === this.sessionGeneration) {
894
972
  workerWatchdog.progress();
895
- await workerWatchdog.race(this.compactHistory(resolved.model as Model<any>, resolved.apiKey, resolved.headers, sessionGeneration, flushEpoch));
973
+ try {
974
+ await workerWatchdog.race(this.compactHistory(ctx, resolved.model as Model<any>, resolved.apiKey, resolved.headers, sessionGeneration, flushEpoch, workerWatchdog.signal));
975
+ } catch (compactionError) {
976
+ // The intervention and its durable messages are already complete. Private
977
+ // history maintenance must never relabel that successful work as failed.
978
+ debugLog("contemplator.compaction_postponed", { reason: compactionError instanceof Error ? compactionError.message : String(compactionError) });
979
+ }
896
980
  }
897
981
  } catch (error) {
898
982
  failed = true;
@@ -1134,6 +1218,24 @@ export class Contemplator {
1134
1218
  }
1135
1219
  }
1136
1220
 
1221
+ /**
1222
+ * Append one private-history entry and attribute its synchronous ledger id.
1223
+ * Pi does not return the id from appendEntry(), so prefer the exact data
1224
+ * object and accept a cloned payload only when exactly one matching entry was
1225
+ * appended. Ambiguity returns undefined rather than pointing at another
1226
+ * writer's message.
1227
+ */
1228
+ private appendContemplatorHistoryEntry(ctx: MemoryUpdateCtx, data: Record<string, unknown>): string | undefined {
1229
+ const branch = ctx.sessionManager.getBranch() as readonly Entry[];
1230
+ const before = branch.length;
1231
+ this.pi.appendEntry(CONTEMPLATOR_MESSAGE, data);
1232
+ const candidates = (ctx.sessionManager.getBranch() as readonly Entry[])
1233
+ .slice(before)
1234
+ .filter((entry) => entry.customType === CONTEMPLATOR_MESSAGE);
1235
+ return candidates.find((entry) => entry.data === data)?.id
1236
+ ?? (candidates.length === 1 ? candidates[0].id : undefined);
1237
+ }
1238
+
1137
1239
  private markTipPersisted(ctx: MemoryUpdateCtx): string | undefined {
1138
1240
  this.restoredTipId = (ctx.sessionManager.getBranch() as Entry[]).at(-1)?.id;
1139
1241
  return this.restoredTipId;
@@ -1157,27 +1259,73 @@ export class Contemplator {
1157
1259
  return own?.id;
1158
1260
  }
1159
1261
 
1160
- private async compactHistory(model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number, flushEpoch: number): Promise<void> {
1161
- const serializedLength = this.history.reduce((total, message) => total + JSON.stringify(message).length, 0);
1162
- if (this.history.length < 12 || serializedLength < 60_000) return;
1163
- const previousMessageCount = this.history.length;
1262
+ private async compactHistory(ctx: MemoryUpdateCtx, model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number, flushEpoch: number, signal?: AbortSignal): Promise<void> {
1263
+ const history = this.history.slice();
1264
+ const historyEntryIds = this.historyEntryIds.slice();
1265
+ const historyTokens = contemplatorHistoryTokens(history);
1266
+ const triggerTokens = contemplatorHistoryTriggerTokens(model);
1267
+ if (history.length < 12 || historyTokens < triggerTokens) return;
1268
+
1269
+ const keepRecentTokens = Math.min(CONTEMPLATOR_HISTORY_KEEP_RECENT_TOKENS, Math.max(1, Math.floor(triggerTokens * 0.5)));
1270
+ const initialPrefixEnd = recentHistoryStart(history, historyEntryIds, keepRecentTokens);
1271
+ if (initialPrefixEnd === undefined) return;
1272
+ const previousMessageCount = history.length;
1164
1273
  debugLog("contemplator.compaction_start", {
1165
1274
  historyMessageCount: previousMessageCount,
1166
- serializedLength,
1275
+ historyTokens,
1276
+ triggerTokens,
1277
+ keepRecentTokens,
1278
+ prefixMessageCount: initialPrefixEnd,
1279
+ retainedMessageCount: history.length - initialPrefixEnd,
1167
1280
  });
1168
- const history = this.history.slice();
1169
- const summaryWithUsage = await generateSummaryWithUsage(history as AgentMessage[], model, 4_000, apiKey, headers);
1281
+
1282
+ const summarizePrefix = async (prefixEnd: number) => generateSummaryWithUsage(
1283
+ history.slice(0, prefixEnd),
1284
+ model,
1285
+ CONTEMPLATOR_HISTORY_SUMMARY_RESERVE_TOKENS,
1286
+ apiKey,
1287
+ headers,
1288
+ signal,
1289
+ CONTEMPLATOR_HISTORY_SUMMARY_INSTRUCTIONS,
1290
+ );
1291
+
1292
+ let prefixEnd = initialPrefixEnd;
1293
+ let summaryWithUsage: Awaited<ReturnType<typeof generateSummaryWithUsage>>;
1294
+ try {
1295
+ summaryWithUsage = await summarizePrefix(prefixEnd);
1296
+ } catch (error) {
1297
+ const failure = error instanceof Error ? error.message : String(error);
1298
+ const fallbackEnd = /generation hit the token cap/i.test(failure)
1299
+ ? smallerPrefixEnd(history, initialPrefixEnd)
1300
+ : undefined;
1301
+ if (fallbackEnd === undefined) {
1302
+ debugLog("contemplator.compaction_postponed", { reason: failure, historyTokens, prefixMessageCount: prefixEnd });
1303
+ return;
1304
+ }
1305
+ prefixEnd = fallbackEnd;
1306
+ debugLog("contemplator.compaction_retry_smaller_prefix", { reason: failure, prefixMessageCount: prefixEnd, retainedMessageCount: history.length - prefixEnd });
1307
+ try {
1308
+ summaryWithUsage = await summarizePrefix(prefixEnd);
1309
+ } catch (fallbackError) {
1310
+ debugLog("contemplator.compaction_postponed", {
1311
+ reason: fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
1312
+ historyTokens,
1313
+ prefixMessageCount: prefixEnd,
1314
+ });
1315
+ return;
1316
+ }
1317
+ }
1318
+
1170
1319
  this.runtime.recordAgentUsage(summaryWithUsage.usage);
1171
1320
  if (sessionGeneration !== this.sessionGeneration || flushEpoch !== this.flushEpoch) {
1172
1321
  debugLog("contemplator.compaction_stale", { reason: "session_or_branch_changed" });
1173
1322
  return;
1174
1323
  }
1175
- const summary = summaryWithUsage.text;
1176
1324
  const summaryModel = model as Model<any> & { api?: unknown; provider?: string; id?: string };
1177
1325
  const summaryUsage = summaryWithUsage.usage;
1178
- this.history = [{
1326
+ const summaryMessage = {
1179
1327
  role: "assistant",
1180
- content: [{ type: "text", text: `Previous contemplator context summary:\n${summary}` }],
1328
+ content: [{ type: "text", text: `Previous contemplator context summary:\n${summaryWithUsage.text}` }],
1181
1329
  api: summaryModel.api,
1182
1330
  provider: summaryModel.provider ?? "unknown",
1183
1331
  model: summaryModel.id ?? "contemplator",
@@ -1191,12 +1339,37 @@ export class Contemplator {
1191
1339
  },
1192
1340
  stopReason: "stop",
1193
1341
  timestamp: Date.now(),
1194
- } as AgentMessage];
1195
- this.pi.appendEntry(CONTEMPLATOR_MESSAGE, { version: 1, compacted: true, message: this.history[0] });
1342
+ } as AgentMessage;
1343
+ // A flush currently owns private-history mutation while this awaits the
1344
+ // model, and session/tree movement invalidates the generation above. Still,
1345
+ // preserve any future append-only writer rather than replacing a stale
1346
+ // snapshot and silently dropping messages. Non-append mutation is unsafe to
1347
+ // merge, so postpone and let a later pass compact the live transcript.
1348
+ const liveHistory = this.history;
1349
+ const liveHistoryEntryIds = this.historyEntryIds;
1350
+ const snapshotIsLivePrefix = liveHistory.length >= history.length
1351
+ && history.every((message, index) => liveHistory[index] === message && liveHistoryEntryIds[index] === historyEntryIds[index]);
1352
+ if (!snapshotIsLivePrefix) {
1353
+ debugLog("contemplator.compaction_postponed", { reason: "private history changed during compaction" });
1354
+ return;
1355
+ }
1356
+ const retainedMessages = liveHistory.slice(prefixEnd);
1357
+ const retainedMessageEntryIds = liveHistoryEntryIds.slice(prefixEnd).filter((id): id is string => typeof id === "string");
1358
+ if (retainedMessageEntryIds.length !== retainedMessages.length) {
1359
+ debugLog("contemplator.compaction_postponed", { reason: "retained history lacked durable entry ids", retainedMessageCount: retainedMessages.length, retainedReferenceCount: retainedMessageEntryIds.length });
1360
+ return;
1361
+ }
1362
+ const checkpoint = { version: 2, compacted: true, message: summaryMessage, retainedMessageEntryIds };
1363
+ const checkpointEntryId = this.appendContemplatorHistoryEntry(ctx, checkpoint);
1364
+ this.history = [summaryMessage, ...retainedMessages];
1365
+ this.historyEntryIds = [checkpointEntryId, ...retainedMessageEntryIds];
1366
+ this.markTipPersisted(ctx);
1196
1367
  debugLog("contemplator.compaction_complete", {
1197
1368
  previousMessageCount,
1198
1369
  newMessageCount: this.history.length,
1199
- summaryLength: summary.length,
1370
+ prefixMessageCount: prefixEnd,
1371
+ retainedMessageCount: retainedMessages.length,
1372
+ summaryLength: summaryWithUsage.text.length,
1200
1373
  });
1201
1374
  }
1202
1375
  }