openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
984 lines (983 loc) • 33 kB
JavaScript
import { l as normalizeOptionalString } from "../../string-coerce-1VuiLaaR.mjs";
import { n as coerceErrorMessage } from "../../error-coercion-W9BtigRm.mjs";
import { a as resolveGlobalSingleton } from "../../global-singleton-0m-JziJW.mjs";
import { o as asRecord } from "../../record-coerce-TIsquoVR.mjs";
import { a as canonicalPathFromExistingAncestor } from "../../fs-safe-BBzelG8F.mjs";
import { t as formatErrorMessage } from "../../errors-BvOkmQM5.mjs";
import { c as SqliteWorkerError } from "../../sqlite-worker-contract-C51cJHzg.mjs";
import { t as KeyedAsyncQueue } from "../../keyed-async-queue-CTreGrmR.mjs";
import { l as openSqliteWorkerStore } from "../../sqlite-worker-store-C0QRqven.mjs";
import { t as ErrorCodes } from "../../gateway-error-details-gTekQWj2.mjs";
import { d as errorShape } from "../../error-codes-D0WRV4Bq.mjs";
import { t as canonicalizeBase64 } from "../../base64-CU53EX1R.mjs";
import "../../error-runtime-oJAVOxQN.mjs";
import "../../string-coerce-runtime-BbIJhy7G.mjs";
import "../../sqlite-runtime-DP6ZKsOz.mjs";
import "../../file-access-runtime-BSYnDRNI.mjs";
import { t as definePluginEntry } from "../../plugin-entry-8p_0SEiv.mjs";
import "../../media-runtime-Bn5-G6uL.mjs";
import "../../gateway-runtime-hhusjSIO.mjs";
import { a as revisionWindow, c as OBSERVATION_JSON_SCHEMA, d as buildCardsPrompt, f as buildObservationInstructions, l as buildAskPrompt, m as dayKeyFor, n as parseCardsJson, o as selectBatchFrames, p as buildStandupPrompt, r as parseObservationSegments, s as validateCardCoverage, t as CARD_LOOKBACK_MS, u as buildCardsCorrectionPrompt } from "../../analyze-CrFMbNLN.mjs";
import { pathToFileURL } from "node:url";
import path from "node:path";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
//#region extensions/logbook/src/config.ts
const DEFAULTS = {
captureEnabled: true,
captureIntervalSeconds: 30,
analysisIntervalMinutes: 15,
screenIndex: 0,
maxWidth: 1440,
retentionDays: 14
};
function clampNumber(value, fallback, min, max) {
return Math.min(max, Math.max(min, Math.round(typeof value === "number" && Number.isFinite(value) ? value : fallback)));
}
function resolveLogbookConfig(raw) {
const value = asRecord(raw);
return {
captureEnabled: value.captureEnabled !== false,
captureIntervalSeconds: clampNumber(value.captureIntervalSeconds, DEFAULTS.captureIntervalSeconds, 5, 600),
analysisIntervalMinutes: clampNumber(value.analysisIntervalMinutes, DEFAULTS.analysisIntervalMinutes, 3, 120),
nodeId: normalizeOptionalString(value.nodeId),
screenIndex: clampNumber(value.screenIndex, DEFAULTS.screenIndex, 0, 16),
maxWidth: clampNumber(value.maxWidth, DEFAULTS.maxWidth, 480, 3840),
visionModel: normalizeOptionalString(value.visionModel),
retentionDays: clampNumber(value.retentionDays, DEFAULTS.retentionDays, 1, 365)
};
}
/** Splits a "provider/model" ref; model ids may themselves contain slashes. */
function parseModelRef(ref) {
const slash = ref.indexOf("/");
if (slash <= 0 || slash === ref.length - 1) return null;
return {
provider: ref.slice(0, slash),
model: ref.slice(slash + 1)
};
}
//#endregion
//#region extensions/logbook/src/frame-io.ts
const MAX_FRAME_OPERATIONS = 128;
/** Frame reads and pruning share admission while SQLite remains owned by the worker. */
function acquireLogbookFrameIo(root) {
const state = resolveGlobalSingleton(Symbol.for("openclaw.logbookFrameIo"), () => ({
queue: new KeyedAsyncQueue(),
pending: 0
}));
const accepted = /* @__PURE__ */ new Set();
let closing;
return {
run(operation) {
if (closing) return Promise.reject(/* @__PURE__ */ new Error("Logbook frame I/O is closed"));
if (state.pending >= MAX_FRAME_OPERATIONS) return Promise.reject(Object.assign(/* @__PURE__ */ new Error("Logbook frame I/O queue capacity reached"), { code: "overloaded" }));
state.pending += 1;
const result = state.queue.enqueue(root, operation);
accepted.add(result);
const settle = () => {
state.pending -= 1;
accepted.delete(result);
};
result.then(settle, settle);
return result;
},
close() {
closing ??= Promise.allSettled(accepted).then(() => void 0);
return closing;
}
};
}
//#endregion
//#region extensions/logbook/src/store.ts
var LogbookStore = class LogbookStore {
static async open(dataDir, workerModuleUrl) {
if (!dataDir) throw new Error("Logbook data directory is required");
const root = await canonicalPathFromExistingAncestor(path.resolve(dataDir));
const worker = await openSqliteWorkerStore({
moduleUrl: workerModuleUrl,
databasePath: path.join(dataDir, "logbook.sqlite"),
input: { dataDir: root }
});
return new LogbookStore(dataDir, worker, acquireLogbookFrameIo(root));
}
constructor(dataDir, worker, frameIo) {
this.dataDir = dataDir;
this.worker = worker;
this.frameIo = frameIo;
this.framesDir = path.join(dataDir, "frames");
}
close() {
this.closing ??= (async () => {
await this.frameIo.close();
await this.worker.close();
})();
return this.closing;
}
execute(type, input) {
if (this.closing) return Promise.reject(new SqliteWorkerError("Logbook store is closed", "closed"));
return this.worker.execute({
type,
input
});
}
frameFilePath(day, capturedAtMs) {
return path.join(this.framesDir, day, `${capturedAtMs}.jpg`);
}
insertFrame(params) {
return this.execute("insertFrame", params);
}
lastFrame() {
return this.execute("lastFrame", void 0);
}
unbatchedActiveFrames(limit) {
return this.execute("unbatchedActiveFrames", { limit });
}
countUnbatchedActiveFrames() {
return this.execute("countUnbatchedActiveFrames", void 0);
}
frameById(id) {
return this.execute("frameById", { id });
}
framesInRange(startMs, endMs) {
return this.execute("framesInRange", {
startMs,
endMs
});
}
createBatch(params) {
return this.execute("createBatch", params);
}
setBatchStatus(batchId, status, error, model) {
return this.execute("setBatchStatus", {
batchId,
status,
error,
model
});
}
latestBatch() {
return this.execute("latestBatch", void 0);
}
resetRunningBatches() {
return this.execute("resetRunningBatches", void 0);
}
resetErrorBatches() {
return this.execute("resetErrorBatches", void 0);
}
nextPendingBatch() {
return this.execute("nextPendingBatch", void 0);
}
replaceObservations(batchId, day, segments) {
return this.execute("replaceObservations", {
batchId,
day,
segments
});
}
observationsInRange(day, startMs, endMs, tailLimit) {
return this.execute("observationsInRange", {
day,
startMs,
endMs,
tailLimit
});
}
cardsForDay(day, window) {
return this.execute("cardsForDay", {
day,
window
});
}
countCardsForDay(day) {
return this.execute("countCardsForDay", { day });
}
replaceCardsInWindow(day, startMs, endMs, drafts, options) {
return this.execute("replaceCardsInWindow", {
day,
startMs,
endMs,
drafts,
selectKeyframes: options?.selectKeyframes
});
}
listDays() {
return this.execute("listDays", void 0);
}
timelineForDay(day) {
return this.execute("timelineForDay", { day });
}
getStandup(day) {
return this.execute("getStandup", { day });
}
saveStandup(day, text) {
return this.execute("saveStandup", {
day,
text
});
}
captureFrame(params) {
const buffer = Buffer.from(params.buffer);
const input = {
...params,
buffer
};
return this.frameIo.run(async () => {
const contentHash = createHash("sha256").update(buffer).digest("hex");
const previous = await this.worker.execute({
type: "lastFrame",
input: void 0
});
const filePath = this.frameFilePath(input.day, input.capturedAtMs);
await mkdir(path.dirname(filePath), {
recursive: true,
mode: 448
});
await writeFile(filePath, buffer, { mode: 384 });
return await this.worker.execute({
type: "insertFrame",
input: {
capturedAtMs: input.capturedAtMs,
day: input.day,
path: filePath,
screenIndex: input.screenIndex,
width: input.width,
height: input.height,
byteSize: buffer.byteLength,
contentHash,
idle: previous?.contentHash === contentHash
}
});
});
}
framePayload(id) {
return this.frameIo.run(async () => {
const frame = await this.worker.execute({
type: "frameById",
input: { id }
});
if (!frame) return null;
return {
frameId: frame.id,
capturedAtMs: frame.capturedAtMs,
width: frame.width,
height: frame.height,
format: "jpeg",
base64: (await readFile(frame.path)).toString("base64")
};
});
}
batchImages(batchId) {
return this.frameIo.run(async () => {
const frames = await this.worker.execute({
type: "sampledBatchFrames",
input: { batchId }
});
const images = [];
for (const frame of frames) images.push({
frame,
buffer: await readFile(frame.path)
});
return images;
});
}
pruneFrames(olderThanMs) {
return this.frameIo.run(() => this.worker.execute({
type: "pruneFrames",
input: { olderThanMs }
}));
}
};
//#endregion
//#region extensions/logbook/src/service.ts
const ANALYSIS_TICK_MS = 6e4;
const PRUNE_TICK_MS = 36e5;
const MODEL_MISSING_MESSAGE = "no vision model: set plugins.entries.logbook.config.visionModel or configure tools.media";
const MODEL_MISSING_LOG_INTERVAL_MS = 6e5;
const CAPTURE_FAILURE_PAUSE_TICKS = 10;
const CAPTURE_FAILURE_THRESHOLD = 3;
const JPEG_QUALITY = .6;
const STRUCTURED_MEDIA_PROVIDER = "codex";
/** node.invoke responses wrap the node result in {payload, payloadJSON}. */
function unwrapInvokePayload(raw) {
if (!raw || typeof raw !== "object") return null;
const envelope = raw;
if (envelope.payload && typeof envelope.payload === "object") return envelope.payload;
if (typeof envelope.payloadJSON === "string" && envelope.payloadJSON.length > 0) try {
return JSON.parse(envelope.payloadJSON);
} catch {
return null;
}
return "base64" in envelope || "error" in envelope ? envelope : null;
}
/** Capture commands in preference order: app nodes first, headless node hosts second. */
const CAPTURE_COMMANDS = ["screen.snapshot", "logbook.snapshot"];
var LogbookService = class {
constructor(config, deps) {
this.config = config;
this.deps = deps;
this.store = null;
this.operations = /* @__PURE__ */ new Set();
this.analysisInFlight = false;
this.capturePaused = false;
this.captureFailures = 0;
this.captureBackoffTicks = 0;
this.lastModelMissingLogMs = 0;
this.cachedNode = null;
this.failedNodeIds = /* @__PURE__ */ new Set();
}
async start() {
if (this.starting || this.stopping) throw new Error("Logbook service cannot be started again");
this.starting = this.trackOperation(async () => {
const store = await LogbookStore.open(this.deps.dataDir, this.deps.workerModuleUrl);
this.store = store;
try {
if (this.deps.scheduler.signal.aborted) return;
await store.resetRunningBatches();
if (this.deps.scheduler.signal.aborted) return;
await this.pruneStore(store);
if (this.deps.scheduler.signal.aborted) return;
for (const [id, everyMs, run] of [
[
"capture",
this.config.captureIntervalSeconds * 1e3,
() => this.captureTick()
],
[
"analysis",
ANALYSIS_TICK_MS,
() => this.analysisTick()
],
[
"prune",
PRUNE_TICK_MS,
() => this.prune()
]
]) this.deps.scheduler.schedule({
id,
delayMs: everyMs,
everyMs,
run
});
this.deps.logger.info(`logbook: started (capture every ${this.config.captureIntervalSeconds}s, analysis window ${this.config.analysisIntervalMinutes}m, data ${this.deps.dataDir})`);
} catch (error) {
this.store = null;
await store.close();
throw error;
}
});
await this.starting;
}
stop() {
if (this.stopping) return this.stopping;
this.deps.scheduler.beginClose();
this.stopping = Promise.allSettled([this.deps.scheduler.stop(), ...this.operations]).then(async () => {
const store = this.store;
this.store = null;
await store?.close();
});
return this.stopping;
}
trackOperation(run) {
const operation = Promise.resolve().then(run);
this.operations.add(operation);
const settled = () => this.operations.delete(operation);
operation.then(settled, settled);
return operation;
}
requireStore() {
if (this.deps.scheduler.signal.aborted || !this.store) throw new Error("Logbook service is not running");
return this.store;
}
setCapturePaused(paused) {
this.capturePaused = paused;
if (!paused) {
this.captureBackoffTicks = 0;
this.captureFailures = 0;
}
}
async resolveNode() {
if (this.cachedNode) return { node: this.cachedNode };
const { nodes } = await this.deps.runtime.nodes.list({ connected: true });
const captureCommand = (node) => CAPTURE_COMMANDS.find((command) => (node.commands ?? []).includes(command));
const commandRank = (node) => CAPTURE_COMMANDS.indexOf(captureCommand(node));
const candidates = nodes.filter((node) => captureCommand(node) !== void 0).toSorted((a, b) => commandRank(a) - commandRank(b) || a.nodeId.localeCompare(b.nodeId));
const wanted = this.config.nodeId?.toLowerCase();
let pool = candidates.filter((node) => !this.failedNodeIds.has(node.nodeId));
if (pool.length === 0) {
this.failedNodeIds.clear();
pool = candidates;
}
const picked = wanted ? candidates.find((node) => node.nodeId.toLowerCase() === wanted || node.displayName?.toLowerCase() === wanted) : pool[0];
const command = picked ? captureCommand(picked) : void 0;
if (!picked || !command) {
const inventory = nodes.map((node) => `${node.displayName ?? node.nodeId}(${(node.commands ?? []).join("/") || "no commands"})`).join(", ") || "none";
return { reason: `no connected node exposes ${CAPTURE_COMMANDS.join(" or ")}; connected: ${inventory}` };
}
this.cachedNode = {
nodeId: picked.nodeId,
displayName: picked.displayName,
command
};
return { node: this.cachedNode };
}
async captureTick() {
const store = this.store;
if (this.deps.scheduler.signal.aborted || !this.config.captureEnabled || this.capturePaused || !store) return;
if (this.captureBackoffTicks > 0) {
this.captureBackoffTicks -= 1;
return;
}
return this.trackOperation(async () => {
try {
const resolved = await this.resolveNode();
if ("reason" in resolved) {
if (this.lastCaptureError !== resolved.reason) this.deps.logger.warn(`logbook: ${resolved.reason}`);
this.lastCaptureError = resolved.reason;
return;
}
const node = resolved.node;
const raw = unwrapInvokePayload(await this.deps.runtime.nodes.invoke({
nodeId: node.nodeId,
command: node.command,
params: {
screenIndex: this.config.screenIndex,
maxWidth: this.config.maxWidth,
quality: JPEG_QUALITY,
format: "jpeg"
},
timeoutMs: 3e4
}));
if (raw?.error) throw new Error(raw.error);
const rawBase64 = raw?.base64;
if (rawBase64 === void 0 || rawBase64 === "") throw new Error(`${node.command} returned no image payload`);
if (typeof rawBase64 !== "string") throw new Error(`${node.command} returned invalid image payload`);
const base64 = canonicalizeBase64(rawBase64);
if (!base64) throw new Error(`${node.command} returned invalid image payload`);
const buffer = Buffer.from(base64, "base64");
const capturedAtMs = Date.now();
const day = dayKeyFor(capturedAtMs);
await store.captureFrame({
capturedAtMs,
day,
screenIndex: this.config.screenIndex,
width: raw?.width,
height: raw?.height,
buffer
});
this.lastCaptureAtMs = capturedAtMs;
this.lastCaptureError = void 0;
this.captureFailures = 0;
this.failedNodeIds.clear();
} catch (err) {
this.captureFailures += 1;
if (this.cachedNode) this.failedNodeIds.add(this.cachedNode.nodeId);
this.cachedNode = null;
this.lastCaptureError = coerceErrorMessage(err);
if (this.captureFailures >= CAPTURE_FAILURE_THRESHOLD) {
this.captureBackoffTicks = CAPTURE_FAILURE_PAUSE_TICKS;
this.deps.logger.warn(`logbook: capture failing (${this.lastCaptureError}); backing off for ${CAPTURE_FAILURE_PAUSE_TICKS} ticks`);
}
}
});
}
resolveVisionModel() {
if (this.config.visionModel) {
const ref = parseModelRef(this.config.visionModel);
return ref ? {
ref,
source: "config"
} : { source: "missing" };
}
const media = this.deps.fullConfig.tools?.media;
if (media?.image?.enabled === false) return { source: "missing" };
const entries = media?.models ?? [];
for (const entry of entries) if (entry.type !== "cli" && entry.provider?.trim().toLowerCase() === STRUCTURED_MEDIA_PROVIDER && typeof entry.model === "string" && (!entry.capabilities || entry.capabilities.includes("image"))) return {
ref: {
provider: STRUCTURED_MEDIA_PROVIDER,
model: entry.model,
profile: entry.profile,
preferredProfile: entry.preferredProfile
},
source: "media-defaults"
};
return { source: "missing" };
}
async analyzeNow() {
const store = this.requireStore();
if (this.analysisInFlight) return {
started: false,
reason: "analysis already running"
};
if (!this.resolveVisionModel().ref) return {
started: false,
reason: MODEL_MISSING_MESSAGE
};
this.analysisInFlight = true;
return this.trackOperation(async () => {
let handedOff = false;
try {
await store.resetErrorBatches();
if (!await store.nextPendingBatch()) {
if (!await this.enqueueNextBatch(store, true)) return {
started: false,
reason: "no unanalyzed activity captured yet"
};
}
this.requireStore();
this.analysisInFlight = false;
this.analysisTick();
handedOff = true;
return { started: true };
} finally {
if (!handedOff) this.analysisInFlight = false;
}
});
}
async analysisTick() {
const store = this.store;
if (this.deps.scheduler.signal.aborted || this.analysisInFlight || !store) return;
if (!this.resolveVisionModel().ref) {
const now = Date.now();
if (now - this.lastModelMissingLogMs > MODEL_MISSING_LOG_INTERVAL_MS) try {
if ((await store.countUnbatchedActiveFrames() > 0 || Boolean(await store.nextPendingBatch())) && !this.deps.scheduler.signal.aborted) {
this.lastModelMissingLogMs = now;
this.deps.logger.warn(`logbook: analysis paused; ${MODEL_MISSING_MESSAGE}`);
}
} catch (error) {
if (!this.deps.scheduler.signal.aborted) this.deps.logger.error(`logbook: analysis tick failed: ${String(error)}`);
}
return;
}
this.analysisInFlight = true;
return this.trackOperation(async () => {
try {
if (this.deps.scheduler.signal.aborted) return;
await this.enqueueElapsedWindow(store);
for (let i = 0; i < 4 && !this.deps.scheduler.signal.aborted; i += 1) {
const batch = await store.nextPendingBatch();
if (!batch || this.deps.scheduler.signal.aborted) return;
await this.runBatch(store, batch);
}
} catch (err) {
this.deps.logger.error(`logbook: analysis tick failed: ${String(err)}`);
} finally {
this.analysisInFlight = false;
}
});
}
async enqueueNextBatch(store, force = false) {
const selection = selectBatchFrames({
frames: await store.unbatchedActiveFrames(2e3),
windowMs: this.config.analysisIntervalMinutes * 6e4,
nowMs: Date.now(),
force
});
if (!selection) return false;
await store.createBatch({
day: dayKeyFor(selection.startMs),
startMs: selection.startMs,
endMs: selection.endMs,
frameIds: selection.frameIds
});
return true;
}
async enqueueElapsedWindow(store) {
while (!this.deps.scheduler.signal.aborted && await this.enqueueNextBatch(store));
}
async runBatch(store, batch) {
const vision = this.resolveVisionModel();
if (!vision.ref) return;
await store.setBatchStatus(batch.id, "running", void 0, `${vision.ref.provider}/${vision.ref.model}`);
try {
const sampled = await store.batchImages(batch.id);
const images = sampled.map(({ frame, buffer }) => ({
type: "image",
buffer,
fileName: path.basename(frame.path),
mime: "image/jpeg"
}));
const observationResult = await this.deps.runtime.mediaUnderstanding.extractStructuredWithModel({
provider: vision.ref.provider,
model: vision.ref.model,
profile: vision.ref.profile,
preferredProfile: vision.ref.preferredProfile,
input: images,
instructions: buildObservationInstructions({
frameTimes: sampled.map(({ frame }) => frame.capturedAtMs),
startMs: batch.startMs,
endMs: batch.endMs
}),
schemaName: "logbook.observations",
jsonSchema: OBSERVATION_JSON_SCHEMA,
cfg: this.deps.fullConfig,
timeoutMs: 18e4
});
const segments = parseObservationSegments({
raw: observationResult.text ?? "",
day: batch.day,
startMs: batch.startMs,
endMs: batch.endMs
});
if (segments.length === 0) {
await store.setBatchStatus(batch.id, "error", "vision model returned no usable segments");
return;
}
await store.replaceObservations(batch.id, batch.day, segments);
await this.reviseCards(store, batch);
await store.setBatchStatus(batch.id, "done");
} catch (err) {
const message = coerceErrorMessage(err);
await store.setBatchStatus(batch.id, "error", message);
this.deps.logger.warn(`logbook: batch ${batch.id} failed: ${message}`);
}
}
async reviseCards(store, batch) {
const lookbackStart = batch.startMs - CARD_LOOKBACK_MS;
const previousCards = await store.cardsForDay(batch.day, {
startMs: lookbackStart,
endMs: batch.endMs
});
const observations = await store.observationsInRange(batch.day, Math.min(lookbackStart, batch.startMs), batch.endMs);
const window = revisionWindow({
batchStartMs: batch.startMs,
batchEndMs: batch.endMs,
previousCards
});
const prompt = buildCardsPrompt({
day: batch.day,
observations,
previousCards,
windowStartMs: window.startMs,
windowEndMs: window.endMs
});
const requiredSpans = [...previousCards.map((card) => ({
startMs: card.startMs,
endMs: card.endMs
})), {
startMs: batch.startMs,
endMs: batch.endMs
}];
const evaluate = (raw) => {
const parsed = parseCardsJson({
raw,
day: batch.day
});
if (!parsed.ok) return parsed;
const coverage = validateCardCoverage({
drafts: parsed.drafts,
requiredSpans,
windowStartMs: window.startMs,
windowEndMs: window.endMs
});
return coverage.ok ? parsed : {
ok: false,
error: coverage.error
};
};
const first = await this.deps.runtime.llm.complete({
messages: [{
role: "user",
content: prompt
}],
purpose: "logbook.cards",
maxTokens: 4e3
});
let parsed = evaluate(first.text);
if (!parsed.ok) parsed = evaluate((await this.deps.runtime.llm.complete({
messages: [
{
role: "user",
content: prompt
},
{
role: "assistant",
content: first.text
},
{
role: "user",
content: buildCardsCorrectionPrompt(parsed.error)
}
],
purpose: "logbook.cards.repair",
maxTokens: 4e3
})).text);
if (!parsed.ok) throw new Error(`card synthesis failed validation: ${parsed.error}`);
await store.replaceCardsInWindow(batch.day, window.startMs, window.endMs, parsed.drafts, { selectKeyframes: true });
}
async standup(day, refresh) {
const store = this.requireStore();
return this.trackOperation(async () => {
if (!refresh) {
const cached = await store.getStandup(day);
if (cached) return cached;
}
const previousDay = dayKeyFor((/* @__PURE__ */ new Date(`${day}T12:00:00`)).getTime() - 864e5);
const result = await this.deps.runtime.llm.complete({
messages: [{
role: "user",
content: buildStandupPrompt({
day,
cards: await store.cardsForDay(day),
previousDayCards: await store.cardsForDay(previousDay)
})
}],
purpose: "logbook.standup",
maxTokens: 800
});
await store.saveStandup(day, result.text.trim());
const saved = await store.getStandup(day);
if (!saved) throw new Error("standup save failed");
return saved;
});
}
async ask(day, question) {
const store = this.requireStore();
return this.trackOperation(async () => {
const observations = await store.observationsInRange(day, 0, Number.MAX_SAFE_INTEGER, 200);
return (await this.deps.runtime.llm.complete({
messages: [{
role: "user",
content: buildAskPrompt({
day,
cards: await store.cardsForDay(day),
observations,
question
})
}],
purpose: "logbook.ask",
maxTokens: 600
})).text.trim();
});
}
async timelineForDay(day) {
const store = this.requireStore();
return this.trackOperation(() => store.timelineForDay(day));
}
async listDays() {
const store = this.requireStore();
return this.trackOperation(() => store.listDays());
}
async framePayload(id) {
const store = this.requireStore();
return this.trackOperation(() => store.framePayload(id));
}
async framesInRange(startMs, endMs) {
const store = this.requireStore();
return this.trackOperation(() => store.framesInRange(startMs, endMs));
}
async status() {
const store = this.requireStore();
return this.trackOperation(async () => {
const today = dayKeyFor(Date.now());
const latestBatch = await store.latestBatch();
const vision = this.resolveVisionModel();
return {
captureEnabled: this.config.captureEnabled,
capturePaused: this.capturePaused,
captureIntervalSeconds: this.config.captureIntervalSeconds,
analysisIntervalMinutes: this.config.analysisIntervalMinutes,
retentionDays: this.config.retentionDays,
nodeId: this.cachedNode?.nodeId ?? this.config.nodeId,
nodeName: this.cachedNode?.displayName,
lastCaptureAtMs: this.lastCaptureAtMs,
lastCaptureError: this.lastCaptureError,
pendingFrames: await store.countUnbatchedActiveFrames(),
analysisRunning: this.analysisInFlight,
lastBatch: latestBatch ? {
id: latestBatch.id,
day: latestBatch.day,
status: latestBatch.status,
endMs: latestBatch.endMs,
error: latestBatch.error
} : void 0,
visionModel: vision.ref ? `${vision.ref.provider}/${vision.ref.model}` : void 0,
visionModelSource: vision.source,
today,
todayCards: await store.countCardsForDay(today),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
};
});
}
async prune() {
const store = this.store;
if (this.deps.scheduler.signal.aborted || !store) return;
return this.trackOperation(async () => {
try {
await this.pruneStore(store);
} catch (error) {
this.deps.logger.error(`logbook: pruning failed: ${String(error)}`);
}
});
}
async pruneStore(store) {
const cutoff = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1e3;
const removed = await store.pruneFrames(cutoff);
if (removed > 0) this.deps.logger.info(`logbook: pruned ${removed} frames older than ${this.config.retentionDays}d`);
}
};
//#endregion
//#region extensions/logbook/index.ts
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const logbookConfigSchema = { parse: resolveLogbookConfig };
function readDayParam(params) {
const day = params?.day;
if (day === void 0) return dayKeyFor(Date.now());
if (typeof day !== "string" || !DAY_PATTERN.test(day)) throw new Error("day must be YYYY-MM-DD");
return day;
}
function readNumberParam(params, key) {
const value = params?.[key];
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${key} must be a positive number`);
return value;
}
var logbook_default = definePluginEntry({
id: "logbook",
name: "Logbook",
description: "Automatic work journal built from periodic screen snapshots",
configSchema: logbookConfigSchema,
nodeHostCommands: [{
command: "logbook.snapshot",
hasActiveWork: () => false,
cap: "screen",
dangerous: false,
handle: async (paramsJSON) => {
const { handleLogbookSnapshot } = await import("../../node-host-DJGWlxsb.mjs");
let params;
try {
params = paramsJSON ? JSON.parse(paramsJSON) : void 0;
} catch {
params = void 0;
}
return JSON.stringify(await handleLogbookSnapshot(params));
}
}],
register(api) {
const config = logbookConfigSchema.parse(api.pluginConfig);
let service = null;
let opening = null;
let generation = 0;
let stopping;
let retired = false;
const stopService = () => {
generation++;
const current = service ?? opening;
service = null;
opening = null;
return stopping ??= current?.stop();
};
const requireService = () => {
if (!service) throw new Error("Logbook service is not running");
return service;
};
const handle = (run) => async ({ params, respond }) => {
try {
respond(true, await run(params));
} catch (err) {
const message = formatErrorMessage(err);
respond(false, { error: message }, errorShape(ErrorCodes.UNAVAILABLE, message));
}
};
api.session.controls.registerControlUiDescriptor({
surface: "tab",
id: "logbook",
label: "Logbook",
description: "Your day as a timeline, built from screen snapshots.",
icon: "sun",
group: "control",
requiredScopes: ["operator.write"]
});
api.registerNodeInvokePolicy({
commands: ["logbook.snapshot"],
defaultPlatforms: ["macos"],
handle: async (ctx) => {
if ((ctx.config.gateway?.nodes?.commands?.deny ?? []).includes("screen.snapshot")) return {
ok: false,
code: "SCREEN_CAPTURE_DENIED",
message: "screen capture is denied by gateway.nodes.commands.deny (screen.snapshot); Logbook capture stays blocked until it is removed"
};
return await ctx.invokeNode();
}
});
api.registerService({
id: "logbook",
apiVersion: 2,
start: async (ctx) => {
if (retired) throw new Error("Logbook plugin runtime has been retired");
const currentGeneration = ++generation;
await stopping;
if (retired || currentGeneration !== generation) return;
stopping = void 0;
if (!api.runtimeSource) throw new Error("Logbook requires an OpenClaw host with runtime entrypoint metadata");
const next = new LogbookService(config, {
runtime: api.runtime,
fullConfig: ctx.config,
logger: ctx.logger,
scheduler: ctx.scheduler,
dataDir: path.join(ctx.stateDir, "logbook"),
workerModuleUrl: new URL(`./src/store.worker${path.extname(api.runtimeSource)}`, pathToFileURL(api.runtimeSource))
});
opening = next;
try {
await next.start();
if (retired || currentGeneration !== generation) {
await next.stop();
return;
}
service = next;
} catch (error) {
await next.stop();
throw error;
} finally {
if (opening === next) opening = null;
}
},
stop: stopService
});
api.lifecycle.registerRuntimeLifecycle({
id: "logbook-service",
cleanup: ({ reason, sessionKey, runId }) => {
if (sessionKey === void 0 && runId === void 0 && (reason === "restart" || reason === "disable")) {
retired = true;
return stopService();
}
}
});
const registerRead = (method, run) => api.registerGatewayMethod(method, handle(run), { scope: "operator.read" });
const registerWrite = (method, run) => api.registerGatewayMethod(method, handle(run), { scope: "operator.write" });
api.registerGatewayMethod("logbook.status", handle(() => requireService().status()), {
scope: "operator.read",
profileAccess: "independent"
});
registerRead("logbook.days", async () => ({ days: await requireService().listDays() }));
registerRead("logbook.timeline", (params) => requireService().timelineForDay(readDayParam(params)));
registerWrite("logbook.frames", async (params) => {
const startMs = readNumberParam(params, "startMs");
const endMs = readNumberParam(params, "endMs");
return { frames: await requireService().framesInRange(startMs, endMs) };
});
registerWrite("logbook.frame", async (params) => {
const frameId = readNumberParam(params, "frameId");
const frame = await requireService().framePayload(frameId);
if (!frame) throw new Error(`frame ${frameId} not found`);
return frame;
});
registerWrite("logbook.standup", (params) => {
const refresh = params?.refresh === true;
return requireService().standup(readDayParam(params), refresh);
});
registerWrite("logbook.ask", async (params) => {
const question = params?.question;
if (typeof question !== "string" || question.trim().length === 0) throw new Error("question is required");
return { answer: await requireService().ask(readDayParam(params), question.trim()) };
});
registerWrite("logbook.capture.set", (params) => {
const paused = params?.paused === true;
const svc = requireService();
svc.setCapturePaused(paused);
return svc.status();
});
registerWrite("logbook.analyze.now", () => requireService().analyzeNow());
}
});
//#endregion
export { logbook_default as default };