@aexol/spectral 0.4.12 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/serve.js +3 -2
- package/dist/memory/branch.js +97 -44
- package/dist/memory/serialize.js +39 -2
- package/dist/memory/types.js +14 -0
- package/dist/relay/dispatcher.js +22 -5
- package/dist/server/handlers/sessions.js +19 -0
- package/dist/server/pi-bridge.js +93 -1
- package/dist/server/session-stream.js +311 -9
- package/dist/server/storage.js +58 -2
- package/package.json +1 -1
package/dist/commands/serve.js
CHANGED
|
@@ -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
|
-
|
|
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") {
|
package/dist/memory/branch.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
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
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
426
|
+
const restoredSnapshot = getLatestRestoredSnapshot(entries);
|
|
427
|
+
let thresholdStartIdx;
|
|
375
428
|
if (priorCompactionIdx === -1) {
|
|
376
|
-
|
|
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
|
-
|
|
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 >=
|
|
449
|
+
if (fromIdx >= thresholdStartIdx)
|
|
397
450
|
result.push(entry.data);
|
|
398
451
|
}
|
|
399
452
|
return result;
|
package/dist/memory/serialize.js
CHANGED
|
@@ -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
|
-
|
|
157
|
-
|
|
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
|
}
|
package/dist/memory/types.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/relay/dispatcher.js
CHANGED
|
@@ -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, {
|
|
@@ -450,11 +465,13 @@ export function handleClientMessage(frame, deps) {
|
|
|
450
465
|
}
|
|
451
466
|
const content = message.content;
|
|
452
467
|
const isLoop = message.loop === true;
|
|
468
|
+
const loopMaxIterations = message.loopMaxIterations;
|
|
469
|
+
const loopGoal = message.loopGoal;
|
|
453
470
|
const validImages = coerceImages(message.images);
|
|
454
471
|
// Set autonomous iterative loop state before firing the prompt.
|
|
455
472
|
// loop:true → start/renew loop with the current content as original prompt;
|
|
456
473
|
// loop:false → stop any active loop.
|
|
457
|
-
manager.setLoopActive(sessionId, isLoop, content);
|
|
474
|
+
manager.setLoopActive(sessionId, isLoop, content, loopMaxIterations, loopGoal);
|
|
458
475
|
// 2. Attach (idempotent). On first attach we capture the replay payload
|
|
459
476
|
// and synthesize a `session_ready` ws_event so the browser sees the
|
|
460
477
|
// same first frame it would have on a direct WS connection.
|
|
@@ -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
|
package/dist/server/pi-bridge.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
@@ -377,6 +642,7 @@ export class SessionStreamManager {
|
|
|
377
642
|
// the next agent_end would otherwise trigger another prompt.
|
|
378
643
|
stream.loopActive = false;
|
|
379
644
|
stream.loopOriginalPrompt = null;
|
|
645
|
+
stream.loopGoal = null;
|
|
380
646
|
stream.loopIterationCount = 0;
|
|
381
647
|
// Dispose the pi bridge immediately — this tears down pi's session and
|
|
382
648
|
// unsubscribe. The bridge's own event handler is detached; no further
|
|
@@ -466,13 +732,22 @@ export class SessionStreamManager {
|
|
|
466
732
|
* The loop stops when the agent emits `<LOOP_DONE>` in its response or the
|
|
467
733
|
* safety iteration limit is reached.
|
|
468
734
|
*/
|
|
469
|
-
setLoopActive(sessionId, active, originalPrompt) {
|
|
735
|
+
setLoopActive(sessionId, active, originalPrompt, maxIterations, goal) {
|
|
470
736
|
const stream = this.streams.get(sessionId);
|
|
471
737
|
if (stream) {
|
|
472
738
|
stream.loopActive = active;
|
|
473
739
|
stream.loopOriginalPrompt = active ? (originalPrompt ?? null) : null;
|
|
474
|
-
if (
|
|
740
|
+
if (active) {
|
|
741
|
+
if (maxIterations !== undefined && maxIterations > 0) {
|
|
742
|
+
stream.loopMaxIterations = Math.min(maxIterations, MAX_LOOP_ITERATIONS);
|
|
743
|
+
}
|
|
744
|
+
stream.loopGoal = goal?.trim() || null;
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
475
747
|
stream.loopIterationCount = 0;
|
|
748
|
+
stream.loopMaxIterations = MAX_LOOP_ITERATIONS;
|
|
749
|
+
stream.loopGoal = null;
|
|
750
|
+
}
|
|
476
751
|
}
|
|
477
752
|
}
|
|
478
753
|
/**
|
|
@@ -560,6 +835,20 @@ export class SessionStreamManager {
|
|
|
560
835
|
* entry → prepareCompaction() returns undefined → "Already compacted"
|
|
561
836
|
* → the trigger's onError handler catches it harmlessly.
|
|
562
837
|
*/
|
|
838
|
+
buildLoopPrompt(stream) {
|
|
839
|
+
const parts = [];
|
|
840
|
+
if (stream.loopGoal) {
|
|
841
|
+
parts.push(`[GOAL]: ${stream.loopGoal}`);
|
|
842
|
+
parts.push("");
|
|
843
|
+
}
|
|
844
|
+
parts.push(stream.loopOriginalPrompt);
|
|
845
|
+
if (stream.loopGoal) {
|
|
846
|
+
parts.push("");
|
|
847
|
+
parts.push("After completing your work, evaluate whether the goal has been FULLY achieved. " +
|
|
848
|
+
'Include <LOOP_DONE> in your response ONLY if you are confident the goal is completely met.');
|
|
849
|
+
}
|
|
850
|
+
return parts.join("\n");
|
|
851
|
+
}
|
|
563
852
|
async sendNextLoopIteration(stream) {
|
|
564
853
|
const shouldCompact = stream.bridge.compact &&
|
|
565
854
|
typeof stream.contextWindowUsed === "number" &&
|
|
@@ -581,7 +870,7 @@ export class SessionStreamManager {
|
|
|
581
870
|
}
|
|
582
871
|
}
|
|
583
872
|
}
|
|
584
|
-
await this.prompt(stream.sessionId, stream
|
|
873
|
+
await this.prompt(stream.sessionId, this.buildLoopPrompt(stream), undefined);
|
|
585
874
|
}
|
|
586
875
|
// --- internals ----------------------------------------------------------
|
|
587
876
|
createStream(sessionId, history) {
|
|
@@ -612,17 +901,22 @@ export class SessionStreamManager {
|
|
|
612
901
|
loopActive: false,
|
|
613
902
|
loopIterationCount: 0,
|
|
614
903
|
loopOriginalPrompt: null,
|
|
904
|
+
loopMaxIterations: MAX_LOOP_ITERATIONS,
|
|
905
|
+
loopGoal: null,
|
|
615
906
|
forkCompactSourceId: forkSourceId ?? null,
|
|
616
907
|
compacting: false,
|
|
617
908
|
contextWindowUsed: null,
|
|
618
909
|
contextWindowMax: null,
|
|
619
910
|
};
|
|
911
|
+
const memorySnapshot = this.store.getSessionMemorySnapshot(sessionId);
|
|
912
|
+
const bridgeHistory = selectHistoryForBridge(history, memorySnapshot != null);
|
|
620
913
|
const bridgeOpts = {
|
|
621
914
|
cwd,
|
|
622
915
|
agentDir: this.agentDir,
|
|
623
916
|
backendUrl: this.backendUrl,
|
|
624
917
|
machineJwt: this.machineJwt,
|
|
625
|
-
history,
|
|
918
|
+
history: bridgeHistory,
|
|
919
|
+
memorySnapshot,
|
|
626
920
|
emit: (event) => this.handleBridgeEvent(stream, event),
|
|
627
921
|
onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
|
|
628
922
|
try {
|
|
@@ -769,9 +1063,14 @@ export class SessionStreamManager {
|
|
|
769
1063
|
}
|
|
770
1064
|
if (event.type === "compaction_end") {
|
|
771
1065
|
stream.compacting = false;
|
|
1066
|
+
if (!event.aborted) {
|
|
1067
|
+
prunePersistedHistoryAfterCompaction(this.store, stream.sessionId, stream.bridge);
|
|
1068
|
+
}
|
|
1069
|
+
persistObservationalMemorySnapshot(this.store, stream.sessionId, stream.bridge);
|
|
772
1070
|
}
|
|
773
1071
|
this.broadcast(stream, event);
|
|
774
1072
|
if (event.type === "agent_end") {
|
|
1073
|
+
persistObservationalMemorySnapshot(this.store, stream.sessionId, stream.bridge);
|
|
775
1074
|
// Final flush + clear batch-persist tracking. `onAssistantMessageComplete`
|
|
776
1075
|
// has already written the authoritative final row to SQLite (it fires on
|
|
777
1076
|
// `message_end`, which precedes `agent_end`), so the staged row is already
|
|
@@ -797,15 +1096,17 @@ export class SessionStreamManager {
|
|
|
797
1096
|
stream.loopActive = false;
|
|
798
1097
|
stream.loopIterationCount = 0;
|
|
799
1098
|
stream.loopOriginalPrompt = null;
|
|
1099
|
+
stream.loopGoal = null;
|
|
800
1100
|
this.broadcast(stream, {
|
|
801
1101
|
type: "loop_complete",
|
|
802
1102
|
iterations: completedIterations,
|
|
803
1103
|
});
|
|
804
1104
|
}
|
|
805
|
-
else if (stream.loopIterationCount >=
|
|
806
|
-
console.log(`[loop] max iterations (${
|
|
1105
|
+
else if (stream.loopIterationCount >= stream.loopMaxIterations) {
|
|
1106
|
+
console.log(`[loop] max iterations (${stream.loopMaxIterations}) reached, stopping`);
|
|
807
1107
|
stream.loopActive = false;
|
|
808
1108
|
stream.loopOriginalPrompt = null;
|
|
1109
|
+
stream.loopGoal = null;
|
|
809
1110
|
this.broadcast(stream, {
|
|
810
1111
|
type: "loop_max_iterations",
|
|
811
1112
|
iterations: stream.loopIterationCount,
|
|
@@ -813,17 +1114,18 @@ export class SessionStreamManager {
|
|
|
813
1114
|
}
|
|
814
1115
|
else {
|
|
815
1116
|
stream.loopIterationCount++;
|
|
816
|
-
console.log(`[loop] iteration ${stream.loopIterationCount}/${
|
|
1117
|
+
console.log(`[loop] iteration ${stream.loopIterationCount}/${stream.loopMaxIterations}`);
|
|
817
1118
|
this.broadcast(stream, {
|
|
818
1119
|
type: "loop_iteration",
|
|
819
1120
|
iteration: stream.loopIterationCount,
|
|
820
|
-
maxIterations:
|
|
1121
|
+
maxIterations: stream.loopMaxIterations,
|
|
821
1122
|
prompt: stream.loopOriginalPrompt,
|
|
822
1123
|
});
|
|
823
1124
|
void this.sendNextLoopIteration(stream).catch((err) => {
|
|
824
1125
|
console.error(`[loop] iteration failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
825
1126
|
stream.loopActive = false;
|
|
826
1127
|
stream.loopOriginalPrompt = null;
|
|
1128
|
+
stream.loopGoal = null;
|
|
827
1129
|
});
|
|
828
1130
|
}
|
|
829
1131
|
}
|
package/dist/server/storage.js
CHANGED
|
@@ -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 =
|
|
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
|
/**
|