@aexol/spectral 0.4.11 → 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.
- 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 +19 -4
- package/dist/server/handlers/sessions.js +19 -0
- package/dist/server/pi-bridge.js +93 -1
- package/dist/server/session-stream.js +311 -43
- 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, {
|
|
@@ -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,8 +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;
|
|
50
|
-
/**
|
|
51
|
-
|
|
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;
|
|
52
67
|
/**
|
|
53
68
|
* Number of accumulated wire events before flushing the in-flight turn
|
|
54
69
|
* to SQLite. Batch-persisting means a server crash mid-turn only loses
|
|
@@ -57,6 +72,160 @@ const LOOP_COMPACTION_MAX_WAIT_MS = 30_000;
|
|
|
57
72
|
const BATCH_FLUSH_INTERVAL = 10;
|
|
58
73
|
/** Marker the agent emits in its response to signal the task is complete. */
|
|
59
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
|
+
}
|
|
60
229
|
export class SessionStreamManager {
|
|
61
230
|
store;
|
|
62
231
|
cwd;
|
|
@@ -147,6 +316,97 @@ export class SessionStreamManager {
|
|
|
147
316
|
}
|
|
148
317
|
return ids;
|
|
149
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
|
+
}
|
|
150
410
|
/**
|
|
151
411
|
* Persist a user message and forward it to pi. Resolves after the user
|
|
152
412
|
* message is persisted + pi is invoked (NOT after the turn completes —
|
|
@@ -195,12 +455,15 @@ export class SessionStreamManager {
|
|
|
195
455
|
// subscribers, cwd, and other metadata are preserved.
|
|
196
456
|
if (stream.startError) {
|
|
197
457
|
const history = this.store.getSession(sessionId)?.messages;
|
|
458
|
+
const memorySnapshot = this.store.getSessionMemorySnapshot(sessionId);
|
|
459
|
+
const bridgeHistory = selectHistoryForBridge(history, memorySnapshot != null);
|
|
198
460
|
const bridgeOpts = {
|
|
199
461
|
cwd: stream.cwd,
|
|
200
462
|
agentDir: this.agentDir,
|
|
201
463
|
backendUrl: this.backendUrl,
|
|
202
464
|
machineJwt: this.machineJwt,
|
|
203
|
-
history,
|
|
465
|
+
history: bridgeHistory,
|
|
466
|
+
memorySnapshot,
|
|
204
467
|
emit: (event) => this.handleBridgeEvent(stream, event),
|
|
205
468
|
onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
|
|
206
469
|
try {
|
|
@@ -546,42 +809,44 @@ export class SessionStreamManager {
|
|
|
546
809
|
* A duplicate call from the extension's delayed compaction trigger is
|
|
547
810
|
* harmless — pi throws "Already compacted" which the extension catches.
|
|
548
811
|
*/
|
|
549
|
-
async sendNextLoopIteration(stream) {
|
|
550
|
-
// The observational-memory extension's compaction-trigger fires on
|
|
551
|
-
// agent_end (via setTimeout, so it's slightly delayed). We wait for
|
|
552
|
-
// either:
|
|
553
|
-
// (a) compaction to start and finish (stream.compacting becomes
|
|
554
|
-
// true then false), or
|
|
555
|
-
// (b) a grace period to elapse without compaction starting (context
|
|
556
|
-
// was below the threshold, or the extension deferred).
|
|
557
|
-
//
|
|
558
|
-
// This guarantees the LLM sees the compacted context when the
|
|
559
|
-
// compaction threshold was reached, and doesn't delay the loop when
|
|
560
|
-
// no compaction is needed.
|
|
561
|
-
const GRACE_MS = 500; // give the trigger's setTimeout a chance
|
|
562
|
-
const start = Date.now();
|
|
563
|
-
// Wait for the trigger to start compaction, or for grace period to pass.
|
|
564
|
-
while (!stream.compacting && Date.now() - start < GRACE_MS) {
|
|
565
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
566
|
-
}
|
|
567
|
-
if (stream.compacting) {
|
|
568
|
-
console.log("[loop] compaction in progress, waiting...");
|
|
569
|
-
await this.waitForCompactionOrTimeout(stream);
|
|
570
|
-
console.log("[loop] compaction finished, sending next iteration");
|
|
571
|
-
}
|
|
572
|
-
await this.prompt(stream.sessionId, stream.loopOriginalPrompt, undefined);
|
|
573
|
-
}
|
|
574
812
|
/**
|
|
575
|
-
*
|
|
813
|
+
* Send the next prompt for an autonomous loop iteration, compacting first
|
|
814
|
+
* if the context window exceeds the threshold.
|
|
815
|
+
*
|
|
816
|
+
* Instead of polling for the extension's delayed compaction-trigger (which
|
|
817
|
+
* fires via setTimeout), we proactively call bridge.compact() directly.
|
|
818
|
+
* bridge.compact() → session.compact() → fires session_before_compact
|
|
819
|
+
* (where the extension's compaction-hook provides the observational-memory
|
|
820
|
+
* summary), then appends the compaction entry, reloads the compacted
|
|
821
|
+
* context into agent.state.messages, and emits compaction_start/end.
|
|
822
|
+
*
|
|
823
|
+
* The extension's trigger still fires (via setTimeout), but by the time
|
|
824
|
+
* its callback runs, bridge.compact() has already appended the compaction
|
|
825
|
+
* entry → prepareCompaction() returns undefined → "Already compacted"
|
|
826
|
+
* → the trigger's onError handler catches it harmlessly.
|
|
576
827
|
*/
|
|
577
|
-
async
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
828
|
+
async sendNextLoopIteration(stream) {
|
|
829
|
+
const shouldCompact = stream.bridge.compact &&
|
|
830
|
+
typeof stream.contextWindowUsed === "number" &&
|
|
831
|
+
stream.contextWindowUsed > LOOP_COMPACTION_THRESHOLD_TOKENS;
|
|
832
|
+
if (shouldCompact) {
|
|
833
|
+
try {
|
|
834
|
+
console.log(`[loop] compacting context (~${stream.contextWindowUsed.toLocaleString()} tokens > ${LOOP_COMPACTION_THRESHOLD_TOKENS.toLocaleString()} threshold)`);
|
|
835
|
+
await stream.bridge.compact();
|
|
836
|
+
console.log("[loop] compaction complete, sending next iteration");
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
840
|
+
if (msg === "Already compacted" ||
|
|
841
|
+
msg === "Nothing to compact (session too small)") {
|
|
842
|
+
console.log("[loop] compaction already done or not needed, proceeding");
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
console.error(`[loop] compaction failed: ${msg}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
584
848
|
}
|
|
849
|
+
await this.prompt(stream.sessionId, stream.loopOriginalPrompt, undefined);
|
|
585
850
|
}
|
|
586
851
|
// --- internals ----------------------------------------------------------
|
|
587
852
|
createStream(sessionId, history) {
|
|
@@ -614,16 +879,18 @@ export class SessionStreamManager {
|
|
|
614
879
|
loopOriginalPrompt: null,
|
|
615
880
|
forkCompactSourceId: forkSourceId ?? null,
|
|
616
881
|
compacting: false,
|
|
617
|
-
compactionEndResolve: null,
|
|
618
882
|
contextWindowUsed: null,
|
|
619
883
|
contextWindowMax: null,
|
|
620
884
|
};
|
|
885
|
+
const memorySnapshot = this.store.getSessionMemorySnapshot(sessionId);
|
|
886
|
+
const bridgeHistory = selectHistoryForBridge(history, memorySnapshot != null);
|
|
621
887
|
const bridgeOpts = {
|
|
622
888
|
cwd,
|
|
623
889
|
agentDir: this.agentDir,
|
|
624
890
|
backendUrl: this.backendUrl,
|
|
625
891
|
machineJwt: this.machineJwt,
|
|
626
|
-
history,
|
|
892
|
+
history: bridgeHistory,
|
|
893
|
+
memorySnapshot,
|
|
627
894
|
emit: (event) => this.handleBridgeEvent(stream, event),
|
|
628
895
|
onAssistantMessageComplete: ({ messageId, content, eventsJsonl }) => {
|
|
629
896
|
try {
|
|
@@ -763,20 +1030,21 @@ export class SessionStreamManager {
|
|
|
763
1030
|
if (event.contextWindowMax != null)
|
|
764
1031
|
stream.contextWindowMax = event.contextWindowMax;
|
|
765
1032
|
}
|
|
766
|
-
// Track compaction lifecycle so
|
|
767
|
-
//
|
|
1033
|
+
// Track compaction lifecycle so prompt() can block new messages while
|
|
1034
|
+
// compaction is running.
|
|
768
1035
|
if (event.type === "compaction_start") {
|
|
769
1036
|
stream.compacting = true;
|
|
770
1037
|
}
|
|
771
1038
|
if (event.type === "compaction_end") {
|
|
772
1039
|
stream.compacting = false;
|
|
773
|
-
if (
|
|
774
|
-
stream.
|
|
775
|
-
stream.compactionEndResolve = null;
|
|
1040
|
+
if (!event.aborted) {
|
|
1041
|
+
prunePersistedHistoryAfterCompaction(this.store, stream.sessionId, stream.bridge);
|
|
776
1042
|
}
|
|
1043
|
+
persistObservationalMemorySnapshot(this.store, stream.sessionId, stream.bridge);
|
|
777
1044
|
}
|
|
778
1045
|
this.broadcast(stream, event);
|
|
779
1046
|
if (event.type === "agent_end") {
|
|
1047
|
+
persistObservationalMemorySnapshot(this.store, stream.sessionId, stream.bridge);
|
|
780
1048
|
// Final flush + clear batch-persist tracking. `onAssistantMessageComplete`
|
|
781
1049
|
// has already written the authoritative final row to SQLite (it fires on
|
|
782
1050
|
// `message_end`, which precedes `agent_end`), so the staged row is already
|
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
|
/**
|