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.
- package/README.md +3 -1
- package/dist/config/runtime-config-session-title-model.test.js +22 -0
- package/dist/config/runtime-config.js +24 -15
- package/dist/domain/debug-store.js +18 -0
- package/dist/domain/glasses-display-system-prompt.js +52 -0
- package/dist/domain/glasses-display-system-prompt.test.js +44 -0
- package/dist/domain/glasses-ui-system-prompt.js +6 -22
- package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
- package/dist/domain/prompt-channel-fragments.js +32 -0
- package/dist/domain/prompt-channel-fragments.test.js +70 -0
- package/dist/gateway/gateway-timing-ledger.js +15 -3
- package/dist/gateway/openclaw-client.js +80 -3
- package/dist/index.js +22 -0
- package/dist/runtime/channel-two-hook.js +36 -0
- package/dist/runtime/container-env.js +41 -0
- package/dist/runtime/display-toggle-states.js +98 -0
- package/dist/runtime/plugin-version-service.js +23 -0
- package/dist/runtime/register-session-title-distiller.js +100 -0
- package/dist/runtime/relay-core.js +307 -68
- package/dist/runtime/relay-service.js +120 -13
- package/dist/runtime/relay-worker-entry.js +26 -0
- package/dist/runtime/relay-worker-protocol.js +0 -4
- package/dist/runtime/relay-worker-supervisor.js +43 -79
- package/dist/runtime/relay-worker-transport.js +41 -0
- package/dist/runtime/session-service.js +159 -15
- package/dist/runtime/session-title-distiller-budget.js +36 -0
- package/dist/runtime/session-title-distiller-helpers.js +130 -0
- package/dist/runtime/session-title-distiller.js +354 -0
- package/dist/runtime/session-title-record.js +21 -0
- package/dist/runtime/stable-prompt-snapshot.js +119 -0
- package/dist/tools/glasses-ui-cron.js +9 -3
- package/dist/tools/glasses-ui-paint-floor.js +10 -3
- package/dist/tools/glasses-ui-recipes.js +13 -178
- package/dist/tools/glasses-ui-surfaces.js +8 -1
- package/dist/tools/glasses-ui-tool-description.test.js +16 -0
- package/dist/tools/glasses-ui-tool.js +98 -60
- package/dist/tools/session-title-tool.js +14 -76
- package/dist/tools/session-title-tool.test.js +53 -0
- package/dist/version.js +2 -2
- package/openclaw.plugin.json +9 -0
- package/package.json +6 -4
- package/skills/glasses-ui/SKILL.md +163 -0
- package/dist/runtime/downstream-server.js +0 -2057
- 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
|
|
6
|
-
//
|
|
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 =
|
|
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;
|