opencode-lore 0.4.3 → 0.4.4
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/package.json +1 -1
- package/src/db.ts +44 -1
- package/src/gradient.ts +21 -6
- package/src/index.ts +79 -33
package/package.json
CHANGED
package/src/db.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { mkdirSync } from "fs";
|
|
4
4
|
|
|
5
|
-
const SCHEMA_VERSION =
|
|
5
|
+
const SCHEMA_VERSION = 4;
|
|
6
6
|
|
|
7
7
|
const MIGRATIONS: string[] = [
|
|
8
8
|
`
|
|
@@ -130,6 +130,17 @@ const MIGRATIONS: string[] = [
|
|
|
130
130
|
-- VACUUM must run outside a transaction and cannot be in a multi-statement
|
|
131
131
|
-- exec, so it is handled specially in the migrate() function.
|
|
132
132
|
`,
|
|
133
|
+
`
|
|
134
|
+
-- Version 4: Persistent session state for error recovery.
|
|
135
|
+
-- Stores forceMinLayer so it survives OpenCode restarts. Without this,
|
|
136
|
+
-- a "prompt too long" error recovery (escalate to layer 2) is lost if
|
|
137
|
+
-- the process restarts before the next turn.
|
|
138
|
+
CREATE TABLE IF NOT EXISTS session_state (
|
|
139
|
+
session_id TEXT PRIMARY KEY,
|
|
140
|
+
force_min_layer INTEGER NOT NULL DEFAULT 0,
|
|
141
|
+
updated_at INTEGER NOT NULL
|
|
142
|
+
);
|
|
143
|
+
`,
|
|
133
144
|
];
|
|
134
145
|
|
|
135
146
|
function dataDir() {
|
|
@@ -229,3 +240,35 @@ export function isFirstRun(): boolean {
|
|
|
229
240
|
.get() as { count: number };
|
|
230
241
|
return row.count === 0;
|
|
231
242
|
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Persistent session state (error recovery)
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Load persisted forceMinLayer for a session. Returns 0 if none stored.
|
|
250
|
+
*/
|
|
251
|
+
export function loadForceMinLayer(sessionID: string): number {
|
|
252
|
+
const row = db()
|
|
253
|
+
.query("SELECT force_min_layer FROM session_state WHERE session_id = ?")
|
|
254
|
+
.get(sessionID) as { force_min_layer: number } | null;
|
|
255
|
+
return row?.force_min_layer ?? 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Persist forceMinLayer for a session. Deletes the row when layer is 0
|
|
260
|
+
* (consumed) to avoid unbounded growth.
|
|
261
|
+
*/
|
|
262
|
+
export function saveForceMinLayer(sessionID: string, layer: number): void {
|
|
263
|
+
if (layer === 0) {
|
|
264
|
+
db()
|
|
265
|
+
.query("DELETE FROM session_state WHERE session_id = ?")
|
|
266
|
+
.run(sessionID);
|
|
267
|
+
} else {
|
|
268
|
+
db()
|
|
269
|
+
.query(
|
|
270
|
+
"INSERT OR REPLACE INTO session_state (session_id, force_min_layer, updated_at) VALUES (?, ?, ?)",
|
|
271
|
+
)
|
|
272
|
+
.run(sessionID, layer, Date.now());
|
|
273
|
+
}
|
|
274
|
+
}
|
package/src/gradient.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Message, Part } from "@opencode-ai/sdk";
|
|
2
|
-
import { db, ensureProject } from "./db";
|
|
2
|
+
import { db, ensureProject, loadForceMinLayer, saveForceMinLayer } from "./db";
|
|
3
3
|
import { config } from "./config";
|
|
4
4
|
import { formatDistillations } from "./prompt";
|
|
5
5
|
import { normalize } from "./markdown";
|
|
@@ -53,9 +53,12 @@ let calibratedOverhead: number | null = null;
|
|
|
53
53
|
// lore-curator) from corrupting the main session's sticky-layer guard and
|
|
54
54
|
// delta-estimation state when their transform() calls return layer 0.
|
|
55
55
|
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
56
|
+
// forceMinLayer is the one field that MUST survive process restarts: when the
|
|
57
|
+
// API returns "prompt is too long", the error handler sets forceMinLayer=2.
|
|
58
|
+
// If OpenCode restarts before the next turn, the escalation is lost and the
|
|
59
|
+
// overflow repeats. forceMinLayer is persisted to SQLite (session_state table)
|
|
60
|
+
// and loaded on first access. All other state rebuilds from the first API
|
|
61
|
+
// response via UNCALIBRATED_SAFETY.
|
|
59
62
|
// ---------------------------------------------------------------------------
|
|
60
63
|
|
|
61
64
|
type SessionState = {
|
|
@@ -102,6 +105,11 @@ function getSessionState(sessionID: string): SessionState {
|
|
|
102
105
|
let state = sessionStates.get(sessionID);
|
|
103
106
|
if (!state) {
|
|
104
107
|
state = makeSessionState();
|
|
108
|
+
// Restore persisted forceMinLayer from DB — survives process restarts.
|
|
109
|
+
// Critical for "prompt too long" recovery: the error handler sets
|
|
110
|
+
// forceMinLayer=2, but if OpenCode restarts before the next turn,
|
|
111
|
+
// the in-memory escalation would be lost without this.
|
|
112
|
+
state.forceMinLayer = loadForceMinLayer(sessionID) as SafetyLayer;
|
|
105
113
|
sessionStates.set(sessionID, state);
|
|
106
114
|
}
|
|
107
115
|
return state;
|
|
@@ -213,10 +221,12 @@ export function getLastLayer(sessionID?: string): SafetyLayer {
|
|
|
213
221
|
export function setForceMinLayer(layer: SafetyLayer, sessionID?: string) {
|
|
214
222
|
if (sessionID) {
|
|
215
223
|
getSessionState(sessionID).forceMinLayer = layer;
|
|
224
|
+
saveForceMinLayer(sessionID, layer);
|
|
216
225
|
} else {
|
|
217
226
|
// Fallback for tests / callers without session ID: set on all active sessions
|
|
218
|
-
for (const state of sessionStates.
|
|
227
|
+
for (const [sid, state] of sessionStates.entries()) {
|
|
219
228
|
state.forceMinLayer = layer;
|
|
229
|
+
saveForceMinLayer(sid, layer);
|
|
220
230
|
}
|
|
221
231
|
}
|
|
222
232
|
}
|
|
@@ -225,8 +235,12 @@ export function setForceMinLayer(layer: SafetyLayer, sessionID?: string) {
|
|
|
225
235
|
export function resetCalibration(sessionID?: string) {
|
|
226
236
|
calibratedOverhead = null;
|
|
227
237
|
if (sessionID) {
|
|
238
|
+
saveForceMinLayer(sessionID, 0); // clear persisted state
|
|
228
239
|
sessionStates.delete(sessionID);
|
|
229
240
|
} else {
|
|
241
|
+
for (const sid of sessionStates.keys()) {
|
|
242
|
+
saveForceMinLayer(sid, 0);
|
|
243
|
+
}
|
|
230
244
|
sessionStates.clear();
|
|
231
245
|
}
|
|
232
246
|
}
|
|
@@ -812,11 +826,12 @@ function transformInner(input: {
|
|
|
812
826
|
// --- Force escalation (reactive error recovery) ---
|
|
813
827
|
// When the API previously rejected with "prompt is too long", skip layers
|
|
814
828
|
// below the forced minimum to ensure enough trimming on the next attempt.
|
|
815
|
-
// One-shot: consumed here and reset to 0.
|
|
829
|
+
// One-shot: consumed here and reset to 0 (both in-memory and on disk).
|
|
816
830
|
const sid = input.sessionID ?? input.messages[0]?.info.sessionID;
|
|
817
831
|
const sessState = sid ? getSessionState(sid) : makeSessionState();
|
|
818
832
|
let effectiveMinLayer = sessState.forceMinLayer;
|
|
819
833
|
sessState.forceMinLayer = 0;
|
|
834
|
+
if (sid && effectiveMinLayer > 0) saveForceMinLayer(sid, 0);
|
|
820
835
|
|
|
821
836
|
// --- Approach A: Cache-preserving passthrough ---
|
|
822
837
|
// Use exact token count from the previous API response when available.
|
package/src/index.ts
CHANGED
|
@@ -15,10 +15,51 @@ import {
|
|
|
15
15
|
setForceMinLayer,
|
|
16
16
|
getLastTransformedCount,
|
|
17
17
|
} from "./gradient";
|
|
18
|
-
import { formatKnowledge } from "./prompt";
|
|
18
|
+
import { formatKnowledge, formatDistillations } from "./prompt";
|
|
19
19
|
import { createRecallTool } from "./reflect";
|
|
20
20
|
import { shouldImport, importFromFile, exportToFile } from "./agents-file";
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Detect whether an error from session.error is a context overflow ("prompt too long").
|
|
24
|
+
* Matches both APIError wrapper shape (error.data.message) and direct shape (error.message).
|
|
25
|
+
*/
|
|
26
|
+
export function isContextOverflow(rawError: unknown): boolean {
|
|
27
|
+
const error = rawError as
|
|
28
|
+
| { name?: string; message?: string; data?: { message?: string } }
|
|
29
|
+
| undefined;
|
|
30
|
+
const errorMessage = error?.data?.message ?? error?.message ?? "";
|
|
31
|
+
return (
|
|
32
|
+
typeof errorMessage === "string" &&
|
|
33
|
+
(errorMessage.includes("prompt is too long") ||
|
|
34
|
+
errorMessage.includes("context length exceeded") ||
|
|
35
|
+
errorMessage.includes("maximum context length") ||
|
|
36
|
+
errorMessage.includes("ContextWindowExceededError") ||
|
|
37
|
+
errorMessage.includes("too many tokens"))
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the synthetic recovery message injected after a context overflow.
|
|
43
|
+
* Contains the distilled session history so the model can continue.
|
|
44
|
+
*/
|
|
45
|
+
export function buildRecoveryMessage(
|
|
46
|
+
summaries: Array<{ observations: string; generation: number }>,
|
|
47
|
+
): string {
|
|
48
|
+
const historyText = summaries.length > 0
|
|
49
|
+
? formatDistillations(summaries)
|
|
50
|
+
: "";
|
|
51
|
+
|
|
52
|
+
return [
|
|
53
|
+
"<system-reminder>",
|
|
54
|
+
"The previous turn failed with a context overflow error (prompt too long).",
|
|
55
|
+
"Lore has automatically compressed the conversation history.",
|
|
56
|
+
"Review the session history below and continue where you left off.",
|
|
57
|
+
"",
|
|
58
|
+
historyText || "(No distilled history available — check recent messages for context.)",
|
|
59
|
+
"</system-reminder>",
|
|
60
|
+
].join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
22
63
|
export const LorePlugin: Plugin = async (ctx) => {
|
|
23
64
|
const projectPath = ctx.worktree || ctx.directory;
|
|
24
65
|
await load(ctx.directory);
|
|
@@ -226,45 +267,50 @@ export const LorePlugin: Plugin = async (ctx) => {
|
|
|
226
267
|
| undefined;
|
|
227
268
|
if (errorSessionID && await shouldSkip(errorSessionID)) return;
|
|
228
269
|
|
|
229
|
-
// Detect "prompt is too long" API errors and auto-recover
|
|
230
|
-
// 1. Force the gradient transform to escalate on the next call (skip layer 0/1)
|
|
231
|
-
// 2. Force distillation to capture all temporal data before compaction
|
|
232
|
-
// 3. Trigger compaction so the session recovers without user intervention
|
|
270
|
+
// Detect "prompt is too long" API errors and auto-recover.
|
|
233
271
|
const rawError = (event.properties as Record<string, unknown>).error;
|
|
234
|
-
// Diagnostic: log the full error shape so we can verify our detection matches
|
|
235
272
|
console.error("[lore] session.error received:", JSON.stringify(rawError, null, 2));
|
|
236
273
|
|
|
237
|
-
|
|
238
|
-
| { name?: string; message?: string; data?: { message?: string } }
|
|
239
|
-
| undefined;
|
|
240
|
-
// Match both shapes: error.data.message (APIError wrapper) and error.message (direct)
|
|
241
|
-
const errorMessage = error?.data?.message ?? error?.message ?? "";
|
|
242
|
-
const isPromptTooLong =
|
|
243
|
-
typeof errorMessage === "string" &&
|
|
244
|
-
(errorMessage.includes("prompt is too long") ||
|
|
245
|
-
errorMessage.includes("context length exceeded") ||
|
|
246
|
-
errorMessage.includes("maximum context length") ||
|
|
247
|
-
errorMessage.includes("ContextWindowExceededError") ||
|
|
248
|
-
errorMessage.includes("too many tokens"));
|
|
249
|
-
|
|
250
|
-
console.error(
|
|
251
|
-
`[lore] session.error isPromptTooLong=${isPromptTooLong} (name=${error?.name}, message=${errorMessage.substring(0, 120)})`,
|
|
252
|
-
);
|
|
253
|
-
|
|
254
|
-
if (isPromptTooLong) {
|
|
274
|
+
if (isContextOverflow(rawError) && errorSessionID) {
|
|
255
275
|
console.error(
|
|
256
|
-
`[lore] detected
|
|
276
|
+
`[lore] detected context overflow — auto-recovering (session: ${errorSessionID.substring(0, 16)})`,
|
|
257
277
|
);
|
|
258
|
-
|
|
259
|
-
//
|
|
260
|
-
// Do NOT call session.summarize() here — it sends all messages to the model,
|
|
261
|
-
// which would overflow again and create a stuck compaction loop.
|
|
278
|
+
|
|
279
|
+
// 1. Force layer 2 on next transform (persisted to DB — survives restarts).
|
|
262
280
|
setForceMinLayer(2, errorSessionID);
|
|
263
281
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
282
|
+
// 2. Distill all undistilled messages so nothing is lost.
|
|
283
|
+
await backgroundDistill(errorSessionID, true);
|
|
284
|
+
|
|
285
|
+
// 3. Auto-recover: inject a synthetic message that goes through the normal
|
|
286
|
+
// chat path. The gradient transform fires with forceMinLayer=2, compressing
|
|
287
|
+
// the context to fit. The model receives the distilled summaries and
|
|
288
|
+
// continues where it left off — no user intervention needed.
|
|
289
|
+
try {
|
|
290
|
+
const summaries = distillation.loadForSession(projectPath, errorSessionID);
|
|
291
|
+
const recoveryText = buildRecoveryMessage(
|
|
292
|
+
summaries.map(s => ({ observations: s.observations, generation: s.generation })),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
console.error(
|
|
296
|
+
`[lore] sending auto-recovery message to session ${errorSessionID.substring(0, 16)}`,
|
|
297
|
+
);
|
|
298
|
+
await ctx.client.session.prompt({
|
|
299
|
+
path: { id: errorSessionID },
|
|
300
|
+
body: {
|
|
301
|
+
parts: [{ type: "text", text: recoveryText, synthetic: true }],
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
console.error(
|
|
305
|
+
`[lore] auto-recovery message sent successfully`,
|
|
306
|
+
);
|
|
307
|
+
} catch (recoveryError) {
|
|
308
|
+
// Recovery is best-effort — don't let it crash the event handler.
|
|
309
|
+
// The persisted forceMinLayer will still help on the user's next message.
|
|
310
|
+
console.error(
|
|
311
|
+
`[lore] auto-recovery failed (forceMinLayer still persisted):`,
|
|
312
|
+
recoveryError,
|
|
313
|
+
);
|
|
268
314
|
}
|
|
269
315
|
}
|
|
270
316
|
}
|