@aexol/spectral 0.4.12 → 0.5.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.
@@ -231,12 +231,13 @@ export async function runServe(opts = {}) {
231
231
  // (the backend is the only producer and is exercised by tests).
232
232
  relay.on("frame", (frame) => {
233
233
  if (frame.kind === "rest_request") {
234
- const response = handleRestRequest(frame, {
234
+ void handleRestRequest(frame, {
235
235
  store,
236
236
  manager,
237
237
  publishMetaEvent: deferredPublishMetaEvent,
238
+ }).then((response) => {
239
+ relay.send(response);
238
240
  });
239
- relay.send(response);
240
241
  return;
241
242
  }
242
243
  if (frame.kind === "client_message") {
@@ -1,12 +1,19 @@
1
- import { OBSERVATION_CUSTOM_TYPE, isObservationEntryData, isReflectionRecord, isSupportedMemoryDetails, } from "./types.js";
1
+ import { OBSERVATION_CUSTOM_TYPE, OBSERVATIONAL_MEMORY_CONTEXT_CUSTOM_TYPE, OBSERVATIONAL_MEMORY_SNAPSHOT_CUSTOM_TYPE, isObservationEntryData, isReflectionRecord, isRestoredMemorySnapshot, isSupportedMemoryDetails, } from "./types.js";
2
+ import { sourceEntryReferenceIds } from "./serialize.js";
2
3
  import { estimateEntryTokens } from "./tokens.js";
3
- const RAW_TYPES = new Set(["message", "custom_message", "branch_summary"]);
4
4
  export function isSourceEntry(entry) {
5
- return RAW_TYPES.has(entry.type);
5
+ if (entry.type === "message" || entry.type === "branch_summary")
6
+ return true;
7
+ if (entry.type !== "custom_message")
8
+ return false;
9
+ return entry.customType !== OBSERVATIONAL_MEMORY_CONTEXT_CUSTOM_TYPE;
6
10
  }
7
11
  function isObservationEntry(entry) {
8
12
  return entry.type === "custom" && entry.customType === OBSERVATION_CUSTOM_TYPE;
9
13
  }
14
+ function isRestoredSnapshotEntry(entry) {
15
+ return entry.type === "custom" && entry.customType === OBSERVATIONAL_MEMORY_SNAPSHOT_CUSTOM_TYPE;
16
+ }
10
17
  export function findLastCompactionIndex(entries) {
11
18
  for (let i = entries.length - 1; i >= 0; i--) {
12
19
  if (entries[i].type === "compaction")
@@ -31,16 +38,43 @@ export function lastObservationCoverEndIdx(entries) {
31
38
  }
32
39
  return maxIdx;
33
40
  }
41
+ function getLatestRestoredSnapshot(entries) {
42
+ for (let i = entries.length - 1; i >= 0; i--) {
43
+ const entry = entries[i];
44
+ if (!isRestoredSnapshotEntry(entry))
45
+ continue;
46
+ if (!isRestoredMemorySnapshot(entry.data))
47
+ continue;
48
+ return entry.data;
49
+ }
50
+ return undefined;
51
+ }
34
52
  function rawTokensFromIndex(entries, startIndex) {
35
53
  let total = 0;
36
54
  for (let i = Math.max(0, startIndex); i < entries.length; i++) {
37
- if (RAW_TYPES.has(entries[i].type))
55
+ if (isSourceEntry(entries[i]))
38
56
  total += estimateEntryTokens(entries[i]);
39
57
  }
40
58
  return total;
41
59
  }
60
+ function branchIndexAfterCoveredSourceCount(entries, coveredSourceCount) {
61
+ if (coveredSourceCount <= 0)
62
+ return 0;
63
+ let seen = 0;
64
+ for (let i = 0; i < entries.length; i++) {
65
+ if (!isSourceEntry(entries[i]))
66
+ continue;
67
+ seen += 1;
68
+ if (seen >= coveredSourceCount)
69
+ return i + 1;
70
+ }
71
+ return entries.length;
72
+ }
42
73
  export function rawTokensSinceLastBound(entries) {
43
- return rawTokensFromIndex(entries, lastObservationCoverEndIdx(entries) + 1);
74
+ const coveredByLiveObservation = lastObservationCoverEndIdx(entries) + 1;
75
+ const restoredSnapshot = getLatestRestoredSnapshot(entries);
76
+ const coveredBySnapshot = branchIndexAfterCoveredSourceCount(entries, restoredSnapshot?.coveredSourceCount ?? 0);
77
+ return rawTokensFromIndex(entries, Math.max(coveredByLiveObservation, coveredBySnapshot));
44
78
  }
45
79
  export function rawTokensSinceLastCompaction(entries) {
46
80
  const compactionIdx = findLastCompactionIndex(entries);
@@ -62,7 +96,7 @@ function liveTailStartIndex(entries) {
62
96
  }
63
97
  export function firstRawIdAfter(entries, afterIndex) {
64
98
  for (let i = Math.max(0, afterIndex + 1); i < entries.length; i++) {
65
- if (RAW_TYPES.has(entries[i].type))
99
+ if (isSourceEntry(entries[i]))
66
100
  return entries[i].id;
67
101
  }
68
102
  return undefined;
@@ -74,7 +108,7 @@ export function gapRawEntries(entries, newFirstKeptEntryId) {
74
108
  return [];
75
109
  const result = [];
76
110
  for (let i = lastBoundIdx + 1; i < newKeptIdx; i++) {
77
- if (RAW_TYPES.has(entries[i].type))
111
+ if (isSourceEntry(entries[i]))
78
112
  result.push(entries[i]);
79
113
  }
80
114
  return result;
@@ -111,49 +145,65 @@ function collectIndexedObservations(entries) {
111
145
  function observationKey(observation) {
112
146
  return `${observation.observationEntryId}:${observation.observationRecordIndex}`;
113
147
  }
114
- function resolveSourceEntries(entries, sourceEntryIds) {
148
+ function sourceEntryMatchesRequestedId(entry, requested) {
149
+ if (!isSourceEntry(entry))
150
+ return false;
151
+ if (entry.id === requested)
152
+ return true;
153
+ return sourceEntryReferenceIds(entry).includes(requested);
154
+ }
155
+ function buildSourceEntryResolution(entries, sourceEntryIds) {
115
156
  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) {
157
+ const matchedEntries = new Map();
158
+ for (const entry of entries) {
159
+ for (const requestedId of requested) {
160
+ if (!sourceEntryMatchesRequestedId(entry, requestedId))
161
+ continue;
162
+ matchedEntries.set(entry.id, entry);
163
+ }
164
+ }
165
+ const missingSourceEntryIds = [];
166
+ const nonSourceEntryIds = [];
167
+ for (const requestedId of requested) {
168
+ const directEntry = entries.find((entry) => entry.id === requestedId);
169
+ if (directEntry && !isSourceEntry(directEntry)) {
170
+ nonSourceEntryIds.push(requestedId);
171
+ continue;
172
+ }
173
+ const matched = entries.some((entry) => sourceEntryMatchesRequestedId(entry, requestedId));
174
+ if (!matched)
175
+ missingSourceEntryIds.push(requestedId);
176
+ }
177
+ const sourceEntries = entries.filter((entry) => matchedEntries.has(entry.id) && isSourceEntry(entry));
178
+ return { requested, sourceEntries, missingSourceEntryIds, nonSourceEntryIds };
179
+ }
180
+ function resolveSourceEntries(entries, sourceEntryIds) {
181
+ const resolved = buildSourceEntryResolution(entries, sourceEntryIds);
182
+ if (resolved.missingSourceEntryIds.length > 0 || resolved.nonSourceEntryIds.length > 0) {
124
183
  return {
125
184
  status: "source_unavailable",
126
- sourceEntryIds: requested,
185
+ sourceEntryIds: resolved.requested,
127
186
  sourceEntries: [],
128
- missingSourceEntryIds,
129
- nonSourceEntryIds,
187
+ missingSourceEntryIds: resolved.missingSourceEntryIds,
188
+ nonSourceEntryIds: resolved.nonSourceEntryIds,
130
189
  };
131
190
  }
132
- const sourceEntries = entries.filter((entry) => requestedSet.has(entry.id));
133
191
  return {
134
192
  status: "ok",
135
- sourceEntryIds: sourceEntries.map((entry) => entry.id),
136
- sourceEntries,
193
+ sourceEntryIds: resolved.requested,
194
+ sourceEntries: resolved.sourceEntries,
137
195
  missingSourceEntryIds: [],
138
196
  nonSourceEntryIds: [],
139
197
  };
140
198
  }
141
199
  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));
200
+ const resolved = buildSourceEntryResolution(entries, sourceEntryIds);
151
201
  return {
152
- status: missingSourceEntryIds.length > 0 || nonSourceEntryIds.length > 0 ? "source_unavailable" : "ok",
153
- sourceEntryIds: requested,
154
- sourceEntries,
155
- missingSourceEntryIds,
156
- nonSourceEntryIds,
202
+ status: resolved.missingSourceEntryIds.length > 0 || resolved.nonSourceEntryIds.length > 0 ? "source_unavailable" : "ok",
203
+ sourceEntryIds: resolved.requested,
204
+ sourceEntries: resolved.sourceEntries,
205
+ missingSourceEntryIds: resolved.missingSourceEntryIds,
206
+ nonSourceEntryIds: resolved.nonSourceEntryIds,
157
207
  };
158
208
  }
159
209
  function memoryObservationFromIndexed(entries, indexed) {
@@ -243,10 +293,12 @@ export function recallObservationSources(entries, observationId) {
243
293
  }
244
294
  function getPriorMemoryDetails(entries) {
245
295
  const idx = findLastCompactionIndex(entries);
246
- if (idx === -1)
247
- return undefined;
248
- const details = entries[idx].details;
249
- return isSupportedMemoryDetails(details) ? details : undefined;
296
+ if (idx !== -1) {
297
+ const details = entries[idx].details;
298
+ if (isSupportedMemoryDetails(details))
299
+ return details;
300
+ }
301
+ return getLatestRestoredSnapshot(entries)?.details;
250
302
  }
251
303
  export function recallMemorySources(entries, memoryId) {
252
304
  const indexedObservations = collectIndexedObservations(entries);
@@ -371,9 +423,10 @@ function collectObservationsPendingNextCompaction(entries) {
371
423
  for (let i = 0; i < entries.length; i++)
372
424
  idToIdx.set(entries[i].id, i);
373
425
  const priorCompactionIdx = findLastCompactionIndex(entries);
374
- let thresholdIdx;
426
+ const restoredSnapshot = getLatestRestoredSnapshot(entries);
427
+ let thresholdStartIdx;
375
428
  if (priorCompactionIdx === -1) {
376
- thresholdIdx = -1;
429
+ thresholdStartIdx = branchIndexAfterCoveredSourceCount(entries, restoredSnapshot?.coveredSourceCount ?? 0);
377
430
  }
378
431
  else {
379
432
  const priorFirstKept = entries[priorCompactionIdx].firstKeptEntryId;
@@ -382,7 +435,7 @@ function collectObservationsPendingNextCompaction(entries) {
382
435
  const idx = idToIdx.get(priorFirstKept);
383
436
  if (idx === undefined)
384
437
  throw new Error(`prior firstKeptEntryId "${priorFirstKept}" not found in entries`);
385
- thresholdIdx = idx;
438
+ thresholdStartIdx = idx;
386
439
  }
387
440
  const result = [];
388
441
  for (const entry of entries) {
@@ -393,7 +446,7 @@ function collectObservationsPendingNextCompaction(entries) {
393
446
  const fromIdx = idToIdx.get(entry.data.coversFromId);
394
447
  if (fromIdx === undefined)
395
448
  continue;
396
- if (fromIdx >= thresholdIdx)
449
+ if (fromIdx >= thresholdStartIdx)
397
450
  result.push(entry.data);
398
451
  }
399
452
  return result;
@@ -1,3 +1,4 @@
1
+ import { hashId } from "./ids.js";
1
2
  function pad(n) {
2
3
  return n.toString().padStart(2, "0");
3
4
  }
@@ -141,9 +142,44 @@ export function serializeBranchEntries(entries) {
141
142
  }
142
143
  return blocks.join("\n\n");
143
144
  }
145
+ export const DURABLE_SOURCE_ENTRY_ID_PREFIX = "srcv1:";
144
146
  function isSourceRenderableEntry(entry) {
145
147
  return entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary";
146
148
  }
149
+ function sourceTimestampSignature(entry) {
150
+ if (entry.type === "message" && entry.message && typeof entry.message === "object") {
151
+ const msg = entry.message;
152
+ if (msg.timestamp !== undefined)
153
+ return String(msg.timestamp);
154
+ }
155
+ if (entry.timestamp !== undefined)
156
+ return String(entry.timestamp);
157
+ return "";
158
+ }
159
+ export function durableSourceEntryId(entry) {
160
+ if (!isSourceRenderableEntry(entry))
161
+ return undefined;
162
+ const rendered = serializeBranchEntries([entry]);
163
+ if (!rendered.trim())
164
+ return undefined;
165
+ const fingerprint = [
166
+ "source-v1",
167
+ `type:${entry.type}`,
168
+ `customType:${entry.customType ?? ""}`,
169
+ `timestamp:${sourceTimestampSignature(entry)}`,
170
+ rendered,
171
+ ].join("\n");
172
+ return `${DURABLE_SOURCE_ENTRY_ID_PREFIX}${hashId(fingerprint)}`;
173
+ }
174
+ export function sourceEntryReferenceIds(entry) {
175
+ const refs = [];
176
+ if (entry.id)
177
+ refs.push(entry.id);
178
+ const durableId = durableSourceEntryId(entry);
179
+ if (durableId && durableId !== entry.id)
180
+ refs.push(durableId);
181
+ return refs;
182
+ }
147
183
  export function serializeSourceAddressedBranchEntries(entries) {
148
184
  const blocks = [];
149
185
  const sourceEntryIds = [];
@@ -153,8 +189,9 @@ export function serializeSourceAddressedBranchEntries(entries) {
153
189
  const rendered = serializeBranchEntries([entry]);
154
190
  if (!rendered.trim())
155
191
  continue;
156
- sourceEntryIds.push(entry.id);
157
- blocks.push(`[Source entry id: ${entry.id}]\n${rendered}`);
192
+ const sourceEntryId = durableSourceEntryId(entry) ?? entry.id;
193
+ sourceEntryIds.push(sourceEntryId);
194
+ blocks.push(`[Source entry id: ${sourceEntryId}]\n${rendered}`);
158
195
  }
159
196
  return { text: blocks.join("\n\n"), sourceEntryIds };
160
197
  }
@@ -1,4 +1,6 @@
1
1
  export const OBSERVATION_CUSTOM_TYPE = "om.observation";
2
+ export const OBSERVATIONAL_MEMORY_SNAPSHOT_CUSTOM_TYPE = "om.snapshot";
3
+ export const OBSERVATIONAL_MEMORY_CONTEXT_CUSTOM_TYPE = "om.snapshot.context";
2
4
  export const RELEVANCE_VALUES = ["low", "medium", "high", "critical"];
3
5
  export const MEMORY_ID_PATTERN = /^[a-f0-9]{12}$/;
4
6
  function isRelevance(v) {
@@ -93,3 +95,15 @@ export function isObservationEntryData(d) {
93
95
  typeof o.coversUpToId === "string" &&
94
96
  typeof o.tokenCount === "number");
95
97
  }
98
+ export function isRestoredMemorySnapshot(d) {
99
+ if (!d || typeof d !== "object")
100
+ return false;
101
+ const o = d;
102
+ return (o.type === "observational-memory-snapshot" &&
103
+ o.version === 1 &&
104
+ typeof o.coveredSourceCount === "number" &&
105
+ Number.isInteger(o.coveredSourceCount) &&
106
+ o.coveredSourceCount >= 0 &&
107
+ typeof o.summary === "string" &&
108
+ isSupportedMemoryDetails(o.details));
109
+ }
@@ -41,7 +41,7 @@
41
41
  import { BadRequestError, NotFoundError } from "../server/handlers/errors.js";
42
42
  import { handlePathAutocomplete } from "../server/handlers/paths-autocomplete.js";
43
43
  import { handleCreateProject, handleDeleteProject, handleListProjects, handleListSessionsByProject, handleUpdateProject, } from "../server/handlers/projects.js";
44
- import { handleCreateSession, handleDeleteSession, handleForkSession, handleGetSessionDetail, handleUpdateSession, } from "../server/handlers/sessions.js";
44
+ import { handleCompactSession, handleCreateSession, handleDeleteSession, handleForkSession, handleGetSessionDetail, handleGetSessionMemoryStatus, handleUpdateSession, } from "../server/handlers/sessions.js";
45
45
  import { shutdownState } from "../server/shutdown.js";
46
46
  /**
47
47
  * Inline path matcher. Returns `null` for any path/method combination we
@@ -103,6 +103,17 @@ export function matchRoute(method, path) {
103
103
  return { route: "delete_session", id };
104
104
  return null;
105
105
  }
106
+ // /api/sessions/:id/memory and /api/sessions/:id/compact
107
+ const sessionActionMatch = /^\/api\/sessions\/([^/]+)\/(memory|compact)$/.exec(cleanPath);
108
+ if (sessionActionMatch) {
109
+ const id = decodeURIComponent(sessionActionMatch[1]);
110
+ const action = sessionActionMatch[2];
111
+ if (action === "memory" && method === "GET")
112
+ return { route: "get_session_memory", id };
113
+ if (action === "compact" && method === "POST")
114
+ return { route: "compact_session", id };
115
+ return null;
116
+ }
106
117
  // /api/sessions/:id/fork
107
118
  const forkMatch = /^\/api\/sessions\/([^/]+)\/fork$/.exec(cleanPath);
108
119
  if (forkMatch) {
@@ -128,7 +139,7 @@ export function matchRoute(method, path) {
128
139
  * with the original Hono router which also 404'd)
129
140
  * - 500: anything else; `error` field carries a sanitized message
130
141
  */
131
- export function handleRestRequest(frame, deps) {
142
+ export async function handleRestRequest(frame, deps) {
132
143
  const { reqId, method, path, body } = frame;
133
144
  const logger = deps.logger ?? console;
134
145
  const match = matchRoute(method, path);
@@ -141,7 +152,7 @@ export function handleRestRequest(frame, deps) {
141
152
  };
142
153
  }
143
154
  try {
144
- const result = dispatchRoute(match, body, deps);
155
+ const result = await dispatchRoute(match, body, deps);
145
156
  return {
146
157
  kind: "rest_response",
147
158
  reqId,
@@ -189,7 +200,7 @@ export function handleRestRequest(frame, deps) {
189
200
  * The body is `unknown` from the wire; each handler validates its own
190
201
  * shape via the `BadRequestError`-throwing checks they already do.
191
202
  */
192
- function dispatchRoute(match, body, deps) {
203
+ async function dispatchRoute(match, body, deps) {
193
204
  const { store, manager, publishMetaEvent, logger } = deps;
194
205
  const id = match.id ?? "";
195
206
  switch (match.route) {
@@ -256,6 +267,8 @@ function dispatchRoute(match, body, deps) {
256
267
  }
257
268
  case "get_session":
258
269
  return handleGetSessionDetail(store, id);
270
+ case "get_session_memory":
271
+ return handleGetSessionMemoryStatus(store, manager, id);
259
272
  case "update_session": {
260
273
  const session = handleUpdateSession(store, id, asObject(body));
261
274
  safePublish(publishMetaEvent, logger, {
@@ -283,6 +296,8 @@ function dispatchRoute(match, body, deps) {
283
296
  }
284
297
  return { ok: true };
285
298
  }
299
+ case "compact_session":
300
+ return await handleCompactSession(store, manager, id);
286
301
  case "fork_session": {
287
302
  const session = handleForkSession(store, id, asObject(body));
288
303
  safePublish(publishMetaEvent, logger, {
@@ -40,6 +40,25 @@ export function handleDeleteSession(store, id) {
40
40
  if (!deleted)
41
41
  throw new NotFoundError("Session not found");
42
42
  }
43
+ export function handleGetSessionMemoryStatus(store, manager, id) {
44
+ const detail = store.getSession(id);
45
+ if (!detail)
46
+ throw new NotFoundError("Session not found");
47
+ return manager.getSessionMemoryStatus(id);
48
+ }
49
+ export async function handleCompactSession(store, manager, id) {
50
+ const detail = store.getSession(id);
51
+ if (!detail)
52
+ throw new NotFoundError("Session not found");
53
+ try {
54
+ await manager.compactSession(id);
55
+ }
56
+ catch (error) {
57
+ const message = error instanceof Error ? error.message : String(error);
58
+ throw new BadRequestError(message);
59
+ }
60
+ return { ok: true };
61
+ }
43
62
  /**
44
63
  * Fork a session: create a new session copying all messages from the
45
64
  * source, with the `fork_compact_source_id` flag set so the
@@ -56,6 +56,8 @@ import { dirname, resolve } from "node:path";
56
56
  import { fileURLToPath } from "node:url";
57
57
  import aexolMcpExtension from "../extensions/aexol-mcp.js";
58
58
  import observationalMemory from "../memory/index.js";
59
+ import { Runtime } from "../memory/runtime.js";
60
+ import { OBSERVATIONAL_MEMORY_CONTEXT_CUSTOM_TYPE, OBSERVATIONAL_MEMORY_SNAPSHOT_CUSTOM_TYPE, } from "../memory/types.js";
59
61
  import { fetchAllowedModels as defaultFetchAllowedModels, } from "../relay/models-fetch.js";
60
62
  /**
61
63
  * Synthetic provider names registered with pi's `ModelRegistry`. They route
@@ -294,8 +296,28 @@ function sanitizeRehydratedBlock(block) {
294
296
  `\n\n[truncated — ${omitted.toLocaleString()} bytes omitted]`,
295
297
  };
296
298
  }
299
+ function normalizeSnapshotDetails(details) {
300
+ if (details.version === 4)
301
+ return details;
302
+ return {
303
+ type: "observational-memory",
304
+ version: 4,
305
+ observations: details.observations,
306
+ reflections: details.reflections,
307
+ };
308
+ }
309
+ function toRestoredMemorySnapshot(snapshot) {
310
+ return {
311
+ type: "observational-memory-snapshot",
312
+ version: 1,
313
+ coveredSourceCount: snapshot.coveredSourceCount,
314
+ summary: snapshot.summary,
315
+ details: normalizeSnapshotDetails(snapshot.details),
316
+ };
317
+ }
297
318
  export class PiBridge {
298
319
  session;
320
+ sessionManager;
299
321
  unsubscribe;
300
322
  pending;
301
323
  disposed = false;
@@ -322,6 +344,8 @@ export class PiBridge {
322
344
  lastAppliedModelId;
323
345
  /** Current model's credit rates (from availableBaseModels), used for token_usage. */
324
346
  activeCreditRates = null;
347
+ memoryRuntime = new Runtime();
348
+ memoryPhase = "idle";
325
349
  constructor(opts) {
326
350
  this.opts = opts;
327
351
  }
@@ -369,6 +393,7 @@ export class PiBridge {
369
393
  await resourceLoader.reload();
370
394
  // In-memory session: SQLite is our source of truth.
371
395
  const sessionManager = SessionManager.inMemory(this.opts.cwd);
396
+ this.sessionManager = sessionManager;
372
397
  // Rehydrate session history so the LLM sees the full conversation
373
398
  // transcript from the beginning (not just the current prompt).
374
399
  // Tool calls and their results are reconstructed from the persisted
@@ -465,6 +490,22 @@ export class PiBridge {
465
490
  }
466
491
  console.info(`[PiBridge] Rehydrated ${this.opts.history.length} history message(s) into session context`);
467
492
  }
493
+ if (this.opts.memorySnapshot) {
494
+ const restoredSnapshot = toRestoredMemorySnapshot(this.opts.memorySnapshot);
495
+ sessionManager.appendCustomEntry(OBSERVATIONAL_MEMORY_SNAPSHOT_CUSTOM_TYPE, restoredSnapshot);
496
+ // Always inject the snapshot summary into LLM context. When raw
497
+ // history is also rehydrated, selectHistoryForBridge limits it to
498
+ // a small recent tail so duplication with the summary is minimal.
499
+ // The summary carries the compacted older context that would
500
+ // otherwise be lost after reconnect/restart.
501
+ if (restoredSnapshot.summary.trim().length > 0) {
502
+ sessionManager.appendCustomMessageEntry(OBSERVATIONAL_MEMORY_CONTEXT_CUSTOM_TYPE, restoredSnapshot.summary, false, {
503
+ restoredFromSnapshot: true,
504
+ coveredSourceCount: restoredSnapshot.coveredSourceCount,
505
+ });
506
+ }
507
+ console.info(`[PiBridge] Restored observational memory snapshot covering ${restoredSnapshot.coveredSourceCount} source entr${restoredSnapshot.coveredSourceCount === 1 ? "y" : "ies"}`);
508
+ }
468
509
  // Build a model registry that does NOT touch ~/.pi/agent/auth.json or
469
510
  // ~/.pi/agent/models.json — the backend is now the only source of
470
511
  // provider credentials and the only allowed inference target. We then
@@ -506,6 +547,9 @@ export class PiBridge {
506
547
  // Headless UI context: forwards extension notify() calls as wire events
507
548
  // so the browser can surface memory activity, MCP status, etc.
508
549
  const uiContext = createHeadlessUIContext((event) => {
550
+ if (event.type === "agent_notification") {
551
+ this.updateMemoryPhase(event.message, event.system);
552
+ }
509
553
  try {
510
554
  this.opts.emit(event);
511
555
  }
@@ -666,6 +710,38 @@ export class PiBridge {
666
710
  getFirstAvailableModelId() {
667
711
  return this.allowedModels?.[0]?.modelId;
668
712
  }
713
+ getSessionBranch() {
714
+ return (this.sessionManager?.getBranch() ?? []);
715
+ }
716
+ getMemoryActivity() {
717
+ return {
718
+ phase: this.memoryPhase,
719
+ inFlight: {
720
+ observer: this.memoryRuntime.observerInFlight,
721
+ compaction: this.memoryRuntime.compactInFlight || this.memoryRuntime.compactHookInFlight,
722
+ reflection: this.memoryPhase === "reflecting",
723
+ pruner: this.memoryPhase === "pruning",
724
+ },
725
+ };
726
+ }
727
+ updateMemoryPhase(message, system) {
728
+ const lower = message.toLowerCase();
729
+ if (system === "memory_reflection" || lower.includes("reflector") || lower.includes("reflecting")) {
730
+ this.memoryPhase = "reflecting";
731
+ return;
732
+ }
733
+ if (system === "memory_pruner" || lower.includes("pruner") || lower.includes("pruning") || lower.includes("prune")) {
734
+ this.memoryPhase = "pruning";
735
+ return;
736
+ }
737
+ if (system === "memory_compaction" || lower.includes("compaction") || lower.includes("compact")) {
738
+ this.memoryPhase = "compacting";
739
+ return;
740
+ }
741
+ if (system === "memory_observer" || lower.includes("observer")) {
742
+ this.memoryPhase = "observing";
743
+ }
744
+ }
669
745
  async setModel(modelId) {
670
746
  if (!modelId)
671
747
  return true; // nothing to apply — pi keeps its current model
@@ -761,7 +837,18 @@ export class PiBridge {
761
837
  async compact(customInstructions) {
762
838
  if (!this.session)
763
839
  throw new Error("PiBridge.start() not called");
764
- await this.session.compact(customInstructions);
840
+ this.memoryPhase = "compacting";
841
+ try {
842
+ await this.session.compact(customInstructions);
843
+ }
844
+ finally {
845
+ if (this.memoryPhase === "compacting" &&
846
+ !this.memoryRuntime.observerInFlight &&
847
+ !this.memoryRuntime.compactInFlight &&
848
+ !this.memoryRuntime.compactHookInFlight) {
849
+ this.memoryPhase = "idle";
850
+ }
851
+ }
765
852
  }
766
853
  dispose() {
767
854
  if (this.disposed)
@@ -795,6 +882,10 @@ export class PiBridge {
795
882
  * `events_jsonl` for the current assistant message.
796
883
  */
797
884
  emitAndBuffer(event) {
885
+ if (event.type === "compaction_start")
886
+ this.memoryPhase = "compacting";
887
+ if (event.type === "compaction_end")
888
+ this.memoryPhase = "idle";
798
889
  if (this.pending)
799
890
  this.pending.wireEvents.push(event);
800
891
  this.opts.emit(event);
@@ -1019,6 +1110,7 @@ export class PiBridge {
1019
1110
  type: "compaction_end",
1020
1111
  summary: ev.result?.summary ?? "",
1021
1112
  tokensBefore: ev.result?.tokensBefore ?? 0,
1113
+ firstKeptEntryId: ev.result?.firstKeptEntryId,
1022
1114
  aborted: ev.aborted,
1023
1115
  });
1024
1116
  return;
@@ -38,6 +38,11 @@
38
38
  */
39
39
  import { randomUUID } from "node:crypto";
40
40
  import { PiBridge } from "./pi-bridge.js";
41
+ import { getMemoryState, isSourceEntry, rawTokensSinceLastBound, rawTokensSinceLastCompaction, } from "../memory/branch.js";
42
+ import { observationPoolTokens, renderSummary } from "../memory/compaction.js";
43
+ import { loadConfig } from "../memory/config.js";
44
+ import { estimateStringTokens } from "../memory/tokens.js";
45
+ import { reflectionContent } from "../memory/types.js";
41
46
  import { generateSessionTitle, isDefaultTitle, } from "./title-generator.js";
42
47
  const DEFAULT_BRIDGE_FACTORY = (args) => new PiBridge(args);
43
48
  /** Safety limit for autonomous loop iterations per session. */
@@ -47,6 +52,18 @@ const MAX_LOOP_ITERATIONS = 100;
47
52
  * Aligned with the observational memory extension default (memory/config.ts).
48
53
  */
49
54
  const LOOP_COMPACTION_THRESHOLD_TOKENS = 50_000;
55
+ /**
56
+ * Defensive cap for fresh-bridge history replay when we already have a
57
+ * persisted observational-memory snapshot. Pi's own compaction keeps a
58
+ * recent live tail verbatim and summarizes the older prefix; replaying the
59
+ * entire SQLite transcript after restart defeats that and can explode the
60
+ * next prompt. We therefore rehydrate only an approximate recent tail.
61
+ *
62
+ * Keep this conservative — the tail is rehydrated as raw user/assistant
63
+ * messages PLUS pi reconstructs tool-result messages from events_jsonl,
64
+ * so the actual context inflation is 3-8× the stored token estimate.
65
+ */
66
+ const REHYDRATION_TAIL_TOKEN_BUDGET = 20_000;
50
67
  /**
51
68
  * Number of accumulated wire events before flushing the in-flight turn
52
69
  * to SQLite. Batch-persisting means a server crash mid-turn only loses
@@ -55,6 +72,160 @@ const LOOP_COMPACTION_THRESHOLD_TOKENS = 50_000;
55
72
  const BATCH_FLUSH_INTERVAL = 10;
56
73
  /** Marker the agent emits in its response to signal the task is complete. */
57
74
  const LOOP_DONE_MARKER = "<LOOP_DONE>";
75
+ function persistObservationalMemorySnapshot(store, sessionId, bridge) {
76
+ if (!bridge.getSessionBranch)
77
+ return;
78
+ try {
79
+ const entries = bridge.getSessionBranch();
80
+ const memoryState = getMemoryState(entries);
81
+ const allObservations = [...memoryState.committedObs, ...memoryState.pendingObs];
82
+ const hasMemory = memoryState.reflections.length > 0 || allObservations.length > 0;
83
+ if (!hasMemory) {
84
+ store.deleteSessionMemorySnapshot(sessionId);
85
+ return;
86
+ }
87
+ const summary = renderSummary(memoryState.reflections, allObservations);
88
+ const details = {
89
+ type: "observational-memory",
90
+ version: 4,
91
+ observations: allObservations,
92
+ reflections: memoryState.reflections,
93
+ };
94
+ store.upsertSessionMemorySnapshot(sessionId, {
95
+ summary,
96
+ details,
97
+ coveredSourceCount: entries.filter((entry) => isSourceEntry(entry)).length,
98
+ });
99
+ }
100
+ catch (err) {
101
+ console.warn(`[spectral] warn: failed to persist observational memory snapshot for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
102
+ }
103
+ }
104
+ function estimateStoredMessageReplayTokens(message) {
105
+ return (estimateStringTokens(message.content) +
106
+ estimateStringTokens(message.events) +
107
+ (message.images?.length ?? 0) * 2_000);
108
+ }
109
+ function selectHistoryForBridge(history, hasMemorySnapshot) {
110
+ if (!history || history.length === 0)
111
+ return history;
112
+ if (!hasMemorySnapshot)
113
+ return history;
114
+ let total = 0;
115
+ let start = history.length - 1;
116
+ for (; start >= 0; start--) {
117
+ const nextTotal = total + estimateStoredMessageReplayTokens(history[start]);
118
+ if (total > 0 && nextTotal > REHYDRATION_TAIL_TOKEN_BUDGET)
119
+ break;
120
+ total = nextTotal;
121
+ }
122
+ const selected = history.slice(Math.max(0, start + 1));
123
+ return selected.length > 0 ? selected : [history[history.length - 1]];
124
+ }
125
+ function prunePersistedHistoryAfterCompaction(store, sessionId, bridge) {
126
+ if (!bridge.getSessionBranch)
127
+ return;
128
+ try {
129
+ const entries = bridge.getSessionBranch();
130
+ // Pi appends a CompactionEntry (type "compaction") at the end whose
131
+ // firstKeptEntryId marks the first branch entry that survives in LLM context.
132
+ // We scan backwards so we find the most recent compaction (there should be
133
+ // exactly one per compaction pass).
134
+ const lastCompaction = [...entries].reverse().find((entry) => entry.type === "compaction");
135
+ const firstKeptEntryId = lastCompaction?.firstKeptEntryId;
136
+ if (!firstKeptEntryId) {
137
+ console.log(`[spectral] prune: skipped – no compaction entry (or no firstKeptEntryId) ` +
138
+ `in branch for ${sessionId} (${entries.length} entries)`);
139
+ return;
140
+ }
141
+ const keptEntryIndex = entries.findIndex((entry) => entry.id === firstKeptEntryId);
142
+ if (keptEntryIndex === -1) {
143
+ console.log(`[spectral] prune: skipped – firstKeptEntryId "${firstKeptEntryId}" ` +
144
+ `not found in ${entries.length} branch entries for ${sessionId}`);
145
+ return;
146
+ }
147
+ // Count user + assistant messages in the kept portion of the branch.
148
+ // Tool-result entries are separate branch entries but are NOT separate
149
+ // SQLite rows – they live inside the assistant message's events_jsonl.
150
+ // Counting them would inflate the count and make the prune a silent no-op.
151
+ let keptPersistedMessageCount = 0;
152
+ for (let i = keptEntryIndex; i < entries.length; i++) {
153
+ const entry = entries[i];
154
+ if (entry.type !== "message")
155
+ continue;
156
+ const role = entry.message && typeof entry.message === "object" && "role" in entry.message
157
+ ? entry.message.role
158
+ : undefined;
159
+ if (role === "user" || role === "assistant" || role === "system") {
160
+ keptPersistedMessageCount += 1;
161
+ }
162
+ }
163
+ const existing = store.getMessages(sessionId);
164
+ if (existing.length === 0)
165
+ return;
166
+ console.log(`[spectral] prune: ${sessionId} – branch has ${entries.length} entries, ` +
167
+ `kept portion starts at index ${keptEntryIndex} (id=${firstKeptEntryId}), ` +
168
+ `${keptPersistedMessageCount} user+assistant entries kept, ` +
169
+ `${existing.length} SQLite messages`);
170
+ if (keptPersistedMessageCount <= 0) {
171
+ console.log(`[spectral] prune: skipped – no kept messages for ${sessionId}`);
172
+ return;
173
+ }
174
+ if (keptPersistedMessageCount >= existing.length) {
175
+ console.log(`[spectral] prune: skipped – kept message count (${keptPersistedMessageCount}) ` +
176
+ `>= SQLite message count (${existing.length}) for ${sessionId}`);
177
+ return;
178
+ }
179
+ const keptMessages = existing.slice(-keptPersistedMessageCount);
180
+ const pruned = existing.length - keptMessages.length;
181
+ store.deleteMessagesBySession(sessionId);
182
+ for (const msg of keptMessages) {
183
+ store.appendMessage(sessionId, {
184
+ id: msg.id,
185
+ role: msg.role,
186
+ content: msg.content,
187
+ eventsJsonl: msg.events,
188
+ createdAt: msg.createdAt,
189
+ images: msg.images,
190
+ });
191
+ }
192
+ console.log(`[spectral] prune: finished – dropped ${pruned} pre-compaction message(s) ` +
193
+ `for ${sessionId}, ${keptMessages.length} kept`);
194
+ }
195
+ catch (err) {
196
+ console.warn(`[spectral] warn: failed to prune persisted history after compaction for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
197
+ }
198
+ }
199
+ function defaultMemoryStatus(cwd) {
200
+ const config = loadConfig(cwd);
201
+ return {
202
+ mode: config.passive ? "passive" : "active",
203
+ phase: "idle",
204
+ inFlight: {
205
+ observer: false,
206
+ compaction: false,
207
+ reflection: false,
208
+ pruner: false,
209
+ },
210
+ reflections: { count: 0, tokens: 0 },
211
+ observations: {
212
+ committedCount: 0,
213
+ committedTokens: 0,
214
+ pendingCount: 0,
215
+ pendingTokens: 0,
216
+ },
217
+ thresholds: {
218
+ observation: config.observationThresholdTokens,
219
+ compaction: config.compactionThresholdTokens,
220
+ reflection: config.reflectionThresholdTokens,
221
+ },
222
+ progress: {
223
+ sinceObservationBoundTokens: 0,
224
+ sinceLastCompactionTokens: 0,
225
+ observationPoolTokens: 0,
226
+ },
227
+ };
228
+ }
58
229
  export class SessionStreamManager {
59
230
  store;
60
231
  cwd;
@@ -145,6 +316,97 @@ export class SessionStreamManager {
145
316
  }
146
317
  return ids;
147
318
  }
319
+ getSessionMemoryStatus(sessionId) {
320
+ const detail = this.store.getSession(sessionId);
321
+ if (!detail)
322
+ throw new Error(`Unknown sessionId: ${sessionId}`);
323
+ const stream = this.streams.get(sessionId);
324
+ const config = loadConfig(stream?.cwd ?? this.cwd);
325
+ const status = defaultMemoryStatus(stream?.cwd ?? this.cwd);
326
+ status.mode = config.passive ? "passive" : "active";
327
+ const entries = stream?.bridge.getSessionBranch?.();
328
+ if (entries) {
329
+ const memoryState = getMemoryState(entries);
330
+ status.reflections = {
331
+ count: memoryState.reflections.length,
332
+ tokens: memoryState.reflections.reduce((sum, reflection) => sum + estimateStringTokens(reflectionContent(reflection)), 0),
333
+ };
334
+ status.observations = {
335
+ committedCount: memoryState.committedObs.length,
336
+ committedTokens: observationPoolTokens(memoryState.committedObs),
337
+ pendingCount: memoryState.pendingObs.length,
338
+ pendingTokens: observationPoolTokens(memoryState.pendingObs),
339
+ };
340
+ status.progress = {
341
+ sinceObservationBoundTokens: rawTokensSinceLastBound(entries),
342
+ sinceLastCompactionTokens: rawTokensSinceLastCompaction(entries),
343
+ observationPoolTokens: observationPoolTokens([
344
+ ...memoryState.committedObs,
345
+ ...memoryState.pendingObs,
346
+ ]),
347
+ };
348
+ }
349
+ else {
350
+ const snapshot = this.store.getSessionMemorySnapshot(sessionId);
351
+ if (snapshot) {
352
+ status.reflections = {
353
+ count: snapshot.details.reflections.length,
354
+ tokens: snapshot.details.reflections.reduce((sum, reflection) => sum + estimateStringTokens(reflectionContent(reflection)), 0),
355
+ };
356
+ status.observations = {
357
+ committedCount: snapshot.details.observations.length,
358
+ committedTokens: observationPoolTokens(snapshot.details.observations),
359
+ pendingCount: 0,
360
+ pendingTokens: 0,
361
+ };
362
+ status.progress.observationPoolTokens = observationPoolTokens(snapshot.details.observations);
363
+ }
364
+ }
365
+ const activity = stream?.bridge.getMemoryActivity?.();
366
+ if (activity) {
367
+ status.phase = activity.phase;
368
+ status.inFlight = activity.inFlight;
369
+ }
370
+ else if (stream?.compacting) {
371
+ status.phase = "compacting";
372
+ status.inFlight.compaction = true;
373
+ }
374
+ return status;
375
+ }
376
+ async compactSession(sessionId) {
377
+ if (this.disposed)
378
+ throw new Error("SessionStreamManager disposed");
379
+ const detail = this.store.getSession(sessionId);
380
+ if (!detail)
381
+ throw new Error(`Unknown sessionId: ${sessionId}`);
382
+ let stream = this.streams.get(sessionId);
383
+ if (!stream) {
384
+ stream = this.createStream(sessionId, detail.messages);
385
+ this.streams.set(sessionId, stream);
386
+ }
387
+ if (stream.currentTurn) {
388
+ throw new Error("Session is busy with an active turn");
389
+ }
390
+ if (stream.compacting) {
391
+ throw new Error("Session is already being compacted");
392
+ }
393
+ if (!stream.bridge.compact) {
394
+ throw new Error("Manual compaction is not supported for this session");
395
+ }
396
+ await stream.ready;
397
+ stream.compacting = true;
398
+ try {
399
+ await stream.bridge.compact();
400
+ // Prune persisted history synchronously after compaction, so even if the
401
+ // compaction_end event handler races or misses, SQLite reflects the
402
+ // compacted state before the next user prompt or reconnect.
403
+ prunePersistedHistoryAfterCompaction(this.store, sessionId, stream.bridge);
404
+ }
405
+ finally {
406
+ if (stream.compacting)
407
+ stream.compacting = false;
408
+ }
409
+ }
148
410
  /**
149
411
  * Persist a user message and forward it to pi. Resolves after the user
150
412
  * message is persisted + pi is invoked (NOT after the turn completes —
@@ -193,12 +455,15 @@ export class SessionStreamManager {
193
455
  // subscribers, cwd, and other metadata are preserved.
194
456
  if (stream.startError) {
195
457
  const history = this.store.getSession(sessionId)?.messages;
458
+ const memorySnapshot = this.store.getSessionMemorySnapshot(sessionId);
459
+ const bridgeHistory = selectHistoryForBridge(history, memorySnapshot != null);
196
460
  const bridgeOpts = {
197
461
  cwd: stream.cwd,
198
462
  agentDir: this.agentDir,
199
463
  backendUrl: this.backendUrl,
200
464
  machineJwt: this.machineJwt,
201
- history,
465
+ history: bridgeHistory,
466
+ memorySnapshot,
202
467
  emit: (event) => this.handleBridgeEvent(stream, event),
203
468
  onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
204
469
  try {
@@ -617,12 +882,15 @@ export class SessionStreamManager {
617
882
  contextWindowUsed: null,
618
883
  contextWindowMax: null,
619
884
  };
885
+ const memorySnapshot = this.store.getSessionMemorySnapshot(sessionId);
886
+ const bridgeHistory = selectHistoryForBridge(history, memorySnapshot != null);
620
887
  const bridgeOpts = {
621
888
  cwd,
622
889
  agentDir: this.agentDir,
623
890
  backendUrl: this.backendUrl,
624
891
  machineJwt: this.machineJwt,
625
- history,
892
+ history: bridgeHistory,
893
+ memorySnapshot,
626
894
  emit: (event) => this.handleBridgeEvent(stream, event),
627
895
  onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
628
896
  try {
@@ -769,9 +1037,14 @@ export class SessionStreamManager {
769
1037
  }
770
1038
  if (event.type === "compaction_end") {
771
1039
  stream.compacting = false;
1040
+ if (!event.aborted) {
1041
+ prunePersistedHistoryAfterCompaction(this.store, stream.sessionId, stream.bridge);
1042
+ }
1043
+ persistObservationalMemorySnapshot(this.store, stream.sessionId, stream.bridge);
772
1044
  }
773
1045
  this.broadcast(stream, event);
774
1046
  if (event.type === "agent_end") {
1047
+ persistObservationalMemorySnapshot(this.store, stream.sessionId, stream.bridge);
775
1048
  // Final flush + clear batch-persist tracking. `onAssistantMessageComplete`
776
1049
  // has already written the authoritative final row to SQLite (it fires on
777
1050
  // `message_end`, which precedes `agent_end`), so the staged row is already
@@ -36,7 +36,7 @@ import { stripJsoncComments } from "../studio-binding.js";
36
36
  * Since this is local-only conversation history pre-1.0, we explicitly do not
37
37
  * preserve user data across schema changes.
38
38
  */
39
- const SCHEMA_VERSION = 2;
39
+ const SCHEMA_VERSION = 3;
40
40
  const SCHEMA_SQL = `
41
41
  PRAGMA foreign_keys = ON;
42
42
 
@@ -70,6 +70,14 @@ CREATE TABLE IF NOT EXISTS messages (
70
70
  );
71
71
 
72
72
  CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
73
+
74
+ CREATE TABLE IF NOT EXISTS session_memory_snapshots (
75
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
76
+ summary TEXT NOT NULL,
77
+ details_json TEXT NOT NULL,
78
+ covered_source_count INTEGER NOT NULL,
79
+ updated_at INTEGER NOT NULL
80
+ );
73
81
  `;
74
82
  /**
75
83
  * Synchronous binding-file reader for a project at the given filesystem path.
@@ -100,7 +108,7 @@ function applyBindingFields(project) {
100
108
  };
101
109
  }
102
110
  /** Tables we own — used by the migration drop step. */
103
- const KNOWN_TABLES = ["messages", "sessions", "projects"];
111
+ const KNOWN_TABLES = ["session_memory_snapshots", "messages", "sessions", "projects"];
104
112
  /** Best-effort parse of the `images_json` column into `ImageAttachment[]`. */
105
113
  function parseImagesJson(raw) {
106
114
  if (!raw || raw === "")
@@ -148,9 +156,13 @@ export class SessionStore {
148
156
  stmtListMessages;
149
157
  stmtAppendMessage;
150
158
  stmtDeleteMessage;
159
+ stmtDeleteMessagesBySession;
151
160
  stmtTouchSession;
152
161
  stmtRenameSession;
153
162
  stmtSessionWithCount;
163
+ stmtGetSessionMemorySnapshot;
164
+ stmtUpsertSessionMemorySnapshot;
165
+ stmtDeleteSessionMemorySnapshot;
154
166
  // Phase 3: per-session sticky model.
155
167
  stmtGetSessionModel;
156
168
  stmtSetSessionModel;
@@ -276,6 +288,7 @@ export class SessionStore {
276
288
  this.stmtAppendMessage = this.db.prepare(`INSERT OR REPLACE INTO messages (id, session_id, role, content, events_jsonl, images_json, created_at)
277
289
  VALUES (?, ?, ?, ?, ?, ?, ?)`);
278
290
  this.stmtDeleteMessage = this.db.prepare(`DELETE FROM messages WHERE id = ?`);
291
+ this.stmtDeleteMessagesBySession = this.db.prepare(`DELETE FROM messages WHERE session_id = ?`);
279
292
  this.stmtTouchSession = this.db.prepare(`UPDATE sessions SET updated_at = ? WHERE id = ?`);
280
293
  this.stmtRenameSession = this.db.prepare(`UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?`);
281
294
  this.stmtSessionWithCount = this.db.prepare(`
@@ -284,6 +297,12 @@ export class SessionStore {
284
297
  FROM sessions s
285
298
  WHERE s.id = ?
286
299
  `);
300
+ this.stmtGetSessionMemorySnapshot = this.db.prepare(`SELECT session_id, summary, details_json, covered_source_count, updated_at
301
+ FROM session_memory_snapshots WHERE session_id = ?`);
302
+ this.stmtUpsertSessionMemorySnapshot = this.db.prepare(`INSERT OR REPLACE INTO session_memory_snapshots
303
+ (session_id, summary, details_json, covered_source_count, updated_at)
304
+ VALUES (?, ?, ?, ?, ?)`);
305
+ this.stmtDeleteSessionMemorySnapshot = this.db.prepare(`DELETE FROM session_memory_snapshots WHERE session_id = ?`);
287
306
  this.stmtGetSessionModel = this.db.prepare(`SELECT model_id FROM sessions WHERE id = ?`);
288
307
  this.stmtSetSessionModel = this.db.prepare(`UPDATE sessions SET model_id = ? WHERE id = ?`);
289
308
  this.stmtSetForkCompactSource = this.db.prepare(`UPDATE sessions SET fork_compact_source_id = ? WHERE id = ?`);
@@ -463,6 +482,15 @@ export class SessionStore {
463
482
  console.error(`[spectral] error: deleteMessage failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
464
483
  }
465
484
  }
485
+ /** Delete all persisted messages for a session. */
486
+ deleteMessagesBySession(sessionId) {
487
+ try {
488
+ this.stmtDeleteMessagesBySession.run(sessionId);
489
+ }
490
+ catch (err) {
491
+ console.error(`[spectral] error: deleteMessagesBySession failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
492
+ }
493
+ }
466
494
  appendMessage(sessionId, msg) {
467
495
  const id = msg.id ?? randomUUID();
468
496
  const createdAt = msg.createdAt ?? Date.now();
@@ -511,6 +539,34 @@ export class SessionStore {
511
539
  return info.changes > 0;
512
540
  }
513
541
  // ----------------------------------------------------------------------
542
+ // Observational memory snapshot persistence
543
+ // ----------------------------------------------------------------------
544
+ getSessionMemorySnapshot(sessionId) {
545
+ const row = this.stmtGetSessionMemorySnapshot.get(sessionId);
546
+ if (!row)
547
+ return null;
548
+ try {
549
+ return {
550
+ sessionId: row.session_id,
551
+ summary: row.summary,
552
+ details: JSON.parse(row.details_json),
553
+ coveredSourceCount: row.covered_source_count,
554
+ updatedAt: row.updated_at,
555
+ };
556
+ }
557
+ catch (err) {
558
+ console.warn(`[storage] invalid memory snapshot for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
559
+ return null;
560
+ }
561
+ }
562
+ upsertSessionMemorySnapshot(sessionId, snapshot) {
563
+ const now = Date.now();
564
+ this.stmtUpsertSessionMemorySnapshot.run(sessionId, snapshot.summary, JSON.stringify(snapshot.details), snapshot.coveredSourceCount, now);
565
+ }
566
+ deleteSessionMemorySnapshot(sessionId) {
567
+ this.stmtDeleteSessionMemorySnapshot.run(sessionId);
568
+ }
569
+ // ----------------------------------------------------------------------
514
570
  // Per-session sticky model (Phase 3 — Available Models whitelist)
515
571
  // ----------------------------------------------------------------------
516
572
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.4.12",
3
+ "version": "0.5.1",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,