@matthewfl/pi-contemplator 0.0.10 → 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.
Files changed (44) hide show
  1. package/README.md +12 -12
  2. package/package.json +6 -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 +96 -39
  6. package/src/agents/observer/prompts.ts +19 -10
  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 +95 -70
  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 +30 -37
  20. package/src/hooks/compaction-hook.ts +32 -17
  21. package/src/hooks/compaction-resume.ts +4 -4
  22. package/src/hooks/compaction-trigger.ts +33 -11
  23. package/src/hooks/consolidation-trigger.ts +245 -215
  24. package/src/memory-citations.ts +37 -0
  25. package/src/required-tool-choice.ts +28 -0
  26. package/src/runtime.ts +115 -32
  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 +8 -19
  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 +103 -77
  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
@@ -1,31 +1,32 @@
1
- import type { Observation, Reflection } from "./types.js";
1
+ import { chronologicalMemories } from "./pools.js";
2
+ import type { Observation, Summary } from "./types.js";
2
3
 
3
- const CONTEXT_USAGE_INSTRUCTIONS = `These are condensed memories from earlier in this session.
4
+ const CONTEXT_USAGE_INSTRUCTIONS = `These are memories from earlier in this session, shown together in chronological order.
4
5
 
5
- - Reflections: stable, long-lived facts about the user, project, decisions, and constraints. New reflection lines may include ids in brackets.
6
- - Observations: timestamped events from the conversation history, in chronological order. Observation lines include ids in brackets.
6
+ - Summaries are cited, compressed memories. Their lines include their own ids in leading brackets and source citations inside the text.
7
+ - Observations are timestamped records from conversation history. Their lines include ids in leading brackets.
7
8
 
8
- Treat these as past records. When entries conflict, the most recent observation reflects the latest known state. Work that prior observations describe as completed should not be redone unless the user explicitly asks to revisit it.
9
+ Treat these as past records. When entries conflict, the most recent memory reflects the latest known state. Work that a memory describes as completed should not be redone unless the user explicitly asks to revisit it.
9
10
 
10
- When exact source context is needed for precision or traceability, use the recall tool with the relevant observation or reflection id. This is especially useful when a reflection materially affects a decision or is too compressed to continue confidently. Do not use recall as broad search or inject raw source unless it is needed.`;
11
+ When exact source context is needed for precision or traceability, use the recall tool with the relevant observation or summary id. A summary's inline citations can be followed with recall. Do not use recall as broad search or inject raw source unless it is needed.`;
11
12
 
12
13
  export function observationToSummaryLine(observation: Observation): string {
13
14
  return `[${observation.id}] ${observation.timestamp} [${observation.relevance}] ${observation.content}`;
14
15
  }
15
16
 
