opencode-lore 0.4.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-lore",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Three-tier memory architecture for OpenCode — distillation, not summarization",
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 = 3;
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
- // DB persistence is unnecessary: UNCALIBRATED_SAFETY=1.5 safely handles
57
- // the first turn of a resumed session. The Map is bounded — there are never
58
- // more than a handful of active sessions at once.
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.values()) {
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.
@@ -876,7 +891,12 @@ function transformInner(input: {
876
891
  expectedInput = messageTokens + overhead + ltmTokens;
877
892
  }
878
893
 
879
- if (effectiveMinLayer === 0 && expectedInput <= maxInput) {
894
+ // When uncalibrated, apply safety multiplier to the layer-0 decision too.
895
+ // chars/3 undercounts by ~1.63x on real sessions — without this, a session
896
+ // estimated at 146K passes layer 0 but actually costs 214K → overflow.
897
+ const layer0Input = calibrated ? expectedInput : expectedInput * UNCALIBRATED_SAFETY;
898
+
899
+ if (effectiveMinLayer === 0 && layer0Input <= maxInput) {
880
900
  // All messages fit — return unmodified to preserve append-only prompt-cache pattern.
881
901
  // Raw messages are strictly better context than lossy distilled summaries.
882
902
  const messageTokens = calibrated
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
- const error = rawError as
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 'prompt too long' error forcing distillation + layer escalation (session: ${errorSessionID?.substring(0, 16)})`,
276
+ `[lore] detected context overflowauto-recovering (session: ${errorSessionID.substring(0, 16)})`,
257
277
  );
258
- // Force layer 2 on next transform — layers 0 and 1 were already too large.
259
- // The gradient at layers 2-4 will compress the context enough for the next turn.
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
- if (errorSessionID) {
265
- // Force distillation to capture all undistilled messages into the temporal
266
- // store so they're preserved even if the session is later compacted manually.
267
- await backgroundDistill(errorSessionID, true);
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
  }