@matthewfl/pi-contemplator 0.0.9 → 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.
Files changed (44) hide show
  1. package/README.md +17 -11
  2. package/package.json +8 -6
  3. package/src/agents/contemplator/agent.ts +325 -91
  4. package/src/agents/contemplator/prompts.ts +6 -6
  5. package/src/agents/observer/agent.ts +14 -6
  6. package/src/agents/observer/prompts.ts +16 -7
  7. package/src/agents/reviewer/agent.ts +24 -4
  8. package/src/agents/reviewer/prompts.ts +1 -1
  9. package/src/agents/reviewer/tools.ts +24 -9
  10. package/src/agents/stream-errors.ts +1 -1
  11. package/src/agents/summarizer/agent.ts +597 -0
  12. package/src/agents/summarizer/prompts.ts +46 -0
  13. package/src/agents/summarizer/sampling.ts +80 -0
  14. package/src/commands/contemplator-view.ts +22 -1
  15. package/src/commands/settings.ts +73 -69
  16. package/src/commands/status.ts +60 -36
  17. package/src/commands/summarizer-view.ts +58 -0
  18. package/src/commands/view.ts +22 -10
  19. package/src/config.ts +25 -32
  20. package/src/hooks/compaction-hook.ts +36 -19
  21. package/src/hooks/compaction-resume.ts +4 -4
  22. package/src/hooks/compaction-trigger.ts +96 -56
  23. package/src/hooks/consolidation-trigger.ts +213 -196
  24. package/src/memory-citations.ts +37 -0
  25. package/src/required-tool-choice.ts +28 -0
  26. package/src/runtime.ts +116 -33
  27. package/src/session-ledger/fold.ts +82 -53
  28. package/src/session-ledger/index.ts +1 -0
  29. package/src/session-ledger/pools.ts +77 -0
  30. package/src/session-ledger/progress.ts +7 -18
  31. package/src/session-ledger/projection.ts +45 -177
  32. package/src/session-ledger/recall.ts +129 -127
  33. package/src/session-ledger/render-summary.ts +20 -19
  34. package/src/session-ledger/search.ts +99 -115
  35. package/src/session-ledger/types.ts +102 -75
  36. package/src/tools/compact-context.ts +1 -1
  37. package/src/tools/recall-observation.ts +99 -459
  38. package/src/tools/search-memories.ts +31 -72
  39. package/src/agents/dropper/agent.ts +0 -291
  40. package/src/agents/dropper/coverage.ts +0 -128
  41. package/src/agents/dropper/pool.ts +0 -67
  42. package/src/agents/dropper/prompts.ts +0 -48
  43. package/src/agents/reflector/agent.ts +0 -213
  44. package/src/agents/reflector/prompts.ts +0 -81
@@ -0,0 +1,80 @@
1
+ import { estimateStringTokens } from "../../tokens.js";
2
+ import type { Observation, Summary } from "../../session-ledger/index.js";
3
+
4
+ export type SummarizerMemory =
5
+ | { kind: "observation"; memory: Observation }
6
+ | { kind: "summary"; memory: Summary };
7
+
8
+ export type SummarizerSample = {
9
+ memories: SummarizerMemory[];
10
+ sampled: boolean;
11
+ eligibleCount: number;
12
+ selectedCount: number;
13
+ eligibleTokens: number;
14
+ selectedTokens: number;
15
+ budgetTokens: number;
16
+ };
17
+
18
+ export type SummarizerSamplingArgs = {
19
+ memories: SummarizerMemory[];
20
+ /** Maximum rendered old-memory input tokens before sampling. */
21
+ samplingThresholdTokens?: number;
22
+ random?: () => number;
23
+ };
24
+
25
+ type Candidate = {
26
+ item: SummarizerMemory;
27
+ tokens: number;
28
+ weight: number;
29
+ };
30
+
31
+ export function renderSummarizerMemory(item: SummarizerMemory): string {
32
+ if (item.kind === "observation") {
33
+ const memory = item.memory;
34
+ return `[${memory.id}] observation ${memory.timestamp} relevance=${memory.relevance}: ${memory.content}`;
35
+ }
36
+ return `[${item.memory.id}] summary ${item.memory.timestamp} sources=[${item.memory.sourceMemoryIds.join(", ")}]: ${item.memory.content}`;
37
+ }
38
+
39
+ function tokenCost(item: SummarizerMemory): number {
40
+ return Math.max(1, estimateStringTokens(renderSummarizerMemory(item)) + 4);
41
+ }
42
+
43
+ /** Weighted random order without replacement. Weight is exactly inverse length. */
44
+ function weightedOrder(candidates: Candidate[], random: () => number): Candidate[] {
45
+ return candidates
46
+ .map((candidate) => ({ candidate, priority: -Math.log(Math.max(Number.EPSILON, random())) / candidate.weight }))
47
+ .sort((a, b) => a.priority - b.priority || a.candidate.item.memory.id.localeCompare(b.candidate.item.memory.id))
48
+ .map(({ candidate }) => candidate);
49
+ }
50
+
51
+ export function sampleSummarizerMemories(args: SummarizerSamplingArgs): SummarizerSample {
52
+ const random = args.random ?? Math.random;
53
+ const budgetTokens = Math.max(1, Math.floor(args.samplingThresholdTokens ?? 60_000));
54
+ const candidates = args.memories.map((item): Candidate => {
55
+ const tokens = tokenCost(item);
56
+ return { item, tokens, weight: 1 / tokens };
57
+ });
58
+ const eligibleTokens = candidates.reduce((sum, candidate) => sum + candidate.tokens, 0);
59
+ const sampled = eligibleTokens > budgetTokens;
60
+ const selected = sampled ? [] as Candidate[] : candidates;
61
+ if (sampled) {
62
+ let used = 0;
63
+ for (const candidate of weightedOrder(candidates, random)) {
64
+ if (candidate.tokens > budgetTokens - used) continue;
65
+ selected.push(candidate);
66
+ used += candidate.tokens;
67
+ }
68
+ }
69
+ const selectedIds = new Set(selected.map((candidate) => candidate.item.memory.id));
70
+ const memories = args.memories.filter((item) => selectedIds.has(item.memory.id));
71
+ return {
72
+ memories,
73
+ sampled,
74
+ eligibleCount: candidates.length,
75
+ selectedCount: selected.length,
76
+ eligibleTokens,
77
+ selectedTokens: selected.reduce((sum, candidate) => sum + candidate.tokens, 0),
78
+ budgetTokens,
79
+ };
80
+ }
@@ -1,3 +1,4 @@
1
+ import type { ContemplatorRunState } from "../runtime.js";
1
2
  import type { Entry } from "../session-ledger/index.js";
