ocuclaw 1.3.0 → 1.3.2

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.
Files changed (44) hide show
  1. package/README.md +3 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +22 -0
  3. package/dist/config/runtime-config.js +24 -15
  4. package/dist/domain/debug-store.js +18 -0
  5. package/dist/domain/glasses-display-system-prompt.js +52 -0
  6. package/dist/domain/glasses-display-system-prompt.test.js +44 -0
  7. package/dist/domain/glasses-ui-system-prompt.js +6 -22
  8. package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
  9. package/dist/domain/prompt-channel-fragments.js +32 -0
  10. package/dist/domain/prompt-channel-fragments.test.js +70 -0
  11. package/dist/gateway/gateway-timing-ledger.js +15 -3
  12. package/dist/gateway/openclaw-client.js +80 -3
  13. package/dist/index.js +22 -0
  14. package/dist/runtime/channel-two-hook.js +36 -0
  15. package/dist/runtime/container-env.js +41 -0
  16. package/dist/runtime/display-toggle-states.js +98 -0
  17. package/dist/runtime/plugin-version-service.js +23 -0
  18. package/dist/runtime/register-session-title-distiller.js +100 -0
  19. package/dist/runtime/relay-core.js +307 -68
  20. package/dist/runtime/relay-service.js +120 -13
  21. package/dist/runtime/relay-worker-entry.js +26 -0
  22. package/dist/runtime/relay-worker-protocol.js +0 -4
  23. package/dist/runtime/relay-worker-supervisor.js +43 -79
  24. package/dist/runtime/relay-worker-transport.js +41 -0
  25. package/dist/runtime/session-service.js +159 -15
  26. package/dist/runtime/session-title-distiller-budget.js +36 -0
  27. package/dist/runtime/session-title-distiller-helpers.js +130 -0
  28. package/dist/runtime/session-title-distiller.js +354 -0
  29. package/dist/runtime/session-title-record.js +21 -0
  30. package/dist/runtime/stable-prompt-snapshot.js +119 -0
  31. package/dist/tools/glasses-ui-cron.js +9 -3
  32. package/dist/tools/glasses-ui-paint-floor.js +10 -3
  33. package/dist/tools/glasses-ui-recipes.js +13 -178
  34. package/dist/tools/glasses-ui-surfaces.js +8 -1
  35. package/dist/tools/glasses-ui-tool-description.test.js +16 -0
  36. package/dist/tools/glasses-ui-tool.js +98 -60
  37. package/dist/tools/session-title-tool.js +14 -76
  38. package/dist/tools/session-title-tool.test.js +53 -0
  39. package/dist/version.js +2 -2
  40. package/openclaw.plugin.json +9 -0
  41. package/package.json +6 -4
  42. package/skills/glasses-ui/SKILL.md +163 -0
  43. package/dist/runtime/downstream-server.js +0 -2057
  44. package/dist/runtime/plugin-update-service.js +0 -216
