@aexol/spectral 0.3.6 → 0.3.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/dist/cli.js CHANGED
@@ -79,6 +79,10 @@ function resolvePiBin() {
79
79
  function resolveAexolExtensionPath() {
80
80
  return resolve(__dirname, "extensions", "aexol-mcp.js");
81
81
  }
82
+ /** Absolute path to the bundled observational-memory extension, sitting next to this file in dist/. */
83
+ function resolveObservationalMemoryPath() {
84
+ return resolve(__dirname, "memory", "index.js");
85
+ }
82
86
  /** Absolute path to the bundled pi-mcp-adapter extension, sitting next to this file in dist/. */
83
87
  function resolveMcpExtensionPath() {
84
88
  return resolve(__dirname, "mcp", "index.js");
@@ -207,7 +211,14 @@ async function main() {
207
211
  process.stderr.write(`spectral: bundled MCP extension not found at ${mcpExtPath}. This is a packaging bug.\n`);
208
212
  process.exit(1);
209
213
  }
210
- const extFlags = ["--extension", aexolExtPath, "--extension", mcpExtPath];
214
+ // Bundled observational memory extension for tiered agent memory
215
+ // (observer → reflector → pruner pipeline with /om-status, /om-view, recall tool).
216
+ const obsMemPath = resolveObservationalMemoryPath();
217
+ if (!existsSync(obsMemPath)) {
218
+ process.stderr.write(`spectral: bundled observational-memory extension not found at ${obsMemPath}. This is a packaging bug.\n`);
219
+ process.exit(1);
220
+ }
221
+ const extFlags = ["--extension", aexolExtPath, "--extension", mcpExtPath, "--extension", obsMemPath];
211
222
  const finalArgs = [...extFlags, ...args];
212
223
  delegateToPi(finalArgs);
213
224
  }
@@ -0,0 +1,409 @@
1
+ import { OBSERVATION_CUSTOM_TYPE, isObservationEntryData, isReflectionRecord, isSupportedMemoryDetails, } from "./types.js";
2
+ import { estimateEntryTokens } from "./tokens.js";
3
+ const RAW_TYPES = new Set(["message", "custom_message", "branch_summary"]);
4
+ export function isSourceEntry(entry) {
5
+ return RAW_TYPES.has(entry.type);
6
+ }
7
+ function isObservationEntry(entry) {
8
+ return entry.type === "custom" && entry.customType === OBSERVATION_CUSTOM_TYPE;
9
+ }
10
+ export function findLastCompactionIndex(entries) {
11
+ for (let i = entries.length - 1; i >= 0; i--) {
12
+ if (entries[i].type === "compaction")
13
+ return i;
14
+ }
15
+ return -1;
16
+ }
17
+ export function lastObservationCoverEndIdx(entries) {
18
+ const idToIdx = new Map();
19
+ for (let i = 0; i < entries.length; i++)
20
+ idToIdx.set(entries[i].id, i);
21
+ let maxIdx = -1;
22
+ for (let i = 0; i < entries.length; i++) {
23
+ const entry = entries[i];
24
+ if (!isObservationEntry(entry))
25
+ continue;
26
+ if (!isObservationEntryData(entry.data))
27
+ continue;
28
+ const coverIdx = idToIdx.get(entry.data.coversUpToId);
29
+ if (coverIdx !== undefined && coverIdx > maxIdx)
30
+ maxIdx = coverIdx;
31
+ }
32
+ return maxIdx;
33
+ }
34
+ function rawTokensFromIndex(entries, startIndex) {
35
+ let total = 0;
36
+ for (let i = Math.max(0, startIndex); i < entries.length; i++) {
37
+ if (RAW_TYPES.has(entries[i].type))
38
+ total += estimateEntryTokens(entries[i]);
39
+ }
40
+ return total;
41
+ }
42
+ export function rawTokensSinceLastBound(entries) {
43
+ return rawTokensFromIndex(entries, lastObservationCoverEndIdx(entries) + 1);
44
+ }
45
+ export function rawTokensSinceLastCompaction(entries) {
46
+ const compactionIdx = findLastCompactionIndex(entries);
47
+ if (compactionIdx === -1)
48
+ return rawTokensFromIndex(entries, 0);
49
+ return rawTokensFromIndex(entries, liveTailStartIndex(entries));
50
+ }
51
+ function liveTailStartIndex(entries) {
52
+ const compactionIdx = findLastCompactionIndex(entries);
53
+ if (compactionIdx === -1)
54
+ return 0;
55
+ const firstKept = entries[compactionIdx].firstKeptEntryId;
56
+ if (!firstKept)
57
+ throw new Error("compaction entry missing firstKeptEntryId");
58
+ const firstKeptIdx = entries.findIndex((e) => e.id === firstKept);
59
+ if (firstKeptIdx === -1)
60
+ throw new Error(`firstKeptEntryId "${firstKept}" not found in entries`);
61
+ return firstKeptIdx;
62
+ }
63
+ export function firstRawIdAfter(entries, afterIndex) {
64
+ for (let i = Math.max(0, afterIndex + 1); i < entries.length; i++) {
65
+ if (RAW_TYPES.has(entries[i].type))
66
+ return entries[i].id;
67
+ }
68
+ return undefined;
69
+ }
70
+ export function gapRawEntries(entries, newFirstKeptEntryId) {
71
+ const lastBoundIdx = lastObservationCoverEndIdx(entries);
72
+ const newKeptIdx = entries.findIndex((e) => e.id === newFirstKeptEntryId);
73
+ if (newKeptIdx === -1)
74
+ return [];
75
+ const result = [];
76
+ for (let i = lastBoundIdx + 1; i < newKeptIdx; i++) {
77
+ if (RAW_TYPES.has(entries[i].type))
78
+ result.push(entries[i]);
79
+ }
80
+ return result;
81
+ }
82
+ export function rawTailEntriesBetween(entries, fromId, untilId) {
83
+ const fromIdx = entries.findIndex((e) => e.id === fromId);
84
+ const untilIdx = entries.findIndex((e) => e.id === untilId);
85
+ if (fromIdx === -1 || untilIdx === -1 || untilIdx < fromIdx)
86
+ return [];
87
+ const result = [];
88
+ for (let i = fromIdx; i <= untilIdx; i++) {
89
+ if (isSourceEntry(entries[i]))
90
+ result.push(entries[i]);
91
+ }
92
+ return result;
93
+ }
94
+ function uniqueIds(ids) {
95
+ return Array.from(new Set(ids));
96
+ }
97
+ function collectIndexedObservations(entries) {
98
+ const observations = [];
99
+ for (let branchIndex = 0; branchIndex < entries.length; branchIndex++) {
100
+ const entry = entries[branchIndex];
101
+ if (!isObservationEntry(entry))
102
+ continue;
103
+ if (!isObservationEntryData(entry.data))
104
+ continue;
105
+ entry.data.records.forEach((observation, observationRecordIndex) => {
106
+ observations.push({ observation, observationEntryId: entry.id, observationRecordIndex, branchIndex });
107
+ });
108
+ }
109
+ return observations;
110
+ }
111
+ function observationKey(observation) {
112
+ return `${observation.observationEntryId}:${observation.observationRecordIndex}`;
113
+ }
114
+ function resolveSourceEntries(entries, sourceEntryIds) {
115
+ const requested = uniqueIds(sourceEntryIds);
116
+ const requestedSet = new Set(requested);
117
+ const entriesById = new Map(entries.map((entry) => [entry.id, entry]));
118
+ const missingSourceEntryIds = requested.filter((id) => !entriesById.has(id));
119
+ const nonSourceEntryIds = requested.filter((id) => {
120
+ const entry = entriesById.get(id);
121
+ return entry !== undefined && !isSourceEntry(entry);
122
+ });
123
+ if (missingSourceEntryIds.length > 0 || nonSourceEntryIds.length > 0) {
124
+ return {
125
+ status: "source_unavailable",
126
+ sourceEntryIds: requested,
127
+ sourceEntries: [],
128
+ missingSourceEntryIds,
129
+ nonSourceEntryIds,
130
+ };
131
+ }
132
+ const sourceEntries = entries.filter((entry) => requestedSet.has(entry.id));
133
+ return {
134
+ status: "ok",
135
+ sourceEntryIds: sourceEntries.map((entry) => entry.id),
136
+ sourceEntries,
137
+ missingSourceEntryIds: [],
138
+ nonSourceEntryIds: [],
139
+ };
140
+ }
141
+ function resolveSourceEntriesPartial(entries, sourceEntryIds) {
142
+ const requested = uniqueIds(sourceEntryIds);
143
+ const requestedSet = new Set(requested);
144
+ const entriesById = new Map(entries.map((entry) => [entry.id, entry]));
145
+ const missingSourceEntryIds = requested.filter((id) => !entriesById.has(id));
146
+ const nonSourceEntryIds = requested.filter((id) => {
147
+ const entry = entriesById.get(id);
148
+ return entry !== undefined && !isSourceEntry(entry);
149
+ });
150
+ const sourceEntries = entries.filter((entry) => requestedSet.has(entry.id) && isSourceEntry(entry));
151
+ return {
152
+ status: missingSourceEntryIds.length > 0 || nonSourceEntryIds.length > 0 ? "source_unavailable" : "ok",
153
+ sourceEntryIds: requested,
154
+ sourceEntries,
155
+ missingSourceEntryIds,
156
+ nonSourceEntryIds,
157
+ };
158
+ }
159
+ function memoryObservationFromIndexed(entries, indexed) {
160
+ const { observation, observationEntryId, observationRecordIndex } = indexed;
161
+ if (!observation.sourceEntryIds || observation.sourceEntryIds.length === 0) {
162
+ return { status: "no_source", observation, observationEntryId, observationRecordIndex };
163
+ }
164
+ const resolved = resolveSourceEntriesPartial(entries, observation.sourceEntryIds);
165
+ if (resolved.status === "source_unavailable") {
166
+ return {
167
+ status: "source_unavailable",
168
+ observation,
169
+ observationEntryId,
170
+ observationRecordIndex,
171
+ sourceEntryIds: resolved.sourceEntryIds,
172
+ sourceEntries: resolved.sourceEntries,
173
+ missingSourceEntryIds: resolved.missingSourceEntryIds,
174
+ nonSourceEntryIds: resolved.nonSourceEntryIds,
175
+ };
176
+ }
177
+ return {
178
+ status: "ok",
179
+ observation,
180
+ observationEntryId,
181
+ observationRecordIndex,
182
+ sourceEntryIds: resolved.sourceEntryIds,
183
+ sourceEntries: resolved.sourceEntries,
184
+ };
185
+ }
186
+ function uniqueSourceEntriesInBranchOrder(entries, observations) {
187
+ const requested = new Set();
188
+ for (const observation of observations) {
189
+ if (observation.status === "ok" || observation.status === "source_unavailable") {
190
+ for (const entry of observation.sourceEntries)
191
+ requested.add(entry.id);
192
+ }
193
+ }
194
+ return entries.filter((entry) => requested.has(entry.id) && isSourceEntry(entry));
195
+ }
196
+ function uniqueUnavailableSourceIds(observations, field) {
197
+ const ids = [];
198
+ for (const observation of observations) {
199
+ if (observation.status !== "source_unavailable")
200
+ continue;
201
+ ids.push(...observation[field]);
202
+ }
203
+ return uniqueIds(ids);
204
+ }
205
+ export function recallObservationSources(entries, observationId) {
206
+ const matches = [];
207
+ for (const entry of entries) {
208
+ if (!isObservationEntry(entry))
209
+ continue;
210
+ if (!isObservationEntryData(entry.data))
211
+ continue;
212
+ for (const observation of entry.data.records) {
213
+ if (observation.id !== observationId)
214
+ continue;
215
+ if (!observation.sourceEntryIds || observation.sourceEntryIds.length === 0) {
216
+ matches.push({ status: "no_source", observation, observationEntryId: entry.id });
217
+ continue;
218
+ }
219
+ const resolved = resolveSourceEntries(entries, observation.sourceEntryIds);
220
+ if (resolved.status === "source_unavailable") {
221
+ matches.push({
222
+ status: "source_unavailable",
223
+ observation,
224
+ observationEntryId: entry.id,
225
+ sourceEntryIds: resolved.sourceEntryIds,
226
+ missingSourceEntryIds: resolved.missingSourceEntryIds,
227
+ nonSourceEntryIds: resolved.nonSourceEntryIds,
228
+ });
229
+ continue;
230
+ }
231
+ matches.push({
232
+ status: "ok",
233
+ observation,
234
+ observationEntryId: entry.id,
235
+ sourceEntryIds: resolved.sourceEntryIds,
236
+ sourceEntries: resolved.sourceEntries,
237
+ });
238
+ }
239
+ }
240
+ if (matches.length === 0)
241
+ return { status: "not_found", observationId, matches: [], collision: false };
242
+ return { status: "found", observationId, matches, collision: matches.length > 1 };
243
+ }
244
+ function getPriorMemoryDetails(entries) {
245
+ const idx = findLastCompactionIndex(entries);
246
+ if (idx === -1)
247
+ return undefined;
248
+ const details = entries[idx].details;
249
+ return isSupportedMemoryDetails(details) ? details : undefined;
250
+ }
251
+ export function recallMemorySources(entries, memoryId) {
252
+ const indexedObservations = collectIndexedObservations(entries);
253
+ const observationsById = new Map();
254
+ for (const observation of indexedObservations) {
255
+ const matches = observationsById.get(observation.observation.id) ?? [];
256
+ matches.push(observation);
257
+ observationsById.set(observation.observation.id, matches);
258
+ }
259
+ const priorDetails = getPriorMemoryDetails(entries);
260
+ const reflectionMatches = [];
261
+ if (priorDetails) {
262
+ priorDetails.reflections.forEach((reflection, reflectionIndex) => {
263
+ if (!isReflectionRecord(reflection) || reflection.id !== memoryId)
264
+ return;
265
+ reflectionMatches.push({ reflection, reflectionIndex });
266
+ });
267
+ }
268
+ const directIndexedObservations = observationsById.get(memoryId) ?? [];
269
+ const observationsByKey = new Map();
270
+ for (const observation of directIndexedObservations) {
271
+ observationsByKey.set(observationKey(observation), observation);
272
+ }
273
+ const unavailableSupportingObservations = [];
274
+ const unavailableReflectionProvenance = [];
275
+ for (const reflectionMatch of reflectionMatches) {
276
+ if (reflectionMatch.reflection.legacy === true) {
277
+ unavailableReflectionProvenance.push({ ...reflectionMatch, reason: "legacy" });
278
+ continue;
279
+ }
280
+ for (const observationId of reflectionMatch.reflection.supportingObservationIds) {
281
+ const supportingObservations = observationsById.get(observationId);
282
+ if (!supportingObservations || supportingObservations.length === 0) {
283
+ unavailableSupportingObservations.push({ ...reflectionMatch, observationId });
284
+ continue;
285
+ }
286
+ for (const observation of supportingObservations) {
287
+ observationsByKey.set(observationKey(observation), observation);
288
+ }
289
+ }
290
+ }
291
+ const indexedObservationBag = Array.from(observationsByKey.values()).sort((a, b) => {
292
+ if (a.branchIndex !== b.branchIndex)
293
+ return a.branchIndex - b.branchIndex;
294
+ return a.observationRecordIndex - b.observationRecordIndex;
295
+ });
296
+ const observations = indexedObservationBag.map((observation) => memoryObservationFromIndexed(entries, observation));
297
+ const directObservationKeys = new Set(directIndexedObservations.map(observationKey));
298
+ const directObservationMatches = observations.filter((observation) => directObservationKeys.has(observationKey(observation)));
299
+ const sourceEntries = uniqueSourceEntriesInBranchOrder(entries, observations);
300
+ const missingSourceEntryIds = uniqueUnavailableSourceIds(observations, "missingSourceEntryIds");
301
+ const nonSourceEntryIds = uniqueUnavailableSourceIds(observations, "nonSourceEntryIds");
302
+ if (reflectionMatches.length === 0 && directObservationMatches.length === 0) {
303
+ return {
304
+ status: "not_found",
305
+ memoryId,
306
+ reflectionMatches: [],
307
+ directObservationMatches: [],
308
+ observations: [],
309
+ sourceEntries: [],
310
+ unavailableSupportingObservations: [],
311
+ unavailableReflectionProvenance: [],
312
+ missingSourceEntryIds: [],
313
+ nonSourceEntryIds: [],
314
+ collision: false,
315
+ partial: false,
316
+ };
317
+ }
318
+ const hasNoSourceObservationInMemoryRecall = reflectionMatches.length > 0 && observations.some((observation) => observation.status === "no_source");
319
+ return {
320
+ status: "found",
321
+ memoryId,
322
+ reflectionMatches,
323
+ directObservationMatches,
324
+ observations,
325
+ sourceEntries,
326
+ unavailableSupportingObservations,
327
+ unavailableReflectionProvenance,
328
+ missingSourceEntryIds,
329
+ nonSourceEntryIds,
330
+ collision: reflectionMatches.length + directObservationMatches.length > 1,
331
+ partial: unavailableReflectionProvenance.length > 0 ||
332
+ hasNoSourceObservationInMemoryRecall ||
333
+ unavailableSupportingObservations.length > 0 ||
334
+ missingSourceEntryIds.length > 0 ||
335
+ nonSourceEntryIds.length > 0,
336
+ };
337
+ }
338
+ export function collectObservationsByCoverage(entries, priorFirstKeptEntryId, newFirstKeptEntryId) {
339
+ const idToIdx = new Map();
340
+ for (let i = 0; i < entries.length; i++)
341
+ idToIdx.set(entries[i].id, i);
342
+ const newFKIIdx = idToIdx.get(newFirstKeptEntryId);
343
+ if (newFKIIdx === undefined)
344
+ return [];
345
+ let priorFKIIdx;
346
+ if (priorFirstKeptEntryId === undefined) {
347
+ priorFKIIdx = -1;
348
+ }
349
+ else {
350
+ const idx = idToIdx.get(priorFirstKeptEntryId);
351
+ if (idx === undefined)
352
+ throw new Error(`priorFirstKeptEntryId "${priorFirstKeptEntryId}" not found in entries`);
353
+ priorFKIIdx = idx;
354
+ }
355
+ const result = [];
356
+ for (const entry of entries) {
357
+ if (!isObservationEntry(entry))
358
+ continue;
359
+ if (!isObservationEntryData(entry.data))
360
+ continue;
361
+ const fromIdx = idToIdx.get(entry.data.coversFromId);
362
+ if (fromIdx === undefined)
363
+ continue;
364
+ if (fromIdx >= priorFKIIdx && fromIdx < newFKIIdx)
365
+ result.push(entry.data);
366
+ }
367
+ return result;
368
+ }
369
+ function collectObservationsPendingNextCompaction(entries) {
370
+ const idToIdx = new Map();
371
+ for (let i = 0; i < entries.length; i++)
372
+ idToIdx.set(entries[i].id, i);
373
+ const priorCompactionIdx = findLastCompactionIndex(entries);
374
+ let thresholdIdx;
375
+ if (priorCompactionIdx === -1) {
376
+ thresholdIdx = -1;
377
+ }
378
+ else {
379
+ const priorFirstKept = entries[priorCompactionIdx].firstKeptEntryId;
380
+ if (!priorFirstKept)
381
+ throw new Error("prior compaction entry missing firstKeptEntryId");
382
+ const idx = idToIdx.get(priorFirstKept);
383
+ if (idx === undefined)
384
+ throw new Error(`prior firstKeptEntryId "${priorFirstKept}" not found in entries`);
385
+ thresholdIdx = idx;
386
+ }
387
+ const result = [];
388
+ for (const entry of entries) {
389
+ if (!isObservationEntry(entry))
390
+ continue;
391
+ if (!isObservationEntryData(entry.data))
392
+ continue;
393
+ const fromIdx = idToIdx.get(entry.data.coversFromId);
394
+ if (fromIdx === undefined)
395
+ continue;
396
+ if (fromIdx >= thresholdIdx)
397
+ result.push(entry.data);
398
+ }
399
+ return result;
400
+ }
401
+ export function getMemoryState(entries) {
402
+ const priorDetails = getPriorMemoryDetails(entries);
403
+ const pendingData = collectObservationsPendingNextCompaction(entries);
404
+ return {
405
+ reflections: priorDetails?.reflections ?? [],
406
+ committedObs: priorDetails?.observations ?? [],
407
+ pendingObs: pendingData.flatMap((d) => d.records),
408
+ };
409
+ }
@@ -0,0 +1,91 @@
1
+ import { SettingsManager } from "@mariozechner/pi-coding-agent";
2
+ import { getMemoryState, rawTokensSinceLastBound, rawTokensSinceLastCompaction, } from "../branch.js";
3
+ import { observationPoolTokens as estimateObservationPoolTokens } from "../compaction.js";
4
+ import { countByRelevance, formatRelevanceHistogram } from "../relevance.js";
5
+ import { estimateStringTokens } from "../tokens.js";
6
+ import { reflectionContent } from "../types.js";
7
+ export function registerStatusCommand(pi, runtime) {
8
+ pi.registerCommand("om-status", {
9
+ description: "Show observational memory status",
10
+ handler: async (_args, ctx) => {
11
+ runtime.ensureConfig(ctx.cwd);
12
+ const entries = ctx.sessionManager.getBranch();
13
+ const sinceBound = rawTokensSinceLastBound(entries);
14
+ const sinceCompaction = rawTokensSinceLastCompaction(entries);
15
+ const { reflections: committedRefs, committedObs, pendingObs } = getMemoryState(entries);
16
+ const committedRefItems = committedRefs;
17
+ const committedObsTokens = estimateObservationPoolTokens(committedObs);
18
+ const committedObsCount = committedObs.length;
19
+ const committedRefsTokens = committedRefItems.reduce((s, r) => s + estimateStringTokens(reflectionContent(r)), 0);
20
+ const committedRefsCount = committedRefItems.length;
21
+ const pendingObsTokens = estimateObservationPoolTokens(pendingObs);
22
+ const pendingObsCount = pendingObs.length;
23
+ const relevanceHistogram = countByRelevance([...committedObs, ...pendingObs]);
24
+ const keepRecentTokens = SettingsManager.create(ctx.cwd).getCompactionKeepRecentTokens();
25
+ const obsThreshold = runtime.config.observationThresholdTokens;
26
+ const compThreshold = runtime.config.compactionThresholdTokens;
27
+ const refThreshold = runtime.config.reflectionThresholdTokens;
28
+ // Approximation: the real compaction gate excludes pending obs whose coversFromId falls inside
29
+ // the new keep-recent tail (deferred to next cycle) and adds any sync-catch-up gap obs produced
30
+ // at compaction entry. We over-count by the tail slice and can't predict the gap obs here.
31
+ // Precise version would simulate the new firstKeptEntryId by walking back keepRecentTokens from
32
+ // the branch tail and split pending into pre-tail vs tail-covering.
33
+ const observationPoolTokens = estimateObservationPoolTokens([...committedObs, ...pendingObs]);
34
+ const obsPct = Math.min(100, Math.round((sinceBound / obsThreshold) * 100));
35
+ const compPct = Math.min(100, Math.round((sinceCompaction / compThreshold) * 100));
36
+ const refPct = Math.min(100, Math.round((observationPoolTokens / refThreshold) * 100));
37
+ const refLabel = committedRefsCount === 1 ? "entry" : "entries";
38
+ const cObsLabel = committedObsCount === 1 ? "observation" : "observations";
39
+ const pObsLabel = pendingObsCount === 1 ? "observation" : "observations";
40
+ const passiveLines = runtime.config.passive === true
41
+ ? [
42
+ "── Mode ──",
43
+ "Passive: proactive observation and compaction triggers disabled; compaction hook remains active",
44
+ "",
45
+ ]
46
+ : [];
47
+ const activityLines = runtime.config.passive === true
48
+ ? [
49
+ "── Activity ──",
50
+ `Observation trigger: passive (~${sinceBound.toLocaleString()} / ${obsThreshold.toLocaleString()} tokens, ${obsPct}%)`,
51
+ " → proactive observation is disabled; manual/Pi compaction can still run sync catch-up observation",
52
+ `Compaction trigger: passive (~${sinceCompaction.toLocaleString()} / ${compThreshold.toLocaleString()} tokens, ${compPct}%)`,
53
+ " → proactive extension-triggered compaction is disabled; manual/Pi compaction still uses the custom hook",
54
+ `Next reflection: ~${observationPoolTokens.toLocaleString()} / ${refThreshold.toLocaleString()} tokens (${refPct}%)`,
55
+ ` → if observations exceed ${refThreshold.toLocaleString()} tokens when compaction runs, reflections are`,
56
+ ` distilled from them and redundant observations are pruned away`,
57
+ ]
58
+ : [
59
+ "── Activity ──",
60
+ `Next observation: ~${sinceBound.toLocaleString()} / ${obsThreshold.toLocaleString()} tokens (${obsPct}%)`,
61
+ ` → at ${obsThreshold.toLocaleString()} tokens, recent conversation is compressed into new observations`,
62
+ `Next compaction: ~${sinceCompaction.toLocaleString()} / ${compThreshold.toLocaleString()} tokens (${compPct}%)`,
63
+ ` → at ${compThreshold.toLocaleString()} tokens, raw history is replaced by the updated reflections and`,
64
+ ` observations, keeping only the last ${keepRecentTokens.toLocaleString()} tokens of conversation verbatim`,
65
+ `Next reflection: ~${observationPoolTokens.toLocaleString()} / ${refThreshold.toLocaleString()} tokens (${refPct}%)`,
66
+ ` → if observations exceed ${refThreshold.toLocaleString()} tokens when compaction runs, reflections are`,
67
+ ` distilled from them and redundant observations are pruned away`,
68
+ ];
69
+ const lines = [
70
+ ...passiveLines,
71
+ "── Memory ──",
72
+ `Reflections: ~${committedRefsTokens.toLocaleString()} tokens (${committedRefsCount} ${refLabel}) — durable insights`,
73
+ `Observations:`,
74
+ ` committed ~${committedObsTokens.toLocaleString()} tokens (${committedObsCount} ${cObsLabel}) — folded into last compaction`,
75
+ ` pending ~${pendingObsTokens.toLocaleString()} tokens (${pendingObsCount} ${pObsLabel}) — waiting for next compaction`,
76
+ ` relevance ${formatRelevanceHistogram(relevanceHistogram)}`,
77
+ "",
78
+ ...activityLines,
79
+ ];
80
+ if (runtime.observerInFlight || runtime.compactInFlight) {
81
+ lines.push("");
82
+ lines.push("── In flight ──");
83
+ if (runtime.observerInFlight)
84
+ lines.push("Observer: running");
85
+ if (runtime.compactInFlight)
86
+ lines.push("Compaction: running");
87
+ }
88
+ ctx.ui.notify(lines.join("\n"), "info");
89
+ },
90
+ });
91
+ }
@@ -0,0 +1,61 @@
1
+ import { getMemoryState } from "../branch.js";
2
+ import { observationPoolTokens as estimateObservationPoolTokens } from "../compaction.js";
3
+ import { countByRelevance, formatRelevanceHistogram } from "../relevance.js";
4
+ import { estimateStringTokens } from "../tokens.js";
5
+ import { reflectionContent, reflectionToPromptLine } from "../types.js";
6
+ export function registerViewCommand(pi, runtime) {
7
+ pi.registerCommand("om-view", {
8
+ description: "Print observational memory details (reflections + observations)",
9
+ handler: async (_args, ctx) => {
10
+ runtime.ensureConfig(ctx.cwd);
11
+ const entries = ctx.sessionManager.getBranch();
12
+ const { reflections: committedRefs, committedObs, pendingObs } = getMemoryState(entries);
13
+ const committedRefItems = committedRefs;
14
+ const committedRefTokens = committedRefItems.reduce((s, r) => s + estimateStringTokens(reflectionContent(r)), 0);
15
+ const committedRefCount = committedRefItems.length;
16
+ const committedObsTokens = estimateObservationPoolTokens(committedObs);
17
+ const committedObsCount = committedObs.length;
18
+ const pendingObsTokens = estimateObservationPoolTokens(pendingObs);
19
+ const pendingObsCount = pendingObs.length;
20
+ const totalObsCount = committedObsCount + pendingObsCount;
21
+ const totalObsTokens = estimateObservationPoolTokens([...committedObs, ...pendingObs]);
22
+ const totalTokens = committedRefTokens + totalObsTokens;
23
+ const relevanceHistogram = countByRelevance([...committedObs, ...pendingObs]);
24
+ const plural = (n, singular, plural) => (n === 1 ? singular : plural);
25
+ const renderObs = (r) => `[${r.id}] ${r.timestamp} [${r.relevance}] ${r.content}`;
26
+ const sections = [];
27
+ sections.push(`Memory: ${committedRefCount} ${plural(committedRefCount, "reflection", "reflections")} · ` +
28
+ `${totalObsCount} ${plural(totalObsCount, "observation", "observations")} ` +
29
+ `(${committedObsCount} committed, ${pendingObsCount} pending) · ` +
30
+ `~${totalTokens.toLocaleString()} tokens · ` +
31
+ `relevance ${formatRelevanceHistogram(relevanceHistogram)}`);
32
+ sections.push("");
33
+ sections.push(`── Reflections (${committedRefCount} ${plural(committedRefCount, "entry", "entries")}, ~${committedRefTokens.toLocaleString()} tokens) ──`);
34
+ if (committedRefItems.length > 0) {
35
+ sections.push(committedRefItems.map(reflectionToPromptLine).join("\n\n"));
36
+ }
37
+ else {
38
+ sections.push("(none)");
39
+ }
40
+ sections.push("");
41
+ sections.push(`── Observations — committed (${committedObsCount} ${plural(committedObsCount, "observation", "observations")}, ~${committedObsTokens.toLocaleString()} tokens) ──`);
42
+ if (committedObs.length > 0) {
43
+ sections.push(committedObs.map(renderObs).join("\n"));
44
+ }
45
+ else {
46
+ sections.push("(none)");
47
+ }
48
+ sections.push("");
49
+ sections.push(`── Observations — pending (${pendingObsCount} ${plural(pendingObsCount, "observation", "observations")}, ~${pendingObsTokens.toLocaleString()} tokens) ──`);
50
+ if (pendingObs.length > 0) {
51
+ sections.push(pendingObs.map(renderObs).join("\n"));
52
+ }
53
+ else {
54
+ sections.push("(none)");
55
+ }
56
+ sections.push("");
57
+ sections.push("Tip: use /tree to browse the raw messages still live in the session.");
58
+ ctx.ui.notify(sections.join("\n"), "info");
59
+ },
60
+ });
61
+ }