Skip to content

Commit e7efcf5

Browse files
committed
fix: persist note nudge state to DB for cache-safe restart survival
Note nudge trigger and sticky state now lives in session_meta instead of in-memory Maps. After restart, deferred triggers aren't lost and delivered nudges replay at the same anchored message — no cache bust. - Added DB persistence functions following sticky turn reminder pattern - Trigger deferral: nudge waits for next user message (not current turn) - Sticky replay: appendReminderToUserMessageById at anchored message - All callers updated to pass db through - Commit detection guard: hadPriorCommitState prevents false triggers after restart
1 parent c8a13f2 commit e7efcf5

File tree

11 files changed

+303
-72
lines changed

11 files changed

+303
-72
lines changed

src/cli/config-paths.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,13 @@ function getConfigDir(): string {
1414
const envDir = process.env.OPENCODE_CONFIG_DIR?.trim();
1515
if (envDir) return envDir;
1616

17-
switch (process.platform) {
18-
case "win32": {
19-
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
20-
return join(appData, "opencode");
21-
}
22-
case "darwin":
23-
case "linux":
24-
default: {
25-
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
26-
return join(xdgConfig, "opencode");
27-
}
17+
if (process.platform === "win32") {
18+
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
19+
return join(appData, "opencode");
2820
}
21+
22+
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
23+
return join(xdgConfig, "opencode");
2924
}
3025

3126
function findOmoConfig(configDir: string): string | null {

src/features/magic-context/storage-db.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ CREATE INDEX IF NOT EXISTS idx_dream_queue_pending ON dream_queue(started_at, en
172172
nudge_anchor_text TEXT DEFAULT '',
173173
sticky_turn_reminder_text TEXT DEFAULT '',
174174
sticky_turn_reminder_message_id TEXT DEFAULT '',
175+
note_nudge_trigger_pending INTEGER DEFAULT 0,
176+
note_nudge_trigger_message_id TEXT DEFAULT '',
177+
note_nudge_sticky_text TEXT DEFAULT '',
178+
note_nudge_sticky_message_id TEXT DEFAULT '',
175179
is_subagent INTEGER DEFAULT 0,
176180
last_context_percentage REAL DEFAULT 0,
177181
last_input_tokens INTEGER DEFAULT 0,
@@ -228,6 +232,10 @@ CREATE INDEX IF NOT EXISTS idx_dream_queue_pending ON dream_queue(started_at, en
228232
ensureColumn(db, "session_meta", "nudge_anchor_text", "TEXT DEFAULT ''");
229233
ensureColumn(db, "session_meta", "sticky_turn_reminder_text", "TEXT DEFAULT ''");
230234
ensureColumn(db, "session_meta", "sticky_turn_reminder_message_id", "TEXT DEFAULT ''");
235+
ensureColumn(db, "session_meta", "note_nudge_trigger_pending", "INTEGER DEFAULT 0");
236+
ensureColumn(db, "session_meta", "note_nudge_trigger_message_id", "TEXT DEFAULT ''");
237+
ensureColumn(db, "session_meta", "note_nudge_sticky_text", "TEXT DEFAULT ''");
238+
ensureColumn(db, "session_meta", "note_nudge_sticky_message_id", "TEXT DEFAULT ''");
231239
ensureColumn(db, "session_meta", "times_execute_threshold_reached", "INTEGER DEFAULT 0");
232240
ensureColumn(db, "session_meta", "compartment_in_progress", "INTEGER DEFAULT 0");
233241
ensureColumn(db, "session_meta", "system_prompt_hash", "TEXT DEFAULT ''");

src/features/magic-context/storage-meta-persisted.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,25 @@ interface PersistedStickyTurnReminderRow {
1818
sticky_turn_reminder_message_id: string;
1919
}
2020

21+
interface PersistedNoteNudgeRow {
22+
note_nudge_trigger_pending: number;
23+
note_nudge_trigger_message_id: string;
24+
note_nudge_sticky_text: string;
25+
note_nudge_sticky_message_id: string;
26+
}
27+
2128
export interface PersistedStickyTurnReminder {
2229
text: string;
2330
messageId: string | null;
2431
}
2532

33+
export interface PersistedNoteNudge {
34+
triggerPending: boolean;
35+
triggerMessageId: string | null;
36+
stickyText: string | null;
37+
stickyMessageId: string | null;
38+
}
39+
2640
function isPersistedUsageRow(row: unknown): row is PersistedUsageRow {
2741
if (row === null || typeof row !== "object") return false;
2842
const r = row as Record<string, unknown>;
@@ -48,6 +62,26 @@ function isPersistedStickyTurnReminderRow(row: unknown): row is PersistedStickyT
4862
);
4963
}
5064

65+
function isPersistedNoteNudgeRow(row: unknown): row is PersistedNoteNudgeRow {
66+
if (row === null || typeof row !== "object") return false;
67+
const r = row as Record<string, unknown>;
68+
return (
69+
typeof r.note_nudge_trigger_pending === "number" &&
70+
typeof r.note_nudge_trigger_message_id === "string" &&
71+
typeof r.note_nudge_sticky_text === "string" &&
72+
typeof r.note_nudge_sticky_message_id === "string"
73+
);
74+
}
75+
76+
function getDefaultPersistedNoteNudge(): PersistedNoteNudge {
77+
return {
78+
triggerPending: false,
79+
triggerMessageId: null,
80+
stickyText: null,
81+
stickyMessageId: null,
82+
};
83+
}
84+
5185
export function loadPersistedUsage(
5286
db: Database,
5387
sessionId: string,
@@ -164,3 +198,74 @@ export function clearPersistedStickyTurnReminder(db: Database, sessionId: string
164198
"UPDATE session_meta SET sticky_turn_reminder_text = '', sticky_turn_reminder_message_id = '' WHERE session_id = ?",
165199
).run(sessionId);
166200
}
201+
202+
export function getPersistedNoteNudge(db: Database, sessionId: string): PersistedNoteNudge {
203+
const result = db
204+
.prepare(
205+
"SELECT note_nudge_trigger_pending, note_nudge_trigger_message_id, note_nudge_sticky_text, note_nudge_sticky_message_id FROM session_meta WHERE session_id = ?",
206+
)
207+
.get(sessionId);
208+
209+
if (!isPersistedNoteNudgeRow(result)) {
210+
return getDefaultPersistedNoteNudge();
211+
}
212+
213+
return {
214+
triggerPending: result.note_nudge_trigger_pending === 1,
215+
triggerMessageId:
216+
result.note_nudge_trigger_message_id.length > 0
217+
? result.note_nudge_trigger_message_id
218+
: null,
219+
stickyText: result.note_nudge_sticky_text.length > 0 ? result.note_nudge_sticky_text : null,
220+
stickyMessageId:
221+
result.note_nudge_sticky_message_id.length > 0
222+
? result.note_nudge_sticky_message_id
223+
: null,
224+
};
225+
}
226+
227+
export function setPersistedNoteNudgeTrigger(
228+
db: Database,
229+
sessionId: string,
230+
triggerMessageId = "",
231+
): void {
232+
db.transaction(() => {
233+
ensureSessionMetaRow(db, sessionId);
234+
db.prepare(
235+
"UPDATE session_meta SET note_nudge_trigger_pending = 1, note_nudge_trigger_message_id = ?, note_nudge_sticky_text = '', note_nudge_sticky_message_id = '' WHERE session_id = ?",
236+
).run(triggerMessageId, sessionId);
237+
})();
238+
}
239+
240+
export function setPersistedNoteNudgeTriggerMessageId(
241+
db: Database,
242+
sessionId: string,
243+
triggerMessageId: string,
244+
): void {
245+
db.transaction(() => {
246+
ensureSessionMetaRow(db, sessionId);
247+
db.prepare(
248+
"UPDATE session_meta SET note_nudge_trigger_message_id = ? WHERE session_id = ?",
249+
).run(triggerMessageId, sessionId);
250+
})();
251+
}
252+
253+
export function setPersistedDeliveredNoteNudge(
254+
db: Database,
255+
sessionId: string,
256+
text: string,
257+
messageId = "",
258+
): void {
259+
db.transaction(() => {
260+
ensureSessionMetaRow(db, sessionId);
261+
db.prepare(
262+
"UPDATE session_meta SET note_nudge_trigger_pending = 0, note_nudge_trigger_message_id = '', note_nudge_sticky_text = ?, note_nudge_sticky_message_id = ? WHERE session_id = ?",
263+
).run(text, messageId, sessionId);
264+
})();
265+
}
266+
267+
export function clearPersistedNoteNudge(db: Database, sessionId: string): void {
268+
db.prepare(
269+
"UPDATE session_meta SET note_nudge_trigger_pending = 0, note_nudge_trigger_message_id = '', note_nudge_sticky_text = '', note_nudge_sticky_message_id = '' WHERE session_id = ?",
270+
).run(sessionId);
271+
}

src/features/magic-context/storage-meta-shared.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ export function getDefaultSessionMeta(sessionId: string): SessionMeta {
7676

7777
export function ensureSessionMetaRow(db: Database, sessionId: string): void {
7878
const defaults = getDefaultSessionMeta(sessionId);
79+
// Note-nudge persistence columns rely on session_meta defaults and are updated
80+
// through storage-meta-persisted helpers, not SessionMeta writes.
7981
db.prepare(
8082
"INSERT OR IGNORE INTO session_meta (session_id, last_response_time, cache_ttl, counter, last_nudge_tokens, last_nudge_band, last_transform_error, is_subagent, last_context_percentage, last_input_tokens, times_execute_threshold_reached, compartment_in_progress, system_prompt_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
8183
).run(

src/hooks/magic-context/compartment-runner-incremental.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export async function runCompartmentAgent(deps: CompartmentRunnerDeps): Promise<
165165

166166
updateSessionMeta(db, sessionId, { compartmentInProgress: false });
167167
completedSuccessfully = true;
168-
onNoteTrigger(sessionId, "historian_complete");
168+
onNoteTrigger(db, sessionId, "historian_complete");
169169
} catch (error: unknown) {
170170
// Historian runs are fail-closed because they update durable compartment state.
171171
const msg = getErrorMessage(error);

src/hooks/magic-context/hook-handlers.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export function createEventHook(args: {
136136
args.flushedSessions.delete(sessionId);
137137
args.lastHeuristicsTurnId.delete(sessionId);
138138
args.commitSeenLastPass?.delete(sessionId);
139-
clearNoteNudgeState(sessionId);
139+
clearNoteNudgeState(args.db, sessionId);
140140
}
141141

142142
const entry = args.contextUsageMap.get(sessionId);
@@ -220,6 +220,7 @@ export function createCommandExecuteBeforeHook(commandHandler: {
220220
}
221221

222222
export function createToolExecuteAfterHook(args: {
223+
db: Parameters<typeof getOrCreateSessionMeta>[0];
223224
recentReduceBySession: RecentReduceBySession;
224225
toolUsageSinceUserTurn: ToolUsageSinceUserTurn;
225226
}) {
@@ -234,10 +235,10 @@ export function createToolExecuteAfterHook(args: {
234235
args.recentReduceBySession.set(typedInput.sessionID, Date.now());
235236
}
236237
if (typedInput.tool === "todowrite") {
237-
onNoteTrigger(typedInput.sessionID, "todos_complete");
238+
onNoteTrigger(args.db, typedInput.sessionID, "todos_complete");
238239
}
239240
if (typedInput.tool === "ctx_note") {
240-
clearNoteNudgeState(typedInput.sessionID);
241+
clearNoteNudgeState(args.db, typedInput.sessionID);
241242
}
242243
args.toolUsageSinceUserTurn.set(typedInput.sessionID, turnUsage + 1);
243244
};

src/hooks/magic-context/hook.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
331331
},
332332
"command.execute.before": createCommandExecuteBeforeHook(commandHandler),
333333
"tool.execute.after": createToolExecuteAfterHook({
334+
db,
334335
recentReduceBySession,
335336
toolUsageSinceUserTurn,
336337
}),

src/hooks/magic-context/note-nudger.test.ts

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
import { Database } from "bun:sqlite";
44
import { afterEach, describe, expect, it } from "bun:test";
55
import { addSessionNote } from "../../features/magic-context/storage-notes";
6-
import { clearNoteNudgeState, getNoteNudgeText, onNoteTrigger } from "./note-nudger";
6+
import {
7+
clearNoteNudgeState,
8+
getNoteNudgeText,
9+
getStickyNoteNudge,
10+
markNoteNudgeDelivered,
11+
onNoteTrigger,
12+
peekNoteNudgeText,
13+
} from "./note-nudger";
714

815
const dbs: Database[] = [];
916

@@ -12,14 +19,31 @@ afterEach(() => {
1219
db.close(false);
1320
}
1421
dbs.length = 0;
15-
clearNoteNudgeState("ses-trigger");
16-
clearNoteNudgeState("ses-empty");
17-
clearNoteNudgeState("ses-clear");
1822
});
1923

2024
function makeDb(): Database {
2125
const db = new Database(":memory:");
2226
db.run(`
27+
CREATE TABLE session_meta (
28+
session_id TEXT PRIMARY KEY,
29+
last_response_time INTEGER DEFAULT 0,
30+
cache_ttl TEXT DEFAULT '5m',
31+
counter INTEGER DEFAULT 0,
32+
last_nudge_tokens INTEGER DEFAULT 0,
33+
last_nudge_band TEXT DEFAULT '',
34+
last_transform_error TEXT DEFAULT '',
35+
is_subagent INTEGER DEFAULT 0,
36+
last_context_percentage REAL DEFAULT 0,
37+
last_input_tokens INTEGER DEFAULT 0,
38+
times_execute_threshold_reached INTEGER DEFAULT 0,
39+
compartment_in_progress INTEGER DEFAULT 0,
40+
system_prompt_hash TEXT DEFAULT '',
41+
note_nudge_trigger_pending INTEGER DEFAULT 0,
42+
note_nudge_trigger_message_id TEXT DEFAULT '',
43+
note_nudge_sticky_text TEXT DEFAULT '',
44+
note_nudge_sticky_message_id TEXT DEFAULT ''
45+
);
46+
2347
CREATE TABLE session_notes (
2448
id INTEGER PRIMARY KEY AUTOINCREMENT,
2549
session_id TEXT NOT NULL,
@@ -31,39 +55,77 @@ function makeDb(): Database {
3155
return db;
3256
}
3357

58+
function getPersistedRow(db: Database, sessionId: string) {
59+
return db
60+
.prepare(
61+
"SELECT note_nudge_trigger_pending AS triggerPending, note_nudge_trigger_message_id AS triggerMessageId, note_nudge_sticky_text AS stickyText, note_nudge_sticky_message_id AS stickyMessageId FROM session_meta WHERE session_id = ?",
62+
)
63+
.get(sessionId) as {
64+
triggerPending: number;
65+
triggerMessageId: string;
66+
stickyText: string;
67+
stickyMessageId: string;
68+
} | null;
69+
}
70+
3471
describe("note-nudger", () => {
35-
it("fires after a trigger when notes exist, then suppresses until the next trigger", () => {
72+
it("persists trigger deferral and sticky delivery state in session_meta", () => {
3673
const db = makeDb();
3774
addSessionNote(db, "ses-trigger", "Follow up later.");
3875

39-
onNoteTrigger("ses-trigger", "historian_complete");
40-
41-
expect(getNoteNudgeText(db, "ses-trigger")).toContain("You have 1 deferred note");
42-
expect(getNoteNudgeText(db, "ses-trigger")).toBeNull();
43-
44-
onNoteTrigger("ses-trigger", "commit_detected");
45-
46-
expect(getNoteNudgeText(db, "ses-trigger")).toContain("You have 1 deferred note");
76+
onNoteTrigger(db, "ses-trigger", "historian_complete");
77+
78+
expect(peekNoteNudgeText(db, "ses-trigger", "u-1")).toBeNull();
79+
expect(getPersistedRow(db, "ses-trigger")).toEqual({
80+
triggerPending: 1,
81+
triggerMessageId: "u-1",
82+
stickyText: "",
83+
stickyMessageId: "",
84+
});
85+
86+
const text = peekNoteNudgeText(db, "ses-trigger", "u-2");
87+
expect(text).toContain("You have 1 deferred note");
88+
89+
markNoteNudgeDelivered(db, "ses-trigger", text!, "u-2");
90+
91+
expect(getPersistedRow(db, "ses-trigger")).toEqual({
92+
triggerPending: 0,
93+
triggerMessageId: "",
94+
stickyText: text,
95+
stickyMessageId: "u-2",
96+
});
97+
expect(getStickyNoteNudge(db, "ses-trigger")).toEqual({ text, messageId: "u-2" });
98+
expect(peekNoteNudgeText(db, "ses-trigger", "u-3")).toBeNull();
4799
});
48100

49101
it("returns null when no notes exist even if triggered", () => {
50102
const db = makeDb();
51103

52-
onNoteTrigger("ses-empty", "todos_complete");
104+
onNoteTrigger(db, "ses-empty", "todos_complete");
53105

54106
expect(getNoteNudgeText(db, "ses-empty")).toBeNull();
55107
});
56108

57-
it("clears session state so prior triggers no longer produce nudges", () => {
109+
it("clears persisted state so prior triggers and stickies no longer produce nudges", () => {
58110
const db = makeDb();
59111
addSessionNote(db, "ses-clear", "Circle back.");
60112

61-
onNoteTrigger("ses-clear", "historian_complete");
62-
clearNoteNudgeState("ses-clear");
113+
onNoteTrigger(db, "ses-clear", "historian_complete");
114+
const text = peekNoteNudgeText(db, "ses-clear", "u-2");
115+
markNoteNudgeDelivered(db, "ses-clear", text!, "u-2");
116+
117+
clearNoteNudgeState(db, "ses-clear");
63118

119+
expect(getPersistedRow(db, "ses-clear")).toEqual({
120+
triggerPending: 0,
121+
triggerMessageId: "",
122+
stickyText: "",
123+
stickyMessageId: "",
124+
});
125+
expect(getStickyNoteNudge(db, "ses-clear")).toBeNull();
64126
expect(getNoteNudgeText(db, "ses-clear")).toBeNull();
65127

66-
onNoteTrigger("ses-clear", "todos_complete");
128+
onNoteTrigger(db, "ses-clear", "todos_complete");
67129

68130
expect(getNoteNudgeText(db, "ses-clear")).toContain("You have 1 deferred note");
69131
});

0 commit comments

Comments
 (0)