@@ -0,0 +1,354 @@
1
+ import * as nodePath from "node:path";
2
+ import {
3
+ isDistillerSessionKey,
4
+ sanitizeTitle,
5
+ buildExcerpt,
6
+ buildDistillerAgentParams,
7
+ internalTranscriptFilename,
8
+ DISTILLER_SESSION_PREFIX,
9
+ extractAssistantTitleFromMessages,
10
+ splitModelRef,
11
+ } from "./session-title-distiller-helpers.js";
12
+ import { isUserOrigin } from "./session-title-record.js";
13
+
14
+ const PROMPT_INSTRUCTION =
15
+ "You are titling a chat session. Read the recent conversation below and reply " +
16
+ "with a 2-5 word noun-phrase title (≤55 chars), or exactly SKIP if no concrete " +
17
+ "topic has emerged yet. Reply with the title only — no quotes, no punctuation, " +
18
+ "no explanation.\n\n";
19
+
20
+ /**
21
+ * Whether an agent.wait result means the run has actually TERMINATED (vs. the
22
+ * wait itself timing out while the run keeps going). Defensive about the exact
23
+ * result shape: an unknown/empty result is treated as terminal so cleanup never
24
+ * loops forever; an explicit wait-timeout / still-running status is non-terminal.
25
+ */
26
+ export function runWaitTerminal(result) {
27
+ if (!result || typeof result !== "object") return true;
28
+ if (result.endedAt != null) return true;
29
+ if (result.stopReason != null) return true;
30
+ if (result.timeoutPhase) return false;
31
+ if (typeof result.status === "string") {
32
+ return !/^(running|active|pending|in_progress|started|accepted)$/i.test(result.status);
33
+ }
34
+ return true;
35
+ }
36
+
37
+ export function createSessionTitleDistiller(deps) {
38
+ const {
39
+ stateDir, getStateDir, nowMs, genId, emitDebug, getSessionTitleModel,
40
+ conversationState, sessionService, isEvenAiSessionKey,
41
+ gatewayBridge, fs, budget, subagentRuntime, cleanupDistillerSession,
42
+ } = deps;
43
+ const timeoutMs = Number.isFinite(deps.timeoutMs) ? deps.timeoutMs : 30000;
44
+ const now = typeof nowMs === "function" ? nowMs : () => Date.now();
45
+ const dbg = typeof emitDebug === "function" ? emitDebug : () => {};
46
+
47
+ const inFlight = new Set();
48
+
49
+ // Latched on the first dispatch-time subagent failure (request-scoped
50
+ // runtime on 2026.6.x): later runs go straight to the raw-RPC bridge. A
51
+ // gateway restart re-probes naturally (fresh distiller instance).
52
+ let subagentDispatchUnusable = false;
53
+
54
+ // Resolve the state dir LAZILY (at cleanup time): the distiller is constructed
55
+ // during plugin registration, before service.start() receives ctx.stateDir, so
56
+ // an eagerly-captured value would be undefined and the transcript (containing
57
+ // the conversation excerpt) would never be deleted from the real state dir.
58
+ function resolveStateDir() {
59
+ if (typeof getStateDir === "function") {
60
+ const dir = getStateDir();
61
+ if (typeof dir === "string" && dir.trim()) return dir;
62
+ }
63
+ return typeof stateDir === "string" && stateDir.trim() ? stateDir : ".";
64
+ }
65
+ function transcriptPath(runId) {
66
+ return nodePath.join(resolveStateDir(), "internal-agent-runs", internalTranscriptFilename(runId));
67
+ }
68
+
69
+ function triggerGatesPass(sessionKey) {
70
+ if (isDistillerSessionKey(sessionKey)) return false;
71
+ if (typeof isEvenAiSessionKey === "function" && isEvenAiSessionKey(sessionKey)) return false;
72
+ if (!sessionService.isNeuralSessionNamesEnabled(sessionKey)) return false;
73
+ if (!sessionService.hasRecordedUserMessage(sessionKey)) return false;
74
+ const rec = sessionService.getSessionTitleRecord(sessionKey);
75
+ if (rec && rec.title) return false; // already has a real OcuClaw title record
76
+ if (sessionService.isSessionUserLocked(sessionKey)) return false;
77
+ if (inFlight.has(sessionKey)) return false;
78
+ if (budget && typeof budget.canRun === "function" && !budget.canRun(sessionKey)) return false;
79
+ return true;
80
+ }
81
+
82
+ // Subscribe up front and resolve when an assistant `message` arrives whose
83
+ // runId matches the gateway's ACCEPTED run id. `matchRunId()` returns null
84
+ // until the agent ack resolves it: the idempotencyKey we send is NOT the run
85
+ // id the gateway echoes on message events (it returns its own accepted runId
86
+ // in the ack), so filtering by the idempotencyKey would never match. Mirrors
87
+ // even-ai-run-waiter, which waits on the ack's runId.
88
+ function captureAssistantText(matchRunId) {
89
+ let off = null;
90
+ let settled = false;
91
+ let resolveFn = null;
92
+ const finish = (text) => {
93
+ if (settled) return;
94
+ settled = true;
95
+ if (typeof off === "function") off();
96
+ clearTimeout(timer);
97
+ if (typeof resolveFn === "function") resolveFn(text);
98
+ };
99
+ const timer = setTimeout(() => finish(null), timeoutMs);
100
+ if (typeof timer.unref === "function") timer.unref();
101
+ const promise = new Promise((resolve) => {
102
+ resolveFn = resolve;
103
+ });
104
+ off = gatewayBridge.on("message", (data) => {
105
+ const expected = matchRunId();
106
+ if (!expected || !data || data.runId !== expected) return;
107
+ if (data.role && data.role !== "assistant") return;
108
+ const content = data.content;
109
+ const text = typeof content === "string"
110
+ ? content
111
+ : Array.isArray(content)
112
+ ? content.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("")
113
+ : "";
114
+ finish(text);
115
+ });
116
+ return { promise, dispose: () => finish(null) };
117
+ }
118
+
119
+ function applyDistilledTitle(sessionKey, acceptedRunId, text) {
120
+ const title = sanitizeTitle(text);
121
+ if (!title) {
122
+ dbg("relay.session", "distiller_skip", "debug", { sessionKey, runId: acceptedRunId }, () => ({}));
123
+ return "skip";
124
+ }
125
+ // Apply-time recheck: trigger-time gates are stale by now. Re-check the
126
+ // feature toggle (the user may have turned automatic titling OFF while the
127
+ // run was in flight) as well as the title record / lock.
128
+ const featureOff = !sessionService.isNeuralSessionNamesEnabled(sessionKey);
129
+ const rec = sessionService.getSessionTitleRecord(sessionKey);
130
+ const locked = sessionService.isSessionUserLocked(sessionKey);
131
+ if (featureOff || (rec && rec.title) || locked || (rec && isUserOrigin(rec.origin))) {
132
+ dbg("relay.session", "stale_discarded", "info", { sessionKey, runId: acceptedRunId }, () => ({ title }));
133
+ return "skip";
134
+ }
135
+ const res = sessionService.setSessionTitle(sessionKey, title, { origin: "topic_distiller" });
136
+ if (res && res.ok) {
137
+ dbg("relay.session", "distiller_titled", "info", { sessionKey, runId: acceptedRunId }, () => ({ title }));
138
+ return "applied";
139
+ }
140
+ dbg("relay.session", "distiller_apply_refused", "info", { sessionKey, runId: acceptedRunId },
141
+ () => ({ code: res && res.code ? res.code : null }));
142
+ return "skip";
143
+ }
144
+
145
+ function buildDistillerInput(opts) {
146
+ const idempotencyKey = genId();
147
+ const distillerKey = `${DISTILLER_SESSION_PREFIX}${idempotencyKey}`;
148
+ const rawMessages =
149
+ opts && Array.isArray(opts.messages)
150
+ ? opts.messages
151
+ : conversationState && typeof conversationState.getRawMessages === "function"
152
+ ? conversationState.getRawMessages()
153
+ : [];
154
+ const excerpt = buildExcerpt(rawMessages, {});
155
+ const message = `${PROMPT_INSTRUCTION}${excerpt}`;
156
+ const model = typeof getSessionTitleModel === "function" ? getSessionTitleModel() : "";
157
+ return { idempotencyKey, distillerKey, message, model };
158
+ }
159
+
160
+ async function runOnceViaGatewayBridge(sessionKey, opts) {
161
+ const { idempotencyKey, distillerKey, message, model } = buildDistillerInput(opts);
162
+ // Prefer the messages captured at agent_end (the completed run's transcript,
163
+ // snapshotted before this fire-and-forget run defers). Only fall back to the
164
+ // live conversation state if none were passed — that global state can belong
165
+ // to a different session by the time this deferred run executes.
166
+ const params = buildDistillerAgentParams({
167
+ sessionKey: distillerKey, idempotencyKey, message, model,
168
+ });
169
+
170
+ // The gateway returns its accepted runId in the ack and echoes THAT on the
171
+ // message stream + names the internal transcript by it. Resolve it before
172
+ // awaiting the capture; subscribe up front so an early message isn't missed.
173
+ let acceptedRunId = null;
174
+ const capture = captureAssistantText(() => acceptedRunId);
175
+ dbg("relay.session", "distiller_run_started", "debug", { sessionKey }, () => ({ chars: message.length, idempotencyKey, via: "gateway-bridge" }));
176
+
177
+ let outcome = "error";
178
+ try {
179
+ const ack = await gatewayBridge.request("agent", params, { expectFinal: false });
180
+ acceptedRunId =
181
+ ack && typeof ack.runId === "string" && ack.runId.trim()
182
+ ? ack.runId.trim()
183
+ : idempotencyKey;
184
+ const text = await capture.promise;
185
+ outcome = applyDistilledTitle(sessionKey, acceptedRunId, text);
186
+ } catch (err) {
187
+ outcome = "error";
188
+ dbg("relay.session", "distiller_error", "warn", { sessionKey, runId: acceptedRunId || idempotencyKey },
189
+ () => ({ message: err && err.message ? err.message : String(err), via: "gateway-bridge" }));
190
+ } finally {
191
+ capture.dispose();
192
+ // The run continues after our local capture (expectFinal:false enqueue), so
193
+ // wait for it to actually terminate before deleting its transcript —
194
+ // otherwise a slow run can write the excerpt-bearing transcript AFTER this
195
+ // cleanup and leave it on disk. Bounded by timeoutMs + best-effort
196
+ // (unsupported/timeout/error just proceeds to the delete).
197
+ if (acceptedRunId) {
198
+ // agent.wait may return a non-terminal timeout while the run keeps
199
+ // going; deleting then would race the run's own transcript write. Loop
200
+ // until the wait reports the run terminated, bounded by attempts so a
201
+ // hung run can't block forever (at the cap we delete best-effort).
202
+ for (let attempt = 0; attempt < 3; attempt++) {
203
+ let waitResult;
204
+ try {
205
+ waitResult = await gatewayBridge.request(
206
+ "agent.wait",
207
+ { runId: acceptedRunId, timeoutMs },
208
+ { expectFinal: true },
209
+ );
210
+ } catch (_e) {
211
+ break; // error/unsupported — proceed to best-effort delete
212
+ }
213
+ if (runWaitTerminal(waitResult)) break;
214
+ }
215
+ }
216
+ // The transcript is keyed by the gateway's accepted runId; fall back to
217
+ // the idempotencyKey only if the ack never returned one.
218
+ const cleanupRunId = acceptedRunId || idempotencyKey;
219
+ try { await fs.rm(transcriptPath(cleanupRunId), { force: true }); } catch (_e) { /* best-effort */ }
220
+ // 2026.6.x writes runId-derived sidecars next to the transcript
221
+ // (<id>.jsonl.codex-app-server.json, <id>.trajectory.jsonl,
222
+ // <id>.trajectory-path.json) — they carry the run content too. Sweep
223
+ // every "<id>."-prefixed sibling.
224
+ if (typeof fs.readdir === "function") {
225
+ try {
226
+ const dir = nodePath.dirname(transcriptPath(cleanupRunId));
227
+ const base = internalTranscriptFilename(cleanupRunId).replace(/\.jsonl$/, "");
228
+ for (const entry of await fs.readdir(dir)) {
229
+ if (typeof entry === "string" && entry.startsWith(`${base}.`)) {
230
+ try { await fs.rm(nodePath.join(dir, entry), { force: true }); } catch (_e) { /* best-effort */ }
231
+ }
232
+ }
233
+ } catch (_e) { /* best-effort */ }
234
+ }
235
+ if (budget && typeof budget.recordOutcome === "function") budget.recordOutcome(sessionKey, outcome);
236
+ }
237
+ }
238
+
239
+ async function runOnceViaSubagent(sessionKey, opts) {
240
+ const { idempotencyKey, distillerKey, message, model } = buildDistillerInput(opts);
241
+ // Background, non-delivered title run on a throwaway distiller session.
242
+ const runParams = {
243
+ sessionKey: distillerKey,
244
+ message,
245
+ idempotencyKey,
246
+ deliver: false,
247
+ lane: "background",
248
+ lightContext: true,
249
+ };
250
+ const ref = splitModelRef(model);
251
+ if (ref) {
252
+ if (ref.provider) runParams.provider = ref.provider;
253
+ runParams.model = ref.model;
254
+ }
255
+ dbg("relay.session", "distiller_run_started", "debug", { sessionKey }, () => ({ chars: message.length, idempotencyKey, via: "subagent" }));
256
+
257
+ // Dispatch OUTSIDE the wait/read/cleanup scope: a dispatch-time throw means
258
+ // no run exists (nothing to wait on, read, or delete). On 2026.6.x the
259
+ // runtime's methods exist at registration but are request-scoped — they
260
+ // throw "only available during a gateway request" from this deferred
261
+ // context. Latch and serve this run (and the rest of the process lifetime)
262
+ // via the raw-RPC bridge instead; the bridge leg records the outcome.
263
+ let runRes;
264
+ try {
265
+ runRes = await subagentRuntime.run(runParams);
266
+ } catch (err) {
267
+ subagentDispatchUnusable = true;
268
+ dbg("relay.session", "distiller_subagent_unusable", "info", { sessionKey, runId: idempotencyKey },
269
+ () => ({ message: err && err.message ? err.message : String(err) }));
270
+ return runOnceViaGatewayBridge(sessionKey, opts);
271
+ }
272
+
273
+ let acceptedRunId =
274
+ runRes && typeof runRes.runId === "string" && runRes.runId.trim()
275
+ ? runRes.runId.trim()
276
+ : idempotencyKey;
277
+ let outcome = "error";
278
+ try {
279
+ const wait = await subagentRuntime.waitForRun({ runId: acceptedRunId, timeoutMs });
280
+ const waitStatus = wait && typeof wait.status === "string" ? wait.status : "ok";
281
+ if (waitStatus === "error") {
282
+ // The background run failed; nothing to read.
283
+ outcome = "error";
284
+ dbg("relay.session", "distiller_error", "warn", { sessionKey, runId: acceptedRunId },
285
+ () => ({ message: wait && wait.error ? wait.error : "subagent run error", via: "subagent", waitStatus }));
286
+ } else if (waitStatus !== "ok") {
287
+ // Non-terminal (e.g. "timeout"): the session has no final reply yet — do
288
+ // NOT read a partial/empty transcript. The budget-bounded retry titles a
289
+ // later untitled turn.
290
+ outcome = "skip";
291
+ dbg("relay.session", "distiller_skip", "debug", { sessionKey, runId: acceptedRunId },
292
+ () => ({ via: "subagent", waitStatus }));
293
+ } else {
294
+ const msgRes = await subagentRuntime.getSessionMessages({ sessionKey: distillerKey, limit: 4 });
295
+ const text = extractAssistantTitleFromMessages(msgRes && msgRes.messages);
296
+ outcome = applyDistilledTitle(sessionKey, acceptedRunId, text);
297
+ }
298
+ } catch (err) {
299
+ outcome = "error";
300
+ dbg("relay.session", "distiller_error", "warn", { sessionKey, runId: acceptedRunId },
301
+ () => ({ message: err && err.message ? err.message : String(err), via: "subagent" }));
302
+ } finally {
303
+ try {
304
+ await subagentRuntime.deleteSession({ sessionKey: distillerKey, deleteTranscript: true });
305
+ } catch (_e) { /* best-effort */ }
306
+ // Canonical-key backstop: on 2026.6.x the native deleteSession resolves
307
+ // OK even when its bare-key sessions.delete matched nothing, leaving the
308
+ // excerpt-bearing transcript + index entry behind (observed live). The
309
+ // relay-side delete resolves the canonical key first; both are
310
+ // idempotent, so always run it.
311
+ if (typeof cleanupDistillerSession === "function") {
312
+ try {
313
+ const res = await cleanupDistillerSession(distillerKey);
314
+ const failed = res && Array.isArray(res.failed) ? res.failed : [];
315
+ if (failed.length) {
316
+ dbg("relay.session", "distiller_cleanup_failed", "warn", { sessionKey, runId: acceptedRunId },
317
+ () => ({ distillerKey, reason: failed[0] && failed[0].reason ? failed[0].reason : "unknown" }));
318
+ }
319
+ } catch (err) {
320
+ dbg("relay.session", "distiller_cleanup_failed", "warn", { sessionKey, runId: acceptedRunId },
321
+ () => ({ distillerKey, reason: err && err.message ? err.message : String(err) }));
322
+ }
323
+ }
324
+ if (budget && typeof budget.recordOutcome === "function") budget.recordOutcome(sessionKey, outcome);
325
+ }
326
+ }
327
+
328
+ async function runOnce(sessionKey, opts) {
329
+ if (subagentRuntime && typeof subagentRuntime.run === "function" && !subagentDispatchUnusable) {
330
+ return runOnceViaSubagent(sessionKey, opts);
331
+ }
332
+ return runOnceViaGatewayBridge(sessionKey, opts);
333
+ }
334
+
335
+ return {
336
+ async maybeRun(sessionKey, opts) {
337
+ if (typeof sessionKey !== "string" || !sessionKey.trim()) return;
338
+ if (!triggerGatesPass(sessionKey)) return;
339
+ // Count only ACTUAL attempts toward the budget ceiling. Recording before
340
+ // the gates would let recursion-guard / feature-disabled / already-titled
341
+ // skips exhaust the untitled-turn ceiling and permanently disable titling
342
+ // (e.g. 25 feature-disabled agent_end events before the user enables it).
343
+ if (budget && typeof budget.recordTurn === "function") budget.recordTurn(sessionKey);
344
+ inFlight.add(sessionKey);
345
+ try {
346
+ await runOnce(sessionKey, opts);
347
+ } finally {
348
+ inFlight.delete(sessionKey);
349
+ }
350
+ },
351
+ };
352
+ }
353
+
354
+ export default createSessionTitleDistiller;
@@ -0,0 +1,21 @@
1
+ const USER_ORIGINS = new Set(["user_ui", "user_tool"]);
2
+
3
+ export function isUserOrigin(origin) {
4
+ return typeof origin === "string" && USER_ORIGINS.has(origin);
5
+ }
6
+
7
+ /**
8
+ * Decide whether a title write is allowed and whether it sets the lock.
9
+ * @param {{userSet?: boolean, origin?: string}|null} previous
10
+ * @param {string} origin - origin of the incoming write
11
+ * @returns {{allowed: true, nextUserSet: boolean} | {allowed: false, code: string}}
12
+ */
13
+ export function decideTitleWrite(previous, origin) {
14
+ const incomingIsUser = isUserOrigin(origin);
15
+ const prevLocked = !!(previous && previous.userSet === true);
16
+ if (!incomingIsUser && prevLocked) {
17
+ return { allowed: false, code: "session_user_locked" };
18
+ }
19
+ const nextUserSet = incomingIsUser || prevLocked;
20
+ return { allowed: true, nextUserSet };
21
+ }
@@ -0,0 +1,119 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as crypto from "node:crypto";
4
+
5
+ const STORE_FILENAME = "ocuclaw-stable-prompts.json";
6
+ const DEFAULT_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
7
+
8
+ function hashText(text) {
9
+ return crypto.createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16);
10
+ }
11
+
12
+ /**
13
+ * Per-session immutable Channel-1 snapshot store.
14
+ *
15
+ * @param {{stateDir?: string, nowMs?: () => number, ttlMs?: number,
16
+ * emitDebug?: Function}} opts
17
+ */
18
+ export function createStablePromptSnapshotStore(opts = {}) {
19
+ const nowMs = typeof opts.nowMs === "function" ? opts.nowMs : () => Date.now();
20
+ const ttlMs = Number.isFinite(opts.ttlMs) ? opts.ttlMs : DEFAULT_TTL_MS;
21
+ const emitDebug = typeof opts.emitDebug === "function" ? opts.emitDebug : () => {};
22
+ const statePath =
23
+ typeof opts.stateDir === "string" && opts.stateDir.trim()
24
+ ? path.join(opts.stateDir.trim(), STORE_FILENAME)
25
+ : null;
26
+
27
+ /** @type {Map<string, {sessionId: string, prompt: string, hash: string, touchedMs: number}>} */
28
+ const byKey = new Map();
29
+
30
+ function load() {
31
+ if (!statePath) return;
32
+ try {
33
+ const raw = fs.readFileSync(statePath, "utf8");
34
+ const parsed = JSON.parse(raw);
35
+ if (parsed && parsed.entries && typeof parsed.entries === "object") {
36
+ for (const [k, v] of Object.entries(parsed.entries)) {
37
+ if (v && typeof v.prompt === "string" && typeof v.sessionId === "string") {
38
+ byKey.set(k, {
39
+ sessionId: v.sessionId,
40
+ prompt: v.prompt,
41
+ hash: typeof v.hash === "string" ? v.hash : hashText(v.prompt),
42
+ touchedMs: Number.isFinite(v.touchedMs) ? v.touchedMs : nowMs(),
43
+ });
44
+ }
45
+ }
46
+ }
47
+ } catch (_err) {
48
+ // Missing/corrupt store is non-fatal: start empty.
49
+ }
50
+ }
51
+
52
+ function persist() {
53
+ if (!statePath) return;
54
+ try {
55
+ const entries = {};
56
+ for (const [k, v] of byKey.entries()) entries[k] = v;
57
+ const tmp = `${statePath}.tmp`;
58
+ fs.writeFileSync(tmp, JSON.stringify({ version: 1, entries }), { mode: 0o600 });
59
+ fs.renameSync(tmp, statePath);
60
+ } catch (err) {
61
+ emitDebug("relay.session", "stable_prompt_persist_failed", "warn", {}, () => ({
62
+ message: err && err.message ? err.message : String(err),
63
+ }));
64
+ }
65
+ }
66
+
67
+ function normSessionId(sessionId) {
68
+ return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : "";
69
+ }
70
+
71
+ load();
72
+
73
+ return {
74
+ /**
75
+ * Return the stored prompt for (sessionKey, sessionId), computing+storing
76
+ * it exactly once. A differing stored sessionId forces a recompute.
77
+ */
78
+ getOrCreate(sessionKey, sessionId, computeFn) {
79
+ const sid = normSessionId(sessionId);
80
+ const existing = byKey.get(sessionKey);
81
+ if (existing && existing.sessionId === sid) {
82
+ existing.touchedMs = nowMs();
83
+ return existing.prompt;
84
+ }
85
+ const prompt = String(computeFn());
86
+ const record = { sessionId: sid, prompt, hash: hashText(prompt), touchedMs: nowMs() };
87
+ byKey.set(sessionKey, record);
88
+ persist();
89
+ emitDebug("relay.session", "stable_prompt_resolved", "info", { sessionKey }, () => ({
90
+ sessionId: sid, chars: prompt.length, hash: record.hash,
91
+ }));
92
+ return prompt;
93
+ },
94
+
95
+ /** True if `candidate` differs from the stored stable prompt for this session. */
96
+ wouldChurn(sessionKey, sessionId, candidate) {
97
+ const existing = byKey.get(sessionKey);
98
+ if (!existing || existing.sessionId !== normSessionId(sessionId)) return false;
99
+ return existing.hash !== hashText(String(candidate));
100
+ },
101
+
102
+ evict(sessionKey) {
103
+ if (byKey.delete(sessionKey)) persist();
104
+ },
105
+
106
+ sweep() {
107
+ const cutoff = nowMs() - ttlMs;
108
+ let changed = false;
109
+ for (const [k, v] of byKey.entries()) {
110
+ if (v.touchedMs < cutoff) { byKey.delete(k); changed = true; }
111
+ }
112
+ if (changed) persist();
113
+ },
114
+
115
+ _size() { return byKey.size; },
116
+ };
117
+ }
118
+
119
+ export default createStablePromptSnapshotStore;
@@ -85,7 +85,7 @@ export function createGlassesUiCronEngine(deps) {
85
85
  return Object.assign({}, outcome, extra);
86
86
  }
