@sovorn/pi-session-memory 0.1.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/README.md +224 -0
- package/package.json +17 -0
- package/src/config.ts +140 -0
- package/src/extension.ts +10 -0
- package/src/formation.ts +700 -0
- package/src/hydration.ts +327 -0
- package/src/memory-command.ts +68 -0
- package/src/projection.ts +349 -0
- package/src/supersession.ts +223 -0
- package/worker/__init__.py +1 -0
- package/worker/__main__.py +31 -0
- package/worker/laya_runtime.py +412 -0
- package/worker/protocol.py +141 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import type { ContextEvent, ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { supersessionStatuses } from "./supersession.ts";
|
|
3
|
+
|
|
4
|
+
const OBSERVATION_TYPE = "pi-session-memory.observation";
|
|
5
|
+
const REFLECTION_TYPE = "pi-session-memory.reflection";
|
|
6
|
+
const MAX_CANDIDATES = 8;
|
|
7
|
+
export const CANDIDATES = 6;
|
|
8
|
+
const MAX_PROJECTION_CHARS = 6_000;
|
|
9
|
+
export const PROJECTION_CHARS = 4_000;
|
|
10
|
+
|
|
11
|
+
export interface ProjectionLimits {
|
|
12
|
+
candidates: number;
|
|
13
|
+
chars: number;
|
|
14
|
+
}
|
|
15
|
+
const MAX_NEED_CHARS = 800;
|
|
16
|
+
const MAX_CANDIDATE_TEXT_CHARS = 500;
|
|
17
|
+
const MAX_GATE_STATE_BYTES = 8_000;
|
|
18
|
+
const MAX_ENTRY_ID_CHARS = 256;
|
|
19
|
+
const MAX_MEMORY_TEXT_CHARS = 1_000;
|
|
20
|
+
const MAX_RAW_SOURCES = 12;
|
|
21
|
+
const MAX_REFLECTION_SOURCES = 6;
|
|
22
|
+
const MEMORY_LABEL = "Relevant prior session memory (validated active-branch provenance; context only):";
|
|
23
|
+
const STOP_WORDS = new Set(["this", "that", "with", "from", "what", "when", "where", "which", "about", "please", "current", "need", "does", "have", "been", "were", "your", "they", "them", "then", "than"]);
|
|
24
|
+
|
|
25
|
+
export type ProjectionGate = "resident" | "projection";
|
|
26
|
+
export interface ProjectionGateRequest {
|
|
27
|
+
gate: ProjectionGate;
|
|
28
|
+
state: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ProjectionSufficiencyDecision {
|
|
31
|
+
accepted: boolean;
|
|
32
|
+
p_true: number;
|
|
33
|
+
confidence: number;
|
|
34
|
+
}
|
|
35
|
+
export interface ProjectionSelectionDecision {
|
|
36
|
+
selected_entry_id: string | null;
|
|
37
|
+
}
|
|
38
|
+
export type ProjectionDecision = ProjectionSufficiencyDecision | ProjectionSelectionDecision;
|
|
39
|
+
type EvaluateProjection = (sessionId: string, request: ProjectionGateRequest, ctx: ExtensionContext) => Promise<ProjectionDecision>;
|
|
40
|
+
type ContextMessages = ContextEvent["messages"];
|
|
41
|
+
|
|
42
|
+
interface ObservationData {
|
|
43
|
+
schemaVersion: 1;
|
|
44
|
+
text: string;
|
|
45
|
+
sourceEntryIds: string[];
|
|
46
|
+
}
|
|
47
|
+
interface ReflectionData {
|
|
48
|
+
schemaVersion: 1;
|
|
49
|
+
text: string;
|
|
50
|
+
supportingObservationIds: string[];
|
|
51
|
+
}
|
|
52
|
+
interface IndexedObservation {
|
|
53
|
+
data: ObservationData;
|
|
54
|
+
index: number;
|
|
55
|
+
}
|
|
56
|
+
interface Candidate {
|
|
57
|
+
entryId: string;
|
|
58
|
+
kind: "observation" | "reflection";
|
|
59
|
+
text: string;
|
|
60
|
+
observationIds: string[];
|
|
61
|
+
sourceEntryIds: string[];
|
|
62
|
+
sourceText: string;
|
|
63
|
+
branchIndex: number;
|
|
64
|
+
}
|
|
65
|
+
type Resident = Pick<Candidate, "entryId" | "kind" | "text">;
|
|
66
|
+
export function registerProjection(pi: ExtensionAPI, evaluate: EvaluateProjection, limits: ProjectionLimits = { candidates: CANDIDATES, chars: PROJECTION_CHARS }): () => void {
|
|
67
|
+
const residents = new Map<string, Resident>();
|
|
68
|
+
pi.on("context", async (event, ctx) => {
|
|
69
|
+
try {
|
|
70
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
71
|
+
let branch = ctx.sessionManager.getBranch();
|
|
72
|
+
const originalMessages = event.messages;
|
|
73
|
+
const latestUserText = lastUserText(originalMessages);
|
|
74
|
+
if (!latestUserText) return { messages: originalMessages };
|
|
75
|
+
const limit = boundedLimit(limits.candidates, CANDIDATES, MAX_CANDIDATES);
|
|
76
|
+
const outputLimit = boundedLimit(limits.chars, PROJECTION_CHARS, MAX_PROJECTION_CHARS);
|
|
77
|
+
|
|
78
|
+
let candidates = currentCandidates(branch);
|
|
79
|
+
const currentTerms = words(latestUserText);
|
|
80
|
+
const memoryNeed = needsMemory(latestUserText, currentTerms, candidates);
|
|
81
|
+
if (!memoryNeed) return { messages: withoutMemoryProjections(originalMessages) };
|
|
82
|
+
|
|
83
|
+
const residentId = residents.get(sessionId)?.entryId;
|
|
84
|
+
if (residentId) {
|
|
85
|
+
const resident = candidateById(branch, residentId);
|
|
86
|
+
if (resident) {
|
|
87
|
+
const state = encodeState({ need: truncate(latestUserText, MAX_NEED_CHARS), candidate: { entryId: resident.entryId, kind: resident.kind, text: resident.text } });
|
|
88
|
+
if (!state) return { messages: withoutMemoryProjections(originalMessages) };
|
|
89
|
+
const decision = await evaluate(sessionId, { gate: "resident", state }, ctx);
|
|
90
|
+
const currentBranch = ctx.sessionManager.getBranch();
|
|
91
|
+
if (ctx.sessionManager.getSessionId() !== sessionId) return { messages: withoutMemoryProjections(originalMessages) };
|
|
92
|
+
const currentResident = candidateById(currentBranch, resident.entryId);
|
|
93
|
+
if (!currentResident || currentResident.text !== resident.text || currentResident.kind !== resident.kind) {
|
|
94
|
+
residents.delete(sessionId);
|
|
95
|
+
return { messages: withoutMemoryProjections(originalMessages) };
|
|
96
|
+
}
|
|
97
|
+
if (!isSufficiencyDecision(decision)) return { messages: withoutMemoryProjections(originalMessages) };
|
|
98
|
+
if (decision.accepted) {
|
|
99
|
+
const text = renderCandidate(currentResident);
|
|
100
|
+
if (text.length > outputLimit) return { messages: withoutMemoryProjections(originalMessages) };
|
|
101
|
+
residents.set(sessionId, asResident(currentResident));
|
|
102
|
+
return { messages: withProjection(originalMessages, text) };
|
|
103
|
+
}
|
|
104
|
+
branch = currentBranch;
|
|
105
|
+
candidates = currentCandidates(branch);
|
|
106
|
+
}
|
|
107
|
+
residents.delete(sessionId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ranked = candidates
|
|
111
|
+
.map((candidate) => ({ candidate, score: candidateScore(candidate, currentTerms, words(activeContextText(originalMessages, latestUserText)), branch.length) }))
|
|
112
|
+
.sort((left, right) => right.score - left.score || right.candidate.branchIndex - left.candidate.branchIndex)
|
|
113
|
+
.slice(0, limit)
|
|
114
|
+
.map(({ candidate }) => candidate);
|
|
115
|
+
if (ranked.length === 0) return { messages: withoutMemoryProjections(originalMessages) };
|
|
116
|
+
|
|
117
|
+
const state = encodeState({
|
|
118
|
+
need: truncate(latestUserText, MAX_NEED_CHARS),
|
|
119
|
+
candidates: ranked.map(({ entryId, kind, text }) => ({ entryId, kind, text: truncate(text, MAX_CANDIDATE_TEXT_CHARS) })),
|
|
120
|
+
});
|
|
121
|
+
if (!state) return { messages: withoutMemoryProjections(originalMessages) };
|
|
122
|
+
const selection = await evaluate(sessionId, { gate: "projection", state }, ctx);
|
|
123
|
+
if (!isSelectionDecision(selection) || selection.selected_entry_id === null) return { messages: withoutMemoryProjections(originalMessages) };
|
|
124
|
+
const selected = ranked.find(({ entryId }) => entryId === selection.selected_entry_id);
|
|
125
|
+
if (!selected) return { messages: withoutMemoryProjections(originalMessages) };
|
|
126
|
+
|
|
127
|
+
const currentBranch = ctx.sessionManager.getBranch();
|
|
128
|
+
if (ctx.sessionManager.getSessionId() !== sessionId) return { messages: withoutMemoryProjections(originalMessages) };
|
|
129
|
+
const revalidated = candidateById(currentBranch, selected.entryId);
|
|
130
|
+
if (!revalidated || revalidated.text !== selected.text || revalidated.kind !== selected.kind) return { messages: withoutMemoryProjections(originalMessages) };
|
|
131
|
+
const text = renderCandidate(revalidated);
|
|
132
|
+
if (text.length > outputLimit) return { messages: withoutMemoryProjections(originalMessages) };
|
|
133
|
+
residents.set(sessionId, asResident(revalidated));
|
|
134
|
+
return { messages: withProjection(originalMessages, text) };
|
|
135
|
+
} catch {
|
|
136
|
+
return { messages: withoutMemoryProjections(event.messages) };
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return () => residents.clear();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function currentCandidates(branch: SessionEntry[]): Candidate[] {
|
|
144
|
+
const statuses = supersessionStatuses(branch);
|
|
145
|
+
return activeCandidates(branch).filter(({ entryId }) => statuses.get(entryId)?.status === "current");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function activeCandidates(branch: SessionEntry[]): Candidate[] {
|
|
149
|
+
const entries = new Map<string, SessionEntry>();
|
|
150
|
+
const indexes = new Map<string, number>();
|
|
151
|
+
branch.forEach((entry, index) => {
|
|
152
|
+
if (entries.has(entry.id)) throw new Error("Active branch contains duplicate entry IDs");
|
|
153
|
+
entries.set(entry.id, entry);
|
|
154
|
+
indexes.set(entry.id, index);
|
|
155
|
+
});
|
|
156
|
+
const rawEntries = new Map<string, Extract<SessionEntry, { type: "message" }>>();
|
|
157
|
+
for (const [id, entry] of entries) if (isMessageEntry(entry)) rawEntries.set(id, entry);
|
|
158
|
+
const observations = new Map<string, IndexedObservation>();
|
|
159
|
+
for (const [id, entry] of entries) {
|
|
160
|
+
if (!isCustomEntry(entry, OBSERVATION_TYPE) || !isObservationData(entry.data) ||
|
|
161
|
+
!entry.data.sourceEntryIds.every((sourceId) => rawEntries.has(sourceId))) continue;
|
|
162
|
+
observations.set(id, { data: entry.data, index: indexes.get(id) ?? 0 });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const candidates: Candidate[] = [];
|
|
166
|
+
for (const [id, value] of observations) {
|
|
167
|
+
const sources = value.data.sourceEntryIds.flatMap((sourceId) => {
|
|
168
|
+
const source = rawEntries.get(sourceId);
|
|
169
|
+
return source ? [messageText(source.message)] : [];
|
|
170
|
+
});
|
|
171
|
+
candidates.push({
|
|
172
|
+
entryId: id,
|
|
173
|
+
kind: "observation",
|
|
174
|
+
text: value.data.text,
|
|
175
|
+
observationIds: [id],
|
|
176
|
+
sourceEntryIds: value.data.sourceEntryIds,
|
|
177
|
+
sourceText: sources.join(" "),
|
|
178
|
+
branchIndex: value.index,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const [id, entry] of entries) {
|
|
183
|
+
if (!isCustomEntry(entry, REFLECTION_TYPE) || !isReflectionData(entry.data)) continue;
|
|
184
|
+
const linked = entry.data.supportingObservationIds.map((observationId) => observations.get(observationId));
|
|
185
|
+
if (linked.some((observation) => observation === undefined)) continue;
|
|
186
|
+
const linkedObservations = linked.filter((observation): observation is IndexedObservation => observation !== undefined);
|
|
187
|
+
const sourceEntryIds = [...new Set(linkedObservations.flatMap(({ data }) => data.sourceEntryIds))];
|
|
188
|
+
const sourceText = sourceEntryIds.flatMap((sourceId) => {
|
|
189
|
+
const source = rawEntries.get(sourceId);
|
|
190
|
+
return source ? [messageText(source.message)] : [];
|
|
191
|
+
}).join(" ");
|
|
192
|
+
candidates.push({
|
|
193
|
+
entryId: id,
|
|
194
|
+
kind: "reflection",
|
|
195
|
+
text: entry.data.text,
|
|
196
|
+
observationIds: entry.data.supportingObservationIds,
|
|
197
|
+
sourceEntryIds,
|
|
198
|
+
sourceText: `${linkedObservations.map(({ data }) => data.text).join(" ")} ${sourceText}`,
|
|
199
|
+
branchIndex: indexes.get(id) ?? 0,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return candidates;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function candidateById(branch: SessionEntry[], entryId: string): Candidate | undefined {
|
|
206
|
+
return currentCandidates(branch).find((candidate) => candidate.entryId === entryId);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function needsMemory(need: string, terms: Set<string>, candidates: Candidate[]): boolean {
|
|
210
|
+
if (/\b(remember|earlier|previous|prior|before|last time|as we|what did we|continue|we decided|already|again|resume)\b/i.test(need)) return true;
|
|
211
|
+
return candidates.some((candidate) => overlap(terms, words(`${candidate.text} ${candidate.sourceText}`)) > 0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function candidateScore(candidate: Candidate, currentTerms: Set<string>, contextTerms: Set<string>, branchLength: number): number {
|
|
215
|
+
const candidateTerms = words(`${candidate.text} ${candidate.sourceText}`);
|
|
216
|
+
const sourceRecency = candidate.branchIndex >= Math.max(0, branchLength - 12) ? 2 : 0;
|
|
217
|
+
const memoryRecency = candidate.branchIndex >= Math.max(0, branchLength - 30) ? 1 : 0;
|
|
218
|
+
return overlap(currentTerms, candidateTerms) * 8 + overlap(contextTerms, candidateTerms) * 2 + sourceRecency + memoryRecency;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function activeContextText(messages: ContextMessages, latestUserText: string): string {
|
|
222
|
+
const recent = messages.slice(-7).filter((message) => message.role !== "user" || messageText(message) !== latestUserText);
|
|
223
|
+
return recent.map((message) => messageText(message)).join(" ");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function lastUserText(messages: ContextMessages): string | undefined {
|
|
227
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
228
|
+
const message = messages[index];
|
|
229
|
+
if (message.role === "user") {
|
|
230
|
+
const text = messageText(message).trim();
|
|
231
|
+
return text.length > 0 ? text : undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function asResident(candidate: Candidate): Resident {
|
|
238
|
+
return { entryId: candidate.entryId, kind: candidate.kind, text: candidate.text };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function renderCandidate(candidate: Candidate): string {
|
|
242
|
+
const links = [
|
|
243
|
+
candidate.observationIds.length > 0 ? `supporting observation IDs: ${candidate.observationIds.join(", ")}` : "",
|
|
244
|
+
candidate.sourceEntryIds.length > 0 ? `source entry IDs: ${candidate.sourceEntryIds.join(", ")}` : "",
|
|
245
|
+
].filter(Boolean).join("; ");
|
|
246
|
+
return `${MEMORY_LABEL}\n[${candidate.kind} ${candidate.entryId}] ${candidate.text}${links ? `\n${links}` : ""}`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isMemoryProjectionMessage(message: ContextMessages[number]): boolean {
|
|
250
|
+
return message.role === "user" && messageText(message).startsWith(MEMORY_LABEL);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function withoutMemoryProjections(messages: ContextMessages): ContextMessages {
|
|
254
|
+
return messages.filter((message) => !isMemoryProjectionMessage(message));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function withProjection(messages: ContextMessages, text: string): ContextMessages {
|
|
258
|
+
const base = withoutMemoryProjections(messages);
|
|
259
|
+
let requestStart = -1;
|
|
260
|
+
for (let index = base.length - 1; index >= 0; index -= 1) {
|
|
261
|
+
if (base[index].role === "user") {
|
|
262
|
+
requestStart = index;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (requestStart < 0) return base;
|
|
267
|
+
const projection = {
|
|
268
|
+
role: "user" as const,
|
|
269
|
+
content: [{ type: "text" as const, text }],
|
|
270
|
+
timestamp: Date.now(),
|
|
271
|
+
};
|
|
272
|
+
return [...base.slice(0, requestStart), projection, ...base.slice(requestStart)];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function encodeState(value: unknown): string | undefined {
|
|
276
|
+
const state = JSON.stringify(value);
|
|
277
|
+
return Buffer.byteLength(state, "utf8") <= MAX_GATE_STATE_BYTES ? state : undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function boundedLimit(value: number, fallback: number, maximum: number): number {
|
|
281
|
+
return Number.isSafeInteger(value) && value >= 1 && value <= maximum ? value : fallback;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function words(text: string): Set<string> {
|
|
285
|
+
const tokens = text.toLocaleLowerCase().match(/[\p{L}\p{N}]{4,}/gu) ?? [];
|
|
286
|
+
return new Set(tokens.map((token) => token.replace(/(ing|ed|es|s)$/u, "").replace(/e$/u, "")).filter((token) => !STOP_WORDS.has(token)));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function overlap(left: Set<string>, right: Set<string>): number {
|
|
290
|
+
let matches = 0;
|
|
291
|
+
for (const token of left) if (right.has(token)) matches += 1;
|
|
292
|
+
return matches;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function truncate(value: string, maximum: number): string {
|
|
296
|
+
return value.slice(0, maximum);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function messageText(message: unknown): string {
|
|
300
|
+
const content = isRecord(message) && "content" in message ? message.content : message;
|
|
301
|
+
if (typeof content === "string") return content;
|
|
302
|
+
if (!Array.isArray(content)) return "";
|
|
303
|
+
return content.flatMap((part): string[] => isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isCustomEntry(entry: unknown, customType: string): entry is Extract<SessionEntry, { type: "custom" }> {
|
|
307
|
+
return isRecord(entry) && entry.type === "custom" && entry.customType === customType && typeof entry.id === "string";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isMessageEntry(entry: unknown): entry is Extract<SessionEntry, { type: "message" }> {
|
|
311
|
+
return isRecord(entry) && entry.type === "message" && typeof entry.id === "string" && isRecord(entry.message) &&
|
|
312
|
+
["user", "assistant", "toolResult"].includes(String(entry.message.role));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isObservationData(value: unknown): value is ObservationData {
|
|
316
|
+
return isRecord(value) && value.schemaVersion === 1 && isMemoryText(value.text) && Array.isArray(value.sourceEntryIds) &&
|
|
317
|
+
value.sourceEntryIds.length > 0 && value.sourceEntryIds.length <= MAX_RAW_SOURCES && value.sourceEntryIds.every(isEntryId) &&
|
|
318
|
+
new Set(value.sourceEntryIds).size === value.sourceEntryIds.length;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function isReflectionData(value: unknown): value is ReflectionData {
|
|
322
|
+
return isRecord(value) && value.schemaVersion === 1 && isMemoryText(value.text) && Array.isArray(value.supportingObservationIds) &&
|
|
323
|
+
value.supportingObservationIds.length > 0 && value.supportingObservationIds.length <= MAX_REFLECTION_SOURCES &&
|
|
324
|
+
value.supportingObservationIds.every(isEntryId) && new Set(value.supportingObservationIds).size === value.supportingObservationIds.length;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isMemoryText(value: unknown): value is string {
|
|
328
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_MEMORY_TEXT_CHARS;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isEntryId(value: unknown): value is string {
|
|
332
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_ENTRY_ID_CHARS;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isSelectionDecision(value: ProjectionDecision): value is ProjectionSelectionDecision {
|
|
336
|
+
return "selected_entry_id" in value && (value.selected_entry_id === null || typeof value.selected_entry_id === "string");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function isSufficiencyDecision(value: ProjectionDecision): value is ProjectionSufficiencyDecision {
|
|
340
|
+
return "accepted" in value && typeof value.accepted === "boolean" && isProbability(value.p_true) && isProbability(value.confidence);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isProbability(value: unknown): value is number {
|
|
344
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
348
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
349
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const OBSERVATION_TYPE = "pi-session-memory.observation";
|
|
4
|
+
const REFLECTION_TYPE = "pi-session-memory.reflection";
|
|
5
|
+
const SUPERSESSION_TYPE = "pi-session-memory.supersession";
|
|
6
|
+
const MAX_MEMORY_TEXT_CHARS = 1_000;
|
|
7
|
+
const MAX_RAW_SOURCES = 12;
|
|
8
|
+
const MAX_REFLECTION_SOURCES = 6;
|
|
9
|
+
const MAX_ENTRY_ID_CHARS = 256;
|
|
10
|
+
const MIN_SUPERSESSION_PROBABILITY = 0.75;
|
|
11
|
+
const MAX_REJECTION_PROBABILITY = 0.25;
|
|
12
|
+
const MIN_SUPERSESSION_CONFIDENCE = 0.75;
|
|
13
|
+
|
|
14
|
+
export type MemoryStatus =
|
|
15
|
+
| { status: "current" }
|
|
16
|
+
| { status: "superseded"; recordEntryId: string; replacementEntryId: string }
|
|
17
|
+
| { status: "stale" }
|
|
18
|
+
| { status: "unresolved" };
|
|
19
|
+
|
|
20
|
+
export type SupersessionDecisionOutcome =
|
|
21
|
+
| { status: "superseded"; decision: { accepted: true; p_true: number; confidence: number } }
|
|
22
|
+
| { status: "rejected" | "unresolved" };
|
|
23
|
+
|
|
24
|
+
export type SupersessionData =
|
|
25
|
+
| {
|
|
26
|
+
schemaVersion: 1;
|
|
27
|
+
status: "superseded";
|
|
28
|
+
supersededEntryId: string;
|
|
29
|
+
replacementEntryId: string;
|
|
30
|
+
decision: { accepted: true; p_true: number; confidence: number };
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
schemaVersion: 1;
|
|
34
|
+
status: "unresolved";
|
|
35
|
+
supersededEntryId: string;
|
|
36
|
+
replacementEntryId: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
interface ObservationData {
|
|
40
|
+
schemaVersion: 1;
|
|
41
|
+
text: string;
|
|
42
|
+
sourceEntryIds: string[];
|
|
43
|
+
}
|
|
44
|
+
interface ReflectionData {
|
|
45
|
+
schemaVersion: 1;
|
|
46
|
+
text: string;
|
|
47
|
+
supportingObservationIds: string[];
|
|
48
|
+
}
|
|
49
|
+
export function supersessionStatuses(branch: SessionEntry[]): Map<string, MemoryStatus> {
|
|
50
|
+
const entries = new Map<string, SessionEntry>();
|
|
51
|
+
const indexes = new Map<string, number>();
|
|
52
|
+
const rawIds = new Set<string>();
|
|
53
|
+
for (const [index, entry] of branch.entries()) {
|
|
54
|
+
if (entries.has(entry.id)) return unresolvedStatuses(branch);
|
|
55
|
+
entries.set(entry.id, entry);
|
|
56
|
+
indexes.set(entry.id, index);
|
|
57
|
+
if (isMessageEntry(entry)) rawIds.add(entry.id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const observations = new Map<string, { data: ObservationData; index: number }>();
|
|
61
|
+
const memories = new Map<string, number>();
|
|
62
|
+
const statuses = new Map<string, MemoryStatus>();
|
|
63
|
+
for (const [id, entry] of entries) {
|
|
64
|
+
if (!isCustomEntry(entry, OBSERVATION_TYPE)) continue;
|
|
65
|
+
if (!isEntryId(id) || !isObservationData(entry.data) || !entry.data.sourceEntryIds.every((sourceId) => rawIds.has(sourceId))) {
|
|
66
|
+
statuses.set(id, { status: "unresolved" });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const index = indexes.get(id) ?? 0;
|
|
70
|
+
observations.set(id, { data: entry.data, index });
|
|
71
|
+
memories.set(id, index);
|
|
72
|
+
statuses.set(id, { status: "current" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const reflections = new Map<string, ReflectionData>();
|
|
76
|
+
for (const [id, entry] of entries) {
|
|
77
|
+
if (!isCustomEntry(entry, REFLECTION_TYPE)) continue;
|
|
78
|
+
if (!isEntryId(id) || !isReflectionData(entry.data) || !entry.data.supportingObservationIds.every((observationId) => observations.has(observationId))) {
|
|
79
|
+
statuses.set(id, { status: "unresolved" });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const index = indexes.get(id) ?? 0;
|
|
83
|
+
reflections.set(id, entry.data);
|
|
84
|
+
memories.set(id, index);
|
|
85
|
+
statuses.set(id, { status: "current" });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const recordStatuses = new Map<string, MemoryStatus>();
|
|
89
|
+
const statusAt = (entryId: string): MemoryStatus => {
|
|
90
|
+
const direct: MemoryStatus = recordStatuses.get(entryId) ?? statuses.get(entryId) ?? { status: "unresolved" };
|
|
91
|
+
if (direct.status !== "current") return direct;
|
|
92
|
+
const reflection = reflections.get(entryId);
|
|
93
|
+
if (!reflection) return direct;
|
|
94
|
+
for (const observationId of reflection.supportingObservationIds) {
|
|
95
|
+
const support: MemoryStatus = recordStatuses.get(observationId) ?? statuses.get(observationId) ?? { status: "unresolved" };
|
|
96
|
+
if (support.status === "unresolved") return { status: "unresolved" };
|
|
97
|
+
if (support.status !== "current") return { status: "stale" };
|
|
98
|
+
}
|
|
99
|
+
return direct;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const [index, entry] of branch.entries()) {
|
|
103
|
+
if (!isCustomEntry(entry, SUPERSESSION_TYPE)) continue;
|
|
104
|
+
const recordId = isEntryId(entry.id) ? entry.id : undefined;
|
|
105
|
+
const targetId = isRecord(entry.data) && isEntryId(entry.data.supersededEntryId) ? entry.data.supersededEntryId : undefined;
|
|
106
|
+
const replacementId = isRecord(entry.data) && isEntryId(entry.data.replacementEntryId) ? entry.data.replacementEntryId : undefined;
|
|
107
|
+
const data = parseSupersessionData(entry.data);
|
|
108
|
+
if (!data || !recordId) {
|
|
109
|
+
if (targetId && statuses.has(targetId)) recordStatuses.set(targetId, { status: "unresolved" });
|
|
110
|
+
if (replacementId && statuses.has(replacementId)) recordStatuses.set(replacementId, { status: "unresolved" });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const oldIndex = memories.get(data.supersededEntryId);
|
|
115
|
+
const replacement = observations.get(data.replacementEntryId);
|
|
116
|
+
const oldStatus = statusAt(data.supersededEntryId);
|
|
117
|
+
const replacementStatus = statusAt(data.replacementEntryId);
|
|
118
|
+
const validLinks = oldIndex !== undefined && replacement !== undefined &&
|
|
119
|
+
oldIndex < replacement.index && replacement.index < index &&
|
|
120
|
+
oldStatus.status === "current" && replacementStatus.status === "current";
|
|
121
|
+
if (!validLinks || data.status === "unresolved") {
|
|
122
|
+
recordStatuses.set(data.supersededEntryId, { status: "unresolved" });
|
|
123
|
+
recordStatuses.set(data.replacementEntryId, { status: "unresolved" });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
recordStatuses.set(data.supersededEntryId, {
|
|
127
|
+
status: "superseded",
|
|
128
|
+
recordEntryId: recordId,
|
|
129
|
+
replacementEntryId: data.replacementEntryId,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const id of statuses.keys()) statuses.set(id, statusAt(id));
|
|
134
|
+
for (const id of recordStatuses.keys()) statuses.set(id, statusAt(id));
|
|
135
|
+
return statuses;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function classifySupersessionDecision(value: unknown): SupersessionDecisionOutcome {
|
|
139
|
+
if (!isRecord(value) || typeof value.accepted !== "boolean" || !isProbability(value.p_true) || !isProbability(value.confidence)) {
|
|
140
|
+
return { status: "unresolved" };
|
|
141
|
+
}
|
|
142
|
+
if (value.accepted && value.p_true >= MIN_SUPERSESSION_PROBABILITY && value.confidence >= MIN_SUPERSESSION_CONFIDENCE) {
|
|
143
|
+
return { status: "superseded", decision: { accepted: true, p_true: value.p_true, confidence: value.confidence } };
|
|
144
|
+
}
|
|
145
|
+
if (!value.accepted && value.p_true <= MAX_REJECTION_PROBABILITY && value.confidence >= MIN_SUPERSESSION_CONFIDENCE) {
|
|
146
|
+
return { status: "rejected" };
|
|
147
|
+
}
|
|
148
|
+
return { status: "unresolved" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseSupersessionData(value: unknown): SupersessionData | undefined {
|
|
152
|
+
if (!isRecord(value) || value.schemaVersion !== 1 || !isEntryId(value.supersededEntryId) || !isEntryId(value.replacementEntryId)) return undefined;
|
|
153
|
+
if (value.status === "unresolved" && hasExactKeys(value, ["schemaVersion", "status", "supersededEntryId", "replacementEntryId"])) {
|
|
154
|
+
return {
|
|
155
|
+
schemaVersion: 1,
|
|
156
|
+
status: "unresolved",
|
|
157
|
+
supersededEntryId: value.supersededEntryId,
|
|
158
|
+
replacementEntryId: value.replacementEntryId,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (value.status !== "superseded" || !hasExactKeys(value, ["schemaVersion", "status", "supersededEntryId", "replacementEntryId", "decision"]) ||
|
|
162
|
+
!isRecord(value.decision) || !hasExactKeys(value.decision, ["accepted", "p_true", "confidence"]) ||
|
|
163
|
+
value.decision.accepted !== true || !isProbability(value.decision.p_true) || value.decision.p_true < MIN_SUPERSESSION_PROBABILITY ||
|
|
164
|
+
!isProbability(value.decision.confidence) || value.decision.confidence < MIN_SUPERSESSION_CONFIDENCE) return undefined;
|
|
165
|
+
return {
|
|
166
|
+
schemaVersion: 1,
|
|
167
|
+
status: "superseded",
|
|
168
|
+
supersededEntryId: value.supersededEntryId,
|
|
169
|
+
replacementEntryId: value.replacementEntryId,
|
|
170
|
+
decision: { accepted: true, p_true: value.decision.p_true, confidence: value.decision.confidence },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function unresolvedStatuses(branch: SessionEntry[]): Map<string, MemoryStatus> {
|
|
175
|
+
const statuses = new Map<string, MemoryStatus>();
|
|
176
|
+
for (const entry of branch) {
|
|
177
|
+
if (isCustomEntry(entry, OBSERVATION_TYPE) || isCustomEntry(entry, REFLECTION_TYPE)) {
|
|
178
|
+
statuses.set(entry.id, { status: "unresolved" });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return statuses;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isCustomEntry(entry: unknown, customType: string): entry is Extract<SessionEntry, { type: "custom" }> {
|
|
185
|
+
return isRecord(entry) && entry.type === "custom" && entry.customType === customType && typeof entry.id === "string";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isMessageEntry(entry: unknown): entry is Extract<SessionEntry, { type: "message" }> {
|
|
189
|
+
return isRecord(entry) && entry.type === "message" && typeof entry.id === "string" && isRecord(entry.message) &&
|
|
190
|
+
["user", "assistant", "toolResult"].includes(String(entry.message.role));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isObservationData(value: unknown): value is ObservationData {
|
|
194
|
+
return isRecord(value) && value.schemaVersion === 1 && isMemoryText(value.text) && Array.isArray(value.sourceEntryIds) &&
|
|
195
|
+
value.sourceEntryIds.length > 0 && value.sourceEntryIds.length <= MAX_RAW_SOURCES && value.sourceEntryIds.every(isEntryId) &&
|
|
196
|
+
new Set(value.sourceEntryIds).size === value.sourceEntryIds.length;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isReflectionData(value: unknown): value is ReflectionData {
|
|
200
|
+
return isRecord(value) && value.schemaVersion === 1 && isMemoryText(value.text) && Array.isArray(value.supportingObservationIds) &&
|
|
201
|
+
value.supportingObservationIds.length > 0 && value.supportingObservationIds.length <= MAX_REFLECTION_SOURCES &&
|
|
202
|
+
value.supportingObservationIds.every(isEntryId) && new Set(value.supportingObservationIds).size === value.supportingObservationIds.length;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isMemoryText(value: unknown): value is string {
|
|
206
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_MEMORY_TEXT_CHARS;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isEntryId(value: unknown): value is string {
|
|
210
|
+
return typeof value === "string" && value.length > 0 && value.length <= MAX_ENTRY_ID_CHARS;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isProbability(value: unknown): value is number {
|
|
214
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function hasExactKeys(value: Record<string, unknown>, keys: string[]): boolean {
|
|
218
|
+
return Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
222
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
223
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pinned, local Laya gate worker."""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Run one persistent JSONL worker process."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
PYTHON_VERSION_ERROR = "Laya worker requires Python 3.11; set PI_SESSION_MEMORY_PYTHON to a Python 3.11 interpreter."
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> int:
|
|
10
|
+
if sys.version_info[:2] != (3, 11):
|
|
11
|
+
print(PYTHON_VERSION_ERROR, file=sys.stderr, flush=True)
|
|
12
|
+
return 1
|
|
13
|
+
|
|
14
|
+
from contextlib import redirect_stdout
|
|
15
|
+
|
|
16
|
+
from worker.laya_runtime import LayaEvaluator
|
|
17
|
+
from worker.protocol import serve
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
with redirect_stdout(sys.stderr):
|
|
21
|
+
evaluator = LayaEvaluator()
|
|
22
|
+
except Exception as error:
|
|
23
|
+
print(f"Laya worker startup failed ({type(error).__name__}).", file=sys.stderr, flush=True)
|
|
24
|
+
return 1
|
|
25
|
+
|
|
26
|
+
serve(sys.stdin.buffer, sys.stdout.buffer, sys.stderr, evaluator.evaluate)
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
raise SystemExit(main())
|