16
- export function reflectionToSummaryLine(reflection: Reflection): string {
17
- return `[${reflection.id}] ${reflection.content}`;
17
+ export function summaryToSummaryLine(summary: Summary): string {
18
+ return `[${summary.id}] ${summary.timestamp} [summary] ${summary.content}`;
18
19
  }
19
20
 
20
- export function renderSummary(reflections: Reflection[], observations: Observation[]): string {
21
- if (reflections.length === 0 && observations.length === 0) return "";
22
-
23
- const parts: string[] = [CONTEXT_USAGE_INSTRUCTIONS];
24
- if (reflections.length > 0) {
25
- parts.push(`## Reflections\n${reflections.map(reflectionToSummaryLine).join("\n")}`);
26
- }
27
- if (observations.length > 0) {
28
- parts.push(`## Observations\n${observations.map(observationToSummaryLine).join("\n")}`);
29
- }
30
- return parts.join("\n\n");
21
+ export function renderSummary(summaries: Summary[], observations: Observation[]): string {
22
+ if (summaries.length === 0 && observations.length === 0) return "";
23
+
24
+ const memories = chronologicalMemories(observations, summaries).map((item) =>
25
+ item.kind === "observation" ? observationToSummaryLine(item.memory) : summaryToSummaryLine(item.memory),
26
+ );
27
+ return [
28
+ CONTEXT_USAGE_INSTRUCTIONS,
29
+ `## Memories (chronological)\n${memories.join("\n")}`,
30
+ "Remember: you can look up the details of a memory by using the recall tool with a memory id contained in square brackets.",
31
+ ].join("\n\n");
31
32
  }
@@ -1,21 +1,30 @@
1
1
  import {
2
- isObservationsDroppedEntry,
2
+ isMemoryDetails,
3
3
  isObservationsRecordedEntry,
4
- isReflectionsRecordedEntry,
5
4
  isReviewResultEntry,
5
+ isSummarizerCommitEntry,
6
+ observationRetention,
6
7
  type Entry,
8
+ type MemoryVisibility,
7
9
  type Relevance,
10
+ type Retention,
8
11
  type ReviewOutcome,
9
12
  type ReviewScope,
10
13
  } from "./types.js";
14
+ import { foldLedger } from "./fold.js";
11
15
 
12
16
  export type MemorySearchResult = {
13
- kind: "observation" | "reflection" | "review";
17
+ kind: "observation" | "summary" | "review";
14
18
  id: string;
15
19
  content: string;
16
20
  relevance?: Relevance;
21
+ retention?: Retention;
17
22
  timestamp?: string;
18
- status?: "active" | "dropped";
23
+ visibility?: MemoryVisibility;
24
+ consumedBySummaryId?: string;
25
+ citedBySummaryIds?: string[];
26
+ sourceMemoryIds?: string[];
27
+ consumedMemoryIds?: string[];
19
28
  scope?: ReviewScope;
20
29
  outcome?: ReviewOutcome;
21
30
  title?: string;
@@ -26,149 +35,124 @@ export type MemorySearch = {
26
35
  query: string;
27
36
  results: MemorySearchResult[];
28
37
  observationsSearched: number;
29
- reflectionsSearched: number;
38
+ summariesSearched: number;
30
39
  reviewsSearched: number;
31
40
  };
32
41
 
33
- type SearchCandidate = Omit<MemorySearchResult, "score"> & {
34
- sourceIndex: number;
35
- };
42
+ export type SearchMemoriesOptions = Record<string, never>;
36
43
 
37
- const RELEVANCE_BOOST: Record<Relevance, number> = {
38
- low: 1,
39
- medium: 2,
40
- high: 3,
41
- critical: 4,
42
- };
44
+ type SearchCandidate = Omit<MemorySearchResult, "score"> & { sourceIndex: number };
45
+
46
+ const RELEVANCE_BOOST: Record<Relevance, number> = { low: 1, medium: 2, high: 3, critical: 4 };
43
47
 
44
48
  function normalizeSearchText(value: string): string {
45
49
  return value.normalize("NFKC").toLocaleLowerCase();
46
50
  }
47
51
 
48
52
  function terms(value: string): string[] {
49
- return Array.from(
50
- new Set(normalizeSearchText(value).match(/[\p{L}\p{N}][\p{L}\p{N}\p{M}_./:-]*/gu) ?? []),
51
- );
53
+ return Array.from(new Set(normalizeSearchText(value).match(/[\p{L}\p{N}][\p{L}\p{N}\p{M}_./:-]*/gu) ?? []));
52
54
  }
53
55
 
54
- function relevanceScore(
55
- content: string,
56
- query: string,
57
- queryTerms: string[],
58
- ): number {
56
+ function relevanceScore(content: string, query: string, queryTerms: string[]): number {
59
57
  const normalizedContent = normalizeSearchText(content);
60
58
  const phrase = normalizeSearchText(query.trim());
61
- const termMatches = queryTerms.reduce(
62
- (total, term) => total + (normalizedContent.includes(term) ? 1 : 0),
63
- 0,
64
- );
65
- const phraseBoost =
66
- phrase.length > 0 && normalizedContent.includes(phrase) ? 5 : 0;
59
+ const termMatches = queryTerms.reduce((total, term) => total + (normalizedContent.includes(term) ? 1 : 0), 0);
60
+ const phraseBoost = phrase.length > 0 && normalizedContent.includes(phrase) ? 5 : 0;
67
61
  if (termMatches === 0 && phraseBoost === 0) return 0;
68
62
  return termMatches * 10 + phraseBoost;
69
63
  }
70
64
 
65
+ function reviewSearchContent(review: ReturnType<typeof foldLedger>["reviews"][number]): string {
66
+ return review.outcome === "proposal"
67
+ ? [review.scope, "proposal", review.title, review.summary, review.evidence, review.conceptualDesign].join("\n")
68
+ : [review.scope, "review concluded with no proposal", review.reason, review.evidenceReviewed, review.reconsiderIf ?? ""].join("\n");
69
+ }
70
+
71
+ function sourceIndexes(entries: Entry[]): Map<string, number> {
72
+ const indexes = new Map<string, number>();
73
+ const add = (id: string, index: number): void => { if (!indexes.has(id)) indexes.set(id, index); };
74
+ for (let index = 0; index < entries.length; index++) {
75
+ const entry = entries[index];
76
+ if (entry.type === "compaction" && isMemoryDetails(entry.details)) {
77
+ for (const memory of entry.details.archive?.observations ?? entry.details.observations) add(memory.id, index);
78
+ for (const memory of entry.details.archive?.summaries ?? entry.details.summaries) add(memory.id, index);
79
+ for (const review of entry.details.reviews ?? []) add(review.id, index);
80
+ continue;
81
+ }
82
+ if (isObservationsRecordedEntry(entry)) for (const memory of entry.data.observations) add(memory.id, index);
83
+ else if (isSummarizerCommitEntry(entry)) for (const memory of entry.data.summaries) add(memory.id, index);
84
+ else if (isReviewResultEntry(entry)) add(entry.data.result.id, index);
85
+ }
86
+ return indexes;
87
+ }
88
+
71
89
  function candidates(entries: Entry[]): {
72
90
  items: SearchCandidate[];
73
91
  observations: number;
74
- reflections: number;
92
+ summaries: number;
75
93
  reviews: number;
76
94
  } {
77
- const dropped = new Set<string>();
78
- for (const entry of entries) {
79
- if (isObservationsDroppedEntry(entry)) {
80
- for (const id of entry.data.observationIds) dropped.add(id);
81
- }
82
- }
83
-
95
+ const folded = foldLedger(entries);
96
+ const indexes = sourceIndexes(entries);
84
97
  const items: SearchCandidate[] = [];
85
- let observations = 0;
86
- let reflections = 0;
87
- let reviews = 0;
88
- for (let sourceIndex = 0; sourceIndex < entries.length; sourceIndex++) {
89
- const entry = entries[sourceIndex];
90
- if (isObservationsRecordedEntry(entry)) {
91
- observations += entry.data.observations.length;
92
- for (const observation of entry.data.observations) {
93
- items.push({
94
- kind: "observation",
95
- id: observation.id,
96
- content: observation.content,
97
- relevance: observation.relevance,
98
- timestamp: observation.timestamp,
99
- status: dropped.has(observation.id) ? "dropped" : "active",
100
- sourceIndex,
101
- });
102
- }
103
- }
104
- if (isReflectionsRecordedEntry(entry)) {
105
- reflections += entry.data.reflections.length;
106
- for (const reflection of entry.data.reflections) {
107
- items.push({
108
- kind: "reflection",
109
- id: reflection.id,
110
- content: reflection.content,
111
- sourceIndex,
112
- });
113
- }
114
- continue;
115
- }
116
- if (isReviewResultEntry(entry)) {
117
- const review = entry.data.result;
118
- reviews++;
119
- const content = review.outcome === "proposal"
120
- ? [review.scope, "proposal", review.title, review.summary, review.evidence, review.conceptualDesign].join("\n")
121
- : [review.scope, "review concluded with no proposal", review.reason, review.evidenceReviewed, review.reconsiderIf ?? ""].join("\n");
122
- items.push({
123
- kind: "review",
124
- id: review.id,
125
- content,
126
- scope: review.scope,
127
- outcome: review.outcome,
128
- title: review.outcome === "proposal" ? review.title : undefined,
129
- sourceIndex,
130
- });
131
- }
98
+
99
+ for (const observation of folded.observations) {
100
+ const consumedBySummaryId = folded.consumedBySummaryId.get(observation.id);
101
+ items.push({
102
+ kind: "observation",
103
+ id: observation.id,
104
+ content: observation.content,
105
+ relevance: observation.relevance,
106
+ retention: observationRetention(observation),
107
+ timestamp: observation.timestamp,
108
+ visibility: consumedBySummaryId ? "summarized" : "visible",
109
+ ...(consumedBySummaryId ? { consumedBySummaryId } : {}),
110
+ citedBySummaryIds: folded.citedBySummaryIds.get(observation.id) ?? [],
111
+ sourceIndex: indexes.get(observation.id) ?? 0,
112
+ });
113
+ }
114
+ for (const summary of folded.summaries) {
115
+ const consumedBySummaryId = folded.consumedBySummaryId.get(summary.id);
116
+ items.push({
117
+ kind: "summary",
118
+ id: summary.id,
119
+ content: summary.content,
120
+ visibility: consumedBySummaryId ? "summarized" : "visible",
121
+ ...(consumedBySummaryId ? { consumedBySummaryId } : {}),
122
+ citedBySummaryIds: folded.citedBySummaryIds.get(summary.id) ?? [],
123
+ sourceMemoryIds: summary.sourceMemoryIds,
124
+ consumedMemoryIds: summary.consumedMemoryIds,
125
+ sourceIndex: indexes.get(summary.id) ?? 0,
126
+ });
127
+ }
128
+ for (const review of folded.reviews) {
129
+ items.push({
130
+ kind: "review",
131
+ id: review.id,
132
+ content: reviewSearchContent(review),
133
+ citedBySummaryIds: folded.citedBySummaryIds.get(review.id) ?? [],
134
+ scope: review.scope,
135
+ outcome: review.outcome,
136
+ title: review.outcome === "proposal" ? review.title : undefined,
137
+ sourceIndex: indexes.get(review.id) ?? 0,
138
+ });
132
139
  }
133
- return { items, observations, reflections, reviews };
140
+ return { items, observations: folded.observations.length, summaries: folded.summaries.length, reviews: folded.reviews.length };
134
141
  }
135
142
 
136
- export function searchMemories(
137
- entries: Entry[],
138
- query: string,
139
- limit = 8,
140
- ): MemorySearch {
143
+ export function searchMemories(entries: Entry[], query: string, limit = 8, _options: SearchMemoriesOptions = {}): MemorySearch {
141
144
  const normalizedQuery = query.trim();
142
145
  const queryTerms = terms(normalizedQuery);
143
146
  const searched = candidates(entries);
144
147
  const results = searched.items
145
148
  .map((candidate): MemorySearchResult | undefined => {
146
- const lexical = relevanceScore(
147
- candidate.content,
148
- normalizedQuery,
149
- queryTerms,
150
- );
149
+ const lexical = relevanceScore(candidate.content, normalizedQuery, queryTerms);
151
150
  if (lexical === 0) return undefined;
152
- const relevanceBoost = candidate.relevance
153
- ? RELEVANCE_BOOST[candidate.relevance]
154
- : 0;
155
- const kindBoost = candidate.kind === "reflection" ? 3 : 0;
156
- const recencyBoost = Math.min(
157
- candidate.sourceIndex / Math.max(entries.length, 1),
158
- 1,
159
- );
160
- return {
161
- kind: candidate.kind,
162
- id: candidate.id,
163
- content: candidate.content,
164
- relevance: candidate.relevance,
165
- timestamp: candidate.timestamp,
166
- status: candidate.status,
167
- scope: candidate.scope,
168
- outcome: candidate.outcome,
169
- title: candidate.title,
170
- score: lexical + relevanceBoost + kindBoost + recencyBoost,
171
- } satisfies MemorySearchResult;
151
+ const relevanceBoost = candidate.relevance ? RELEVANCE_BOOST[candidate.relevance] : 0;
152
+ const kindBoost = candidate.kind === "summary" ? 3 : 0;
153
+ const recencyBoost = Math.min(candidate.sourceIndex / Math.max(entries.length, 1), 1);
154
+ const { sourceIndex: _sourceIndex, ...result } = candidate;
155
+ return { ...result, score: lexical + relevanceBoost + kindBoost + recencyBoost };
172
156
  })
173
157
  .filter((result): result is MemorySearchResult => result !== undefined)
174
158
  .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
@@ -178,7 +162,7 @@ export function searchMemories(
178
162
  query: normalizedQuery,
179
163
  results,
180
164
  observationsSearched: searched.observations,
181
- reflectionsSearched: searched.reflections,
165
+ summariesSearched: searched.summaries,
182
166
  reviewsSearched: searched.reviews,
183
167
  };
184
168
  }
@@ -1,6 +1,6 @@
1
1
  export const OM_OBSERVATIONS_RECORDED = "om.observations.recorded";
2
- export const OM_REFLECTIONS_RECORDED = "om.reflections.recorded";
3
- export const OM_OBSERVATIONS_DROPPED = "om.observations.dropped";
2
+ /** Atomic output of one summarizer pass: new summaries and their consumption edges. */
3
+ export const OM_SUMMARIZER_COMMIT = "om.summarizer.commit";
4
4
  export const OM_REVIEW_REQUEST = "om.review.request";
5
5
  export const OM_REVIEW_RESULT = "om.review.result";
6
6
  /** Persisted assistant/tool output from a short-lived structural reviewer. */
@@ -16,6 +16,10 @@ export const OM_FOLDED = "om.folded";
16
16
  export const RELEVANCE_VALUES = ["low", "medium", "high", "critical"] as const;
17
17
  export type Relevance = (typeof RELEVANCE_VALUES)[number];
18
18
 
19
+ export const RETENTION_VALUES = ["ephemeral", "contextual", "durable"] as const;
20
+ export type Retention = (typeof RETENTION_VALUES)[number];
21
+ export type MemoryVisibility = "visible" | "summarized";
22
+
19
23
  export const MEMORY_ID_PATTERN = /^[a-f0-9]{12}$/;
20
24
 
21
25
  export type Entry = {
@@ -37,29 +41,44 @@ export type Observation = {
37
41
  content: string;
38
42
  timestamp: string;
39
43
  relevance: Relevance;
44
+ /** Missing values safely default to contextual. */
45
+ retention?: Retention;
40
46
  sourceEntryIds: string[];
41
47
  tokenCount: number;
42
48
  };
43
49
 
44
- export type Reflection = {
50
+ /** A durable, cited summary. Its source bodies remain elsewhere in the ledger. */
51
+ export type Summary = {
45
52
  id: string;
46
53
  content: string;
47
- supportingObservationIds: string[];
54
+ /** Newest effective timestamp among the cited source memories. */
55
+ timestamp: string;
56
+ /** Every inline-cited memory id, deduplicated in first-occurrence order. */
57
+ sourceMemoryIds: string[];
58
+ /** Sources newly removed from automatic visibility by this summary. */
59
+ consumedMemoryIds: string[];
48
60
  tokenCount: number;
49
61
  };
50
62
 
51
- export type ObservationsRecordedEntryData = {
52
- observations: Observation[];
53
- coversUpToId: string;
63
+ export type SummarizerCommitMetrics = {
64
+ consumedMemoryCount: number;
65
+ sourceTokens: number;
66
+ summaryTokens: number;
67
+ estimatedTokenReduction: number;
54
68
  };
55
69
 
56
- export type ReflectionsRecordedEntryData = {
57
- reflections: Reflection[];
70
+ export type SummarizerCommitEntryData = {
71
+ version: 1;
72
+ summaries: Summary[];
73
+ /** Entry id of the newest observation batch included in the run snapshot. */
58
74
  coversUpToId: string;
75
+ createdAt: number;
76
+ completedWithDone: boolean;
77
+ metrics: SummarizerCommitMetrics;
59
78
  };
60
79
 
61
- export type ObservationsDroppedEntryData = {
62
- observationIds: string[];
80
+ export type ObservationsRecordedEntryData = {
81
+ observations: Observation[];
63
82
  coversUpToId: string;
64
83
  };
65
84
 
@@ -127,29 +146,44 @@ export type ReviewResult = WorkflowReviewProposal | SoftwareReviewProposal | Rev
127
146
  export type ReviewRequestEntryData = { request: StructuralReviewRequest };
128
147
  export type ReviewResultEntryData = { result: ReviewResult };
129
148
 
149
+ /** Full durable memory state at a compaction boundary. Bodies occur once in this archive. */
150
+ export type MemoryArchive = {
151
+ observations: Observation[];
152
+ summaries: Summary[];
153
+ };
154
+
130
155
  export type MemoryDetails = {
131
156
  type: typeof OM_FOLDED;
132
- version: 1;
157
+ /** Version 2 intentionally breaks the unpublished reflections/lifecycle format. */
158
+ version: 2;
133
159
  fullFold: boolean;
160
+ /** Visible memories injected by this compaction. */
134
161
  observations: Observation[];
135
- reflections: Reflection[];
136
- /** Optional so compactions written before review results remain readable. */
162
+ summaries: Summary[];
163
+ /** Complete durable graph nodes at this compaction boundary. */
164
+ archive?: MemoryArchive;
165
+ /** Reviews remain recallable/searchable but are never automatically injected. */
137
166
  reviews?: ReviewResult[];
138
167
  };
139
168
 
140
- export type V3MemoryCustomType =
169
+ export type MemoryCoverageCustomType =
141
170
  | typeof OM_OBSERVATIONS_RECORDED
142
- | typeof OM_REFLECTIONS_RECORDED
143
- | typeof OM_OBSERVATIONS_DROPPED
144
- | typeof OM_REVIEW_REQUEST
145
- | typeof OM_REVIEW_RESULT;
171
+ | typeof OM_SUMMARIZER_COMMIT;
146
172
 
147
173
  export function isRelevance(value: unknown): value is Relevance {
148
174
  return typeof value === "string" && (RELEVANCE_VALUES as readonly string[]).includes(value);
149
175
  }
150
176
 
177
+ export function isRetention(value: unknown): value is Retention {
178
+ return typeof value === "string" && (RETENTION_VALUES as readonly string[]).includes(value);
179
+ }
180
+
181
+ export function observationRetention(observation: Observation): Retention {
182
+ return observation.retention ?? "contextual";
183
+ }
184
+
151
185
  export function isNonEmptyString(value: unknown): value is string {
152
- return typeof value === "string" && value.length > 0;
186
+ return typeof value === "string" && value.trim().length > 0;
153
187
  }
154
188
 
155
189
  export function isNonEmptyStringArray(value: unknown): value is string[] {
@@ -164,8 +198,16 @@ function isTokenCount(value: unknown): value is number {
164
198
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
165
199
  }
166
200
 
201
+ function isCount(value: unknown): value is number {
202
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
203
+ }
204
+
167
205
  function isPlainRecord(value: unknown): value is Record<string, unknown> {
168
- return !!value && typeof value === "object";
206
+ return !!value && typeof value === "object" && !Array.isArray(value);
207
+ }
208
+
209
+ function hasUniqueMemoryIds(value: unknown, minimum = 0): value is string[] {
210
+ return Array.isArray(value) && value.length >= minimum && value.every(isMemoryId) && new Set(value).size === value.length;
169
211
  }
170
212
 
171
213
  export function isObservation(value: unknown): value is Observation {
@@ -175,45 +217,41 @@ export function isObservation(value: unknown): value is Observation {
175
217
  isNonEmptyString(value.content) &&
176
218
  isNonEmptyString(value.timestamp) &&
177
219
  isRelevance(value.relevance) &&
220
+ (value.retention === undefined || isRetention(value.retention)) &&
178
221
  isNonEmptyStringArray(value.sourceEntryIds) &&
179
222
  isTokenCount(value.tokenCount)
180
223
  );
181
224
  }
182
225
 
183
- export function isReflection(value: unknown): value is Reflection {
226
+ export function isSummary(value: unknown): value is Summary {
184
227
  if (!isPlainRecord(value)) return false;
185
- return (
186
- isMemoryId(value.id) &&
187
- isNonEmptyString(value.content) &&
188
- !/\r|\n/.test(value.content) &&
189
- isNonEmptyStringArray(value.supportingObservationIds) &&
190
- isTokenCount(value.tokenCount)
191
- );
228
+ if (!isMemoryId(value.id) || !isNonEmptyString(value.content) || !isNonEmptyString(value.timestamp) || !isTokenCount(value.tokenCount)) return false;
229
+ if (!hasUniqueMemoryIds(value.sourceMemoryIds, 2) || !hasUniqueMemoryIds(value.consumedMemoryIds, 2)) return false;
230
+ const sourceIds = new Set(value.sourceMemoryIds);
231
+ return value.consumedMemoryIds.every((id) => sourceIds.has(id));
192
232
  }
193
233
 
194
234
  export function isObservationsRecordedData(value: unknown): value is ObservationsRecordedEntryData {
195
235
  if (!isPlainRecord(value)) return false;
196
236
  return (
197
237
  Array.isArray(value.observations) &&
198
- value.observations.length > 0 &&
199
238
  value.observations.every(isObservation) &&
200
239
  isNonEmptyString(value.coversUpToId)
201
240
  );
202
241
  }
203
242
 
204
- export function isReflectionsRecordedData(value: unknown): value is ReflectionsRecordedEntryData {
243
+ function isSummarizerCommitMetrics(value: unknown): value is SummarizerCommitMetrics {
205
244
  if (!isPlainRecord(value)) return false;
206
- return (
207
- Array.isArray(value.reflections) &&
208
- value.reflections.length > 0 &&
209
- value.reflections.every(isReflection) &&
210
- isNonEmptyString(value.coversUpToId)
211
- );
245
+ return isCount(value.consumedMemoryCount) && isTokenCount(value.sourceTokens) &&
246
+ isTokenCount(value.summaryTokens) && isTokenCount(value.estimatedTokenReduction);
212
247
  }
213
248
 
214
- export function isObservationsDroppedData(value: unknown): value is ObservationsDroppedEntryData {
249
+ export function isSummarizerCommitData(value: unknown): value is SummarizerCommitEntryData {
215
250
  if (!isPlainRecord(value)) return false;
216
- return isNonEmptyStringArray(value.observationIds) && isNonEmptyString(value.coversUpToId);
251
+ return value.version === 1 && Array.isArray(value.summaries) && value.summaries.length > 0 &&
252
+ value.summaries.every(isSummary) && isNonEmptyString(value.coversUpToId) &&
253
+ typeof value.createdAt === "number" && Number.isFinite(value.createdAt) &&
254
+ typeof value.completedWithDone === "boolean" && isSummarizerCommitMetrics(value.metrics);
217
255
  }
218
256
 
219
257
  function isReviewScope(value: unknown): value is ReviewScope {
@@ -252,18 +290,22 @@ export function isReviewResult(value: unknown): value is ReviewResult {
252
290
  isNonEmptyString(value.preservedBehavior) && isNonEmptyString(value.expectedEffect) && isNonEmptyString(value.uncertainties);
253
291
  }
254
292
 
293
+ function isMemoryArchive(value: unknown): value is MemoryArchive {
294
+ if (!isPlainRecord(value)) return false;
295
+ return Array.isArray(value.observations) && value.observations.every(isObservation) &&
296
+ Array.isArray(value.summaries) && value.summaries.every(isSummary);
297
+ }
298
+
255
299
  export function isMemoryDetails(value: unknown): value is MemoryDetails {
256
300
  if (!isPlainRecord(value)) return false;
257
- return (
258
- value.type === OM_FOLDED &&
259
- value.version === 1 &&
260
- typeof value.fullFold === "boolean" &&
261
- Array.isArray(value.observations) &&
262
- value.observations.every(isObservation) &&
263
- Array.isArray(value.reflections) &&
264
- value.reflections.every(isReflection) &&
265
- (value.reviews === undefined || (Array.isArray(value.reviews) && value.reviews.every(isReviewResult)))
266
- );
301
+ // No v1 migration is intentional: that format belonged to the unpublished
302
+ // librarian/reflection design and is not semantically compatible with the
303
+ // summarizer consumption graph.
304
+ return value.type === OM_FOLDED && value.version === 2 && typeof value.fullFold === "boolean" &&
305
+ Array.isArray(value.observations) && value.observations.every(isObservation) &&
306
+ Array.isArray(value.summaries) && value.summaries.every(isSummary) &&
307
+ (value.archive === undefined || isMemoryArchive(value.archive)) &&
308
+ (value.reviews === undefined || (Array.isArray(value.reviews) && value.reviews.every(isReviewResult)));
267
309
  }
268
310
 
269
311
  export function isObservationsRecordedEntry(entry: Entry): entry is Entry & {
@@ -274,20 +316,12 @@ export function isObservationsRecordedEntry(entry: Entry): entry is Entry & {
274
316
  return entry.type === "custom" && entry.customType === OM_OBSERVATIONS_RECORDED && isObservationsRecordedData(entry.data);
275
317
  }
276
318
 
277
- export function isReflectionsRecordedEntry(entry: Entry): entry is Entry & {
319
+ export function isSummarizerCommitEntry(entry: Entry): entry is Entry & {
278
320
  type: "custom";
279
- customType: typeof OM_REFLECTIONS_RECORDED;
280
- data: ReflectionsRecordedEntryData;
321
+ customType: typeof OM_SUMMARIZER_COMMIT;
322
+ data: SummarizerCommitEntryData;
281
323
  } {
282
- return entry.type === "custom" && entry.customType === OM_REFLECTIONS_RECORDED && isReflectionsRecordedData(entry.data);
283
- }
284
-
285
- export function isObservationsDroppedEntry(entry: Entry): entry is Entry & {
286
- type: "custom";
287
- customType: typeof OM_OBSERVATIONS_DROPPED;
288
- data: ObservationsDroppedEntryData;
289
- } {
290
- return entry.type === "custom" && entry.customType === OM_OBSERVATIONS_DROPPED && isObservationsDroppedData(entry.data);
324
+ return entry.type === "custom" && entry.customType === OM_SUMMARIZER_COMMIT && isSummarizerCommitData(entry.data);
291
325
  }
292
326
 
293
327
  export function isReviewRequestEntry(entry: Entry): entry is Entry & {
@@ -310,22 +344,14 @@ export function buildObservationsRecordedData(
310
344
  observations: Observation[],
311
345
  coversUpToId: string,
312
346
  ): ObservationsRecordedEntryData | undefined {
313
- if (observations.length === 0 || !isNonEmptyString(coversUpToId)) return undefined;
314
- return { observations, coversUpToId };
347
+ if (!isNonEmptyString(coversUpToId)) return undefined;
348
+ const candidate = { observations, coversUpToId };
349
+ return isObservationsRecordedData(candidate) ? candidate : undefined;
315
350
  }
316
351
 
317
- export function buildReflectionsRecordedData(
318
- reflections: Reflection[],
319
- coversUpToId: string,
320
- ): ReflectionsRecordedEntryData | undefined {
321
- if (reflections.length === 0 || !isNonEmptyString(coversUpToId)) return undefined;
322
- return { reflections, coversUpToId };
323
- }
324
-
325
- export function buildObservationsDroppedData(
326
- observationIds: string[],
327
- coversUpToId: string,
328
- ): ObservationsDroppedEntryData | undefined {
329
- if (observationIds.length === 0 || !isNonEmptyString(coversUpToId)) return undefined;
330
- return { observationIds, coversUpToId };
352
+ export function buildSummarizerCommitData(
353
+ data: Omit<SummarizerCommitEntryData, "version">,
354
+ ): SummarizerCommitEntryData | undefined {
355
+ const candidate: SummarizerCommitEntryData = { version: 1, ...data };
356
+ return isSummarizerCommitData(candidate) ? candidate : undefined;
331
357
  }
@@ -20,7 +20,7 @@ export function createCompactContextTool(runtime: Runtime) {
20
20
  promptGuidelines: [
21
21
  "Use compact_context sparingly when either substantial additional work remains and there is not enough context left to complete it, or accumulated past context has become noisy, stale, or distracting enough that you are struggling to focus on the current task or reason about it reliably.",
22
22
  "Do not use compact_context routinely, for short tasks, or merely because the conversation is long; use it for genuine context-capacity pressure or context degradation that is interfering with the work.",
23
- "Call compact_context by itself, provide short_continuation_prompt with concrete instructions for the next agent step, and stop the current turn; observational memory will compact the context and automatically resume with those instructions.",
23
+ "Call compact_context by itself, provide short_continuation_prompt with concrete instructions for the next agent step, and stop the current turn; pi-contemplator will compact the context and automatically resume with those instructions.",
24
24
  ],
25
25
  parameters: Type.Object({
26
26
  short_continuation_prompt: Type.String({