2
3
 
3
4
  const CONTEMPLATOR_MESSAGE = "om.contemplator.message";
@@ -57,7 +58,26 @@ function renderMessage(message: StoredMessage, compacted: boolean): string {
57
58
  return `${DIM}── ${role}${marker} · ~${tokens} tokens ──${RESET}\n${renderContent(message.content) || `${DIM}(empty message)${RESET}`}`;
58
59
  }
59
60
 
60
- export function renderContemplator(entries: Entry[]): string {
61
+ function liveStateLine(state: ContemplatorRunState): string {
62
+ const pending = `${state.pendingObservations} observations / ${state.pendingSummaries} summaries / ${state.pendingReviews} reviews pending`;
63
+ const timing = `Last start: ${state.lastStartedAt === undefined ? "not run this launch" : new Date(state.lastStartedAt).toISOString()} · Last end: ${state.lastCompletedAt === undefined ? "not completed this launch" : new Date(state.lastCompletedAt).toISOString()}`;
64
+ const error = state.lastError ? `\nLast error: ${state.lastError}` : "";
65
+ if (state.running) return `LIVE · running for ${Math.max(0, Math.floor((Date.now() - (state.lastStartedAt ?? Date.now())) / 60_000))}m · ${pending}\n${timing}${error}`;
66
+ const reason = state.waitingFor === "memories"
67
+ ? "waiting for memory threshold"
68
+ : state.waitingFor === "responses"
69
+ ? "waiting for response spacing"
70
+ : state.waitingFor === "ready"
71
+ ? "ready to launch"
72
+ : state.waitingFor === "disabled"
73
+ ? "disabled"
74
+ : state.waitingFor === "passive"
75
+ ? "passive mode"
76
+ : "idle";
77
+ return `LIVE · ${reason} · ${pending} · ${state.responsesSinceRun} primary responses since last run\n${timing}${error}`;
78
+ }
79
+
80
+ export function renderContemplator(entries: Entry[], state?: ContemplatorRunState): string {
61
81
  const messages: Array<{ message: StoredMessage; compacted: boolean }> = [];
62
82
  const suggestions: Array<{ suggestion: string; delivered: boolean }> = [];
63
83
  const reviews: Array<{ requestId: string; scope: string; outcome: string; memoryId?: string }> = [];
@@ -102,6 +122,7 @@ export function renderContemplator(entries: Entry[]): string {
102
122
  const totalTokens = messages.reduce((total, item) => total + estimateTokens(item.message), 0);
103
123
  const lines = [
104
124
  `${DIM}CONTEMPLATOR · ${messages.length} messages · ~${totalTokens} estimated tokens${RESET}`,
125
+ ...(state ? [`${DIM}${liveStateLine(state)}${RESET}`] : []),
105
126
  "",
106
127
  ];
107
128
  if (messages.length === 0) {
@@ -9,8 +9,8 @@ type ModelRegistryLike = {
9
9
  getAvailable(): Array<{ provider: string; id: string }>;
10
10
  getAll(): Array<{ provider: string; id: string }>;
11
11
  };
12
- type NumberSetting = "observeAfterTokens" | "reflectAfterTokens" | "compactAfterTokens" | "observerChunkMaxTokens" | "observationsPoolMaxTokens" | "observationsPoolTargetTokens" | "agentMaxTurns" | "contemplatorMinNewObservations" | "contemplatorMinNewReflections" | "contemplatorMinTurns";
13
- type BooleanSetting = "contemplatorEnabled" | "showContemplatorMessages" | "reviewerEnabled" | "compactionObserverEnabled" | "showWorkerNotifications" | "passive" | "debugLog";
12
+ type NumberSetting = "observeAfterTokens" | "compactAfterTokens" | "observerChunkMaxTokens" | "newMemoryPoolMaxTokens" | "oldMemoryPoolTargetTokens" | "agentMaxTurns" | "contemplatorMinNewObservations" | "contemplatorMinNewSummaries" | "contemplatorMinTurns" | "summarizerRetriggerTokens" | "summarizerSamplingThresholdTokens";
13
+ type BooleanSetting = "contemplatorEnabled" | "showContemplatorMessages" | "reviewerEnabled" | "summarizerEnabled" | "compactionObserverEnabled" | "showWorkerNotifications" | "passive" | "debugLog";
14
14
 
15
15
  function modelLabel(model: ConfiguredModel | undefined): string {
16
16
  return model ? `${model.provider}/${model.id}` : "current session model";
@@ -20,9 +20,12 @@ function branch(ctx: ExtensionContext): readonly unknown[] {
20
20
  return ctx.sessionManager.getBranch() as readonly unknown[];
21
21
  }
22
22
 
23
- function appendSettings(pi: ExtensionAPI, runtime: Runtime, settings: SessionSettings): void {
23
+ function appendSettings(pi: ExtensionAPI, runtime: Runtime, settings: SessionSettings, ctx?: ExtensionContext): void {
24
24
  runtime.setSessionSettings(settings);
25
25
  pi.appendEntry(OM_SETTINGS, { version: 1, ...settings });
26
+ // Settings that affect worker eligibility should take effect immediately,
27
+ // rather than waiting for an unrelated observer batch or session restart.
28
+ if (ctx) runtime.notifySettingsUpdate(ctx, settings);
26
29
  }
27
30
 
28
31
  function hasOverride(settings: SessionSettings, key: string): boolean {
@@ -33,7 +36,12 @@ function scalarLabel(runtime: Runtime, key: NumberSetting | BooleanSetting | "co
33
36
  const current = runtime.config[key];
34
37
  const defaultValue = runtime.getDefaultConfig()[key];
35
38
  const renderedDefault = defaultValue === undefined ? "derived" : String(defaultValue);
36
- return hasOverride(runtime.getSessionSettings(), key) ? String(current) : `default (${renderedDefault})`;
39
+ return hasOverride(runtime.getSessionSettings(), key) ? String(current) : `${renderedDefault} (default)`;
40
+ }
41
+
42
+ function extensionEnabledLabel(runtime: Runtime): string {
43
+ const enabled = !runtime.config.passive;
44
+ return hasOverride(runtime.getSessionSettings(), "passive") ? String(enabled) : `${enabled} (default)`;
37
45
  }
38
46
 
39
47
  interface ModelOption extends SelectItem {
@@ -124,28 +132,17 @@ async function chooseModel(ctx: ExtensionContext, current: ConfiguredModel | und
124
132
  }
125
133
 
126
134
  async function editNumber(ctx: ExtensionContext, runtime: Runtime, key: NumberSetting, title: string): Promise<number | undefined> {
127
- const value = await ctx.ui.input(`${title} (current: ${scalarLabel(runtime, key)})`, "positive integer; blank cancels");
135
+ const requirement = "positive integer";
136
+ const value = await ctx.ui.input(`${title} (current: ${scalarLabel(runtime, key)})`, `${requirement}; blank cancels`);
128
137
  if (value === undefined || value.trim() === "") return undefined;
129
138
  const parsed = Number(value.trim());
130
139
  if (!Number.isInteger(parsed) || parsed <= 0) {
131
- ctx.ui.notify("Value must be a positive integer.", "warning");
140
+ ctx.ui.notify(`Value must be a ${requirement}.`, "warning");
132
141
  return undefined;
133
142
  }
134
143
  return parsed;
135
144
  }
136
145
 
137
- function validObservationPoolOverride(ctx: ExtensionContext, runtime: Runtime, key: NumberSetting, value: number): boolean {
138
- if (key === "observationsPoolMaxTokens" && value <= runtime.config.observationsPoolTargetTokens) {
139
- ctx.ui.notify("Observation pool max must be greater than the current target. Lower the target first.", "warning");
140
- return false;
141
- }
142
- if (key === "observationsPoolTargetTokens" && value >= runtime.config.observationsPoolMaxTokens) {
143
- ctx.ui.notify("Observation pool target must be less than the current maximum.", "warning");
144
- return false;
145
- }
146
- return true;
147
- }
148
-
149
146
  export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): void {
150
147
  const restoreSettings = (_event: unknown, ctx: ExtensionContext) => {
151
148
  runtime.ensureConfig(ctx.cwd);
@@ -155,19 +152,24 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
155
152
  pi.on("session_tree", restoreSettings);
156
153
 
157
154
  pi.registerCommand("om:settings", {
158
- description: "Configure observational memory for this session",
155
+ description: "Configure pi-contemplator for this session",
159
156
  handler: async (args, ctx) => {
160
157
  runtime.ensureConfig(ctx.cwd);
161
158
  runtime.restoreSessionSettings(branch(ctx));
162
159
  const argument = typeof args === "string" ? args.trim().toLowerCase() : "";
163
160
  if (argument === "on" || argument === "off") {
164
161
  appendSettings(pi, runtime, { contemplatorEnabled: argument === "on" });
165
- ctx.ui.notify(`Contemplation: ${argument === "on" ? "enabled" : "disabled"} for this session.`, "info");
162
+ ctx.ui.notify(`Contemplator: ${argument === "on" ? "enabled" : "disabled"} for this session.`, "info");
166
163
  return;
167
164
  }
168
165
  if (argument === "compaction on" || argument === "compaction off") {
169
166
  appendSettings(pi, runtime, { compactionObserverEnabled: argument.endsWith("on") });
170
- ctx.ui.notify(`Compaction observer: ${argument.endsWith("on") ? "enabled" : "disabled"} for this session.`, "info");
167
+ ctx.ui.notify(`Observe source during compaction: ${argument.endsWith("on") ? "enabled" : "disabled"} for this session.`, "info");
168
+ return;
169
+ }
170
+ if (argument === "summarizer on" || argument === "summarizer off") {
171
+ appendSettings(pi, runtime, { summarizerEnabled: argument.endsWith("on") }, ctx);
172
+ ctx.ui.notify(`Summarizer: ${argument.endsWith("on") ? "enabled" : "disabled"} for this session.`, "info");
171
173
  return;
172
174
  }
173
175
  if (argument === "reviewer on" || argument === "reviewer off") {
@@ -181,81 +183,83 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
181
183
  return;
182
184
  }
183
185
  if (argument) {
184
- ctx.ui.notify("Usage: /om:settings [on|off|messages on|messages off|reviewer on|reviewer off|compaction on|compaction off]", "info");
186
+ ctx.ui.notify("Usage: /om:settings [on|off|messages on|messages off|summarizer on|summarizer off|reviewer on|reviewer off|compaction on|compaction off]", "info");
185
187
  return;
186
188
  }
187
189
 
188
190
  while (true) {
189
191
  const settings = runtime.getSessionSettings();
190
- const choice = await ctx.ui.select("Observational memory settings (session overrides)", [
191
- `Contemplation: ${scalarLabel(runtime, "contemplatorEnabled")}`,
192
- `Contemplation model: ${hasOverride(settings, "contemplatorModel") ? modelLabel(runtime.config.contemplatorModel) : `default (${modelLabel(runtime.getDefaultConfig().contemplatorModel)})`}`,
193
- `Contemplator messages visible: ${scalarLabel(runtime, "showContemplatorMessages")}`,
194
- `Structural reviewer: ${scalarLabel(runtime, "reviewerEnabled")}`,
195
- `Structural reviewer model: ${hasOverride(settings, "reviewerModel") ? modelLabel(runtime.config.reviewerModel) : `default (${modelLabel(runtime.getDefaultConfig().reviewerModel)})`}`,
196
- `Compaction observer: ${scalarLabel(runtime, "compactionObserverEnabled")}`,
197
- `Memory worker model: ${hasOverride(settings, "model") ? modelLabel(runtime.config.model) : `default (${modelLabel(runtime.getDefaultConfig().model)})`}`,
198
- `Observation threshold: ${scalarLabel(runtime, "observeAfterTokens")}`,
199
- `Reflection threshold: ${scalarLabel(runtime, "reflectAfterTokens")}`,
200
- `Compaction threshold: ${scalarLabel(runtime, "compactAfterTokens")}`,
201
- `Compaction mode: ${hasOverride(settings, "compactAfterTokensMode") ? runtime.config.compactAfterTokensMode : `default (${runtime.getDefaultConfig().compactAfterTokensMode})`}`,
202
- `Compaction ratio: ${hasOverride(settings, "compactAfterTokensRatio") ? runtime.config.compactAfterTokensRatio : `default (${runtime.getDefaultConfig().compactAfterTokensRatio})`}`,
203
- `Observer chunk limit: ${scalarLabel(runtime, "observerChunkMaxTokens")}`,
204
- `Observation pool max: ${scalarLabel(runtime, "observationsPoolMaxTokens")}`,
205
- `Observation pool target: ${scalarLabel(runtime, "observationsPoolTargetTokens")}`,
206
- `Worker max turns: ${scalarLabel(runtime, "agentMaxTurns")}`,
207
- `Contemplation observation trigger: ${scalarLabel(runtime, "contemplatorMinNewObservations")}`,
208
- `Contemplation reflection trigger: ${scalarLabel(runtime, "contemplatorMinNewReflections")}`,
209
- `Contemplation turn interval: ${scalarLabel(runtime, "contemplatorMinTurns")}`,
192
+ const choice = await ctx.ui.select("pi-contemplator settings (session overrides)", [
193
+ `Pi-contemplator Enabled: ${extensionEnabledLabel(runtime)}`,
194
+ `Contemplator enabled: ${scalarLabel(runtime, "contemplatorEnabled")}`,
195
+ `Contemplator model: ${hasOverride(settings, "contemplatorModel") ? modelLabel(runtime.config.contemplatorModel) : `${modelLabel(runtime.getDefaultConfig().contemplatorModel)} (default)`}`,
196
+ `Show contemplator messages: ${scalarLabel(runtime, "showContemplatorMessages")}`,
197
+ `Contemplator new-observation trigger (count): ${scalarLabel(runtime, "contemplatorMinNewObservations")}`,
198
+ `Contemplator new-summary trigger (count): ${scalarLabel(runtime, "contemplatorMinNewSummaries")}`,
199
+ `Contemplator response spacing (count): ${scalarLabel(runtime, "contemplatorMinTurns")}`,
200
+ `Summarizer enabled: ${scalarLabel(runtime, "summarizerEnabled")}`,
201
+ `New memory pool protection budget (tokens): ${scalarLabel(runtime, "newMemoryPoolMaxTokens")}`,
202
+ `Old memory pool target (tokens, advisory): ${scalarLabel(runtime, "oldMemoryPoolTargetTokens")}`,
203
+ `Summarizer old-pool retrigger growth (tokens): ${scalarLabel(runtime, "summarizerRetriggerTokens")}`,
204
+ `Summarizer input cap before sampling (tokens): ${scalarLabel(runtime, "summarizerSamplingThresholdTokens")}`,
205
+ `Structural reviewer enabled: ${scalarLabel(runtime, "reviewerEnabled")}`,
206
+ `Structural reviewer model: ${hasOverride(settings, "reviewerModel") ? modelLabel(runtime.config.reviewerModel) : `${modelLabel(runtime.getDefaultConfig().reviewerModel)} (default)`}`,
207
+ `Observe source during compaction: ${scalarLabel(runtime, "compactionObserverEnabled")}`,
208
+ `Observer and summarizer model: ${hasOverride(settings, "model") ? modelLabel(runtime.config.model) : `${modelLabel(runtime.getDefaultConfig().model)} (default)`}`,
209
+ `Observer source backlog trigger (tokens): ${scalarLabel(runtime, "observeAfterTokens")}`,
210
+ `Observer input cap (tokens): ${scalarLabel(runtime, "observerChunkMaxTokens")}`,
211
+ `Observer and summarizer max rounds: ${scalarLabel(runtime, "agentMaxTurns")}`,
212
+ `Automatic compaction source backlog trigger (tokens): ${scalarLabel(runtime, "compactAfterTokens")}`,
213
+ `Automatic compaction threshold mode: ${hasOverride(settings, "compactAfterTokensMode") ? runtime.config.compactAfterTokensMode : `${runtime.getDefaultConfig().compactAfterTokensMode} (default)`}`,
214
+ `Automatic compaction source-backlog context ratio: ${hasOverride(settings, "compactAfterTokensRatio") ? runtime.config.compactAfterTokensRatio : `${runtime.getDefaultConfig().compactAfterTokensRatio} (default)`}`,
210
215
  `Worker notifications: ${scalarLabel(runtime, "showWorkerNotifications")}`,
211
- `Passive mode: ${scalarLabel(runtime, "passive")}`,
212
216
  `Debug logging: ${scalarLabel(runtime, "debugLog")}`,
213
217
  "Done",
214
218
  ]);
215
219
  if (!choice || choice === "Done") return;
216
- if (choice.startsWith("Contemplation:")) appendSettings(pi, runtime, { contemplatorEnabled: !runtime.config.contemplatorEnabled });
217
- else if (choice.startsWith("Contemplator messages visible:")) appendSettings(pi, runtime, { showContemplatorMessages: !runtime.config.showContemplatorMessages });
218
- else if (choice.startsWith("Structural reviewer:")) appendSettings(pi, runtime, { reviewerEnabled: !runtime.config.reviewerEnabled });
219
- else if (choice.startsWith("Compaction observer:")) appendSettings(pi, runtime, { compactionObserverEnabled: !runtime.config.compactionObserverEnabled });
220
+ if (choice.startsWith("Pi-contemplator Enabled:")) appendSettings(pi, runtime, { passive: !runtime.config.passive });
221
+ else if (choice.startsWith("Contemplator enabled:")) appendSettings(pi, runtime, { contemplatorEnabled: !runtime.config.contemplatorEnabled });
222
+ else if (choice.startsWith("Summarizer enabled:")) appendSettings(pi, runtime, { summarizerEnabled: !runtime.config.summarizerEnabled }, ctx);
223
+ else if (choice.startsWith("Show contemplator messages:")) appendSettings(pi, runtime, { showContemplatorMessages: !runtime.config.showContemplatorMessages });
224
+ else if (choice.startsWith("Structural reviewer enabled:")) appendSettings(pi, runtime, { reviewerEnabled: !runtime.config.reviewerEnabled });
225
+ else if (choice.startsWith("Observe source during compaction:")) appendSettings(pi, runtime, { compactionObserverEnabled: !runtime.config.compactionObserverEnabled });
220
226
  else if (choice.startsWith("Worker notifications:")) appendSettings(pi, runtime, { showWorkerNotifications: !runtime.config.showWorkerNotifications });
221
- else if (choice.startsWith("Passive mode:")) appendSettings(pi, runtime, { passive: !runtime.config.passive });
222
227
  else if (choice.startsWith("Debug logging:")) appendSettings(pi, runtime, { debugLog: !runtime.config.debugLog });
223
- else if (choice.startsWith("Contemplation model:")) {
224
- const model = await chooseModel(ctx, runtime.config.contemplatorModel, "Contemplation model");
228
+ else if (choice.startsWith("Contemplator model:")) {
229
+ const model = await chooseModel(ctx, runtime.config.contemplatorModel, "Contemplator model");
225
230
  if (model !== undefined) appendSettings(pi, runtime, { contemplatorModel: model });
226
231
  } else if (choice.startsWith("Structural reviewer model:")) {
227
232
  const model = await chooseModel(ctx, runtime.config.reviewerModel, "Structural reviewer model");
228
233
  if (model !== undefined) appendSettings(pi, runtime, { reviewerModel: model });
229
- } else if (choice.startsWith("Memory worker model:")) {
230
- const model = await chooseModel(ctx, runtime.config.model, "Memory worker model");
234
+ } else if (choice.startsWith("Observer and summarizer model:")) {
235
+ const model = await chooseModel(ctx, runtime.config.model, "Observer and summarizer model");
231
236
  if (model !== undefined) appendSettings(pi, runtime, { model });
232
- } else if (choice.startsWith("Compaction mode:")) {
233
- const mode = await ctx.ui.select("Compaction threshold mode", ["calibrated", "ratio"]);
237
+ } else if (choice.startsWith("Automatic compaction threshold mode:")) {
238
+ const mode = await ctx.ui.select("Automatic compaction threshold mode", ["calibrated", "ratio"]);
234
239
  if (mode === "calibrated" || mode === "ratio") appendSettings(pi, runtime, { compactAfterTokensMode: mode });
235
- } else if (choice.startsWith("Compaction ratio:")) {
236
- const value = await ctx.ui.input(`Compaction ratio (current: ${scalarLabel(runtime, "compactAfterTokensRatio")})`, "decimal between 0 and 1");
240
+ } else if (choice.startsWith("Automatic compaction context ratio:")) {
241
+ const value = await ctx.ui.input(`Automatic compaction source-backlog context ratio (current: ${scalarLabel(runtime, "compactAfterTokensRatio")})`, "decimal between 0 and 1");
237
242
  const ratio = value === undefined ? undefined : Number(value.trim());
238
243
  if (ratio !== undefined && Number.isFinite(ratio) && ratio > 0 && ratio < 1) appendSettings(pi, runtime, { compactAfterTokensRatio: ratio });
239
244
  else if (value !== undefined) ctx.ui.notify("Ratio must be a number between 0 and 1.", "warning");
240
245
  } else {
241
246
  const numberChoice: Array<[string, NumberSetting, string]> = [
242
- ["Observation threshold:", "observeAfterTokens", "Observation threshold"],
243
- ["Reflection threshold:", "reflectAfterTokens", "Reflection threshold"],
244
- ["Compaction threshold:", "compactAfterTokens", "Compaction threshold"],
245
- ["Observer chunk limit:", "observerChunkMaxTokens", "Observer chunk limit"],
246
- ["Observation pool max:", "observationsPoolMaxTokens", "Observation pool max"],
247
- ["Observation pool target:", "observationsPoolTargetTokens", "Observation pool target"],
248
- ["Worker max turns:", "agentMaxTurns", "Worker max turns"],
249
- ["Contemplation observation trigger:", "contemplatorMinNewObservations", "Contemplation observation trigger"],
250
- ["Contemplation reflection trigger:", "contemplatorMinNewReflections", "Contemplation reflection trigger"],
251
- ["Contemplation turn interval:", "contemplatorMinTurns", "Contemplation turn interval"],
247
+ ["Observer source backlog trigger (tokens):", "observeAfterTokens", "Observer source backlog trigger (tokens)"],
248
+ ["Observer input cap (tokens):", "observerChunkMaxTokens", "Observer input cap (tokens)"],
249
+ ["Observer and summarizer max rounds:", "agentMaxTurns", "Observer and summarizer max rounds"],
250
+ ["Automatic compaction source backlog trigger (tokens):", "compactAfterTokens", "Automatic compaction source backlog trigger (tokens)"],
251
+ ["New memory pool protection budget (tokens):", "newMemoryPoolMaxTokens", "New memory pool protection budget (tokens)"],
252
+ ["Old memory pool target (tokens, advisory):", "oldMemoryPoolTargetTokens", "Old memory pool target (tokens, advisory)"],
253
+ ["Contemplator new-observation trigger (count):", "contemplatorMinNewObservations", "Contemplator new-observation trigger (count)"],
254
+ ["Contemplator new-summary trigger (count):", "contemplatorMinNewSummaries", "Contemplator new-summary trigger (count)"],
255
+ ["Contemplator response spacing (count):", "contemplatorMinTurns", "Contemplator response spacing (count)"],
256
+ ["Summarizer old-pool retrigger growth (tokens):", "summarizerRetriggerTokens", "Summarizer old-pool retrigger growth (tokens)"],
257
+ ["Summarizer input cap before sampling (tokens):", "summarizerSamplingThresholdTokens", "Summarizer input cap before sampling (tokens)"],
252
258
  ];
253
259
  const selected = numberChoice.find(([prefix]) => choice.startsWith(prefix));
254
260
  if (selected) {
255
261
  const value = await editNumber(ctx, runtime, selected[1], selected[2]);
256
- if (value !== undefined && validObservationPoolOverride(ctx, runtime, selected[1], value)) {
257
- appendSettings(pi, runtime, { [selected[1]]: value } as SessionSettings);
258
- }
262
+ if (value !== undefined) appendSettings(pi, runtime, { [selected[1]]: value } as SessionSettings, ctx);
259
263
  }
260
264
  }
261
265
  }
@@ -1,5 +1,4 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { observationPoolMetrics } from "../agents/dropper/pool.js";
3
2
  import { resolveCompactAfterTokens } from "../config.js";
4
3
  import type { Runtime } from "../runtime.js";
5
4
  import {
@@ -7,9 +6,9 @@ import {
7
6
  diffProjection,
8
7
  foldLedger,
9
8
  fullProjection,
9
+ partitionMemoryPools,
10
10
  rawTokensSinceLastCompaction,
11
11
  rawTokensSinceObservationCoverage,
12
- rawTokensSinceReflectionCoverage,
13
12
  visibleProjection,
14
13
  type Entry,
15
14
  } from "../session-ledger/index.js";
@@ -37,6 +36,22 @@ function formatDuration(durationMs: number): string {
37
36
  return `${seconds}s`;
38
37
  }
39
38
 
39
+ function formatRunAge(timestamp: number): string {
40
+ return `${new Date(timestamp).toISOString()} (${formatDuration(Math.max(0, Date.now() - timestamp))} ago)`;
41
+ }
42
+
43
+ function contemplatorWaitingLabel(waitingFor: Runtime["contemplatorState"]["waitingFor"]): string {
44
+ switch (waitingFor) {
45
+ case "memories": return "waiting for memory threshold";
46
+ case "responses": return "waiting for response spacing";
47
+ case "ready": return "ready to launch";
48
+ case "running": return "running";
49
+ case "disabled": return "disabled";
50
+ case "passive": return "passive mode";
51
+ default: return "idle; no pending memories";
52
+ }
53
+ }
54
+
40
55
  function truncateStatusText(value: string, limit = 1_000): string {
41
56
  return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
42
57
  }
@@ -60,7 +75,7 @@ function appendSuffixes(line: string, suffixes: (string | undefined)[]): string
60
75
 
61
76
  export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void {
62
77
  pi.registerCommand("om:status", {
63
- description: "Show observational memory status",
78
+ description: "Show pi-contemplator status",
64
79
  handler: async (_args, ctx) => {
65
80
  runtime.ensureConfig(ctx.cwd);
66
81
  const entries = ctx.sessionManager.getBranch() as Entry[];
@@ -70,21 +85,17 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
70
85
  const drift = diffProjection(visible, full);
71
86
 
72
87
  const visibleObservationTokens = tokenSum(visible.observations);
73
- const visibleReflectionTokens = tokenSum(visible.reflections);
74
- const activeObservationPool = observationPoolMetrics(folded.activeObservations, runtime.config.observationsPoolTargetTokens);
88
+ const visibleSummaryTokens = tokenSum(visible.summaries);
89
+ const pools = partitionMemoryPools(folded.activeObservations, folded.activeSummaries, runtime.config.newMemoryPoolMaxTokens);
75
90
  const observationLine = appendSuffixes(
76
- `Observations: ${folded.observations.length} recorded / ${folded.droppedObservationIds.size} dropped / ${folded.activeObservations.length} active / ${visible.observations.length} visible`,
77
- [
78
- addedSuffix(drift.observationsOnlyInFull.length),
79
- removedSuffix(drift.droppedOnlyInFull.length),
80
- ],
91
+ `Observations: ${folded.observations.length} recorded / ${folded.activeObservations.length} active / ${visible.observations.length} visible`,
92
+ [addedSuffix(drift.observationsOnlyInFull.length), removedSuffix(drift.observationsOnlyInVisible.length)],
81
93
  );
82
- const reflectionLine = appendSuffixes(
83
- `Reflections: ${folded.reflections.length} recorded / ${visible.reflections.length} visible`,
84
- [addedSuffix(drift.reflectionsOnlyInFull.length)],
94
+ const summaryLine = appendSuffixes(
95
+ `Summaries: ${folded.summaries.length} recorded / ${folded.activeSummaries.length} active / ${visible.summaries.length} visible`,
96
+ [addedSuffix(drift.summariesOnlyInFull.length), removedSuffix(drift.summariesOnlyInVisible.length)],
85
97
  );
86
98
  const obsProgress = rawTokensSinceObservationCoverage(entries);
87
- const reflectionProgress = rawTokensSinceReflectionCoverage(entries);
88
99
  const compactionProgress = rawTokensSinceLastCompaction(entries);
89
100
  const contextWindow = typeof ctx.model?.contextWindow === "number" ? ctx.model.contextWindow : undefined;
90
101
  const compactThreshold = resolveCompactAfterTokens(runtime.config, contextWindow);
@@ -97,22 +108,27 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
97
108
  ]
98
109
  : [];
99
110
 
111
+ const summarizerTrigger = runtime.summarizerNextTriggerTokens ?? runtime.config.oldMemoryPoolTargetTokens;
112
+ const summarizerSamplingTokens = runtime.config.summarizerSamplingThresholdTokens;
100
113
  const lines = [
101
114
  ...passiveLines,
102
115
  "── Memory ──",
103
116
  observationLine,
104
- reflectionLine,
117
+ summaryLine,
105
118
  "",
106
119
  "── Activity ──",
107
- `Next observation: ~${obsProgress.toLocaleString()} / ${runtime.config.observeAfterTokens.toLocaleString()} tokens (${pct(obsProgress, runtime.config.observeAfterTokens)}%)`,
108
- `Next reflection: ~${reflectionProgress.toLocaleString()} / ${runtime.config.reflectAfterTokens.toLocaleString()} tokens (${pct(reflectionProgress, runtime.config.reflectAfterTokens)}%)`,
109
- `Next compaction: ~${compactionProgress.toLocaleString()} / ${compactThreshold.toLocaleString()} tokens (${pct(compactionProgress, compactThreshold)}%)`,
110
- `Visible observation pool: ~${visibleObservationTokens.toLocaleString()} / ${runtime.config.observationsPoolMaxTokens.toLocaleString()} tokens (${pct(visibleObservationTokens, runtime.config.observationsPoolMaxTokens)}%)`,
111
- `Active observation pool: ~${activeObservationPool.observationTokens.toLocaleString()} / ${runtime.config.observationsPoolTargetTokens.toLocaleString()} target tokens (${pct(activeObservationPool.observationTokens, runtime.config.observationsPoolTargetTokens)}%)`,
112
- `Reflection pool: ~${visibleReflectionTokens.toLocaleString()} tokens`,
120
+ `Observer source backlog: ~${obsProgress.toLocaleString()} / ${runtime.config.observeAfterTokens.toLocaleString()} tokens (${pct(obsProgress, runtime.config.observeAfterTokens)}%)`,
121
+ `Summarizer trigger: old pool ~${pools.oldTokens.toLocaleString()} / ${summarizerTrigger.toLocaleString()} tokens (${pct(pools.oldTokens, summarizerTrigger)}%)`,
122
+ `Automatic compaction source backlog: ~${compactionProgress.toLocaleString()} / ${compactThreshold.toLocaleString()} tokens (${pct(compactionProgress, compactThreshold)}%; injected memory excluded)`,
123
+ `Visible observation pool: ~${visibleObservationTokens.toLocaleString()} tokens`,
124
+ `New memory pool: ~${pools.newTokens.toLocaleString()} / ${runtime.config.newMemoryPoolMaxTokens.toLocaleString()} protection-budget tokens (${pct(pools.newTokens, runtime.config.newMemoryPoolMaxTokens)}%; newest memory always protected whole)`,
125
+ `Old memory pool: ~${pools.oldTokens.toLocaleString()} / ${runtime.config.oldMemoryPoolTargetTokens.toLocaleString()} advisory target tokens (${pct(pools.oldTokens, runtime.config.oldMemoryPoolTargetTokens)}%)`,
126
+ `Summary pool: ~${visibleSummaryTokens.toLocaleString()} visible tokens`,
127
+ `Summarizer: ${runtime.config.summarizerEnabled === false ? "disabled" : "enabled"}; retrigger after +${runtime.config.summarizerRetriggerTokens.toLocaleString()} old-pool tokens / sample above ~${summarizerSamplingTokens.toLocaleString()} tokens`,
113
128
  `Cumulative agent time: ${formatDuration(agentActiveTimeMs(entries))}`,
114
- `Compaction observer: ${runtime.config.compactionObserverEnabled === false ? "disabled" : "enabled"}`,
129
+ `Observe source during compaction: ${runtime.config.compactionObserverEnabled === false ? "disabled" : "enabled"}`,
115
130
  `Contemplator: ${runtime.config.contemplatorEnabled ? "enabled" : "disabled"}`,
131
+ `Contemplator trigger: ${runtime.contemplatorState.pendingObservations} observations / ${runtime.contemplatorState.pendingSummaries} summaries / ${runtime.contemplatorState.pendingReviews} reviews pending; ${runtime.contemplatorState.responsesSinceRun} / ${runtime.config.contemplatorMinTurns} primary responses; ${contemplatorWaitingLabel(runtime.contemplatorState.waitingFor)}`,
116
132
  `Contemplator model: ${runtime.config.contemplatorModel ? `${runtime.config.contemplatorModel.provider}/${runtime.config.contemplatorModel.id}` : "current session model"}`,
117
133
  `Contemplator messages: ${runtime.config.showContemplatorMessages ? "visible" : "hidden"}`,
118
134
  `Structural reviewer: ${runtime.config.reviewerEnabled === false ? "disabled" : "enabled"}`,
@@ -124,6 +140,15 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
124
140
  lines.push(`Token usage: ↑${formatTokens(u.input)} ↓${formatTokens(u.output)}${u.cacheRead ? ` R${formatTokens(u.cacheRead)}` : ""}${u.cacheWrite ? ` W${formatTokens(u.cacheWrite)}` : ""} $${u.cost.toFixed(3)} (${u.runs} call${u.runs === 1 ? "" : "s"})`);
125
141
  }
126
142
 
143
+ lines.push("", "── Last worker runs ──");
144
+ lines.push(`Last observer start: ${runtime.lastObserverStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.lastObserverStartedAt)}`);
145
+ lines.push(`Last observer end: ${runtime.lastObserverCompletedAt === undefined ? "not completed this launch" : formatRunAge(runtime.lastObserverCompletedAt)}`);
146
+ lines.push(`Last summarizer start: ${runtime.lastSummarizerStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.lastSummarizerStartedAt)}`);
147
+ lines.push(`Last summarizer end: ${runtime.lastSummarizerCompletedAt === undefined ? "not completed this launch" : formatRunAge(runtime.lastSummarizerCompletedAt)}`);
148
+ lines.push(`Last contemplator start: ${runtime.contemplatorState.lastStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.contemplatorState.lastStartedAt)}`);
149
+ lines.push(`Last contemplator end: ${runtime.contemplatorState.lastCompletedAt === undefined ? "not completed this launch" : formatRunAge(runtime.contemplatorState.lastCompletedAt)}`);
150
+
151
+ lines.push("", "── Interventions ──");
127
152
  // Probe stats come from the branch ledger (like /om:view contemplator):
128
153
  // deduped by probeId so restore re-queues don't inflate the count, and
129
154
  // entries without a probeId (sent before probe tracking existed) count
@@ -146,12 +171,12 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
146
171
  probeSuggestions[existingIndex] = { suggestion: data.suggestion };
147
172
  }
148
173
  }
149
- if (probeSuggestions.length > 0) {
150
- lines.push(`Probes sent: ${probeSuggestions.length}`);
151
- lines.push(`Last probe: ${probeSuggestions[probeSuggestions.length - 1].suggestion}`);
152
- }
174
+ lines.push(`Probes sent: ${probeSuggestions.length}`);
175
+ if (probeSuggestions.length > 0) lines.push(`Last probe: ${probeSuggestions[probeSuggestions.length - 1].suggestion}`);
153
176
 
154
- const latestReview = full.reviews?.at(-1);
177
+ const reviews = full.reviews ?? [];
178
+ lines.push(`Reviews completed: ${reviews.length}`);
179
+ const latestReview = reviews.at(-1);
155
180
  if (latestReview) {
156
181
  lines.push(`Last review: [${latestReview.id}] ${latestReview.scope} ${latestReview.outcome}`);
157
182
  if (latestReview.outcome === "proposal") lines.push(`Last review summary: ${truncateStatusText(latestReview.summary)}`);
@@ -165,22 +190,21 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
165
190
  }
166
191
  if (latestNotice) lines.push(`Last reviewer notice: ${truncateStatusText(latestNotice)}`);
167
192
 
168
- if (runtime.consolidationInFlight || runtime.compactInFlight || runtime.compactHookInFlight || runtime.reviewInFlight) {
193
+ if (runtime.consolidationInFlight || runtime.summarizerInFlight || runtime.contemplatorState.running || runtime.compactInFlight || runtime.compactHookInFlight || runtime.reviewInFlight) {
169
194
  lines.push("", "── In flight ──");
170
- if (runtime.consolidationInFlight) {
171
- const phase = runtime.consolidationPhase ? ` (${runtime.consolidationPhase})` : "";
172
- lines.push(`Consolidation: running${phase}`);
173
- }
174
- if (runtime.compactInFlight) lines.push("Auto-compaction: running");
195
+ if (runtime.consolidationInFlight) lines.push("Observer: running");
196
+ if (runtime.summarizerInFlight) lines.push("Summarizer: running");
197
+ if (runtime.contemplatorState.running) lines.push("Contemplator: running");
198
+ if (runtime.compactInFlight) lines.push("Automatic compaction: running");
175
199
  if (runtime.compactHookInFlight) lines.push("Compaction hook: running");
176
200
  if (runtime.reviewInFlight) lines.push("Structural review: running");
177
201
  }
178
202
 
179
- if (runtime.lastObserverError || runtime.lastReflectorError || runtime.lastDropperError) {
203
+ if (runtime.lastObserverError || runtime.lastSummarizerError || runtime.contemplatorState.lastError) {
180
204
  lines.push("", "── Last error ──");
181
205
  if (runtime.lastObserverError) lines.push(`Observer: ${runtime.lastObserverError}`);
182
- if (runtime.lastReflectorError) lines.push(`Reflector: ${runtime.lastReflectorError}`);
183
- if (runtime.lastDropperError) lines.push(`Dropper: ${runtime.lastDropperError}`);
206
+ if (runtime.lastSummarizerError) lines.push(`Summarizer: ${runtime.lastSummarizerError}`);
207
+ if (runtime.contemplatorState.lastError) lines.push(`Contemplator: ${runtime.contemplatorState.lastError}`);
184
208
  }
185
209
 
186
210
  ctx.ui.notify(lines.join("\n"), "info");
@@ -0,0 +1,58 @@
1
+ import type { SummarizerRunView } from "../runtime.js";
2
+
3
+ const DIM = "\x1b[2m";
4
+ const RESET = "\x1b[0m";
5
+
6
+ type StoredMessage = { role?: unknown; content?: unknown };
7
+ type ContentPart = { type?: unknown; text?: unknown; thinking?: unknown; name?: unknown; arguments?: unknown; content?: unknown };
8
+
9
+ function renderValue(value: unknown): string {
10
+ if (typeof value === "string") return value;
11
+ if (value === undefined || value === null) return "";
12
+ return JSON.stringify(value, null, 2);
13
+ }
14
+
15
+ function renderContent(content: unknown): string {
16
+ if (typeof content === "string") return content;
17
+ if (!Array.isArray(content)) return renderValue(content);
18
+ return content.map((part: ContentPart) => {
19
+ if (part.type === "text") return typeof part.text === "string" ? part.text : "";
20
+ if (part.type === "thinking") {
21
+ const thinking = typeof part.thinking === "string" ? part.thinking : typeof part.text === "string" ? part.text : renderValue(part);
22
+ return `[thinking]\n${thinking}`;
23
+ }
24
+ if (part.type === "toolCall" || part.type === "tool_use" || part.type === "toolUse") {
25
+ const name = typeof part.name === "string" ? part.name : "unknown tool";
26
+ return `[tool call: ${name}${part.arguments === undefined ? "" : ` ${renderValue(part.arguments)}`}]`;
27
+ }
28
+ if (part.type === "toolResult" || part.type === "tool_result") return `[tool result]\n${renderValue(part.content)}`;
29
+ return `[${String(part.type ?? "content")}] ${renderValue(part)}`;
30
+ }).filter(Boolean).join("\n");
31
+ }
32
+
33
+ function estimateTokens(value: unknown): number {
34
+ return Math.max(1, Math.ceil(JSON.stringify(value).length / 4));
35
+ }
36
+
37
+ /** Render the most recent launch-local summarizer transcript. */
38
+ export function renderSummarizer(run: SummarizerRunView | undefined): string {
39
+ if (!run) return `${DIM}SUMMARIZER${RESET}\n\n${DIM}Summarizer has not run yet during this launch.${RESET}`;
40
+ const messages = run.messages.filter((message): message is StoredMessage => !!message && typeof message === "object");
41
+ const totalTokens = messages.reduce((sum, message) => sum + estimateTokens(message), 0);
42
+ const lines = [
43
+ `${DIM}SUMMARIZER · ${run.status} · ${messages.length} messages · ~${totalTokens.toLocaleString()} estimated tokens${RESET}`,
44
+ `${DIM}Started ${new Date(run.startedAt).toISOString()}${RESET}`,
45
+ `${DIM}${run.completedAt === undefined ? "Not completed" : `Ended ${new Date(run.completedAt).toISOString()}`}${RESET}`,
46
+ "",
47
+ ];
48
+ if (messages.length === 0) lines.push(`${DIM}(no summarizer messages captured yet)${RESET}`);
49
+ for (const [index, message] of messages.entries()) {
50
+ if (index > 0) lines.push("");
51
+ const role = typeof message.role === "string" ? message.role : "unknown";
52
+ lines.push(`${DIM}── ${role} · ~${estimateTokens(message).toLocaleString()} tokens ──${RESET}`);
53
+ lines.push(renderContent(message.content) || `${DIM}(empty message)${RESET}`);
54
+ }
55
+ if (run.summary) lines.push("", `${DIM}── Completion summary ──${RESET}`, run.summary);
56
+ if (run.error) lines.push("", `${DIM}── Failure ──${RESET}`, run.error);
57
+ return lines.join("\n");
58
+ }