87
87
 
88
- function resolveAndClean(state, extra) {
88
+ function resolveAndClean(state, extra, opts) {
89
89
  if (state.resolved) return;
90
90
  state.resolved = true;
91
91
  if (state.nextTickTimer) clearTimeoutFn(state.nextTickTimer);
@@ -93,6 +93,12 @@ export function createGlassesUiCronEngine(deps) {
93
93
  state.nextTickTimer = null;
94
94
  state.maxDurationTimer = null;
95
95
  active.delete(state.surfaceId);
96
+ // silent: tear down timers/state WITHOUT firing onResolve. Used when the
97
+ // cron slot is being RECYCLED (a replace render swapping the surface's
98
+ // content in place) — a synthesized outcome here would reach
99
+ // surfaceStore.resolve with no pending call and LATCH a bogus exit that
100
+ // discards the very render doing the replacing (B7, found 2026-06-11).
101
+ if (opts && opts.silent === true) return;
96
102
  try {
97
103
  state.onResolve(makeOutcome(state, extra));
98
104
  } catch (_) {
@@ -326,10 +332,10 @@ export function createGlassesUiCronEngine(deps) {
326
332
  });
327
333
  });
328
334
  },
329
- stop(surfaceId, outcome) {
335
+ stop(surfaceId, outcome, opts) {
330
336
  const state = active.get(surfaceId);
331
337
  if (!state) return false;
332
- resolveAndClean(state, outcome || { result: "preempted" });
338
+ resolveAndClean(state, outcome || { result: "preempted" }, opts);
333
339
  return true;
334
340
  },
335
341
  stopAllForSession(sessionKey, outcome) {
@@ -2,15 +2,22 @@
2
2
  //
3
3
  // Governs ALL plugin->glass sends (initial RebuildPageContainer render and
4
4
  // every surface_update patch). Collapses bursts to last-write-wins per field
5
- // and emits at most one frame per paintFloorMs (default 150ms, Spike D), with
6
- // a leading-edge send + a trailing send carrying the final merged patch.
5
+ // and emits at most one frame per paintFloorMs, with a leading-edge send + a
6
+ // trailing send carrying the final merged patch.
7
7
  //
8
8
  // There is NO glass-side paint-ack: the only backpressure signal is
9
9
  // relay/BLE transport-side (see the isUnderBackpressure shed in Task 13).
10
10
  // Local fake-list textContainerUpgrade scroll-swaps are client-side and never
11
11
  // reach this coalescer.
12
+ //
13
+ // 250 is the unconditionally hardware-proven floor. Spike D approved lowering
14
+ // to 150 ONLY once the backpressure shed has a live signal; the shed's
15
+ // isGlassesSendBufferOverHighWater query is implemented nowhere yet, so the
16
+ // shed is inert and 150 would run without its safety condition. Restore 150
17
+ // when the relay-service bridge lands and is validated on hardware
18
+ // (roadmap step 4, docs/superpowers/plans/2026-06-10-glasses-ui-state-reset-and-roadmap.md).
12
19
 
13
- export const DEFAULT_PAINT_FLOOR_MS = 150;
20
+ export const DEFAULT_PAINT_FLOOR_MS = 250;
14
21
 
15
22
  export function createPaintFloorCoalescer(deps) {
16
23
  const paintFloorMs = Number.isFinite(deps.paintFloorMs) ? deps.paintFloorMs : DEFAULT_PAINT_FLOOR_MS;