Files
openclaw/extensions/logbook/index.ts
Peter Steinberger c730d8f1f1 feat(logbook): automatic work journal plugin with Control UI timeline tab (#99930)
* feat(logbook): automatic work journal plugin with a plugin-contributed Control UI tab

Squash of PR #99930 work for rebase onto the Control UI route refactor:
- extensions/logbook: Dayflow-style capture -> observations -> timeline cards
  pipeline with SQLite store, node capture commands, standup/ask, retention
- plugin SDK/gateway seam: surface "tab" Control UI descriptors projected
  into hello-ok controlUiTabs (scope-filtered, deterministic order)
- Control UI: dynamic plugin tabs with bundled Logbook view
- docs, tests, labeler wiring

* feat(ui): port plugin tabs and Logbook to the route-owned Control UI architecture

- shared /plugin route carries the tab id in the query (?id=<tab>), matching
  the router's exact-path contract
- openclaw-plugin-page renders bundled views (Logbook), sandboxed plugin
  frames (descriptor path), or the unavailable card
- sidebar renders hello controlUiTabs after each group's static routes
- Logbook view/controller live under ui/src/pages/plugin/

* fix(ui): namespace plugin tabs by pluginId to prevent cross-plugin tab id collisions

* fix(logbook): prefer app capture nodes and rotate off failing nodes

* fix(plugins): reject protocol-relative Control UI tab paths

* fix(logbook): harden automatic journal

* docs(changelog): remove maintainer self-credit

* chore(ui): refresh locale metadata after rebase

* fix(logbook): preserve analysis window boundaries

* fix(logbook): align status privacy and timezone

* fix(ui): stop hidden plugin tab polling
2026-07-05 11:50:44 -07:00

215 lines
7.3 KiB
TypeScript

// Logbook plugin entrypoint: automatic work journal built from screen snapshots.
import { readFileSync } from "node:fs";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
ErrorCodes,
errorShape,
type GatewayRequestHandlerOptions,
} from "openclaw/plugin-sdk/gateway-runtime";
import {
definePluginEntry,
type OpenClawPluginApi,
type OpenClawPluginNodeHostCommand,
} from "openclaw/plugin-sdk/plugin-entry";
import { resolveLogbookConfig } from "./src/config.js";
import { LogbookService } from "./src/service.js";
import { dayKeyFor } from "./src/store.js";
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const logbookConfigSchema = {
parse(value: unknown) {
return resolveLogbookConfig(value);
},
};
function readDayParam(params: unknown): string {
const day = (params as { day?: unknown } | undefined)?.day;
if (day === undefined) {
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: unknown, key: string): number {
const value = (params as Record<string, unknown> | undefined)?.[key];
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
throw new Error(`${key} must be a positive number`);
}
return value;
}
const logbookNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
{
command: "logbook.snapshot",
cap: "screen",
dangerous: false,
handle: async (paramsJSON) => {
const { handleLogbookSnapshot } = await import("./src/node-host.js");
let params: unknown;
try {
params = paramsJSON ? JSON.parse(paramsJSON) : undefined;
} catch {
params = undefined;
}
return JSON.stringify(await handleLogbookSnapshot(params));
},
},
];
export default definePluginEntry({
id: "logbook",
name: "Logbook",
description: "Automatic work journal built from periodic screen snapshots",
configSchema: logbookConfigSchema,
nodeHostCommands: logbookNodeHostCommands,
register(api: OpenClawPluginApi) {
const config = logbookConfigSchema.parse(api.pluginConfig);
let service: LogbookService | null = null;
const requireService = () => {
if (!service) {
throw new Error("Logbook service is not running");
}
return service;
};
const sendError = (respond: GatewayRequestHandlerOptions["respond"], err: unknown) => {
const message = formatErrorMessage(err);
respond(false, { error: message }, errorShape(ErrorCodes.UNAVAILABLE, message));
};
const handle =
(run: (params: unknown) => unknown) =>
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
respond(true, await run(params));
} catch (err) {
sendError(respond, err);
}
};
// Declares the dashboard tab; the Control UI renders it only while this
// plugin is active, so no core code references the plugin id.
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"],
});
// Adds logbook.snapshot to the default macOS node allowlist; without a
// policy the gateway strips plugin commands from pairing surfaces.
api.registerNodeInvokePolicy({
commands: ["logbook.snapshot"],
defaultPlatforms: ["macos"],
handle: async (ctx) => {
// Honor the operator's screen-capture kill switch: a screen.snapshot
// deny must block this capture command too, not just the app node's.
const denied = ctx.config.gateway?.nodes?.denyCommands ?? [];
if (denied.includes("screen.snapshot")) {
return {
ok: false,
code: "SCREEN_CAPTURE_DENIED",
message:
"screen capture is denied by gateway.nodes.denyCommands (screen.snapshot); Logbook capture stays blocked until it is removed",
};
}
return await ctx.invokeNode();
},
});
api.registerService({
id: "logbook",
start: (ctx) => {
service = new LogbookService(config, {
runtime: api.runtime,
fullConfig: ctx.config,
logger: ctx.logger,
dataDir: path.join(ctx.stateDir, "logbook"),
});
service.start();
},
stop: () => {
service?.stop();
service = null;
},
});
// Unscoped plugin methods are authorized as operator.admin; explicit
// scopes keep the tab usable for read/write-scoped Control UI sessions.
const registerRead = (method: string, run: (params: unknown) => unknown) =>
api.registerGatewayMethod(method, handle(run), { scope: "operator.read" });
const registerWrite = (method: string, run: (params: unknown) => unknown) =>
api.registerGatewayMethod(method, handle(run), { scope: "operator.write" });
// Raw frame bytes are the most sensitive payload (full screen contents),
// so they require write scope while derived text stays readable.
registerRead("logbook.status", () => requireService().status());
registerRead("logbook.days", () => ({ days: requireService().listDays() }));
registerRead("logbook.timeline", (params) => {
const day = readDayParam(params);
const svc = requireService();
return { day, cards: svc.cardsForDay(day), stats: svc.dayStats(day) };
});
registerWrite("logbook.frames", (params) => {
const startMs = readNumberParam(params, "startMs");
const endMs = readNumberParam(params, "endMs");
const frames = requireService()
.framesInRange(startMs, endMs)
.map((frame) => ({ id: frame.id, capturedAtMs: frame.capturedAtMs, idle: frame.idle }));
return { frames };
});
registerWrite("logbook.frame", (params) => {
const frameId = readNumberParam(params, "frameId");
const frame = requireService().frameById(frameId);
if (!frame) {
throw new Error(`frame ${frameId} not found`);
}
return {
frameId: frame.id,
capturedAtMs: frame.capturedAtMs,
width: frame.width,
height: frame.height,
format: "jpeg",
base64: readFileSync(frame.path).toString("base64"),
};
});
// Standup and ask spend model tokens; capture/analyze mutate runtime state.
registerWrite("logbook.standup", (params) => {
const refresh = (params as { refresh?: unknown } | undefined)?.refresh === true;
return requireService().standup(readDayParam(params), refresh);
});
registerWrite("logbook.ask", async (params) => {
const question = (params as { question?: unknown } | undefined)?.question;
if (typeof question !== "string" || question.trim().length === 0) {
throw new Error("question is required");
}
const answer = await requireService().ask(readDayParam(params), question.trim());
return { answer };
});
registerWrite("logbook.capture.set", (params) => {
const paused = (params as { paused?: unknown } | undefined)?.paused === true;
const svc = requireService();
svc.setCapturePaused(paused);
return svc.status();
});
registerWrite("logbook.analyze.now", () => requireService().analyzeNow());
},
});