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
This commit is contained in:
Peter Steinberger
2026-07-05 11:50:44 -07:00
committed by GitHub
parent 3f380635f5
commit c730d8f1f1
92 changed files with 6754 additions and 131 deletions
+5
View File
@@ -36,6 +36,11 @@
- any-glob-to-any-file:
- "extensions/googlechat/**"
- "docs/channels/googlechat.md"
"plugin: logbook":
- changed-files:
- any-glob-to-any-file:
- "extensions/logbook/**"
- "docs/plugins/logbook.md"
"plugin: google-meet":
- changed-files:
- any-glob-to-any-file:
+1
View File
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- **Logbook work journal:** add a disabled-by-default bundled plugin that turns paired-node screen snapshots into a private timeline, daily standup, and timeline-grounded Q&A in a plugin-contributed Control UI tab. (#99930)
- **Control UI message context:** reveal per-message token, context, and model details from the timestamp on hover or activation instead of showing a separate Context button.
- **Control UI session titles:** reveal truncated recent-session names with a reduced-motion-safe hover animation.
- **Control UI sidebar usage:** remove the provider usage quota row from the expanded sidebar while keeping usage details available in the chat composer and Usage page. Thanks @shakkernerd.
@@ -22,6 +22,7 @@ struct MacGatewayChatTransportMappingTests {
server: [:],
features: [:],
snapshot: snapshot,
controluitabs: nil,
pluginsurfaceurls: nil,
auth: [:],
policy: [:])
@@ -125,6 +125,7 @@ public struct HelloOk: Codable, Sendable {
public let server: [String: AnyCodable]
public let features: [String: AnyCodable]
public let snapshot: Snapshot
public let controluitabs: [[String: AnyCodable]]?
public let pluginsurfaceurls: [String: AnyCodable]?
public let auth: [String: AnyCodable]
public let policy: [String: AnyCodable]
@@ -135,6 +136,7 @@ public struct HelloOk: Codable, Sendable {
server: [String: AnyCodable],
features: [String: AnyCodable],
snapshot: Snapshot,
controluitabs: [[String: AnyCodable]]?,
pluginsurfaceurls: [String: AnyCodable]?,
auth: [String: AnyCodable],
policy: [String: AnyCodable])
@@ -144,6 +146,7 @@ public struct HelloOk: Codable, Sendable {
self.server = server
self.features = features
self.snapshot = snapshot
self.controluitabs = controluitabs
self.pluginsurfaceurls = pluginsurfaceurls
self.auth = auth
self.policy = policy
@@ -155,6 +158,7 @@ public struct HelloOk: Codable, Sendable {
case server
case features
case snapshot
case controluitabs = "controlUiTabs"
case pluginsurfaceurls = "pluginSurfaceUrls"
case auth
case policy
+1
View File
@@ -1273,6 +1273,7 @@
"plugins/codex-harness",
"plugins/codex-native-plugins",
"plugins/codex-computer-use",
"plugins/logbook",
"plugins/google-meet",
"plugins/workboard",
"plugins/webhooks",
+12
View File
@@ -5523,6 +5523,18 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Native Runtime
- H2: Troubleshooting
## plugins/logbook.md
- Route: /plugins/logbook
- Headings:
- H2: Default state
- H2: Requirements
- H2: How it works
- H2: Configuration
- H2: Dashboard tab
- H2: Gateway methods
- H2: Privacy notes
## plugins/manage-plugins.md
- Route: /plugins/manage-plugins
+139
View File
@@ -0,0 +1,139 @@
---
summary: "Optional automatic work journal built from periodic screen snapshots"
read_when:
- You want a Dayflow-style timeline of your day in the Control UI
- You are enabling or configuring the bundled Logbook plugin
- You want standup summaries or day recall grounded in screen activity
title: "Logbook plugin"
---
The Logbook plugin turns screen activity into an automatic work journal. It
captures periodic screen snapshots from a paired node (for example the OpenClaw
Mac app), summarizes them with a vision model into timestamped observations,
and synthesizes those into timeline cards you can browse in the
[Control UI](/web/control-ui). On top of the timeline it generates daily
standup notes and answers questions about your day.
Everything stays local: snapshots and the timeline database live under the
Gateway state directory. Only analysis batches are sent to the model you
configure, so pick a local model if snapshots must never leave the machine.
## Default state
Logbook is a bundled plugin and is disabled by default. Screen capture is
opt-in.
Enable it with:
```bash
openclaw plugins enable logbook
openclaw gateway restart
```
Then open the dashboard and pick the Logbook tab:
```bash
openclaw dashboard
```
The Logbook tab is contributed through the plugin Control UI tab surface
(`registerControlUiDescriptor` with `surface: "tab"`), so it appears in the
sidebar only while the plugin is enabled on the connected gateway.
## Requirements
- A connected node that can capture the screen. The macOS app node advertises
`screen.snapshot` by default (see [Nodes](/nodes)); headless macOS node
hosts (`openclaw node host run`) get a plugin-provided `logbook.snapshot`
command backed by the system `screencapture` tool when Logbook is enabled.
- A vision model whose media-understanding provider supports structured
extraction (the bundled Codex plugin does, for example `codex/gpt-5.5`).
Logbook resolves the model in order:
1. `plugins.entries.logbook.config.visionModel` (`"provider/model"` ref)
2. the first image-capable Codex entry under `tools.media.image.models` or
`tools.media.models` (other media providers do not currently expose the
structured extraction contract Logbook requires)
- Timeline card synthesis, standup notes, and "ask your day" answers use the
default agent model via the plugin LLM runtime.
## How it works
1. **Capture**: every `captureIntervalSeconds` (default 30s) Logbook invokes
`screen.snapshot` on the capture node and stores a scaled JPEG frame.
Consecutive identical frames are marked idle and excluded from analysis.
2. **Observe**: once an analysis window (default 15 minutes) elapses, the
frames are sent to the vision model, which returns timestamped activity
observations ("VS Code: editing store.ts, fixing a type error").
3. **Synthesize**: observations plus the last 45 minutes of existing cards are
revised into timeline cards (10-60 minutes each) with a title, summary,
category, main app, and any brief distractions.
4. **Prune**: frames older than `retentionDays` (default 14) are deleted.
Cards, observations, and standups are kept.
Frames and the timeline database live under `<state-dir>/logbook/`.
## Configuration
```json
{
"plugins": {
"entries": {
"logbook": {
"enabled": true,
"config": {
"captureIntervalSeconds": 30,
"analysisIntervalMinutes": 15,
"screenIndex": 0,
"maxWidth": 1440,
"nodeId": "my-mac",
"visionModel": "codex/gpt-5.5",
"retentionDays": 14,
"captureEnabled": true
}
}
}
}
}
```
All keys are optional. Leave `nodeId` unset to use the first connected node
that supports `screen.snapshot`. Set `captureEnabled: false` to keep the
timeline UI available without capturing; the dashboard also has a session-only
pause toggle.
## Dashboard tab
- **Timeline**: expandable cards per activity with category colors, the main
app, distraction chips, and a snapshot keyframe.
- **Day at a glance**: focus ratio, category breakdown, top apps.
- **Daily standup**: turns yesterday plus today into a ready-to-paste update.
- **Ask your day**: natural-language questions answered from the tracked
timeline ("when did I review the gateway PR?").
- **Analyze now**: closes the current capture window immediately instead of
waiting for the analysis interval.
## Gateway methods
Logbook registers Gateway RPC methods for the dashboard. `logbook.status`,
`logbook.days`, and `logbook.timeline` return derived text and are readable
with `operator.read`. Everything that returns raw screenshot pixels
(`logbook.frames`, `logbook.frame`), spends model tokens (`logbook.standup`,
`logbook.ask`), or mutates runtime state (`logbook.capture.set`,
`logbook.analyze.now`) requires `operator.write`. The Control UI tab requires
`operator.write` because the bundled view exposes those actions and raw frame
previews; read-only clients may call the derived-text methods directly.
## Privacy notes
- Snapshots can contain anything on screen, including secrets. Frames never
leave the machine except as model input for analysis batches.
- Use a structured-extraction provider that runs locally, when available and
explicitly configured, for a fully on-device pipeline.
- Frames, the timeline database, and temporary captures are written with
owner-only file permissions.
- Adding `screen.snapshot` to `gateway.nodes.denyCommands` is the
screen-capture kill switch: it blocks app-node capture and Logbook's own
`logbook.snapshot` command alike.
- Setting `tools.media.image.enabled: false` also stops Logbook from borrowing
the media image models for analysis; only an explicit `visionModel` in the
plugin config is used then.
+23 -1
View File
@@ -202,7 +202,7 @@ plugins.
| `api.registerTrustedToolPolicy(...)` | Manifest-gated trusted pre-plugin tool policy that can block or rewrite tool params |
| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation |
| `api.registerCommand(...)` | Scoped plugin commands; command results can set `continueAgent: true` or `suppressReply: true`; Discord native commands support `descriptionLocalizations` |
| `api.session.controls.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces |
| `api.session.controls.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, settings, or tab surfaces |
| `api.lifecycle.registerRuntimeLifecycle(...)` | Cleanup callbacks for plugin-owned runtime resources on reset/delete/reload paths |
| `api.agent.events.registerAgentEventSubscription(...)` | Sanitized event subscriptions for workflow state and monitors |
| `api.runContext.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)` | Per-run plugin scratch state cleared on terminal run lifecycle |
@@ -211,6 +211,28 @@ plugins.
| `api.session.workflow.scheduleSessionTurn(...)` / `unscheduleSessionTurnsByTag(...)` | Bundled-only Cron-backed scheduled session turns plus tag-based cleanup |
| `api.session.controls.registerSessionAction(...)` | Typed session actions clients can dispatch through the Gateway |
A `surface: "tab"` descriptor adds a sidebar tab to the Control UI. Active
plugins' tab descriptors are advertised to dashboard clients in the gateway
hello (`controlUiTabs`), so the tab appears only while the plugin is enabled.
Bundled plugins may ship a first-class dashboard view for their tab; other
plugins can set `path` to a plugin HTTP route (see
`api.registerHttpRoute(...)`) that the dashboard renders in a sandboxed frame.
`icon` is a dashboard icon name hint, `group` picks the sidebar section
(`control` or `agent`), `order` sorts among plugin tabs, and `requiredScopes`
hides the tab from connections lacking those operator scopes:
```typescript
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"],
});
```
Use the grouped namespaces for new plugin code:
- `api.session.state.registerSessionExtension(...)`
+214
View File
@@ -0,0 +1,214 @@
// 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());
},
});
+83
View File
@@ -0,0 +1,83 @@
{
"id": "logbook",
"name": "Logbook",
"description": "Automatic work journal: captures periodic screen snapshots from a paired node and turns them into a reviewable timeline of your day.",
"enabledByDefault": false,
"activation": {
"onStartup": true
},
"uiHints": {
"captureIntervalSeconds": {
"label": "Capture Interval (s)",
"help": "Seconds between screen snapshots. Lower is more detailed but uses more disk and analysis tokens."
},
"analysisIntervalMinutes": {
"label": "Analysis Window (min)",
"help": "Snapshot window size summarized per analysis batch."
},
"nodeId": {
"label": "Capture Node",
"help": "Node id or name that provides screen.snapshot. Defaults to the first connected node that supports it."
},
"screenIndex": {
"label": "Screen Index",
"help": "Zero-based display index to capture.",
"advanced": true
},
"maxWidth": {
"label": "Snapshot Max Width (px)",
"help": "Snapshots are scaled down to this width before storage and analysis.",
"advanced": true
},
"visionModel": {
"label": "Vision Model",
"placeholder": "codex/gpt-5.5",
"help": "provider/model used to read snapshots; the provider must support structured extraction (the bundled Codex plugin does). Defaults to the first image-capable Codex entry under tools.media."
},
"captureEnabled": {
"label": "Capture Enabled",
"help": "Master switch for the snapshot loop. The timeline UI stays available when off."
},
"retentionDays": {
"label": "Frame Retention (days)",
"help": "Snapshots older than this are deleted. Timeline cards are kept.",
"advanced": true
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"captureEnabled": {
"type": "boolean",
"default": true
},
"captureIntervalSeconds": {
"type": "number",
"default": 30
},
"analysisIntervalMinutes": {
"type": "number",
"default": 15
},
"nodeId": {
"type": "string"
},
"screenIndex": {
"type": "number",
"default": 0
},
"maxWidth": {
"type": "number",
"default": 1440
},
"visionModel": {
"type": "string"
},
"retentionDays": {
"type": "number",
"default": 14
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@openclaw/logbook",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw automatic work journal plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}
+331
View File
@@ -0,0 +1,331 @@
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
clockToMs,
extractJsonPayload,
parseCardsJson,
parseObservationSegments,
pickKeyframeId,
revisionWindow,
sampleFrames,
selectBatchFrames,
validateCardCoverage,
} from "./analyze.js";
const DAY = "2026-07-03";
const dayMs = (clock: string) => {
const ms = clockToMs(DAY, clock);
if (ms === null) {
throw new Error(`bad clock ${clock}`);
}
return ms;
};
describe("clockToMs", () => {
it("parses 24h and 12h clocks on the local day", () => {
expect(dayMs("13:05:30") - dayMs("13:05:00")).toBe(30_000);
expect(dayMs("1:05 pm")).toBe(dayMs("13:05:00"));
expect(dayMs("12:00 am")).toBe(dayMs("00:00:00"));
});
it("rejects malformed input", () => {
expect(clockToMs(DAY, "25:00:00")).toBeNull();
expect(clockToMs(DAY, "half past nine")).toBeNull();
expect(clockToMs("not-a-day", "10:00:00")).toBeNull();
});
it("preserves local wall-clock time across a DST transition", () => {
const moduleUrl = new URL("./analyze.ts", import.meta.url).href;
const output = execFileSync(
process.execPath,
[
"--import",
"tsx",
"--eval",
`const { clockToMs } = await import(${JSON.stringify(moduleUrl)}); process.stdout.write(JSON.stringify([clockToMs("2026-03-08", "10:00:00"), clockToMs("2026-03-08", "02:30:00")]));`,
],
{ encoding: "utf8", env: { ...process.env, TZ: "America/New_York" } },
);
expect(JSON.parse(output)).toEqual([Date.UTC(2026, 2, 8, 14), null]);
});
});
describe("extractJsonPayload", () => {
it("strips fences and surrounding prose", () => {
expect(extractJsonPayload('```json\n{"a":1}\n```')).toBe('{"a":1}');
expect(extractJsonPayload('Here you go:\n[{"a":1}]\nHope that helps!')).toBe('[{"a":1}]');
});
});
describe("parseObservationSegments", () => {
const startMs = dayMs("10:00:00");
const endMs = dayMs("10:15:00");
it("parses and clamps segments into the batch window", () => {
const raw = JSON.stringify({
segments: [
{ start: "09:55:00", end: "10:05:00", description: "VS Code: editing store.ts" },
{ start: "10:05:00", end: "10:20:00", description: "Chrome: reviewing PR #99" },
],
});
const segments = parseObservationSegments({ raw, day: DAY, startMs, endMs });
expect(segments).toHaveLength(2);
expect(segments[0].startMs).toBe(startMs);
expect(segments[1].endMs).toBe(endMs);
});
it("returns empty on unparseable output", () => {
expect(parseObservationSegments({ raw: "no json here", day: DAY, startMs, endMs })).toEqual([]);
});
});
describe("parseCardsJson", () => {
const windowStartMs = dayMs("10:00:00");
const windowEndMs = dayMs("11:00:00");
const card = (overrides: Record<string, unknown> = {}) => ({
startTime: "10:00:00",
endTime: "10:30:00",
category: "coding",
title: "Working on logbook store",
summary: "Implemented SQLite store",
detailedSummary: "Added frames and cards tables.",
distractions: [],
appSites: { primary: "github.com" },
...overrides,
});
it("accepts a valid card array and normalizes fields", () => {
const result = parseCardsJson({
raw: JSON.stringify([
card({ category: "CODING", appSites: { primary: "https://GitHub.com/openclaw" } }),
]),
day: DAY,
windowStartMs,
windowEndMs,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.drafts[0].category).toBe("coding");
expect(result.drafts[0].appPrimary).toBe("github.com");
}
});
it("maps unknown categories to other", () => {
const result = parseCardsJson({
raw: JSON.stringify([card({ category: "quantum-vibes" })]),
day: DAY,
windowStartMs,
windowEndMs,
});
expect(result.ok && result.drafts[0].category).toBe("other");
});
it("trims sub-minute overlaps and rejects large ones", () => {
const trimmed = parseCardsJson({
raw: JSON.stringify([
card({ startTime: "10:00:00", endTime: "10:30:30" }),
card({ startTime: "10:30:00", endTime: "11:00:00", title: "Second" }),
]),
day: DAY,
windowStartMs,
windowEndMs,
});
expect(trimmed.ok).toBe(true);
if (trimmed.ok) {
expect(trimmed.drafts[1].startMs).toBe(trimmed.drafts[0].endMs);
}
const rejected = parseCardsJson({
raw: JSON.stringify([
card({ startTime: "10:00:00", endTime: "10:45:00" }),
card({ startTime: "10:30:00", endTime: "11:00:00", title: "Second" }),
]),
day: DAY,
windowStartMs,
windowEndMs,
});
expect(rejected.ok).toBe(false);
if (!rejected.ok) {
expect(rejected.error).toContain("overlap");
}
});
it("reports actionable errors for the correction round-trip", () => {
const result = parseCardsJson({
raw: JSON.stringify([card({ startTime: "later that day" })]),
day: DAY,
windowStartMs,
windowEndMs,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("startTime");
}
});
});
describe("selectBatchFrames", () => {
const windowMs = 15 * 60_000;
const t0 = dayMs("10:00:00");
const frame = (id: number, offsetSec: number) => ({ id, capturedAtMs: t0 + offsetSec * 1000 });
it("keeps an in-progress window open", () => {
const frames = [frame(1, 0), frame(2, 30), frame(3, 60)];
expect(selectBatchFrames({ frames, windowMs, nowMs: t0 + 5 * 60_000 })).toBeNull();
});
it("closes an elapsed window at its boundary so batches meet cleanly", () => {
const frames = [frame(1, 0), frame(2, 30), frame(3, 60)];
const selection = selectBatchFrames({ frames, windowMs, nowMs: t0 + windowMs + 1000 });
expect(selection?.frameIds).toEqual([1, 2, 3]);
expect(selection?.startMs).toBe(t0);
expect(selection?.endMs).toBe(t0 + windowMs);
});
it("keeps the boundary when the next frame starts the following window", () => {
const frames = [frame(1, 0), frame(2, 30), frame(3, 60), frame(4, 15 * 60)];
const selection = selectBatchFrames({ frames, windowMs, nowMs: t0 + windowMs + 1000 });
expect(selection?.frameIds).toEqual([1, 2, 3]);
expect(selection?.endMs).toBe(t0 + windowMs);
});
it("splits on capture gaps without claiming the idle span", () => {
const frames = [frame(1, 0), frame(2, 30), frame(3, 400)];
const selection = selectBatchFrames({ frames, windowMs, nowMs: t0 + 60_000 });
expect(selection?.frameIds).toEqual([1, 2]);
expect(selection?.endMs).toBe(t0 + 30_000 + 1);
});
it("force-closes an in-progress window at the last observed frame", () => {
const frames = [frame(1, 0), frame(2, 30)];
expect(selectBatchFrames({ frames, windowMs, nowMs: t0 + 60_000 })).toBeNull();
const forced = selectBatchFrames({ frames, windowMs, nowMs: t0 + 60_000, force: true });
expect(forced?.frameIds).toEqual([1, 2]);
expect(forced?.endMs).toBe(t0 + 30_000 + 1);
});
it("splits at local midnight so batch clocks stay on one day", () => {
const nearMidnight = new Date(`${DAY}T23:59:30`).getTime();
const frames = [
{ id: 1, capturedAtMs: nearMidnight },
{ id: 2, capturedAtMs: nearMidnight + 20_000 },
{ id: 3, capturedAtMs: nearMidnight + 50_000 },
];
const selection = selectBatchFrames({ frames, windowMs, nowMs: nearMidnight + 60_000 });
expect(selection?.frameIds).toEqual([1, 2]);
expect(selection?.endMs).toBe(nearMidnight + 20_000 + 1);
});
it("caps an elapsed window at midnight without a next-day frame", () => {
const nearMidnight = new Date(`${DAY}T23:59:30`).getTime();
const midnight = new Date(nearMidnight);
midnight.setHours(24, 0, 0, 0);
const frames = [
{ id: 1, capturedAtMs: nearMidnight },
{ id: 2, capturedAtMs: nearMidnight + 20_000 },
];
const selection = selectBatchFrames({ frames, windowMs, nowMs: nearMidnight + windowMs });
expect(selection?.frameIds).toEqual([1, 2]);
expect(selection?.endMs).toBe(midnight.getTime());
});
});
describe("sampleFrames", () => {
it("keeps small sets and evenly samples large ones", () => {
expect(sampleFrames([1, 2, 3], 16)).toEqual([1, 2, 3]);
const sampled = sampleFrames(
Array.from({ length: 100 }, (_, i) => i),
16,
);
expect(sampled.length).toBeLessThanOrEqual(16);
expect(sampled[0]).toBe(0);
expect(sampled[sampled.length - 1]).toBe(99);
});
});
describe("validateCardCoverage", () => {
const window = { windowStartMs: dayMs("10:00:00"), windowEndMs: dayMs("11:00:00") };
const span = (start: string, end: string) => ({ startMs: dayMs(start), endMs: dayMs(end) });
it("accepts drafts covering all required spans within tolerance", () => {
const result = validateCardCoverage({
drafts: [span("10:01:00", "10:30:00"), span("10:30:00", "10:59:00")],
requiredSpans: [span("10:00:00", "11:00:00")],
...window,
});
expect(result.ok).toBe(true);
});
it("rejects partial outputs that would erase previous cards", () => {
// Model returned only the new batch span, dropping the 10:00-10:30 card.
const result = validateCardCoverage({
drafts: [span("10:30:00", "11:00:00")],
requiredSpans: [span("10:00:00", "10:30:00"), span("10:30:00", "11:00:00")],
...window,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("not covered");
}
});
it("rejects drafts outside the revision window", () => {
const result = validateCardCoverage({
drafts: [span("09:00:00", "11:00:00")],
requiredSpans: [span("10:00:00", "11:00:00")],
...window,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("outside the revision window");
}
});
it("tolerates gaps inside required spans up to the tolerance", () => {
const result = validateCardCoverage({
drafts: [span("10:00:00", "10:29:00"), span("10:30:30", "11:00:00")],
requiredSpans: [span("10:00:00", "11:00:00")],
...window,
});
expect(result.ok).toBe(true);
});
});
describe("revisionWindow / pickKeyframeId", () => {
it("expands the window to cover previous draft cards", () => {
const window = revisionWindow({
batchStartMs: dayMs("10:30:00"),
batchEndMs: dayMs("10:45:00"),
previousCards: [
{
id: 1,
day: DAY,
startMs: dayMs("10:00:00"),
endMs: dayMs("10:30:00"),
title: "t",
summary: "s",
detail: "",
category: "coding",
distractions: [],
},
],
});
expect(window.startMs).toBe(dayMs("10:00:00"));
expect(window.endMs).toBe(dayMs("10:45:00"));
});
it("picks the frame closest to the card midpoint", () => {
const frames = [
{ id: 1, capturedAtMs: dayMs("10:00:00") },
{ id: 2, capturedAtMs: dayMs("10:14:00") },
{ id: 3, capturedAtMs: dayMs("10:29:00") },
];
const keyframe = pickKeyframeId(
{ startMs: dayMs("10:00:00"), endMs: dayMs("10:30:00") },
frames,
);
expect(keyframe).toBe(2);
});
});
+412
View File
@@ -0,0 +1,412 @@
// Logbook analysis pipeline: frames -> observations -> revised timeline cards.
// Pure parsing/validation lives here so tests can cover it without the SDK.
import { CARD_CATEGORIES } from "./prompts.js";
import { dayKeyFor } from "./store.js";
import type { LogbookCard, LogbookCardDraft, LogbookDistraction } from "./types.js";
/** Cards within this window before a batch are treated as a revisable draft. */
export const CARD_LOOKBACK_MS = 45 * 60 * 1000;
/** Frame gap that splits one analysis window into separate batches. */
export const BATCH_MAX_GAP_MS = 2 * 60 * 1000;
/** Upper bound of images sent to the vision model per batch. */
export const MAX_FRAMES_PER_CALL = 16;
export type ParsedSegment = { startMs: number; endMs: number; text: string };
/** Parses "HH:MM:SS" (or "H:MM", with optional am/pm) on a local day into epoch ms. */
export function clockToMs(day: string, clock: string): number | null {
const match = /^\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?\s*$/i.exec(clock);
if (!match) {
return null;
}
let hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3] ?? "0");
const meridiem = match[4]?.toLowerCase();
if (meridiem === "pm" && hours < 12) {
hours += 12;
}
if (meridiem === "am" && hours === 12) {
hours = 0;
}
if (hours > 23 || minutes > 59 || seconds > 59) {
return null;
}
const dayMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
if (!dayMatch) {
return null;
}
const year = Number(dayMatch[1]);
const monthIndex = Number(dayMatch[2]) - 1;
const dayOfMonth = Number(dayMatch[3]);
const date = new Date(year, monthIndex, dayOfMonth, hours, minutes, seconds);
// Component construction preserves wall-clock time across DST boundaries;
// rejecting normalized fields also excludes invalid dates and skipped hours.
if (
date.getFullYear() !== year ||
date.getMonth() !== monthIndex ||
date.getDate() !== dayOfMonth ||
date.getHours() !== hours ||
date.getMinutes() !== minutes ||
date.getSeconds() !== seconds
) {
return null;
}
return date.getTime();
}
/** Strips code fences and extracts the outermost JSON array/object from model text. */
export function extractJsonPayload(raw: string): string {
const cleaned = raw.replaceAll("```json", "").replaceAll("```", "").trim();
const firstBracket = cleaned.search(/[[{]/);
if (firstBracket < 0) {
return cleaned;
}
const open = cleaned[firstBracket];
const close = open === "[" ? "]" : "}";
const lastClose = cleaned.lastIndexOf(close);
if (lastClose > firstBracket) {
return cleaned.slice(firstBracket, lastClose + 1);
}
return cleaned;
}
export function parseObservationSegments(params: {
raw: string;
day: string;
startMs: number;
endMs: number;
}): ParsedSegment[] {
let parsed: unknown;
try {
parsed = JSON.parse(extractJsonPayload(params.raw));
} catch {
return [];
}
const list = Array.isArray(parsed)
? parsed
: parsed &&
typeof parsed === "object" &&
Array.isArray((parsed as { segments?: unknown }).segments)
? (parsed as { segments: unknown[] }).segments
: [];
const segments: ParsedSegment[] = [];
for (const entry of list) {
if (!entry || typeof entry !== "object") {
continue;
}
const record = entry as Record<string, unknown>;
const description = typeof record.description === "string" ? record.description.trim() : "";
const startMs = typeof record.start === "string" ? clockToMs(params.day, record.start) : null;
const endMs = typeof record.end === "string" ? clockToMs(params.day, record.end) : null;
if (!description || startMs === null || endMs === null) {
continue;
}
const clampedStart = Math.max(params.startMs, Math.min(startMs, params.endMs));
const clampedEnd = Math.max(clampedStart, Math.min(endMs, params.endMs));
segments.push({ startMs: clampedStart, endMs: clampedEnd, text: description });
}
return segments.toSorted((a, b) => a.startMs - b.startMs);
}
type RawCard = {
startTime?: unknown;
endTime?: unknown;
category?: unknown;
title?: unknown;
summary?: unknown;
detailedSummary?: unknown;
distractions?: unknown;
appSites?: unknown;
};
export type CardParseResult =
| { ok: true; drafts: LogbookCardDraft[] }
| { ok: false; error: string };
function normalizeCategory(value: unknown): string {
const category = typeof value === "string" ? value.trim().toLowerCase() : "";
return (CARD_CATEGORIES as readonly string[]).includes(category) ? category : "other";
}
function normalizeDomain(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const domain = value
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.split(/[/?#]/)[0];
return domain && domain.length <= 100 ? domain : undefined;
}
function parseDistractions(day: string, value: unknown): LogbookDistraction[] {
if (!Array.isArray(value)) {
return [];
}
const distractions: LogbookDistraction[] = [];
for (const entry of value) {
if (!entry || typeof entry !== "object") {
continue;
}
const record = entry as Record<string, unknown>;
const startMs = typeof record.startTime === "string" ? clockToMs(day, record.startTime) : null;
const endMs = typeof record.endTime === "string" ? clockToMs(day, record.endTime) : null;
const title = typeof record.title === "string" ? record.title.trim() : "";
if (startMs === null || endMs === null || !title || endMs <= startMs) {
continue;
}
distractions.push({ startMs, endMs, title });
}
return distractions;
}
export function parseCardsJson(params: {
raw: string;
day: string;
windowStartMs: number;
windowEndMs: number;
}): CardParseResult {
let parsed: unknown;
try {
parsed = JSON.parse(extractJsonPayload(params.raw));
} catch (err) {
return { ok: false, error: `Output is not valid JSON: ${(err as Error).message}` };
}
if (!Array.isArray(parsed)) {
return { ok: false, error: "Output must be a JSON array of cards." };
}
const drafts: LogbookCardDraft[] = [];
const problems: string[] = [];
parsed.forEach((entry, index) => {
if (!entry || typeof entry !== "object") {
problems.push(`Card ${index}: not an object.`);
return;
}
const raw = entry as RawCard;
const title = typeof raw.title === "string" ? raw.title.trim() : "";
const summary = typeof raw.summary === "string" ? raw.summary.trim() : "";
const startMs = typeof raw.startTime === "string" ? clockToMs(params.day, raw.startTime) : null;
const endMs = typeof raw.endTime === "string" ? clockToMs(params.day, raw.endTime) : null;
if (startMs === null || endMs === null) {
problems.push(`Card ${index}: startTime/endTime must be HH:MM:SS local time.`);
return;
}
if (endMs <= startMs) {
problems.push(`Card ${index}: endTime must be after startTime.`);
return;
}
if (!title || !summary) {
problems.push(`Card ${index}: title and summary are required.`);
return;
}
const appSites =
raw.appSites && typeof raw.appSites === "object"
? (raw.appSites as Record<string, unknown>)
: {};
drafts.push({
day: params.day,
startMs,
endMs,
title,
summary,
detail: typeof raw.detailedSummary === "string" ? raw.detailedSummary.trim() : "",
category: normalizeCategory(raw.category),
appPrimary: normalizeDomain(appSites.primary),
appSecondary: normalizeDomain(appSites.secondary),
distractions: parseDistractions(params.day, raw.distractions),
keyframeId: undefined,
});
});
if (problems.length > 0) {
return { ok: false, error: problems.join("\n") };
}
if (drafts.length === 0) {
return { ok: false, error: "Output contained no valid cards." };
}
const sorted = drafts.toSorted((a, b) => a.startMs - b.startMs);
for (let i = 1; i < sorted.length; i += 1) {
const overlapMs = sorted[i - 1].endMs - sorted[i].startMs;
if (overlapMs > 60 * 1000) {
return {
ok: false,
error: `Cards ${i - 1} and ${i} overlap by ${Math.round(overlapMs / 60000)} minutes; adjacent cards must meet cleanly.`,
};
}
if (overlapMs > 0) {
// Trim sub-minute overlaps instead of round-tripping to the model again.
sorted[i] = { ...sorted[i], startMs: sorted[i - 1].endMs };
}
}
return { ok: true, drafts: sorted };
}
/** Sub-minute slack so minute-rounded model times do not fail coverage checks. */
export const COVERAGE_TOLERANCE_MS = 2 * 60 * 1000;
function formatClockForError(ms: number): string {
const date = new Date(ms);
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
/**
* Validates that drafts cover every required span before the destructive
* window replacement. Without this, a syntactically valid but partial model
* output would silently erase previously synthesized cards.
*/
export function validateCardCoverage(params: {
drafts: Array<{ startMs: number; endMs: number }>;
requiredSpans: Array<{ startMs: number; endMs: number }>;
windowStartMs: number;
windowEndMs: number;
toleranceMs?: number;
}): { ok: true } | { ok: false; error: string } {
const tolerance = params.toleranceMs ?? COVERAGE_TOLERANCE_MS;
const problems: string[] = [];
for (const draft of params.drafts) {
if (
draft.startMs < params.windowStartMs - tolerance ||
draft.endMs > params.windowEndMs + tolerance
) {
problems.push(
`Card ${formatClockForError(draft.startMs)}-${formatClockForError(draft.endMs)} lies outside the revision window ${formatClockForError(params.windowStartMs)}-${formatClockForError(params.windowEndMs)}.`,
);
}
}
const covered = params.drafts
.map((draft) => ({ startMs: draft.startMs, endMs: draft.endMs }))
.toSorted((a, b) => a.startMs - b.startMs);
for (const span of params.requiredSpans) {
let cursor = span.startMs;
for (const interval of covered) {
if (interval.endMs <= cursor) {
continue;
}
if (interval.startMs > cursor + tolerance) {
break;
}
cursor = Math.max(cursor, interval.endMs);
if (cursor >= span.endMs - tolerance) {
break;
}
}
if (cursor < span.endMs - tolerance) {
problems.push(
`Time ${formatClockForError(Math.max(cursor, span.startMs))}-${formatClockForError(span.endMs)} from the previous timeline is not covered; do not drop existing cards or observed time.`,
);
}
}
if (problems.length > 0) {
return { ok: false, error: problems.join("\n") };
}
return { ok: true };
}
/** Union of the revision window: previous draft cards plus the new batch range. */
export function revisionWindow(params: {
batchStartMs: number;
batchEndMs: number;
previousCards: LogbookCard[];
}): { startMs: number; endMs: number } {
let startMs = params.batchStartMs;
let endMs = params.batchEndMs;
for (const card of params.previousCards) {
startMs = Math.min(startMs, card.startMs);
endMs = Math.max(endMs, card.endMs);
}
return { startMs, endMs };
}
/** Groups pending frames into one batch window, splitting on large gaps. */
export function selectBatchFrames(params: {
frames: Array<{ id: number; capturedAtMs: number }>;
/** Close an in-progress window immediately instead of waiting for elapse. */
force?: boolean;
windowMs: number;
nowMs: number;
}): { frameIds: number[]; startMs: number; endMs: number } | null {
if (params.frames.length === 0) {
return null;
}
const first = params.frames[0];
const firstDay = dayKeyFor(first.capturedAtMs);
const nextDayStart = new Date(first.capturedAtMs);
nextDayStart.setHours(24, 0, 0, 0);
const windowEnd = Math.min(first.capturedAtMs + params.windowMs, nextDayStart.getTime());
const selected: Array<{ id: number; capturedAtMs: number }> = [];
let previousTs = first.capturedAtMs;
let endedEarly = false;
for (const frame of params.frames) {
if (frame.capturedAtMs >= windowEnd) {
endedEarly = dayKeyFor(frame.capturedAtMs) !== firstDay;
break;
}
if (selected.length > 0 && frame.capturedAtMs - previousTs > BATCH_MAX_GAP_MS) {
endedEarly = true;
break;
}
// Batches never span local midnight: every downstream clock (observations,
// cards, day keys) is parsed against the batch's single day.
if (selected.length > 0 && dayKeyFor(frame.capturedAtMs) !== firstDay) {
endedEarly = true;
break;
}
selected.push(frame);
previousTs = frame.capturedAtMs;
}
if (selected.length === 0) {
return null;
}
const last = selected[selected.length - 1];
// Only close a batch once its window has elapsed (or a gap/midnight ended
// it), so a window in progress keeps accumulating frames; `force` closes an
// in-progress window immediately (analyze now).
const windowElapsed = params.nowMs >= windowEnd;
if (!windowElapsed && !endedEarly && !params.force) {
return null;
}
// A normally elapsed window ends at its boundary so consecutive batches meet
// cleanly; ending at the last frame would leak one capture interval per
// batch as a permanent timeline gap. Early or forced closures must not claim
// time past the last observed frame (idle gap, next day, or the future).
const endMs = windowElapsed && !endedEarly ? windowEnd : last.capturedAtMs + 1;
return {
frameIds: selected.map((frame) => frame.id),
startMs: first.capturedAtMs,
endMs,
};
}
/** Evenly samples frames so a batch stays within the per-call image budget. */
export function sampleFrames<T>(frames: T[], max: number): T[] {
if (frames.length <= max) {
return frames;
}
const sampled: T[] = [];
const step = (frames.length - 1) / (max - 1);
for (let i = 0; i < max; i += 1) {
sampled.push(frames[Math.round(i * step)]);
}
return [...new Set(sampled)];
}
/** Picks the frame closest to a card's midpoint as its keyframe. */
export function pickKeyframeId(
card: { startMs: number; endMs: number },
frames: Array<{ id: number; capturedAtMs: number }>,
): number | undefined {
if (frames.length === 0) {
return undefined;
}
const midpoint = card.startMs + (card.endMs - card.startMs) / 2;
let best = frames[0];
for (const frame of frames) {
if (Math.abs(frame.capturedAtMs - midpoint) < Math.abs(best.capturedAtMs - midpoint)) {
best = frame;
}
}
return best.id;
}
+67
View File
@@ -0,0 +1,67 @@
// Logbook plugin config resolution: clamps operator input into safe runtime bounds.
export type LogbookConfig = {
captureEnabled: boolean;
captureIntervalSeconds: number;
analysisIntervalMinutes: number;
nodeId?: string;
screenIndex: number;
maxWidth: number;
visionModel?: string;
retentionDays: number;
};
const DEFAULTS = {
captureEnabled: true,
captureIntervalSeconds: 30,
analysisIntervalMinutes: 15,
screenIndex: 0,
maxWidth: 1440,
retentionDays: 14,
} as const;
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
const num = typeof value === "number" && Number.isFinite(value) ? value : fallback;
return Math.min(max, Math.max(min, Math.round(num)));
}
function optionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function resolveLogbookConfig(raw: unknown): LogbookConfig {
const value = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
return {
captureEnabled: value.captureEnabled !== false,
captureIntervalSeconds: clampNumber(
value.captureIntervalSeconds,
DEFAULTS.captureIntervalSeconds,
5,
600,
),
analysisIntervalMinutes: clampNumber(
value.analysisIntervalMinutes,
DEFAULTS.analysisIntervalMinutes,
3,
120,
),
nodeId: optionalString(value.nodeId),
screenIndex: clampNumber(value.screenIndex, DEFAULTS.screenIndex, 0, 16),
maxWidth: clampNumber(value.maxWidth, DEFAULTS.maxWidth, 480, 3840),
visionModel: optionalString(value.visionModel),
retentionDays: clampNumber(value.retentionDays, DEFAULTS.retentionDays, 1, 365),
};
}
/** Splits a "provider/model" ref; model ids may themselves contain slashes. */
export function parseModelRef(ref: string): { provider: string; model: string } | null {
const slash = ref.indexOf("/");
if (slash <= 0 || slash === ref.length - 1) {
return null;
}
return { provider: ref.slice(0, slash), model: ref.slice(slash + 1) };
}
+87
View File
@@ -0,0 +1,87 @@
// Logbook node-host command: screen capture for headless node hosts (macOS).
// Nodes without the OpenClaw app (plain `openclaw node host run`) advertise
// logbook.snapshot so capture works anywhere the plugin is enabled.
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
const execFileAsync = promisify(execFile);
export type LogbookSnapshotParams = {
screenIndex?: number;
maxWidth?: number;
quality?: number;
};
export type LogbookSnapshotPayload = { format: "jpeg"; base64: string } | { error: string };
function readParams(value: unknown): LogbookSnapshotParams {
if (!value || typeof value !== "object") {
return {};
}
const record = value as Record<string, unknown>;
const num = (key: string) => {
const candidate = record[key];
return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : undefined;
};
return { screenIndex: num("screenIndex"), maxWidth: num("maxWidth"), quality: num("quality") };
}
export async function handleLogbookSnapshot(rawParams: unknown): Promise<LogbookSnapshotPayload> {
if (process.platform !== "darwin") {
return { error: `logbook.snapshot is not supported on ${process.platform}` };
}
const params = readParams(rawParams);
const screenIndex = Math.max(0, Math.round(params.screenIndex ?? 0));
const maxWidth = params.maxWidth && params.maxWidth >= 480 ? Math.round(params.maxWidth) : 1440;
const qualityPct = Math.min(
100,
Math.max(
10,
Math.round(
(params.quality && params.quality > 0 && params.quality <= 1 ? params.quality : 0.6) * 100,
),
),
);
// The shared helper rejects unsafe temp roots; the private subdirectory
// keeps captures out of the broader OpenClaw temp namespace.
const captureDir = path.join(resolvePreferredOpenClawTmpDir(), "logbook");
await mkdir(captureDir, { recursive: true, mode: 0o700 });
await chmod(captureDir, 0o700);
const filePath = path.join(captureDir, `logbook-snapshot-${randomUUID()}.jpg`);
try {
// Pre-create owner-only: screencapture truncates the existing inode, so
// the capture never becomes world-readable even if the dir mode drifts.
await writeFile(filePath, "", { mode: 0o600 });
// -x: no capture sound; -C: include cursor; -D is 1-based display index.
await execFileAsync("screencapture", [
"-x",
"-C",
"-D",
String(screenIndex + 1),
"-t",
"jpg",
filePath,
]);
await execFileAsync("sips", [
"--resampleHeightWidthMax",
String(maxWidth),
"-s",
"format",
"jpeg",
"-s",
"formatOptions",
String(qualityPct),
filePath,
]);
const buffer = await readFile(filePath);
return { format: "jpeg", base64: buffer.toString("base64") };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
} finally {
await rm(filePath, { force: true });
}
}
+211
View File
@@ -0,0 +1,211 @@
// Logbook prompt builders. Two-stage pipeline (observe frames, then revise
// timeline cards) adapted from the approach popularized by Dayflow (MIT).
import type { LogbookCard, LogbookObservation } from "./types.js";
export const CARD_MIN_MINUTES = 10;
export const CARD_MAX_MINUTES = 60;
export const CARD_CATEGORIES = [
"coding",
"review",
"writing",
"research",
"comms",
"meetings",
"design",
"ops",
"browsing",
"media",
"other",
] as const;
function formatClock(ms: number): string {
const date = new Date(ms);
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${hours}:${minutes}:${seconds}`;
}
export const OBSERVATION_JSON_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["segments"],
properties: {
segments: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["start", "end", "description"],
properties: {
start: { type: "string", description: "HH:MM:SS within the covered window" },
end: { type: "string", description: "HH:MM:SS within the covered window" },
description: { type: "string" },
},
},
},
},
} as const;
export function buildObservationInstructions(params: {
frameTimes: number[];
startMs: number;
endMs: number;
}): string {
const start = formatClock(params.startMs);
const end = formatClock(params.endMs);
const times = params.frameTimes.map(formatClock).join(", ");
return [
`These are ${params.frameTimes.length} screenshots of one computer screen, captured in order between ${start} and ${end} (local time).`,
`Capture timestamps: ${times}.`,
"",
"Write an activity log detailed enough that the user could reconstruct what they did.",
'For each segment ask: "What EXACTLY did they do? What SPECIFIC things are visible?"',
"Capture exact app/site names, file names, URLs, page or PR titles, usernames, search queries, and numbers when readable.",
"",
'Bad: "Checked email". Good: "Gmail: read \'Budget approval\' from dana@acme.com, replied briefly".',
'Bad: "Working on code". Good: "VS Code: editing store.ts, fixing a type error in replaceCardsInWindow".',
"",
"Return 2-8 segments covering the whole window in order, no gaps, no overlaps.",
"If the screen barely changes, return one segment describing the sustained activity.",
"Group by GOAL, not app: debugging across editor, terminal, and browser is one segment.",
`Timestamps must be HH:MM:SS between ${start} and ${end}.`,
].join("\n");
}
export function buildCardsPrompt(params: {
day: string;
observations: LogbookObservation[];
previousCards: LogbookCard[];
windowStartMs: number;
windowEndMs: number;
}): string {
const transcript = params.observations
.map((obs) => `[${formatClock(obs.startMs)} - ${formatClock(obs.endMs)}] ${obs.text}`)
.join("\n");
const previous = JSON.stringify(
params.previousCards.map((card) => ({
startTime: formatClock(card.startMs),
endTime: formatClock(card.endMs),
category: card.category,
title: card.title,
summary: card.summary,
detailedSummary: card.detail,
distractions: card.distractions.map((d) => ({
startTime: formatClock(d.startMs),
endTime: formatClock(d.endMs),
title: d.title,
})),
appSites: { primary: card.appPrimary ?? "", secondary: card.appSecondary ?? "" },
})),
null,
2,
);
return [
"You are synthesizing a user's screen activity log into timeline cards. Each card is one coherent activity.",
"",
"CORE PRINCIPLE:",
`Each card = one main thing the user did. Time is a constraint (${CARD_MIN_MINUTES}-${CARD_MAX_MINUTES} min per card), not a goal.`,
"",
"SPLIT into a new card only when the user's GOAL changes, not just the tool. MERGE when consecutive activities serve the same goal (app switches, debugging across editor + terminal + browser, iterating on the same document). Default to merging: fewer rich cards beat many granular ones.",
"",
"DISTRACTIONS: a brief (<5 min) unrelated interruption inside a card. Anything sustained (>10 min) is its own card.",
"",
"CONTINUITY: adjacent cards meet cleanly; never introduce gaps or overlaps. Preserve genuine idle gaps from the source data.",
"",
`CATEGORY: one of ${CARD_CATEGORIES.join(", ")}.`,
"",
'APP SITES: identify the main app or site per card as a canonical lower-case domain (figma.com, docs.google.com, github.com). Use "terminal" for terminals. Omit secondary when unclear. Never invent brands.',
"",
"REVISION CONTRACT:",
"\"Previous cards\" is a draft you are revising and extending with the new observations. Your output must cover the union of the previous cards' time range and the new observations' range; you may restructure freely inside it, but do not drop covered time. The final card may be shorter than the minimum.",
"",
`Day: ${params.day}. Window under revision: ${formatClock(params.windowStartMs)} to ${formatClock(params.windowEndMs)}.`,
"",
"Previous cards:",
previous,
"",
"New observations:",
transcript || "(none)",
"",
"Return ONLY a raw JSON array, no code fences, in this exact shape:",
`[
{
"startTime": "13:12:00",
"endTime": "13:41:00",
"category": "coding",
"title": "",
"summary": "",
"detailedSummary": "",
"distractions": [{ "startTime": "13:15:00", "endTime": "13:18:00", "title": "" }],
"appSites": { "primary": "", "secondary": "" }
}
]`,
].join("\n");
}
export function buildCardsCorrectionPrompt(validationError: string): string {
return [
"The previous JSON output failed validation:",
validationError,
"",
"Return the FULL corrected JSON array (not a diff). Same coverage, no gaps or overlaps, JSON only.",
].join("\n");
}
export function buildStandupPrompt(params: {
day: string;
cards: LogbookCard[];
previousDayCards: LogbookCard[];
}): string {
const render = (cards: LogbookCard[]) =>
cards
.map(
(card) =>
`- ${formatClock(card.startMs)}-${formatClock(card.endMs)} [${card.category}] ${card.title}: ${card.summary}`,
)
.join("\n") || "(no tracked activity)";
return [
`Write a concise daily standup for ${params.day} based on the user's tracked screen activity.`,
"",
"Yesterday's timeline:",
render(params.previousDayCards),
"",
"Today's timeline so far:",
render(params.cards),
"",
"Output markdown with exactly three sections: '## Done' (yesterday's concrete accomplishments, merged and deduplicated), '## Today' (in-progress threads worth continuing), '## Blockers' (only if evidence shows something stuck; otherwise write 'None observed').",
"Keep it under 150 words. No preamble.",
].join("\n");
}
export function buildAskPrompt(params: {
day: string;
cards: LogbookCard[];
observations: LogbookObservation[];
question: string;
}): string {
const cards = params.cards
.map(
(card) =>
`- ${formatClock(card.startMs)}-${formatClock(card.endMs)} [${card.category}] ${card.title}: ${card.summary} ${card.detail}`,
)
.join("\n");
const observations = params.observations
.map((obs) => `- ${formatClock(obs.startMs)}-${formatClock(obs.endMs)}: ${obs.text}`)
.join("\n");
return [
`Answer the user's question about their tracked day (${params.day}) using ONLY the evidence below.`,
"If the evidence does not contain the answer, say so plainly. Reference times like 14:05 when useful.",
"",
"Timeline cards:",
cards || "(none)",
"",
"Detailed observations:",
observations || "(none)",
"",
`Question: ${params.question}`,
"",
"Answer in 1-4 sentences.",
].join("\n");
}
+181
View File
@@ -0,0 +1,181 @@
import { mkdtempSync, rmSync } from "node:fs";
import { realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveLogbookConfig } from "./config.js";
import { LogbookService } from "./service.js";
type NodeRecord = { nodeId: string; displayName?: string; commands: string[] };
const quietLogger = {
info() {},
warn() {},
error() {},
debug() {},
};
function makeService(params: {
nodes: NodeRecord[];
invoke: (args: { nodeId: string; command: string }) => Promise<unknown>;
config?: Record<string, unknown>;
fullConfig?: Record<string, unknown>;
}) {
const dataDir = realpathSync(mkdtempSync(path.join(tmpdir(), "logbook-service-test-")));
const invoked: Array<{ nodeId: string; command: string }> = [];
const runtime = {
nodes: {
list: async () => ({ nodes: params.nodes }),
invoke: async (args: { nodeId: string; command: string }) => {
invoked.push({ nodeId: args.nodeId, command: args.command });
return await params.invoke(args);
},
},
};
const service = new LogbookService(
resolveLogbookConfig({ captureEnabled: true, ...params.config }),
{
runtime: runtime as never,
fullConfig: (params.fullConfig ?? {}) as never,
logger: quietLogger as never,
dataDir,
},
);
service.start();
const tick = () =>
(service as unknown as { captureTick(): Promise<void> }).captureTick.call(service);
return { service, invoked, tick, dataDir };
}
const framePayload = {
payload: { format: "jpeg", base64: Buffer.from("fake-jpeg").toString("base64") },
};
describe("LogbookService capture node selection", () => {
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0)) {
cleanup();
}
});
it("prefers app nodes over headless node hosts regardless of node id order", async () => {
const { service, invoked, tick, dataDir } = makeService({
nodes: [
{ nodeId: "a-headless", commands: ["logbook.snapshot"] },
{ nodeId: "b-mac-app", commands: ["screen.snapshot"] },
],
invoke: async () => framePayload,
});
cleanups.push(() => {
service.stop();
rmSync(dataDir, { recursive: true, force: true });
});
await tick();
expect(invoked).toEqual([{ nodeId: "b-mac-app", command: "screen.snapshot" }]);
});
it("rotates to the next capture node after a failure instead of re-picking the broken one", async () => {
const { service, invoked, tick, dataDir } = makeService({
nodes: [
{ nodeId: "a-broken", commands: ["logbook.snapshot"] },
{ nodeId: "b-working", commands: ["logbook.snapshot"] },
],
invoke: async ({ nodeId }) => {
if (nodeId === "a-broken") {
return { payload: { error: "logbook.snapshot is not supported on linux" } };
}
return framePayload;
},
});
cleanups.push(() => {
service.stop();
rmSync(dataDir, { recursive: true, force: true });
});
await tick();
await tick();
expect(invoked.map((call) => call.nodeId)).toEqual(["a-broken", "b-working"]);
expect(service.status().lastCaptureError).toBeUndefined();
});
});
describe("LogbookService vision model selection", () => {
const cleanups: Array<() => void> = [];
afterEach(() => {
for (const cleanup of cleanups.splice(0)) {
cleanup();
}
});
it("borrows only a media provider with structured extraction", () => {
const { service, dataDir } = makeService({
nodes: [],
invoke: async () => framePayload,
fullConfig: {
tools: {
media: {
image: {
models: [
{ provider: "openai", model: "gpt-5.5", capabilities: ["image"] },
{ provider: " Codex ", model: "gpt-5.5", capabilities: ["image"] },
],
},
},
},
},
});
cleanups.push(() => {
service.stop();
rmSync(dataDir, { recursive: true, force: true });
});
expect(service.status()).toMatchObject({
visionModel: "codex/gpt-5.5",
visionModelSource: "media-defaults",
});
});
it("reports a missing model when borrowed defaults cannot extract structured data", () => {
const { service, dataDir } = makeService({
nodes: [],
invoke: async () => framePayload,
fullConfig: {
tools: {
media: {
models: [{ provider: "openai", model: "gpt-5.5", capabilities: ["image"] }],
},
},
},
});
cleanups.push(() => {
service.stop();
rmSync(dataDir, { recursive: true, force: true });
});
expect(service.status()).toMatchObject({
visionModel: undefined,
visionModelSource: "missing",
});
});
});
describe("LogbookService status", () => {
it("returns the capture-host timezone without exposing the state path", () => {
const { service, dataDir } = makeService({
nodes: [],
invoke: async () => framePayload,
});
try {
expect(service.status()).toMatchObject({
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
expect(service.status()).not.toHaveProperty("dataDir");
} finally {
service.stop();
rmSync(dataDir, { recursive: true, force: true });
}
});
});
+696
View File
@@ -0,0 +1,696 @@
// Logbook background service: snapshot capture loop, batch analysis, retention.
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import type {
OpenClawConfig,
OpenClawPluginApi,
PluginLogger,
} from "openclaw/plugin-sdk/plugin-entry";
import {
CARD_LOOKBACK_MS,
MAX_FRAMES_PER_CALL,
parseCardsJson,
parseObservationSegments,
pickKeyframeId,
revisionWindow,
sampleFrames,
selectBatchFrames,
validateCardCoverage,
} from "./analyze.js";
import { parseModelRef, type LogbookConfig } from "./config.js";
import {
buildAskPrompt,
buildCardsCorrectionPrompt,
buildCardsPrompt,
buildObservationInstructions,
buildStandupPrompt,
OBSERVATION_JSON_SCHEMA,
} from "./prompts.js";
import { dayKeyFor, LogbookStore } from "./store.js";
import type { LogbookBatch, LogbookCard } from "./types.js";
const ANALYSIS_TICK_MS = 60 * 1000;
const PRUNE_TICK_MS = 60 * 60 * 1000;
const MODEL_MISSING_MESSAGE =
"no vision model: set plugins.entries.logbook.config.visionModel or configure tools.media";
const MODEL_MISSING_LOG_INTERVAL_MS = 10 * 60 * 1000;
const CAPTURE_FAILURE_PAUSE_TICKS = 10;
const CAPTURE_FAILURE_THRESHOLD = 3;
const JPEG_QUALITY = 0.6;
// Only Codex currently implements the structured image-extraction contract.
// Borrowed defaults must not select a provider that will fail every batch.
const STRUCTURED_MEDIA_PROVIDER = "codex";
type SnapshotPayload = {
format?: string;
base64?: string;
width?: number;
height?: number;
error?: string;
};
/** node.invoke responses wrap the node result in {payload, payloadJSON}. */
function unwrapInvokePayload(raw: unknown): SnapshotPayload | null {
if (!raw || typeof raw !== "object") {
return null;
}
const envelope = raw as { payload?: unknown; payloadJSON?: string | null };
if (envelope.payload && typeof envelope.payload === "object") {
return envelope.payload as SnapshotPayload;
}
if (typeof envelope.payloadJSON === "string" && envelope.payloadJSON.length > 0) {
try {
return JSON.parse(envelope.payloadJSON) as SnapshotPayload;
} catch {
return null;
}
}
// Tolerate transports that already deliver the bare node payload.
return "base64" in envelope || "error" in envelope ? (envelope as SnapshotPayload) : null;
}
/** Capture commands in preference order: app nodes first, headless node hosts second. */
const CAPTURE_COMMANDS = ["screen.snapshot", "logbook.snapshot"] as const;
export type LogbookStatus = {
captureEnabled: boolean;
capturePaused: boolean;
captureIntervalSeconds: number;
analysisIntervalMinutes: number;
retentionDays: number;
nodeId?: string;
nodeName?: string;
lastCaptureAtMs?: number;
lastCaptureError?: string;
pendingFrames: number;
analysisRunning: boolean;
lastBatch?: Pick<LogbookBatch, "id" | "day" | "status" | "endMs" | "error">;
visionModel?: string;
visionModelSource: "config" | "media-defaults" | "missing";
today: string;
todayCards: number;
timeZone: string;
};
export class LogbookService {
private store: LogbookStore | null = null;
private captureTimer: NodeJS.Timeout | null = null;
private analysisTimer: NodeJS.Timeout | null = null;
private pruneTimer: NodeJS.Timeout | null = null;
private captureInFlight = false;
private analysisInFlight = false;
private capturePaused = false;
private captureFailures = 0;
private captureBackoffTicks = 0;
private lastCaptureAtMs: number | undefined;
private lastCaptureError: string | undefined;
private lastModelMissingLogMs = 0;
private cachedNode: { nodeId: string; displayName?: string; command: string } | null = null;
// Nodes whose captures failed this rotation; skipped until every candidate
// has failed once, then retried so transient outages self-heal.
private failedNodeIds = new Set<string>();
constructor(
private readonly config: LogbookConfig,
private readonly deps: {
runtime: NonNullable<OpenClawPluginApi["runtime"]>;
fullConfig: OpenClawConfig;
logger: PluginLogger;
dataDir: string;
},
) {}
start(): void {
this.store = new LogbookStore(this.deps.dataDir);
// Batches interrupted by a gateway restart go back to pending.
this.store.resetRunningBatches();
this.captureTimer = setInterval(() => {
void this.captureTick();
}, this.config.captureIntervalSeconds * 1000);
this.captureTimer.unref?.();
this.analysisTimer = setInterval(() => {
void this.analysisTick();
}, ANALYSIS_TICK_MS);
this.analysisTimer.unref?.();
this.pruneTimer = setInterval(() => {
this.prune();
}, PRUNE_TICK_MS);
this.pruneTimer.unref?.();
this.prune();
this.deps.logger.info(
`logbook: started (capture every ${this.config.captureIntervalSeconds}s, analysis window ${this.config.analysisIntervalMinutes}m, data ${this.deps.dataDir})`,
);
}
stop(): void {
for (const timer of [this.captureTimer, this.analysisTimer, this.pruneTimer]) {
if (timer) {
clearInterval(timer);
}
}
this.captureTimer = null;
this.analysisTimer = null;
this.pruneTimer = null;
this.store?.close();
this.store = null;
}
private requireStore(): LogbookStore {
if (!this.store) {
throw new Error("Logbook service is not running");
}
return this.store;
}
// ── Capture ────────────────────────────────────────────────────────
setCapturePaused(paused: boolean): void {
this.capturePaused = paused;
if (!paused) {
this.captureBackoffTicks = 0;
this.captureFailures = 0;
}
}
private async resolveNode(): Promise<
{ node: { nodeId: string; displayName?: string; command: string } } | { reason: string }
> {
if (this.cachedNode) {
return { node: this.cachedNode };
}
const { nodes } = await this.deps.runtime.nodes.list({ connected: true });
const captureCommand = (node: { commands?: string[] }) =>
CAPTURE_COMMANDS.find((command) => (node.commands ?? []).includes(command));
// App nodes (screen.snapshot) come first: plugin node-host commands are
// advertised on every platform, but logbook.snapshot only captures on
// macOS, so headless hosts are a fallback rather than the default pick.
const commandRank = (node: { commands?: string[] }) =>
CAPTURE_COMMANDS.indexOf(captureCommand(node) as (typeof CAPTURE_COMMANDS)[number]);
const candidates = nodes
.filter((node) => captureCommand(node) !== undefined)
.toSorted((a, b) => commandRank(a) - commandRank(b) || a.nodeId.localeCompare(b.nodeId));
const wanted = this.config.nodeId?.toLowerCase();
// Failed nodes rotate to the back until everything has failed once;
// without this, a broken node that sorts first is re-picked every tick.
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) : undefined;
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 };
}
private async captureTick(): Promise<void> {
if (!this.config.captureEnabled || this.capturePaused || this.captureInFlight || !this.store) {
return;
}
if (this.captureBackoffTicks > 0) {
this.captureBackoffTicks -= 1;
return;
}
this.captureInFlight = true;
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 invoked = 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: 30_000,
});
const raw = unwrapInvokePayload(invoked);
if (raw?.error) {
throw new Error(raw.error);
}
const base64 = raw?.base64;
if (!base64) {
throw new Error(`${node.command} returned no image payload`);
}
const buffer = Buffer.from(base64, "base64");
const capturedAtMs = Date.now();
const day = dayKeyFor(capturedAtMs);
const contentHash = createHash("sha256").update(buffer).digest("hex");
// Unchanged consecutive frames mean the user is idle (or away); they are
// stored for the filmstrip but excluded from analysis batches.
const idle = this.store.lastFrame()?.contentHash === contentHash;
const filePath = this.store.frameFilePath(day, capturedAtMs);
// Screen captures can contain secrets; keep them owner-only.
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
writeFileSync(filePath, buffer, { mode: 0o600 });
this.store.insertFrame({
capturedAtMs,
day,
path: filePath,
screenIndex: this.config.screenIndex,
width: raw?.width,
height: raw?.height,
byteSize: buffer.byteLength,
contentHash,
idle,
});
this.lastCaptureAtMs = capturedAtMs;
this.lastCaptureError = undefined;
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 = err instanceof Error ? err.message : String(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`,
);
}
} finally {
this.captureInFlight = false;
}
}
// ── Analysis ───────────────────────────────────────────────────────
private resolveVisionModel(): {
ref?: { provider: string; model: string; profile?: string; preferredProfile?: string };
source: LogbookStatus["visionModelSource"];
} {
if (this.config.visionModel) {
const ref = parseModelRef(this.config.visionModel);
return ref ? { ref, source: "config" } : { source: "missing" };
}
const media = this.deps.fullConfig.tools?.media;
// Operators who disabled image understanding must not have screenshots
// routed to a provider via the borrowed media defaults.
if (media?.image?.enabled === false) {
return { source: "missing" };
}
const entries = [...(media?.image?.models ?? []), ...(media?.models ?? [])];
for (const entry of entries) {
const usable =
entry.type !== "cli" &&
entry.provider?.trim().toLowerCase() === STRUCTURED_MEDIA_PROVIDER &&
typeof entry.model === "string" &&
(!entry.capabilities || entry.capabilities.includes("image"));
if (usable) {
return {
// Auth profile fields ride along so profile-scoped media credentials
// keep working when Logbook borrows the media-understanding default.
ref: {
provider: STRUCTURED_MEDIA_PROVIDER,
model: entry.model as string,
profile: entry.profile,
preferredProfile: entry.preferredProfile,
},
source: "media-defaults",
};
}
}
return { source: "missing" };
}
async analyzeNow(): Promise<{ started: boolean; reason?: string }> {
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 };
}
// Explicit user action is the retry path for failed batches; automatic
// retries could loop model spend on a persistently failing batch.
store.resetErrorBatches();
if (!store.nextPendingBatch()) {
const frames = store.unbatchedActiveFrames(2000);
// Force-close the current window so "analyze now" needs no elapsed time.
const selection = selectBatchFrames({
frames,
windowMs: this.config.analysisIntervalMinutes * 60_000,
nowMs: Date.now(),
force: true,
});
if (!selection) {
return { started: false, reason: "no unanalyzed activity captured yet" };
}
store.createBatch({
day: dayKeyFor(selection.startMs),
startMs: selection.startMs,
endMs: selection.endMs,
frameIds: selection.frameIds,
});
}
void this.analysisTick();
return { started: true };
}
private async analysisTick(): Promise<void> {
if (this.analysisInFlight || !this.store) {
return;
}
// Without a vision model, leave frames unbatched and batches pending so
// everything analyzes once the operator configures one; erroring here
// would permanently strand the assigned frames.
if (!this.resolveVisionModel().ref) {
const now = Date.now();
if (now - this.lastModelMissingLogMs > MODEL_MISSING_LOG_INTERVAL_MS) {
this.lastModelMissingLogMs = now;
this.deps.logger.warn(`logbook: analysis paused; ${MODEL_MISSING_MESSAGE}`);
}
return;
}
this.analysisInFlight = true;
try {
this.enqueueElapsedWindow();
for (let i = 0; i < 4; i += 1) {
const batch = this.store.nextPendingBatch();
if (!batch) {
return;
}
await this.runBatch(batch);
}
} catch (err) {
this.deps.logger.error(`logbook: analysis tick failed: ${String(err)}`);
} finally {
this.analysisInFlight = false;
}
}
private enqueueElapsedWindow(): void {
const store = this.requireStore();
// Windows close on elapsed wall-clock or on a capture gap; both cases are
// resolved by selectBatchFrames against the oldest unbatched frame.
while (true) {
const frames = store.unbatchedActiveFrames(2000);
const selection = selectBatchFrames({
frames,
windowMs: this.config.analysisIntervalMinutes * 60_000,
nowMs: Date.now(),
});
if (!selection) {
return;
}
store.createBatch({
day: dayKeyFor(selection.startMs),
startMs: selection.startMs,
endMs: selection.endMs,
frameIds: selection.frameIds,
});
}
}
private async runBatch(batch: LogbookBatch): Promise<void> {
const store = this.requireStore();
const vision = this.resolveVisionModel();
if (!vision.ref) {
// Stay pending: the analysis tick pauses until a model is configured.
return;
}
store.setBatchStatus(
batch.id,
"running",
undefined,
`${vision.ref.provider}/${vision.ref.model}`,
);
try {
const frames = store.batchFrames(batch.id);
const sampled = sampleFrames(frames, MAX_FRAMES_PER_CALL);
const images = sampled.map((frame) => ({
type: "image" as const,
buffer: readFileSync(frame.path),
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: 180_000,
});
const segments = parseObservationSegments({
raw: observationResult.text ?? "",
day: batch.day,
startMs: batch.startMs,
endMs: batch.endMs,
});
if (segments.length === 0) {
store.setBatchStatus(batch.id, "error", "vision model returned no usable segments");
return;
}
store.replaceObservations(batch.id, batch.day, segments);
await this.reviseCards(batch);
store.setBatchStatus(batch.id, "done");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
store.setBatchStatus(batch.id, "error", message);
this.deps.logger.warn(`logbook: batch ${batch.id} failed: ${message}`);
}
}
private async reviseCards(batch: LogbookBatch): Promise<void> {
const store = this.requireStore();
const lookbackStart = batch.startMs - CARD_LOOKBACK_MS;
const previousCards = store
.cardsForDay(batch.day)
.filter((card) => card.endMs > lookbackStart && card.startMs < batch.endMs);
const observations = 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,
});
// Coverage is validated alongside parsing: a partial-but-valid output must
// trigger the repair round-trip instead of erasing previous cards below.
const requiredSpans = [
...previousCards.map((card) => ({ startMs: card.startMs, endMs: card.endMs })),
{ startMs: batch.startMs, endMs: batch.endMs },
];
const evaluate = (raw: string) => {
const parsed = parseCardsJson({
raw,
day: batch.day,
windowStartMs: window.startMs,
windowEndMs: window.endMs,
});
if (!parsed.ok) {
return parsed;
}
const coverage = validateCardCoverage({
drafts: parsed.drafts,
requiredSpans,
windowStartMs: window.startMs,
windowEndMs: window.endMs,
});
return coverage.ok ? parsed : { ok: false as const, error: coverage.error };
};
const first = await this.deps.runtime.llm.complete({
messages: [{ role: "user", content: prompt }],
purpose: "logbook.cards",
maxTokens: 4000,
});
let parsed = evaluate(first.text);
if (!parsed.ok) {
const retry = 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: 4000,
});
parsed = evaluate(retry.text);
}
if (!parsed.ok) {
throw new Error(`card synthesis failed validation: ${parsed.error}`);
}
const windowFrames = store
.framesInRange(window.startMs, window.endMs)
.map((frame) => ({ id: frame.id, capturedAtMs: frame.capturedAtMs }));
const drafts = parsed.drafts.map((draft) =>
Object.assign(draft, { keyframeId: pickKeyframeId(draft, windowFrames) }),
);
store.replaceCardsInWindow(batch.day, window.startMs, window.endMs, drafts);
}
// ── Q&A / standup ──────────────────────────────────────────────────
async standup(
day: string,
refresh: boolean,
): Promise<{ day: string; text: string; updatedMs: number }> {
const store = this.requireStore();
if (!refresh) {
const cached = store.getStandup(day);
if (cached) {
return cached;
}
}
const previousDay = dayKeyFor(new Date(`${day}T12:00:00`).getTime() - 24 * 60 * 60 * 1000);
const result = await this.deps.runtime.llm.complete({
messages: [
{
role: "user",
content: buildStandupPrompt({
day,
cards: store.cardsForDay(day),
previousDayCards: store.cardsForDay(previousDay),
}),
},
],
purpose: "logbook.standup",
maxTokens: 800,
});
store.saveStandup(day, result.text.trim());
const saved = store.getStandup(day);
if (!saved) {
throw new Error("standup save failed");
}
return saved;
}
async ask(day: string, question: string): Promise<string> {
const store = this.requireStore();
const observations = store.observationsInRange(day, 0, Number.MAX_SAFE_INTEGER).slice(-200);
const result = await this.deps.runtime.llm.complete({
messages: [
{
role: "user",
content: buildAskPrompt({
day,
cards: store.cardsForDay(day),
observations,
question,
}),
},
],
purpose: "logbook.ask",
maxTokens: 600,
});
return result.text.trim();
}
// ── Introspection ──────────────────────────────────────────────────
cardsForDay(day: string): LogbookCard[] {
return this.requireStore().cardsForDay(day);
}
listDays(): ReturnType<LogbookStore["listDays"]> {
return this.requireStore().listDays();
}
dayStats(day: string): ReturnType<LogbookStore["dayStats"]> {
return this.requireStore().dayStats(day);
}
frameById(id: number): ReturnType<LogbookStore["frameById"]> {
return this.requireStore().frameById(id);
}
framesInRange(startMs: number, endMs: number): ReturnType<LogbookStore["framesInRange"]> {
return this.requireStore().framesInRange(startMs, endMs);
}
status(): LogbookStatus {
const store = this.requireStore();
const today = dayKeyFor(Date.now());
const latestBatch = 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: store.countUnbatchedActiveFrames(),
analysisRunning: this.analysisInFlight,
lastBatch: latestBatch
? {
id: latestBatch.id,
day: latestBatch.day,
status: latestBatch.status,
endMs: latestBatch.endMs,
error: latestBatch.error,
}
: undefined,
visionModel: vision.ref ? `${vision.ref.provider}/${vision.ref.model}` : undefined,
visionModelSource: vision.source,
today,
todayCards: store.cardsForDay(today).length,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
}
private prune(): void {
if (!this.store) {
return;
}
const cutoff = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
const removed = this.store.pruneFrames(cutoff);
if (removed > 0) {
this.deps.logger.info(
`logbook: pruned ${removed} frames older than ${this.config.retentionDays}d`,
);
}
}
}
+184
View File
@@ -0,0 +1,184 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { LogbookStore, dayKeyFor } from "./store.js";
import type { LogbookCardDraft } from "./types.js";
const DAY = "2026-07-03";
function draft(overrides: Partial<LogbookCardDraft> = {}): LogbookCardDraft {
const base = new Date(`${DAY}T10:00:00`).getTime();
return {
day: DAY,
startMs: base,
endMs: base + 30 * 60_000,
title: "Card",
summary: "Summary",
detail: "",
category: "coding",
appPrimary: "github.com",
appSecondary: undefined,
distractions: [],
keyframeId: undefined,
...overrides,
};
}
describe("LogbookStore", () => {
let dir: string;
let store: LogbookStore;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), "logbook-store-"));
store = new LogbookStore(dir);
});
afterEach(() => {
store.close();
rmSync(dir, { recursive: true, force: true });
});
const insertFrame = (capturedAtMs: number, opts?: { idle?: boolean; hash?: string }) => {
const day = dayKeyFor(capturedAtMs);
const filePath = store.frameFilePath(day, capturedAtMs);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, "jpeg-bytes");
return store.insertFrame({
capturedAtMs,
day,
path: filePath,
screenIndex: 0,
byteSize: 9,
contentHash: opts?.hash ?? `hash-${capturedAtMs}`,
idle: opts?.idle ?? false,
});
};
it("tracks unbatched active frames and excludes idle ones", () => {
const t0 = Date.now();
insertFrame(t0);
insertFrame(t0 + 1000, { idle: true });
insertFrame(t0 + 2000);
expect(store.countUnbatchedActiveFrames()).toBe(2);
const batchId = store.createBatch({
day: dayKeyFor(t0),
startMs: t0,
endMs: t0 + 3000,
frameIds: store.unbatchedActiveFrames(10).map((frame) => frame.id),
});
expect(store.countUnbatchedActiveFrames()).toBe(0);
expect(store.batchFrames(batchId)).toHaveLength(2);
});
it("resets running batches to pending on startup recovery", () => {
const t0 = Date.now();
insertFrame(t0);
const batchId = store.createBatch({
day: dayKeyFor(t0),
startMs: t0,
endMs: t0 + 1000,
frameIds: [1],
});
store.setBatchStatus(batchId, "running");
store.resetRunningBatches();
expect(store.nextPendingBatch()?.id).toBe(batchId);
});
it("replaces only cards overlapping the revision window", () => {
const base = new Date(`${DAY}T09:00:00`).getTime();
store.replaceCardsInWindow(DAY, base, base + 4 * 60 * 60_000, [
draft({ startMs: base, endMs: base + 30 * 60_000, title: "Early" }),
draft({ startMs: base + 60 * 60_000, endMs: base + 90 * 60_000, title: "Mid" }),
]);
// Revise only the window covering "Mid"; "Early" must survive untouched.
store.replaceCardsInWindow(DAY, base + 50 * 60_000, base + 2 * 60 * 60_000, [
draft({ startMs: base + 55 * 60_000, endMs: base + 95 * 60_000, title: "Mid revised" }),
]);
const titles = store.cardsForDay(DAY).map((card) => card.title);
expect(titles).toEqual(["Early", "Mid revised"]);
});
it("round-trips distractions and computes day stats", () => {
const base = new Date(`${DAY}T10:00:00`).getTime();
store.replaceCardsInWindow(DAY, base, base + 60 * 60_000, [
draft({
distractions: [{ startMs: base + 5 * 60_000, endMs: base + 10 * 60_000, title: "Twitter" }],
}),
]);
const cards = store.cardsForDay(DAY);
expect(cards[0].distractions).toEqual([
{ startMs: base + 5 * 60_000, endMs: base + 10 * 60_000, title: "Twitter" },
]);
const stats = store.dayStats(DAY);
expect(stats.trackedMs).toBe(30 * 60_000);
expect(stats.distractionMs).toBe(5 * 60_000);
expect(stats.categories[0]).toEqual({ category: "coding", ms: 30 * 60_000 });
expect(stats.apps[0].domain).toBe("github.com");
});
it("prunes old frame rows and files but keeps recent ones", () => {
const now = Date.now();
const oldId = insertFrame(now - 20 * 24 * 60 * 60_000);
const newId = insertFrame(now);
const oldPath = store.frameById(oldId)?.path ?? "";
expect(store.pruneFrames(now - 14 * 24 * 60 * 60_000)).toBe(1);
expect(store.frameById(oldId)).toBeNull();
expect(existsSync(oldPath)).toBe(false);
expect(store.frameById(newId)).not.toBeNull();
});
it("detaches pruned keyframes from surviving cards", () => {
const now = Date.now();
const oldId = insertFrame(now - 20 * 24 * 60 * 60_000);
store.replaceCardsInWindow(DAY, 0, Number.MAX_SAFE_INTEGER, [draft({ keyframeId: oldId })]);
store.pruneFrames(now - 14 * 24 * 60 * 60_000);
expect(store.cardsForDay(DAY)[0]?.keyframeId).toBeUndefined();
});
it("replaces observations on batch retry instead of appending", () => {
const t0 = Date.now();
const frameId = insertFrame(t0);
const batchId = store.createBatch({
day: DAY,
startMs: t0,
endMs: t0 + 1000,
frameIds: [frameId],
});
store.replaceObservations(batchId, DAY, [{ startMs: t0, endMs: t0 + 500, text: "first run" }]);
store.replaceObservations(batchId, DAY, [{ startMs: t0, endMs: t0 + 500, text: "retry run" }]);
const observations = store.observationsInRange(DAY, 0, Number.MAX_SAFE_INTEGER);
expect(observations).toHaveLength(1);
expect(observations[0].text).toBe("retry run");
});
it("requeues errored batches for explicit retry", () => {
const t0 = Date.now();
const frameId = insertFrame(t0);
const batchId = store.createBatch({
day: dayKeyFor(t0),
startMs: t0,
endMs: t0 + 1000,
frameIds: [frameId],
});
store.setBatchStatus(batchId, "error", "boom");
expect(store.nextPendingBatch()).toBeNull();
expect(store.resetErrorBatches()).toBe(1);
const requeued = store.nextPendingBatch();
expect(requeued?.id).toBe(batchId);
expect(requeued?.error).toBeUndefined();
});
it("keeps capture data owner-only on disk", () => {
const mode = (p: string) => statSync(p).mode & 0o777;
expect(mode(dir)).toBe(0o700);
expect(mode(store.framesDir)).toBe(0o700);
expect(mode(path.join(dir, "logbook.sqlite"))).toBe(0o600);
});
it("stores and updates standups", () => {
store.saveStandup(DAY, "## Done\n- shipped");
store.saveStandup(DAY, "## Done\n- shipped more");
expect(store.getStandup(DAY)?.text).toContain("shipped more");
});
});
+605
View File
@@ -0,0 +1,605 @@
// Logbook SQLite store: frames on disk, everything else in one plugin-owned DB.
// Uses node:sqlite prepared statements directly (extension-local store, same
// pattern as memory-core/imessage); the shared Kysely helpers are core-only.
import { chmodSync, mkdirSync, rmdirSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import type {
LogbookBatch,
LogbookBatchStatus,
LogbookCard,
LogbookCardDraft,
LogbookDayStats,
LogbookDistraction,
LogbookFrame,
LogbookObservation,
} from "./types.js";
type SqliteModule = typeof import("node:sqlite");
type Database = import("node:sqlite").DatabaseSync;
function loadNodeSqlite(): SqliteModule {
const req = createRequire(import.meta.url);
return req("node:sqlite") as SqliteModule;
}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS frames (
id INTEGER PRIMARY KEY AUTOINCREMENT,
captured_at_ms INTEGER NOT NULL,
day TEXT NOT NULL,
path TEXT NOT NULL,
screen_index INTEGER NOT NULL DEFAULT 0,
width INTEGER,
height INTEGER,
byte_size INTEGER NOT NULL DEFAULT 0,
content_hash TEXT NOT NULL,
idle INTEGER NOT NULL DEFAULT 0,
batch_id INTEGER
);
CREATE INDEX IF NOT EXISTS idx_logbook_frames_day ON frames (day, captured_at_ms);
CREATE INDEX IF NOT EXISTS idx_logbook_frames_unbatched ON frames (batch_id) WHERE batch_id IS NULL;
CREATE TABLE IF NOT EXISTS batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day TEXT NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
frame_count INTEGER NOT NULL DEFAULT 0,
model TEXT,
created_ms INTEGER NOT NULL,
updated_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_logbook_batches_day ON batches (day, start_ms);
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id INTEGER NOT NULL,
day TEXT NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
text TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_logbook_observations_day ON observations (day, start_ms);
CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day TEXT NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
title TEXT NOT NULL,
summary TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT 'other',
app_primary TEXT,
app_secondary TEXT,
distractions TEXT NOT NULL DEFAULT '[]',
keyframe_id INTEGER,
updated_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_logbook_cards_day ON cards (day, start_ms);
CREATE TABLE IF NOT EXISTS standups (
day TEXT PRIMARY KEY,
text TEXT NOT NULL,
updated_ms INTEGER NOT NULL
);
`;
type FrameRow = {
id: number;
captured_at_ms: number;
day: string;
path: string;
screen_index: number;
width: number | null;
height: number | null;
byte_size: number;
idle: number;
};
type CardRow = {
id: number;
day: string;
start_ms: number;
end_ms: number;
title: string;
summary: string;
detail: string;
category: string;
app_primary: string | null;
app_secondary: string | null;
distractions: string;
keyframe_id: number | null;
};
function toFrame(row: FrameRow): LogbookFrame {
return {
id: row.id,
capturedAtMs: row.captured_at_ms,
day: row.day,
path: row.path,
screenIndex: row.screen_index,
width: row.width ?? undefined,
height: row.height ?? undefined,
byteSize: row.byte_size,
idle: row.idle === 1,
};
}
function parseDistractions(raw: string): LogbookDistraction[] {
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(
(entry): entry is LogbookDistraction =>
entry !== null &&
typeof entry === "object" &&
typeof (entry as LogbookDistraction).title === "string" &&
typeof (entry as LogbookDistraction).startMs === "number" &&
typeof (entry as LogbookDistraction).endMs === "number",
);
} catch {
return [];
}
}
function toCard(row: CardRow): LogbookCard {
return {
id: row.id,
day: row.day,
startMs: row.start_ms,
endMs: row.end_ms,
title: row.title,
summary: row.summary,
detail: row.detail,
category: row.category,
appPrimary: row.app_primary ?? undefined,
appSecondary: row.app_secondary ?? undefined,
distractions: parseDistractions(row.distractions),
keyframeId: row.keyframe_id ?? undefined,
};
}
/** Formats an epoch-ms timestamp as a local-time YYYY-MM-DD day key. */
export function dayKeyFor(ms: number): string {
const date = new Date(ms);
const month = String(date.getMonth() + 1).padStart(2, "0");
const dayOfMonth = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${month}-${dayOfMonth}`;
}
export class LogbookStore {
private readonly db: Database;
readonly framesDir: string;
constructor(readonly dataDir: string) {
// Frames and the DB hold raw screen contents; keep everything owner-only
// even when the surrounding state dir is more permissive.
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
chmodSync(dataDir, 0o700);
this.framesDir = path.join(dataDir, "frames");
mkdirSync(this.framesDir, { recursive: true, mode: 0o700 });
chmodSync(this.framesDir, 0o700);
const { DatabaseSync } = loadNodeSqlite();
const dbPath = path.join(dataDir, "logbook.sqlite");
this.db = new DatabaseSync(dbPath);
// WAL/SHM sidecars inherit the main DB file's permissions.
chmodSync(dbPath, 0o600);
this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA busy_timeout = 1000");
this.db.exec(SCHEMA);
}
close(): void {
this.db.close();
}
frameFilePath(day: string, capturedAtMs: number): string {
return path.join(this.framesDir, day, `${capturedAtMs}.jpg`);
}
insertFrame(params: {
capturedAtMs: number;
day: string;
path: string;
screenIndex: number;
width?: number;
height?: number;
byteSize: number;
contentHash: string;
idle: boolean;
}): number {
const result = this.db
.prepare(
`INSERT INTO frames (captured_at_ms, day, path, screen_index, width, height, byte_size, content_hash, idle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
params.capturedAtMs,
params.day,
params.path,
params.screenIndex,
params.width ?? null,
params.height ?? null,
params.byteSize,
params.contentHash,
params.idle ? 1 : 0,
);
return Number(result.lastInsertRowid);
}
lastFrame(): { capturedAtMs: number; contentHash: string } | null {
const row = this.db
.prepare(
`SELECT captured_at_ms, content_hash FROM frames ORDER BY captured_at_ms DESC LIMIT 1`,
)
.get() as { captured_at_ms: number; content_hash: string } | undefined;
return row ? { capturedAtMs: row.captured_at_ms, contentHash: row.content_hash } : null;
}
unbatchedActiveFrames(limit: number): LogbookFrame[] {
const rows = this.db
.prepare(
`SELECT id, captured_at_ms, day, path, screen_index, width, height, byte_size, idle
FROM frames WHERE batch_id IS NULL AND idle = 0
ORDER BY captured_at_ms ASC LIMIT ?`,
)
.all(limit) as FrameRow[];
return rows.map(toFrame);
}
countUnbatchedActiveFrames(): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS n FROM frames WHERE batch_id IS NULL AND idle = 0`)
.get() as { n: number };
return row.n;
}
frameById(id: number): LogbookFrame | null {
const row = this.db
.prepare(
`SELECT id, captured_at_ms, day, path, screen_index, width, height, byte_size, idle
FROM frames WHERE id = ?`,
)
.get(id) as FrameRow | undefined;
return row ? toFrame(row) : null;
}
framesInRange(startMs: number, endMs: number): LogbookFrame[] {
const rows = this.db
.prepare(
`SELECT id, captured_at_ms, day, path, screen_index, width, height, byte_size, idle
FROM frames WHERE captured_at_ms >= ? AND captured_at_ms < ?
ORDER BY captured_at_ms ASC`,
)
.all(startMs, endMs) as FrameRow[];
return rows.map(toFrame);
}
createBatch(params: { day: string; startMs: number; endMs: number; frameIds: number[] }): number {
const now = Date.now();
const result = this.db
.prepare(
`INSERT INTO batches (day, start_ms, end_ms, status, frame_count, created_ms, updated_ms)
VALUES (?, ?, ?, 'pending', ?, ?, ?)`,
)
.run(params.day, params.startMs, params.endMs, params.frameIds.length, now, now);
const batchId = Number(result.lastInsertRowid);
const assign = this.db.prepare(`UPDATE frames SET batch_id = ? WHERE id = ?`);
for (const frameId of params.frameIds) {
assign.run(batchId, frameId);
}
return batchId;
}
setBatchStatus(
batchId: number,
status: LogbookBatchStatus,
error?: string,
model?: string,
): void {
this.db
.prepare(
`UPDATE batches SET status = ?, error = ?, model = COALESCE(?, model), updated_ms = ? WHERE id = ?`,
)
.run(status, error ?? null, model ?? null, Date.now(), batchId);
}
latestBatch(): LogbookBatch | null {
const row = this.db
.prepare(
`SELECT id, day, start_ms, end_ms, status, error, frame_count, model
FROM batches ORDER BY id DESC LIMIT 1`,
)
.get() as
| {
id: number;
day: string;
start_ms: number;
end_ms: number;
status: LogbookBatchStatus;
error: string | null;
frame_count: number;
model: string | null;
}
| undefined;
if (!row) {
return null;
}
return {
id: row.id,
day: row.day,
startMs: row.start_ms,
endMs: row.end_ms,
status: row.status,
error: row.error ?? undefined,
frameCount: row.frame_count,
model: row.model ?? undefined,
};
}
/** Requeues batches stuck in `running` after a crash so frames are not orphaned. */
resetRunningBatches(): void {
this.db
.prepare(`UPDATE batches SET status = 'pending', updated_ms = ? WHERE status = 'running'`)
.run(Date.now());
}
/** Requeues failed batches for an explicit user-driven retry (analyze now). */
resetErrorBatches(): number {
const result = this.db
.prepare(
`UPDATE batches SET status = 'pending', error = NULL, updated_ms = ? WHERE status = 'error'`,
)
.run(Date.now());
return Number(result.changes);
}
nextPendingBatch(): LogbookBatch | null {
const row = this.db
.prepare(
`SELECT id, day, start_ms, end_ms, status, error, frame_count, model
FROM batches WHERE status = 'pending' ORDER BY start_ms ASC LIMIT 1`,
)
.get() as
| {
id: number;
day: string;
start_ms: number;
end_ms: number;
status: LogbookBatchStatus;
error: string | null;
frame_count: number;
model: string | null;
}
| undefined;
if (!row) {
return null;
}
return {
id: row.id,
day: row.day,
startMs: row.start_ms,
endMs: row.end_ms,
status: row.status,
error: row.error ?? undefined,
frameCount: row.frame_count,
model: row.model ?? undefined,
};
}
batchFrames(batchId: number): LogbookFrame[] {
const rows = this.db
.prepare(
`SELECT id, captured_at_ms, day, path, screen_index, width, height, byte_size, idle
FROM frames WHERE batch_id = ? ORDER BY captured_at_ms ASC`,
)
.all(batchId) as FrameRow[];
return rows.map(toFrame);
}
/**
* Replaces a batch's observations atomically. Batch retries (analyze now
* after an error) rerun the vision stage, so appending would duplicate
* evidence into card synthesis, standups, and ask answers.
*/
replaceObservations(
batchId: number,
day: string,
segments: Array<{ startMs: number; endMs: number; text: string }>,
): void {
this.db.exec("BEGIN");
try {
this.db.prepare(`DELETE FROM observations WHERE batch_id = ?`).run(batchId);
const insert = this.db.prepare(
`INSERT INTO observations (batch_id, day, start_ms, end_ms, text) VALUES (?, ?, ?, ?, ?)`,
);
for (const segment of segments) {
insert.run(batchId, day, segment.startMs, segment.endMs, segment.text);
}
this.db.exec("COMMIT");
} catch (err) {
this.db.exec("ROLLBACK");
throw err;
}
}
observationsInRange(day: string, startMs: number, endMs: number): LogbookObservation[] {
const rows = this.db
.prepare(
`SELECT id, batch_id, day, start_ms, end_ms, text FROM observations
WHERE day = ? AND end_ms > ? AND start_ms < ? ORDER BY start_ms ASC`,
)
.all(day, startMs, endMs) as Array<{
id: number;
batch_id: number;
day: string;
start_ms: number;
end_ms: number;
text: string;
}>;
return rows.map((row) => ({
id: row.id,
batchId: row.batch_id,
day: row.day,
startMs: row.start_ms,
endMs: row.end_ms,
text: row.text,
}));
}
cardsForDay(day: string): LogbookCard[] {
const rows = this.db
.prepare(
`SELECT id, day, start_ms, end_ms, title, summary, detail, category, app_primary, app_secondary, distractions, keyframe_id
FROM cards WHERE day = ? ORDER BY start_ms ASC`,
)
.all(day) as CardRow[];
return rows.map(toCard);
}
cardById(id: number): LogbookCard | null {
const row = this.db
.prepare(
`SELECT id, day, start_ms, end_ms, title, summary, detail, category, app_primary, app_secondary, distractions, keyframe_id
FROM cards WHERE id = ?`,
)
.get(id) as CardRow | undefined;
return row ? toCard(row) : null;
}
/**
* Replaces cards overlapping [startMs, endMs) for a day in one transaction.
* The analysis lookback treats recent cards as a revisable draft, so partial
* writes here would surface as duplicated or missing timeline segments.
*/
replaceCardsInWindow(
day: string,
startMs: number,
endMs: number,
drafts: LogbookCardDraft[],
): void {
const now = Date.now();
this.db.exec("BEGIN");
try {
this.db
.prepare(`DELETE FROM cards WHERE day = ? AND end_ms > ? AND start_ms < ?`)
.run(day, startMs, endMs);
const insert = this.db.prepare(
`INSERT INTO cards (day, start_ms, end_ms, title, summary, detail, category, app_primary, app_secondary, distractions, keyframe_id, updated_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const draft of drafts) {
insert.run(
draft.day,
draft.startMs,
draft.endMs,
draft.title,
draft.summary,
draft.detail,
draft.category,
draft.appPrimary ?? null,
draft.appSecondary ?? null,
JSON.stringify(draft.distractions),
draft.keyframeId ?? null,
now,
);
}
this.db.exec("COMMIT");
} catch (err) {
this.db.exec("ROLLBACK");
throw err;
}
}
listDays(): Array<{ day: string; cards: number; firstMs: number; lastMs: number }> {
const rows = this.db
.prepare(
`SELECT day, COUNT(*) AS cards, MIN(start_ms) AS first_ms, MAX(end_ms) AS last_ms
FROM cards GROUP BY day ORDER BY day DESC`,
)
.all() as Array<{ day: string; cards: number; first_ms: number; last_ms: number }>;
return rows.map((row) => ({
day: row.day,
cards: row.cards,
firstMs: row.first_ms,
lastMs: row.last_ms,
}));
}
dayStats(day: string): LogbookDayStats {
const cards = this.cardsForDay(day);
const categories = new Map<string, number>();
const apps = new Map<string, number>();
let trackedMs = 0;
let distractionMs = 0;
for (const card of cards) {
const duration = Math.max(0, card.endMs - card.startMs);
trackedMs += duration;
categories.set(card.category, (categories.get(card.category) ?? 0) + duration);
if (card.appPrimary) {
apps.set(card.appPrimary, (apps.get(card.appPrimary) ?? 0) + duration);
}
for (const distraction of card.distractions) {
distractionMs += Math.max(0, distraction.endMs - distraction.startMs);
}
}
const byMsDesc = (a: { ms: number }, b: { ms: number }) => b.ms - a.ms;
return {
trackedMs,
distractionMs,
categories: [...categories.entries()]
.map(([category, ms]) => ({ category, ms }))
.toSorted(byMsDesc),
apps: [...apps.entries()].map(([domain, ms]) => ({ domain, ms })).toSorted(byMsDesc),
};
}
getStandup(day: string): { day: string; text: string; updatedMs: number } | null {
const row = this.db
.prepare(`SELECT day, text, updated_ms FROM standups WHERE day = ?`)
.get(day) as { day: string; text: string; updated_ms: number } | undefined;
return row ? { day: row.day, text: row.text, updatedMs: row.updated_ms } : null;
}
saveStandup(day: string, text: string): void {
this.db
.prepare(
`INSERT INTO standups (day, text, updated_ms) VALUES (?, ?, ?)
ON CONFLICT(day) DO UPDATE SET text = excluded.text, updated_ms = excluded.updated_ms`,
)
.run(day, text, Date.now());
}
/** Deletes frame rows and files older than the retention window. */
pruneFrames(olderThanMs: number): number {
const rows = this.db
.prepare(`SELECT id, path, day FROM frames WHERE captured_at_ms < ?`)
.all(olderThanMs) as Array<{ id: number; path: string; day: string }>;
if (rows.length === 0) {
return 0;
}
const days = new Set<string>();
for (const row of rows) {
rmSync(row.path, { force: true });
days.add(row.day);
}
// Cards outlive their frames; a dangling keyframe_id would make the UI
// retry a preview fetch forever, so detach it before the rows disappear.
this.db
.prepare(
`UPDATE cards SET keyframe_id = NULL
WHERE keyframe_id IN (SELECT id FROM frames WHERE captured_at_ms < ?)`,
)
.run(olderThanMs);
this.db.prepare(`DELETE FROM frames WHERE captured_at_ms < ?`).run(olderThanMs);
for (const day of days) {
// Best-effort: removes now-empty day directories, keeps non-empty ones.
try {
rmdirSync(path.join(this.framesDir, day));
} catch {}
}
return rows.length;
}
}
+65
View File
@@ -0,0 +1,65 @@
// Shared Logbook domain shapes used by the store, pipeline, and gateway methods.
export type LogbookFrame = {
id: number;
capturedAtMs: number;
day: string;
path: string;
screenIndex: number;
width?: number;
height?: number;
byteSize: number;
idle: boolean;
};
export type LogbookBatchStatus = "pending" | "running" | "done" | "error";
export type LogbookBatch = {
id: number;
day: string;
startMs: number;
endMs: number;
status: LogbookBatchStatus;
error?: string;
frameCount: number;
model?: string;
};
export type LogbookObservation = {
id: number;
batchId: number;
day: string;
startMs: number;
endMs: number;
text: string;
};
export type LogbookDistraction = {
startMs: number;
endMs: number;
title: string;
};
export type LogbookCard = {
id: number;
day: string;
startMs: number;
endMs: number;
title: string;
summary: string;
detail: string;
category: string;
appPrimary?: string;
appSecondary?: string;
distractions: LogbookDistraction[];
keyframeId?: number;
};
export type LogbookCardDraft = Omit<LogbookCard, "id">;
export type LogbookDayStats = {
trackedMs: number;
distractionMs: number;
categories: Array<{ category: string; ms: number }>;
apps: Array<{ domain: string; ms: number }>;
};
@@ -101,6 +101,24 @@ export const HelloOkSchema = Type.Object(
{ additionalProperties: false },
),
snapshot: SnapshotSchema,
// Additive: plugin-declared Control UI tabs (surface "tab" descriptors).
controlUiTabs: Type.Optional(
Type.Array(
Type.Object(
{
pluginId: NonEmptyString,
id: NonEmptyString,
label: NonEmptyString,
description: Type.Optional(Type.String()),
icon: Type.Optional(Type.String()),
path: Type.Optional(Type.String()),
group: Type.Optional(Type.Union([Type.Literal("control"), Type.Literal("agent")])),
order: Type.Optional(Type.Number()),
},
{ additionalProperties: false },
),
),
),
pluginSurfaceUrls: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)),
auth: Type.Object(
{
+9
View File
@@ -982,6 +982,15 @@ importers:
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/logbook:
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
openclaw:
specifier: workspace:*
version: link:../..
extensions/matrix:
dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs':
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import type { PluginControlUiDescriptor } from "../plugins/host-hooks.js";
import { projectControlUiPluginTabs } from "./control-ui-plugin-tabs.js";
function tabDescriptor(
overrides: Partial<PluginControlUiDescriptor> = {},
): PluginControlUiDescriptor {
return {
id: "logbook",
surface: "tab",
label: "Logbook",
...overrides,
};
}
describe("projectControlUiPluginTabs", () => {
it("projects only tab descriptors", () => {
const tabs = projectControlUiPluginTabs(
[
{ pluginId: "logbook", descriptor: tabDescriptor() },
{ pluginId: "other", descriptor: tabDescriptor({ id: "run-panel", surface: "run" }) },
],
["operator.admin"],
);
expect(tabs.map((tab) => tab.id)).toEqual(["logbook"]);
expect(tabs[0].pluginId).toBe("logbook");
});
it("hides tabs whose required scopes are not granted", () => {
const entries = [
{
pluginId: "logbook",
descriptor: tabDescriptor({ requiredScopes: ["operator.write"] }),
},
{
pluginId: "adminy",
descriptor: tabDescriptor({
id: "adminy",
label: "Admin",
requiredScopes: ["operator.admin"],
}),
},
];
expect(projectControlUiPluginTabs(entries, ["operator.read"])).toEqual([]);
// Admin implies write for visibility.
expect(projectControlUiPluginTabs(entries, ["operator.write"]).map((tab) => tab.id)).toEqual([
"logbook",
]);
expect(projectControlUiPluginTabs(entries, ["operator.admin"]).map((tab) => tab.id)).toEqual([
"adminy",
"logbook",
]);
});
it("orders deterministically by order, label, then id", () => {
const tabs = projectControlUiPluginTabs(
[
{ pluginId: "b", descriptor: tabDescriptor({ id: "beta", label: "Beta" }) },
{ pluginId: "a", descriptor: tabDescriptor({ id: "alpha", label: "Alpha", order: 5 }) },
{ pluginId: "c", descriptor: tabDescriptor({ id: "zed", label: "Beta" }) },
],
[],
);
expect(tabs.map((tab) => tab.id)).toEqual(["beta", "zed", "alpha"]);
});
});
+64
View File
@@ -0,0 +1,64 @@
// Projects plugin "tab" Control UI descriptors into the hello payload so the
// dashboard renders plugin tabs without hardcoding plugin ids in core.
import type { PluginControlUiDescriptor } from "../plugins/host-hooks.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { authorizeOperatorScopesForRequiredScope } from "./method-scopes.js";
export type ControlUiPluginTab = {
pluginId: string;
id: string;
label: string;
description?: string;
icon?: string;
path?: string;
group?: "control" | "agent";
order?: number;
};
type ControlUiDescriptorEntry = {
pluginId: string;
descriptor: PluginControlUiDescriptor;
};
/** Pure projection of tab descriptors visible to the presented scopes. */
export function projectControlUiPluginTabs(
entries: readonly ControlUiDescriptorEntry[],
scopes: readonly string[],
): ControlUiPluginTab[] {
const tabs: ControlUiPluginTab[] = [];
for (const entry of entries) {
const descriptor = entry.descriptor;
if (descriptor.surface !== "tab") {
continue;
}
const visible = (descriptor.requiredScopes ?? []).every(
(scope) => authorizeOperatorScopesForRequiredScope(scope, scopes).allowed,
);
if (!visible) {
continue;
}
tabs.push({
pluginId: entry.pluginId,
id: descriptor.id,
label: descriptor.label,
description: descriptor.description,
icon: descriptor.icon,
path: descriptor.path,
group: descriptor.group,
order: descriptor.order,
});
}
// Deterministic ordering keeps hello payloads stable across connects.
return tabs.toSorted(
(left, right) =>
(left.order ?? 0) - (right.order ?? 0) ||
left.label.localeCompare(right.label) ||
left.id.localeCompare(right.id),
);
}
/** Lists active plugins' tab descriptors visible to the presented scopes. */
export function listControlUiPluginTabs(scopes: readonly string[]): ControlUiPluginTab[] {
const registry = getActivePluginRegistry();
return projectControlUiPluginTabs(registry?.controlUiDescriptors ?? [], scopes);
}
@@ -109,6 +109,7 @@ import { verifyAgentRuntimeIdentityToken } from "../../agent-runtime-identity-to
import { AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING, type AuthRateLimiter } from "../../auth-rate-limit.js";
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js";
import { listControlUiPluginTabs } from "../../control-ui-plugin-tabs.js";
import { normalizeDeviceMetadataForAuth } from "../../device-auth.js";
import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../../method-scopes.js";
import type { GatewayMethodRegistry } from "../../methods/registry.js";
@@ -2097,6 +2098,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
snapshot.stateVersion.health = getHealthVersion();
}
const helloOkAuthScopes = deviceToken ? deviceToken.scopes : scopes;
const controlUiTabs = listControlUiPluginTabs(helloOkAuthScopes);
const helloOk = {
type: "hello-ok",
protocol: PROTOCOL_VERSION,
@@ -2106,6 +2108,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
},
features: { methods: gatewayMethods, events },
snapshot,
...(controlUiTabs.length > 0 ? { controlUiTabs } : {}),
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
auth: {
role,
@@ -45,6 +45,7 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [
"google-meet",
"llm-task",
"lobster",
"logbook",
"memory-wiki",
"ollama",
"openshell",
+15 -1
View File
@@ -90,14 +90,28 @@ export type PluginToolMetadataRegistration = {
tags?: string[];
};
export type PluginControlUiTabGroup = "control" | "agent";
export type PluginControlUiDescriptor = {
id: string;
surface: "session" | "tool" | "run" | "settings";
/** "tab" adds a Control UI sidebar tab; other surfaces attach to existing views. */
surface: "session" | "tool" | "run" | "settings" | "tab";
label: string;
description?: string;
placement?: string;
schema?: PluginJsonValue;
requiredScopes?: OperatorScope[];
/** Icon name hint for tab descriptors; unknown names fall back to a generic icon. */
icon?: string;
/**
* Gateway HTTP path (e.g. /plugins/<id>/panel) rendered in a sandboxed frame
* when the Control UI has no bundled view for this tab.
*/
path?: string;
/** Sidebar group for tab descriptors; defaults to "control". */
group?: PluginControlUiTabGroup;
/** Sort order among plugin tabs; lower renders first. */
order?: number;
};
export type PluginSessionActionContext = {
+84
View File
@@ -36,4 +36,88 @@ describe("plugin registry Control UI descriptors", () => {
}),
]);
});
it("accepts tab descriptors and normalizes their placement fields", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({ id: "tab-fixture", name: "Tab Fixture" }),
register(api) {
api.registerControlUiDescriptor({
surface: "tab",
id: "journal",
label: "Journal",
icon: "sun",
group: "control",
order: 5,
requiredScopes: ["operator.read"],
});
},
});
expect(registry.registry.controlUiDescriptors).toEqual([
expect.objectContaining({
pluginId: "tab-fixture",
descriptor: expect.objectContaining({
id: "journal",
surface: "tab",
label: "Journal",
icon: "sun",
group: "control",
order: 5,
requiredScopes: ["operator.read"],
}),
}),
]);
});
it("rejects protocol-relative tab paths that would iframe external content", () => {
for (const path of ["//attacker.example/panel", "/\\attacker.example/panel"]) {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({ id: "external-tab", name: "External Tab" }),
register(api) {
api.registerControlUiDescriptor({
surface: "tab",
id: "journal",
label: "Journal",
path,
});
},
});
expect(registry.registry.controlUiDescriptors ?? []).toEqual([]);
expect(registry.registry.diagnostics).toContainEqual(
expect.objectContaining({ level: "error", pluginId: "external-tab" }),
);
}
});
it("rejects tab descriptors whose path is not absolute", () => {
const { config, registry } = createPluginRegistryFixture();
registerTestPlugin({
registry,
config,
record: createPluginRecord({ id: "bad-tab-fixture", name: "Bad Tab Fixture" }),
register(api) {
api.registerControlUiDescriptor({
surface: "tab",
id: "journal",
label: "Journal",
path: "relative/frame.html",
});
},
});
expect(registry.registry.controlUiDescriptors ?? []).toEqual([]);
expect(registry.registry.diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
pluginId: "bad-tab-fixture",
message: expect.stringContaining("gateway-local absolute path"),
}),
);
});
});
+28
View File
@@ -1951,6 +1951,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
"tool",
"run",
"settings",
"tab",
]);
const registerSessionExtension = (
@@ -2260,6 +2261,29 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
return;
}
const icon = normalizeOptionalHostHookString(descriptor.icon);
const tabPath = normalizeOptionalHostHookString(descriptor.path);
// Single leading slash only: "//host" and "/\\host" are protocol-relative
// URLs in browsers, which would let a descriptor iframe external content.
const isLocalAbsolutePath =
tabPath === undefined ||
(tabPath.startsWith("/") && !tabPath.startsWith("//") && !tabPath.startsWith("/\\"));
if (!isLocalAbsolutePath) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `control UI descriptor path must be a gateway-local absolute path: ${id}`,
});
return;
}
// Tab placement/order are projected to hello clients; keep junk values out.
const group =
descriptor.group === "control" || descriptor.group === "agent" ? descriptor.group : undefined;
const order =
typeof descriptor.order === "number" && Number.isFinite(descriptor.order)
? descriptor.order
: undefined;
(registry.controlUiDescriptors ??= []).push({
pluginId: record.id,
pluginName: record.name,
@@ -2273,6 +2297,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
...(requiredScopes !== undefined
? { requiredScopes: requiredScopes as OperatorScope[] }
: {}),
icon,
path: tabPath,
group,
order,
},
source: record.source,
rootDir: record.rootDir,
+1
View File
@@ -0,0 +1 @@
node_modules/
+12
View File
@@ -173,6 +173,17 @@ function isTrustedRetryEndpoint(url: string): boolean {
}
}
export type GatewayControlUiPluginTab = {
pluginId: string;
id: string;
label: string;
description?: string;
icon?: string;
path?: string;
group?: "control" | "agent";
order?: number;
};
export type GatewayHelloOk = {
type: "hello-ok";
protocol: number;
@@ -188,6 +199,7 @@ export type GatewayHelloOk = {
scopes: string[];
issuedAtMs?: number;
};
controlUiTabs?: GatewayControlUiPluginTab[];
pluginSurfaceUrls?: Record<string, string>;
policy?: { tickIntervalMs?: number };
};
+22
View File
@@ -15,6 +15,7 @@ import {
routeIdFromPath,
type RouteId,
} from "./app-routes.ts";
import { pluginTabKey, pluginTabRefFromSearch, pluginTabSearch } from "./pages/plugin/route.ts";
/** All route identifiers derived from visible groups plus routed settings slices. */
const ALL_ROUTES: RouteId[] = Array.from(
@@ -244,6 +245,27 @@ describe("inferBasePathFromPathname", () => {
});
});
describe("plugin tabs route", () => {
it("round-trips the shared /plugin route", () => {
expect(pathForRoute("plugin", "")).toBe("/plugin");
expect(routeIdFromPath("/plugin", "")).toBe("plugin");
// The tab id travels in the search, not the pathname.
expect(routeIdFromPath("/plugin/logbook", "")).toBeNull();
});
it("round-trips a namespaced tab reference through the search", () => {
const ref = { pluginId: "logbook", id: "logbook" };
expect(pluginTabRefFromSearch(pluginTabSearch(ref))).toEqual(ref);
expect(pluginTabKey(ref)).toBe("logbook/logbook");
// Distinct plugins with the same local tab id stay distinct.
expect(pluginTabKey({ pluginId: "other", id: "logbook" })).not.toBe(pluginTabKey(ref));
});
it("stays out of the static sidebar sections", () => {
expect(SIDEBAR_SECTIONS.flatMap((g) => g.routes)).not.toContain("plugin");
});
});
describe("SIDEBAR_SECTIONS", () => {
it("contains all expected groups", () => {
expect(SIDEBAR_SECTIONS.map((g) => g.label)).toEqual(["chat", "control", "agent", "settings"]);
+2
View File
@@ -61,6 +61,7 @@ const NAVIGATION_ICONS: NavigationItem = {
debug: "bug",
logs: "scrollText",
dreams: "moon",
plugin: "puzzle",
};
export function isSettingsNavigationRoute(routeId: NavigationRouteId): boolean {
@@ -159,6 +160,7 @@ const NAVIGATION_COPY: Record<NavigationRouteId, { titleKey: string; subtitleKey
debug: { titleKey: "tabs.debug", subtitleKey: "subtitles.debug" },
logs: { titleKey: "tabs.logs", subtitleKey: "subtitles.logs" },
dreams: { titleKey: "tabs.dreams", subtitleKey: "subtitles.dreams" },
plugin: { titleKey: "tabs.plugin", subtitleKey: "subtitles.plugin" },
};
export function titleForRoute(routeId: NavigationRouteId): string {
+1
View File
@@ -24,6 +24,7 @@ export const APP_ROUTE_DEFINITIONS = {
skills: { path: "/skills" },
cron: { path: "/cron" },
nodes: { path: "/nodes" },
plugin: { path: "/plugin" },
dreams: { path: "/dreaming", aliases: ["/dreams"] },
} as const;
+2
View File
@@ -14,6 +14,7 @@ import { page as instancesPage } from "./pages/instances/route.ts";
import { page as logsPage } from "./pages/logs/route.ts";
import { page as nodesPage } from "./pages/nodes/route.ts";
import { page as overviewPage } from "./pages/overview/route.ts";
import { page as pluginPage } from "./pages/plugin/route.ts";
import { page as sessionsPage } from "./pages/sessions/route.ts";
import { page as skillWorkshopPage } from "./pages/skill-workshop/route.ts";
import { page as skillsPage } from "./pages/skills/route.ts";
@@ -50,6 +51,7 @@ export const APP_ROUTE_TREE = [
cronPage,
nodesPage,
dreamsPage,
pluginPage,
] as const;
const appRoutes = APP_ROUTE_TREE as readonly AppRoute[];
+7
View File
@@ -26,6 +26,7 @@ import { searchForSession } from "../lib/sessions/index.ts";
import { resolveAgentIdFromSessionKey } from "../lib/sessions/session-key.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../lib/string-coerce.ts";
import { renderDevicePairSetup } from "../pages/nodes/view-pairing.ts";
import { pluginTabKey, pluginTabRefFromSearch } from "../pages/plugin/route.ts";
import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts";
import {
applicationContext,
@@ -605,6 +606,11 @@ class OpenClawShell extends LitElement {
return nothing;
}
const activeRoute = this.routeState.routeId ?? "chat";
// Plugin tabs share one route; the search picks the active item.
const activePluginTabId =
activeRoute === "plugin"
? pluginTabKey(pluginTabRefFromSearch(this.routeState.location?.search ?? ""))
: "";
const navDrawerOpen = this.navDrawerOpen && !this.onboarding;
const navCollapsed = this.navCollapsed && !navDrawerOpen;
return html`
@@ -648,6 +654,7 @@ class OpenClawShell extends LitElement {
<openclaw-app-sidebar
.basePath=${context.basePath}
.activeRouteId=${activeRoute}
.activePluginTabId=${activePluginTabId}
.enabledRouteIds=${APP_ROUTE_IDS}
.sessionKey=${this.activeSessionKey}
.collapsed=${navCollapsed}
+40 -2
View File
@@ -2,7 +2,7 @@ import { consume } from "@lit/context";
import { LitElement, html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import type { GatewayBrowserClient, GatewayControlUiPluginTab } from "../api/gateway.ts";
import type { SessionsListResult } from "../api/types.ts";
import {
cancelRoutePreload,
@@ -45,7 +45,8 @@ import {
resolvePreferredSessionForAgent,
resolveSessionAgentFilterOptions,
} from "../lib/sessions/session-options.ts";
import { icons } from "./icons.ts";
import { pluginTabKey, pluginTabSearch } from "../pages/plugin/route.ts";
import { icons, type IconName } from "./icons.ts";
type SidebarRecentSession = {
key: string;
@@ -77,6 +78,7 @@ export class AppSidebar extends LitElement {
@property({ attribute: false }) basePath = "";
@property({ attribute: false }) activeRouteId?: NavigationRouteId;
@property({ attribute: false }) activePluginTabId = "";
@property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[];
@property({ attribute: false }) collapsed = false;
@property({ attribute: false }) connected = false;
@@ -406,6 +408,39 @@ export class AppSidebar extends LitElement {
: link;
}
/** Plugin-declared tabs (hello controlUiTabs) render after a group's static routes. */
private pluginTabsForGroup(groupLabel: string): GatewayControlUiPluginTab[] {
const tabs = this.context?.gateway.snapshot.hello?.controlUiTabs ?? [];
return tabs.filter((tab) => (tab.group ?? "control") === groupLabel);
}
private renderPluginTab(tab: GatewayControlUiPluginTab) {
const ref = { pluginId: tab.pluginId, id: tab.id };
const search = pluginTabSearch(ref);
const href = `${pathForRoute("plugin", this.basePath)}${search}`;
const active = this.activeRouteId === "plugin" && this.activePluginTabId === pluginTabKey(ref);
const iconName = tab.icon && Object.hasOwn(icons, tab.icon) ? (tab.icon as IconName) : "puzzle";
const link = html`
<a
href=${href}
class="nav-item ${active ? "nav-item--active" : ""}"
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
}
event.preventDefault();
this.onNavigate?.("plugin", { search });
}}
>
<span class="nav-item__icon" aria-hidden="true">${icons[iconName]}</span>
${!this.collapsed ? html`<span class="nav-item__text">${tab.label}</span>` : nothing}
</a>
`;
return this.collapsed
? html`<openclaw-tooltip .content=${tab.label}>${link}</openclaw-tooltip>`
: link;
}
private renderRecentSession(session: SidebarRecentSession) {
const context = this.context;
const archiveAllowed = canArchiveSessionRow(
@@ -712,6 +747,9 @@ export class AppSidebar extends LitElement {
: nothing}
<div class="nav-section__items">
${group.routes.map((routeId) => this.renderRoute(routeId))}
${this.pluginTabsForGroup(group.label).map((tab) =>
this.renderPluginTab(tab),
)}
</div>
</section>
`;
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:57.561Z",
"generatedAt": "2026-07-05T18:04:23.104Z",
"locale": "ar",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:56.437Z",
"generatedAt": "2026-07-05T18:04:22.137Z",
"locale": "de",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:56.620Z",
"generatedAt": "2026-07-05T18:04:22.303Z",
"locale": "es",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:59.045Z",
"generatedAt": "2026-07-05T18:04:24.661Z",
"locale": "fa",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:57.171Z",
"generatedAt": "2026-07-05T18:04:22.788Z",
"locale": "fr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:21:08.284Z",
"generatedAt": "2026-07-05T18:04:22.962Z",
"locale": "hi",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+1
View File
@@ -1384,6 +1384,7 @@
{"cache_key":"f4256e497e210eb030faa3897256db44a3aff0a3f68758f78f7bf1a940bc69ae","model":"gpt-5.5","provider":"openai","segment_id":"sessionsView.statusUnknown","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Unknown","text_hash":"b764cdc0eab7137467211272fa539f1260d1bf2e71bcf6ff3bdc960f5c16aa14","tgt_lang":"hi","translated":"अज्ञात","updated_at":"2026-06-26T21:30:26.471Z"}
{"cache_key":"f448efc1f13df7ccfd284b4539f11d6bfb9c03de0a8ed51b141b77cdfe9ea8dc","model":"gpt-5.5","provider":"openai","segment_id":"overview.connection.docsLink","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Read the docs →","text_hash":"412b59669150e05afaf4671cae2d9708750732c55d290efffeec7dcf7fef80e7","tgt_lang":"hi","translated":"डॉक्स पढ़ें →","updated_at":"2026-06-26T21:33:34.535Z"}
{"cache_key":"f44b70020b627d8d6145223248d9ed1cf978607807b5c5891c78db96898139f3","model":"gpt-5.5","provider":"openai","segment_id":"cron.errors.invalidRunTime","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Invalid run time.","text_hash":"51465fa3cb94966411a49d8d1972fe997ac028fd249e05df55db8a2179975b48","tgt_lang":"hi","translated":"अमान्य रन समय।","updated_at":"2026-06-26T21:38:17.792Z"}
{"cache_key":"f44fe46f0abcd8aed4a3d1a0504e776a8119101573d77600bdf66f8a6c3686fd","model":"gpt-5.5","provider":"openai","segment_id":"tabs.plugin","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Plugin","text_hash":"ab1173eed1d477d9e951c2316a74d1923220e64d1bbaeadf03c88e20576c7450","tgt_lang":"hi","translated":"प्लगइन","updated_at":"2026-06-26T21:31:18.653Z"}
{"cache_key":"f46842d62bb11b7a4327dddd2bab13f3e4a4277f9c5f63754cc514cbcbc2c32a","model":"gpt-5.5","provider":"openai","segment_id":"overview.palette.items.shellCommand","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Shell Command","text_hash":"52d381f0bd0846bde3d4ae73fe838cc2e69d7547283223ab4ae2d761bbd62cb3","tgt_lang":"hi","translated":"शेल कमांड","updated_at":"2026-06-26T21:33:46.154Z"}
{"cache_key":"f49df7e3255a1b474f191ea9269400d3eece061c5ca04fe8a3a4727f5ff88cf9","model":"gpt-5.5","provider":"openai","segment_id":"workboard.agentDefaultLinked","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Using default agent {agent}","text_hash":"c2dc79d94a40f34e62724402b74e3a97855189709c8070c664184a04b00b2e92","tgt_lang":"hi","translated":"डिफ़ॉल्ट एजेंट {agent} का उपयोग किया जा रहा है","updated_at":"2026-06-26T21:32:18.718Z"}
{"cache_key":"f4a4aada5a4122d636cc37ef0b374e7e830ab9a7e05052846cb563eecd3e1904","model":"gpt-5.5","provider":"openai","segment_id":"usage.scope.familyIncluded","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Historical lineage includes {count} session instances.","text_hash":"93a5b77f61319f28b678391340649847cb190e03824c847dd7a627cb7d282847","tgt_lang":"hi","translated":"ऐतिहासिक वंशावली में {count} सेशन इंस्टेंस शामिल हैं।","updated_at":"2026-06-26T21:34:37.383Z"}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:58.278Z",
"generatedAt": "2026-07-05T18:04:23.812Z",
"locale": "id",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:57.747Z",
"generatedAt": "2026-07-05T18:04:23.282Z",
"locale": "it",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:56.795Z",
"generatedAt": "2026-07-05T18:04:22.493Z",
"locale": "ja-JP",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:56.982Z",
"generatedAt": "2026-07-05T18:04:22.633Z",
"locale": "ko",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:58.891Z",
"generatedAt": "2026-07-05T18:04:24.468Z",
"locale": "nl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:58.442Z",
"generatedAt": "2026-07-05T18:04:23.978Z",
"locale": "pl",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:56.260Z",
"generatedAt": "2026-07-05T18:04:21.981Z",
"locale": "pt-BR",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
-7
View File
@@ -1961,13 +1961,6 @@
"path": "ui/src/pages/chat/components/chat-message.ts",
"text": "Activity"
},
{
"count": 1,
"kind": "html-text",
"name": "text",
"path": "ui/src/pages/chat/components/chat-message.ts",
"text": "Context"
},
{
"count": 2,
"kind": "html-text",
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:21:10.397Z",
"generatedAt": "2026-07-05T18:04:24.823Z",
"locale": "ru",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+5
View File
@@ -371,6 +371,7 @@
{"cache_key":"3a8ce03f916295aef67fc0f9201cd8a801a68e6be844981572c56ac4a5133a68","model":"gpt-5.5","provider":"openai","segment_id":"sessionsView.moveToGroup","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Move session to a group","text_hash":"7db4b663aebb86158b454c49ad05115941c0411cb8ed6182bdc8fd5840f32dff","tgt_lang":"ru","translated":"Переместить сеанс в группу","updated_at":"2026-07-05T14:40:26.836Z"}
{"cache_key":"3a9d3b21ff7c7521fa25ed3d6be33d78ac9b1183f1ceae61223ee0e4bee59ba4","model":"gpt-5.5","provider":"openai","segment_id":"subtitles.workboard","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Agent work queue and session handoff.","text_hash":"c63f26ae216252eb3c4b20dfb263b56b9aab8adac6be5c1f48d596dae7b3a6a4","tgt_lang":"ru","translated":"Очередь задач агента и передача сеанса.","updated_at":"2026-06-26T21:39:27.659Z"}
{"cache_key":"3ae56acfaa48d5d394ad55f29c4c05370287a1e110862e32acc8bc513d6091a7","model":"gpt-5.5","provider":"openai","segment_id":"skillWorkshop.header.useCurrentChat","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Use current chat","text_hash":"fbc1ffd63daa506e927c7a85f6e43acd11e0b8c9f52a3951fc782b236ce9a787","tgt_lang":"ru","translated":"Использовать текущий чат","updated_at":"2026-06-26T21:39:31.958Z"}
{"cache_key":"3af1335ed0e5096223990b9aa2dec1f93708b8c26b0bfcd78a57b8439daa4922","model":"gpt-5.5","provider":"openai","segment_id":"logbook.nav.today","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Today","text_hash":"2b065c7c9ce466e5ebcad757987d5d660ee4c9ea708bc62c43444b53334738ba","tgt_lang":"ru","translated":"Сегодня","updated_at":"2026-06-26T21:40:56.975Z"}
{"cache_key":"3b136f5bfa610e7b4aa9d785590cf31b1ed29bc5e6410edf63d86af76f219cd3","model":"gpt-5.5","provider":"openai","segment_id":"workboard.runDefaultAgent","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Run default agent","text_hash":"15948166c46071d465e41d8a206030c04b3eb9843c9c543de8118b00f0e3b546","tgt_lang":"ru","translated":"Запустить агента по умолчанию","updated_at":"2026-06-26T21:39:54.162Z"}
{"cache_key":"3b306c5bf3c2c7d0cffb95731050cb1759f28e040f0cad999606eda08aa66e6d","model":"gpt-5.5","provider":"openai","segment_id":"cron.quickCreate.schedules.once.label","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Run once","text_hash":"5f041f4be2d3becdcb1363508b415794005ddbcfae08c4d6d5a29d615ea73922","tgt_lang":"ru","translated":"Запустить один раз","updated_at":"2026-06-26T21:42:14.995Z"}
{"cache_key":"3b8a3ecc2219fa0fcdb59b219e615d5de3c1cb7afa2e97596d6488f3ffdb2ae7","model":"gpt-5.5","provider":"openai","segment_id":"cron.form.main","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Main","text_hash":"eb814be3ca3b78c0734c560518be2a03e8d8f6e7e26447224cc7c7b105e1193e","tgt_lang":"ru","translated":"Основной","updated_at":"2026-06-26T21:42:31.568Z"}
@@ -473,6 +474,7 @@
{"cache_key":"4c5cc8d962f42e7eff4951832e602b0bfea506a4dcaa9e544f073686f8711c19","model":"gpt-5.5","provider":"openai","segment_id":"dreaming.trace.emptyPromoted","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Nothing promoted yet today.","text_hash":"4da842404d1c9c9bd3d2a7bd71fe3b16fb6af8db427d1fb00111f56c4a6f15b2","tgt_lang":"ru","translated":"Сегодня еще ничего не повышено.","updated_at":"2026-06-26T21:40:48.120Z"}
{"cache_key":"4cc74b32b5bc579b80d8a66f565761bd0e8c5b119edb0b13e48a35019694d303","model":"gpt-5.5","provider":"openai","segment_id":"chat.autoScrollAlways","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Always","text_hash":"de9f057a471cdb8d3b082719bdc7ad2031788d042947349723fa83c9d13a517a","tgt_lang":"ru","translated":"Всегда","updated_at":"2026-06-26T21:41:50.917Z"}
{"cache_key":"4ccd2b8c80ac5986387f413b3131e75055871f62ef6a902ddfa9ac673bfd1e66","model":"gpt-5.5","provider":"openai","segment_id":"agents.files.loadHint","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Load the agent workspace files to edit core instructions.","text_hash":"dfa4dead18217a28f883b93bceed8058424799e23436f0fc8dbf1d7c61cb4ad8","tgt_lang":"ru","translated":"Загрузите файлы рабочей области агента, чтобы редактировать основные инструкции.","updated_at":"2026-06-26T21:39:11.166Z"}
{"cache_key":"4cd436b18dbd292ac0385cccef071c6d388630774245c71f24ccc6c9856ec229","model":"gpt-5.5","provider":"openai","segment_id":"tabs.plugin","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Plugin","text_hash":"ab1173eed1d477d9e951c2316a74d1923220e64d1bbaeadf03c88e20576c7450","tgt_lang":"ru","translated":"Плагин","updated_at":"2026-06-26T21:39:20.621Z"}
{"cache_key":"4cda973fba862f8aaee884f557aad9e34c83b56bfe29bdd09ca5cd50b3719f3a","model":"gpt-5.5","provider":"openai","segment_id":"cron.jobDetail.cwd","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"CWD","text_hash":"0217f1cb7725737f15a6710df3bcfa3bc10a239f0f7801ec3d7168e675f5ebd6","tgt_lang":"ru","translated":"CWD","updated_at":"2026-06-26T21:42:47.554Z"}
{"cache_key":"4cf07c95cc2db8fd84e860074cf5a43407a563f09594095ddd8a3a9a6e6f9453","model":"gpt-5.5","provider":"openai","segment_id":"agents.selectSubtitle","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Pick an agent to inspect its workspace and tools.","text_hash":"01d17a9ae97ae5e6013aae3c9d01230091c519f9a1b8e214d9e1041e1c4e6bae","tgt_lang":"ru","translated":"Выберите агента, чтобы просмотреть его рабочую область и инструменты.","updated_at":"2026-06-26T21:39:04.015Z"}
{"cache_key":"4d69d6bebce805d9356fc55df35f99040dfcdf9340fcbddbf09bd1f1dff975cd","model":"gpt-5.5","provider":"openai","segment_id":"workboard.agentFilterConfiguredDefaultHelp","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Cards explicitly assigned to the configured default agent.","text_hash":"9bb80530da1dfd473936d94642b83cc668b7362cb65675a565f17569937af92f","tgt_lang":"ru","translated":"Карточки, явно назначенные настроенному агенту по умолчанию.","updated_at":"2026-06-26T21:39:50.999Z"}
@@ -795,6 +797,7 @@
{"cache_key":"84d184fbb2b8748cf5c2181bb0358a1e48140aca32ef1dadc772ff667bc8bf81","model":"gpt-5.5","provider":"openai","segment_id":"workboard.detailAutomation","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Automation","text_hash":"d909750b1bbb71a39b6330ba8f81f4f8f6e889ed96d7ab366e74857909750c64","tgt_lang":"ru","translated":"Автоматизация","updated_at":"2026-06-26T21:39:47.132Z"}
{"cache_key":"84e3b9cdf6d1adc07ca18bd471185c0e78518dfac889661e5734ba9f958ba3b2","model":"gpt-5.5","provider":"openai","segment_id":"workboard.detailTitle","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Card details","text_hash":"93985f84673405070ffdf7e6f64175caff0f2c489c10e40627718525e79af631","tgt_lang":"ru","translated":"Сведения о карточке","updated_at":"2026-06-26T21:39:43.912Z"}
{"cache_key":"84e7113df9ac50d70a37406941c946d32153032c54900470348b3e43d64d7e65","model":"gpt-5.5","provider":"openai","segment_id":"common.lastMessage","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Last message","text_hash":"ee5c88bf416d1e2fba390dbfa3643f063ff8c82ea2d69c79e9051f9a961b818a","tgt_lang":"ru","translated":"Последнее сообщение","updated_at":"2026-06-26T21:38:30.975Z"}
{"cache_key":"85606f5c78ae1f65479cc2a263ea91fa49561e09398f48706cad43e2b363d378","model":"gpt-5.5","provider":"openai","segment_id":"logbook.ask.submit","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Ask","text_hash":"b8c209cdead61a095ec097b7a13c4fc9264c04b3e3bd0fca42ec8dbaeebeeafe","tgt_lang":"ru","translated":"Запрос","updated_at":"2026-06-26T21:39:20.621Z"}
{"cache_key":"85886eff312f2c1897a79a68b352d51ea365f5cde8b2878d843065904598ce7d","model":"gpt-5.5","provider":"openai","segment_id":"cron.errors.systemTextRequired","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"System text is required.","text_hash":"7b13b35a0dabfa257fada59d07a81a0559c20e8a5049419e4969e2c538f110e5","tgt_lang":"ru","translated":"Требуется системный текст.","updated_at":"2026-06-26T21:42:51.343Z"}
{"cache_key":"85a7139e419d82956224395e04fd008357df6e3a5d0c8fe6f966e04717998b96","model":"gpt-5.5","provider":"openai","segment_id":"subtitles.skills","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Skills and API keys.","text_hash":"6ade4da6eeb01dafee4a8d0882ebc1d9e84abd09c1ed699b1ccbcda0a28700a2","tgt_lang":"ru","translated":"Навыки и ключи API.","updated_at":"2026-06-26T21:39:27.659Z"}
{"cache_key":"85b3b2995f49587ebabe1e1ba536f3965bcb8ea0fe97421d994f996f985fa7e4","model":"gpt-5.5","provider":"openai","segment_id":"workboard.eventProtocolViolation","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Protocol violation","text_hash":"367bb2052963f7d75beb672d3ca0430d7d49ac48a2759d578c7df933178fe564","tgt_lang":"ru","translated":"Нарушение протокола","updated_at":"2026-06-26T21:40:14.690Z"}
@@ -901,6 +904,7 @@
{"cache_key":"9612b0a668adab8279904c95a7414df34832bb5d28f1236ae29ae19211d0b1e7","model":"gpt-5.5","provider":"openai","segment_id":"usage.overview.peakErrorHours","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Peak Error Hours","text_hash":"d549fec62ae3b5a839e25b808949b2cae7c3c55b558db510872616464028d103","tgt_lang":"ru","translated":"Часы пиковых ошибок","updated_at":"2026-06-26T21:41:20.199Z"}
{"cache_key":"9636bb48736b76ec504761256e2358094486eb1757512528055effa22d3504c0","model":"gpt-5.5","provider":"openai","segment_id":"sessionsView.compaction","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Compaction","text_hash":"a0ade140bc8e408639e51492b949bc4d31641625ef070015b5d4a5e92ef0edb0","tgt_lang":"ru","translated":"Сжатие","updated_at":"2026-06-26T21:38:51.115Z"}
{"cache_key":"963fc33b2e36e31973d0359366893dd18da4a2f8503f168adc05320b667dc010","model":"gpt-5.5","provider":"openai","segment_id":"sessionsView.statusFailed","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Failed","text_hash":"031a8f0f659df890dfd53c92e45295b0f14c997185bae46e168831e403b273f7","tgt_lang":"ru","translated":"Сбой","updated_at":"2026-06-26T21:38:57.762Z"}
{"cache_key":"96c8c8031dbbd2119db6c278b33c65596007ad2c6f49cd8d7b4fce312fa7db54","model":"gpt-5.5","provider":"openai","segment_id":"daylog.nav.today","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Today","text_hash":"2b065c7c9ce466e5ebcad757987d5d660ee4c9ea708bc62c43444b53334738ba","tgt_lang":"ru","translated":"Сегодня","updated_at":"2026-06-26T21:40:56.975Z"}
{"cache_key":"970abf139d59e102d9b2f8430ad9149dfee39e9afbca30e2d6cec72670e72836","model":"gpt-5.5","provider":"openai","segment_id":"cron.form.command","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Command","text_hash":"713166971d730f81fcf8b757f2ea239d1a0360d9f74e8f5afe60fba97105879c","tgt_lang":"ru","translated":"Команда","updated_at":"2026-06-26T21:42:36.583Z"}
{"cache_key":"9720d069366805e68fcadda60abd8cb527f6e7261adb6c3378ea7688da4c8869","model":"gpt-5.5","provider":"openai","segment_id":"workboard.openSession","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Open session","text_hash":"b205bb47f81a30968789eac28cefb848c4b849245d4d12f9311557c5f56ce770","tgt_lang":"ru","translated":"Открыть сеанс","updated_at":"2026-06-26T21:39:47.132Z"}
{"cache_key":"9723a537d1984aa3ba06058ba3d531b432c9aeeff217e5dbef8c90af582fa7c7","model":"gpt-5.5","provider":"openai","segment_id":"agents.copyIdTitle","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Copy agent ID to clipboard","text_hash":"af2bccd7856c791343d66ad6a2e6aa1a3d4d478754b4da90254fc0c4da3d88d3","tgt_lang":"ru","translated":"Скопировать ID агента в буфер обмена","updated_at":"2026-06-26T21:39:01.088Z"}
@@ -1188,6 +1192,7 @@
{"cache_key":"cdf3c6fba1f060dd3f57e68e0867b8cdbc5c7e1b477489fc8e2427780a73c608","model":"gpt-5.5","provider":"openai","segment_id":"cron.quickCreate.whatHint","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Describe the task in natural language. The agent will run this prompt each time.","text_hash":"740434f6a8f3a54a5bbe7362b393c2d4c4a25789d52c76dddb57c96c25432f0e","tgt_lang":"ru","translated":"Опишите задачу естественным языком. Агент будет выполнять этот запрос каждый раз.","updated_at":"2026-06-26T21:42:14.996Z"}
{"cache_key":"ce02de1f0748649a07e3da041eeefc2d1161686a189e407863c154a349b693a1","model":"gpt-5.5","provider":"openai","segment_id":"chat.runControls.exportChat","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Export chat","text_hash":"d7b74f6046ad8f9f3e42efd67df7db6b4e186c6fa42fb86dda2502c18b740d91","tgt_lang":"ru","translated":"Экспортировать чат","updated_at":"2026-06-26T21:41:54.248Z"}
{"cache_key":"ce125d441670e626b3735b0898713bbf040b4ea8443b55462fe7b32c6a3f7aa5","model":"gpt-5.5","provider":"openai","segment_id":"usage.details.noMessagesMatch","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"No messages match the filters.","text_hash":"64a575d4d77472b6351168a4fadda155dd13148122fa7f9f3e69c721df41dde9","tgt_lang":"ru","translated":"Нет сообщений, соответствующих фильтрам.","updated_at":"2026-06-26T21:41:31.697Z"}
{"cache_key":"ce6588aa1fb2df38d3cc1d156bfb3e878d582b7fba6fa7d999fbb9a0dc9f86f4","model":"gpt-5.5","provider":"openai","segment_id":"daylog.ask.submit","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Ask","text_hash":"b8c209cdead61a095ec097b7a13c4fc9264c04b3e3bd0fca42ec8dbaeebeeafe","tgt_lang":"ru","translated":"Запрос","updated_at":"2026-06-26T21:39:20.621Z"}
{"cache_key":"ce83eacea199cab43b85c88c80a0aaeb8d1afc9ec7e2f04bf57d641d1e66805c","model":"gpt-5.5","provider":"openai","segment_id":"cron.jobList.allJobs","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"all jobs","text_hash":"4f7423bb7529778080be147678febde3dddc81557e3b1de154b7aa3dce5726f5","tgt_lang":"ru","translated":"все задания","updated_at":"2026-06-26T21:42:44.768Z"}
{"cache_key":"ceee50d8575df7c16f5d48d5fbd9a11a0a073513fa9087411041d29125fcb24a","model":"gpt-5.5","provider":"openai","segment_id":"workboard.badgeHeartbeat","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"heartbeat {age}","text_hash":"000637b3800ae069edbbe207cfad0a3f5037f06e9661ee89d70a1dfe6f404485","tgt_lang":"ru","translated":"пульс {age}","updated_at":"2026-06-26T21:40:03.947Z"}
{"cache_key":"cf5815444e5a72f65a6d9bb88d01f104fc59214fef1d4cd3319f7968b697ccce","model":"gpt-5.5","provider":"openai","segment_id":"common.lastProbe","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Last probe","text_hash":"1a9f0db29cc4cfdcbca5e4c46688aac828d86b574e6abb5d0f12ab5c8a0ff6d3","tgt_lang":"ru","translated":"Последняя проверка","updated_at":"2026-06-26T21:38:30.975Z"}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:58.596Z",
"generatedAt": "2026-07-05T18:04:24.141Z",
"locale": "th",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:57.946Z",
"generatedAt": "2026-07-05T18:04:23.493Z",
"locale": "tr",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:58.116Z",
"generatedAt": "2026-07-05T18:04:23.656Z",
"locale": "uk",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:58.744Z",
"generatedAt": "2026-07-05T18:04:24.314Z",
"locale": "vi",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:55.756Z",
"generatedAt": "2026-07-05T18:04:21.496Z",
"locale": "zh-CN",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+6 -6
View File
@@ -1,11 +1,11 @@
{
"fallbackKeys": [],
"generatedAt": "2026-07-05T16:24:56.088Z",
"generatedAt": "2026-07-05T18:04:21.835Z",
"locale": "zh-TW",
"model": "gpt-5.5",
"provider": "openai",
"sourceHash": "f83ff96c8ae1df050c50850a13fe255f5c39ee54040a49328f18a8b44db73e3f",
"totalKeys": 1490,
"translatedKeys": 1490,
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "7bf7fa95371dc60b3f993f635f0d83d008bd7b95ad3c16deeba905841f831784",
"totalKeys": 1527,
"translatedKeys": 1527,
"workflow": 1
}
+62
View File
@@ -488,6 +488,7 @@ export const ar: TranslationMap = {
debug: "تصحيح الأخطاء",
logs: "السجلات",
dreams: "الحلم",
plugin: "المكوّن الإضافي",
},
subtitles: {
agents: "مساحات العمل، والأدوات، والهويات.",
@@ -513,6 +514,7 @@ export const ar: TranslationMap = {
debug: "اللقطات، والأحداث، وRPC.",
logs: "سجلات Gateway المباشرة.",
dreams: "حلم الذاكرة، والدمج، والتأمل.",
plugin: "لوحة مقدمة من المكوّن الإضافي.",
},
skillWorkshop: {
header: {
@@ -573,6 +575,66 @@ export const ar: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "لوحة العمل معطّلة. فعّل",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -492,6 +492,7 @@ export const de: TranslationMap = {
debug: "Debug",
logs: "Protokolle",
dreams: "Träume",
plugin: "Plugin",
},
subtitles: {
agents: "Agent-Arbeitsbereiche, Tools und Identitäten verwalten.",
@@ -518,6 +519,7 @@ export const de: TranslationMap = {
debug: "Gateway-Snapshots, Ereignisse und manuelle RPC-Aufrufe.",
logs: "Live-Verfolgung der Gateway-Protokolldateien.",
dreams: "Speicherkonsolidierung im Schlaf.",
plugin: "Vom Plugin bereitgestelltes Panel.",
},
skillWorkshop: {
header: {
@@ -578,6 +580,66 @@ export const de: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard ist deaktiviert. Aktivieren Sie",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -487,6 +487,7 @@ export const en: TranslationMap = {
debug: "Debug",
logs: "Logs",
dreams: "Dreaming",
plugin: "Plugin",
},
subtitles: {
agents: "Workspaces, tools, identities.",
@@ -512,6 +513,7 @@ export const en: TranslationMap = {
debug: "Snapshots, events, RPC.",
logs: "Live gateway logs.",
dreams: "Memory dreaming, consolidation, and reflection.",
plugin: "Plugin-provided panel.",
},
skillWorkshop: {
header: {
@@ -572,6 +574,66 @@ export const en: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard is disabled. Enable",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -490,6 +490,7 @@ export const es: TranslationMap = {
debug: "Depuración",
logs: "Registros",
dreams: "Sueños",
plugin: "Plugin",
},
subtitles: {
agents: "Gestionar espacios de trabajo, herramientas e identidades de agentes.",
@@ -516,6 +517,7 @@ export const es: TranslationMap = {
debug: "Instantáneas de la puerta de enlace, eventos y llamadas RPC manuales.",
logs: "Seguimiento en vivo de los registros de la puerta de enlace.",
dreams: "Consolidación de la memoria durante el sueño.",
plugin: "Panel proporcionado por el plugin.",
},
skillWorkshop: {
header: {
@@ -576,6 +578,66 @@ export const es: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard está desactivado. Activa",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -490,6 +490,7 @@ export const fa: TranslationMap = {
debug: "اشکال‌زدایی",
logs: "گزارش‌ها",
dreams: "رؤیاپردازی",
plugin: "افزونه",
},
subtitles: {
agents: "فضاهای کاری، ابزارها، هویت‌ها.",
@@ -515,6 +516,7 @@ export const fa: TranslationMap = {
debug: "نماهای لحظه‌ای، رویدادها، RPC.",
logs: "گزارش‌های زنده Gateway.",
dreams: "رؤیاپردازی حافظه، یکپارچه‌سازی و بازتاب.",
plugin: "پنل ارائه‌شده توسط افزونه.",
},
skillWorkshop: {
header: {
@@ -575,6 +577,66 @@ export const fa: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard غیرفعال است. فعال کنید",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -492,6 +492,7 @@ export const fr: TranslationMap = {
debug: "Débogage",
logs: "Journaux",
dreams: "Rêves",
plugin: "Plugin",
},
subtitles: {
agents: "Espaces de travail, outils, identités.",
@@ -518,6 +519,7 @@ export const fr: TranslationMap = {
debug: "Captures, événements, RPC.",
logs: "Journaux Gateway en direct.",
dreams: "Consolidation de la mémoire pendant le sommeil.",
plugin: "Panneau fourni par le plugin.",
},
skillWorkshop: {
header: {
@@ -578,6 +580,66 @@ export const fr: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Le tableau de travail est désactivé. Activez",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -489,6 +489,7 @@ export const hi: TranslationMap = {
debug: "डीबग",
logs: "लॉग्स",
dreams: "ड्रीमिंग",
plugin: "प्लगइन",
},
subtitles: {
agents: "वर्कस्पेस, टूल्स, पहचान।",
@@ -514,6 +515,7 @@ export const hi: TranslationMap = {
debug: "स्नैपशॉट, इवेंट, RPC।",
logs: "लाइव gateway लॉग।",
dreams: "मेमोरी ड्रीमिंग, कंसॉलिडेशन, और रिफ्लेक्शन।",
plugin: "प्लगइन द्वारा उपलब्ध कराया गया पैनल।",
},
skillWorkshop: {
header: {
@@ -573,6 +575,66 @@ export const hi: TranslationMap = {
truncated: "लॉग आउटपुट छोटा कर दिया गया है; नवीनतम भाग दिखाया जा रहा है।",
empty: "कोई लॉग प्रविष्टि नहीं।",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "आज",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "पूछें",
},
},
workboard: {
disabledHelpStart: "Workboard अक्षम है। सक्षम करें",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -489,6 +489,7 @@ export const id: TranslationMap = {
debug: "Debug",
logs: "Log",
dreams: "Mimpi",
plugin: "Plugin",
},
subtitles: {
agents: "Ruang kerja, alat, identitas.",
@@ -514,6 +515,7 @@ export const id: TranslationMap = {
debug: "Snapshot, peristiwa, RPC.",
logs: "Log Gateway langsung.",
dreams: "Konsolidasi memori saat tidur.",
plugin: "Panel yang disediakan plugin.",
},
skillWorkshop: {
header: {
@@ -574,6 +576,66 @@ export const id: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard dinonaktifkan. Aktifkan",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -493,6 +493,7 @@ export const it: TranslationMap = {
debug: "Debug",
logs: "Log",
dreams: "Sogni",
plugin: "Plugin",
},
subtitles: {
agents: "Spazi di lavoro, strumenti, identità.",
@@ -518,6 +519,7 @@ export const it: TranslationMap = {
debug: "Snapshot, eventi, RPC.",
logs: "Log gateway live.",
dreams: "Sogni della memoria, consolidamento e riflessione.",
plugin: "Pannello fornito dal plugin.",
},
skillWorkshop: {
header: {
@@ -578,6 +580,66 @@ export const it: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard è disabilitata. Abilita",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -493,6 +493,7 @@ export const ja_JP: TranslationMap = {
debug: "デバッグ",
logs: "ログ",
dreams: "Dreaming",
plugin: "プラグイン",
},
subtitles: {
agents: "ワークスペース、ツール、ID。",
@@ -518,6 +519,7 @@ export const ja_JP: TranslationMap = {
debug: "スナップショット、イベント、RPC。",
logs: "ライブ Gateway ログ。",
dreams: "スリープ中のメモリ統合。",
plugin: "プラグインが提供するパネル。",
},
skillWorkshop: {
header: {
@@ -578,6 +580,66 @@ export const ja_JP: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard は無効になっています。有効にするには",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -488,6 +488,7 @@ export const ko: TranslationMap = {
debug: "디버그",
logs: "로그",
dreams: "드리밍",
plugin: "플러그인",
},
subtitles: {
agents: "워크스페이스, 도구, 정체성.",
@@ -513,6 +514,7 @@ export const ko: TranslationMap = {
debug: "스냅샷, 이벤트, RPC.",
logs: "실시간 Gateway 로그.",
dreams: "수면 중 메모리 통합.",
plugin: "플러그인이 제공하는 패널입니다.",
},
skillWorkshop: {
header: {
@@ -572,6 +574,66 @@ export const ko: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard가 비활성화되어 있습니다. 활성화하려면",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -491,6 +491,7 @@ export const nl: TranslationMap = {
debug: "Debuggen",
logs: "Logs",
dreams: "Dromen",
plugin: "Plugin",
},
subtitles: {
agents: "Werkruimten, tools, identiteiten.",
@@ -516,6 +517,7 @@ export const nl: TranslationMap = {
debug: "Momentopnamen, gebeurtenissen, RPC.",
logs: "Live Gateway-logs.",
dreams: "Geheugendromen, consolidatie en reflectie.",
plugin: "Door een plugin geleverd paneel.",
},
skillWorkshop: {
header: {
@@ -576,6 +578,66 @@ export const nl: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard is uitgeschakeld. Schakel",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -490,6 +490,7 @@ export const pl: TranslationMap = {
debug: "Debug",
logs: "Logi",
dreams: "Sny",
plugin: "Wtyczka",
},
subtitles: {
agents: "Obszary robocze, narzędzia, tożsamości.",
@@ -515,6 +516,7 @@ export const pl: TranslationMap = {
debug: "Migawki, zdarzenia, RPC.",
logs: "Logi Gateway na żywo.",
dreams: "Konsolidacja pamięci podczas snu.",
plugin: "Panel udostępniany przez wtyczkę.",
},
skillWorkshop: {
header: {
@@ -575,6 +577,66 @@ export const pl: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard jest wyłączony. Włącz",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -489,6 +489,7 @@ export const pt_BR: TranslationMap = {
debug: "Depuração",
logs: "Logs",
dreams: "Sonhos",
plugin: "Plugin",
},
subtitles: {
agents: "Espaços, ferramentas, identidades.",
@@ -514,6 +515,7 @@ export const pt_BR: TranslationMap = {
debug: "Snapshots, eventos, RPC.",
logs: "Logs ao vivo do gateway.",
dreams: "Consolidação de memória durante o sono.",
plugin: "Painel fornecido pelo plugin.",
},
skillWorkshop: {
header: {
@@ -574,6 +576,66 @@ export const pt_BR: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "O Workboard está desativado. Ative",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -492,6 +492,7 @@ export const ru: TranslationMap = {
debug: "Отладка",
logs: "Журналы",
dreams: "Сновидения",
plugin: "Плагин",
},
subtitles: {
agents: "Рабочие пространства, инструменты, идентификаторы.",
@@ -518,6 +519,7 @@ export const ru: TranslationMap = {
debug: "Снимки, события, RPC.",
logs: "Живые журналы шлюза.",
dreams: "Сновидения памяти, консолидация и рефлексия.",
plugin: "Панель, предоставленная плагином.",
},
skillWorkshop: {
header: {
@@ -578,6 +580,66 @@ export const ru: TranslationMap = {
truncated: "Вывод лога усечен; показан последний фрагмент.",
empty: "Нет записей в логе.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Сегодня",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Запрос",
},
},
workboard: {
disabledHelpStart: "Workboard отключен. Включите",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -487,6 +487,7 @@ export const th: TranslationMap = {
debug: "ดีบัก",
logs: "บันทึก",
dreams: "การฝัน",
plugin: "ปลั๊กอิน",
},
subtitles: {
agents: "เวิร์กสเปซ เครื่องมือ และข้อมูลประจำตัว",
@@ -512,6 +513,7 @@ export const th: TranslationMap = {
debug: "สแนปช็อต เหตุการณ์ และ RPC",
logs: "บันทึกเกตเวย์แบบสด",
dreams: "การฝันของหน่วยความจำ การรวมข้อมูล และการสะท้อนคิด",
plugin: "แผงที่ปลั๊กอินจัดเตรียมไว้",
},
skillWorkshop: {
header: {
@@ -571,6 +573,66 @@ export const th: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard ถูกปิดใช้งาน เปิดใช้งาน",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -491,6 +491,7 @@ export const tr: TranslationMap = {
debug: "Hata Ayıklama",
logs: "Günlükler",
dreams: "Düşler",
plugin: "Eklenti",
},
subtitles: {
agents: "Çalışma alanları, araçlar, kimlikler.",
@@ -517,6 +518,7 @@ export const tr: TranslationMap = {
debug: "Anlık görüntüler, olaylar, RPC.",
logs: "Canlı Gateway günlükleri.",
dreams: "Uyku sırasında bellek birleştirme.",
plugin: "Eklenti tarafından sağlanan panel.",
},
skillWorkshop: {
header: {
@@ -577,6 +579,66 @@ export const tr: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard devre dışı. Etkinleştirin",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -490,6 +490,7 @@ export const uk: TranslationMap = {
debug: "Налагодження",
logs: "Журнали",
dreams: "Сни",
plugin: "Плагін",
},
subtitles: {
agents: "Робочі простори, інструменти, ідентичності.",
@@ -516,6 +517,7 @@ export const uk: TranslationMap = {
debug: "Знімки, події, RPC.",
logs: "Журнали шлюзу в реальному часі.",
dreams: "Консолідація пам’яті під час сну.",
plugin: "Панель, надана плагіном.",
},
skillWorkshop: {
header: {
@@ -576,6 +578,66 @@ export const uk: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard вимкнено. Увімкніть",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -489,6 +489,7 @@ export const vi: TranslationMap = {
debug: "Gỡ lỗi",
logs: "Nhật ký",
dreams: "Đang mơ",
plugin: "Plugin",
},
subtitles: {
agents: "Không gian làm việc, công cụ, danh tính.",
@@ -515,6 +516,7 @@ export const vi: TranslationMap = {
debug: "Ảnh chụp, sự kiện, RPC.",
logs: "Nhật ký gateway trực tiếp.",
dreams: "Mơ bộ nhớ, hợp nhất và phản chiếu.",
plugin: "Bảng điều khiển do plugin cung cấp.",
},
skillWorkshop: {
header: {
@@ -575,6 +577,66 @@ export const vi: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard đã bị tắt. Bật",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -486,6 +486,7 @@ export const zh_CN: TranslationMap = {
debug: "调试",
logs: "日志",
dreams: "梦境",
plugin: "插件",
},
subtitles: {
agents: "工作区、工具、身份。",
@@ -511,6 +512,7 @@ export const zh_CN: TranslationMap = {
debug: "快照、事件、RPC。",
logs: "实时网关日志。",
dreams: "睡眠时进行记忆巩固。",
plugin: "插件提供的面板。",
},
skillWorkshop: {
header: {
@@ -570,6 +572,66 @@ export const zh_CN: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard 已禁用。启用",
enableConfigKey: "plugins.entries.workboard.enabled = true",
+62
View File
@@ -486,6 +486,7 @@ export const zh_TW: TranslationMap = {
debug: "調試",
logs: "日誌",
dreams: "夢境",
plugin: "外掛程式",
},
subtitles: {
agents: "工作區、工具、身份。",
@@ -511,6 +512,7 @@ export const zh_TW: TranslationMap = {
debug: "快照、事件、RPC。",
logs: "實時網關日誌。",
dreams: "睡眠期間的記憶整合。",
plugin: "外掛程式提供的面板。",
},
skillWorkshop: {
header: {
@@ -570,6 +572,66 @@ export const zh_TW: TranslationMap = {
truncated: "Log output truncated; showing latest chunk.",
empty: "No log entries.",
},
pluginTabs: {
unavailableTitle: "Plugin panel unavailable",
unavailableSubtitle:
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
},
logbook: {
duration: {
minutes: "{minutes}m",
hours: "{hours}h {minutes}m",
},
nav: {
previousDay: "Previous day",
nextDay: "Next day",
today: "Today",
},
status: {
capturing: "Capturing every {seconds}s",
paused: "Capture paused",
disabled: "Capture off",
nodeHelp: "Node providing screen snapshots.",
pending: "{count} frames queued",
pendingHelp: "Snapshots waiting for the next analysis batch.",
analyzing: "Analyzing…",
captureError: "Capture error",
batchError: "Analysis error",
modelMissing: "No vision model",
modelMissingHelp:
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
},
actions: {
pause: "Pause",
resume: "Resume",
analyzeNow: "Analyze now",
},
empty: {
title: "Nothing on the timeline yet.",
subtitle:
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
},
card: {
keyframeAlt: "Screen snapshot from this activity",
distractions: "Distractions",
},
stats: {
title: "Day at a glance",
focus: "{pct}% focus",
tracked: "{duration} tracked",
},
standup: {
title: "Daily standup",
generate: "Generate",
refresh: "Regenerate",
empty: "Turn today's timeline into a ready-to-paste standup update.",
},
ask: {
title: "Ask your day",
placeholder: "When did I review the gateway PR?",
submit: "Ask",
},
},
workboard: {
disabledHelpStart: "Workboard 已停用。啟用",
enableConfigKey: "plugins.entries.workboard.enabled = true",
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import {
askLogbook,
configureLogbookPolling,
getLogbookState,
loadLogbookStandup,
stopLogbookPolling,
} from "./logbook-controller.ts";
function clientWithRequest(
request: (method: string, params: unknown) => Promise<unknown>,
): GatewayBrowserClient {
return { request } as GatewayBrowserClient;
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("Logbook controller", () => {
const hosts: object[] = [];
afterEach(() => {
for (const host of hosts.splice(0)) {
stopLogbookPolling(host);
}
vi.useRealTimers();
});
it("rebinds polling when the gateway client changes", async () => {
vi.useFakeTimers();
const host = {};
hosts.push(host);
const state = getLogbookState(host);
const firstRequest = vi.fn(async () => ({}));
const secondRequest = vi.fn(async () => ({}));
configureLogbookPolling(state, clientWithRequest(firstRequest), true);
configureLogbookPolling(state, clientWithRequest(secondRequest), true);
await vi.advanceTimersByTimeAsync(30_000);
expect(firstRequest).not.toHaveBeenCalled();
expect(secondRequest).toHaveBeenCalled();
});
it("discards a standup response after the selected day changes", async () => {
const host = {};
hosts.push(host);
const state = getLogbookState(host);
state.day = "2026-07-04";
const pending = deferred<unknown>();
const request = loadLogbookStandup(
state,
clientWithRequest(() => pending.promise),
false,
);
state.day = "2026-07-05";
pending.resolve({ day: "2026-07-04", text: "Old day", updatedMs: 1 });
await request;
expect(state.standup).toBeNull();
});
it("discards an ask response after the selected day changes", async () => {
const host = {};
hosts.push(host);
const state = getLogbookState(host);
state.day = "2026-07-04";
state.askQuestion = "What did I do?";
const pending = deferred<unknown>();
const request = askLogbook(
state,
clientWithRequest(() => pending.promise),
);
state.day = "2026-07-05";
pending.resolve({ answer: "Old day" });
await request;
expect(state.askAnswer).toBeNull();
});
});
+362
View File
@@ -0,0 +1,362 @@
// Control UI controller for the Logbook tab: state, gateway calls, polling.
import type { GatewayBrowserClient } from "../../api/gateway.ts";
export type LogbookStatusPayload = {
captureEnabled: boolean;
capturePaused: boolean;
captureIntervalSeconds: number;
analysisIntervalMinutes: number;
retentionDays: number;
nodeId?: string;
nodeName?: string;
lastCaptureAtMs?: number;
lastCaptureError?: string;
pendingFrames: number;
analysisRunning: boolean;
lastBatch?: { id: number; day: string; status: string; endMs: number; error?: string };
visionModel?: string;
visionModelSource: "config" | "media-defaults" | "missing";
today: string;
todayCards: number;
timeZone: string;
};
export type LogbookDistractionPayload = { startMs: number; endMs: number; title: string };
export type LogbookCardPayload = {
id: number;
day: string;
startMs: number;
endMs: number;
title: string;
summary: string;
detail: string;
category: string;
appPrimary?: string;
appSecondary?: string;
distractions: LogbookDistractionPayload[];
keyframeId?: number;
};
export type LogbookDayStatsPayload = {
trackedMs: number;
distractionMs: number;
categories: Array<{ category: string; ms: number }>;
apps: Array<{ domain: string; ms: number }>;
};
export type LogbookTimelinePayload = {
day: string;
cards: LogbookCardPayload[];
stats: LogbookDayStatsPayload;
};
export type LogbookDaysPayload = {
days: Array<{ day: string; cards: number; firstMs: number; lastMs: number }>;
};
export type LogbookUiState = {
day: string;
/** True once the user navigated to a specific day; unpinned views follow the gateway's today. */
dayPinned: boolean;
status: LogbookStatusPayload | null;
days: LogbookDaysPayload["days"];
timeline: LogbookTimelinePayload | null;
loading: boolean;
error: string | null;
expandedCardIds: Set<number>;
framePreviews: Map<number, string>;
frameLoads: Set<number>;
framePreviewFailed: Set<number>;
standup: { day: string; text: string; updatedMs: number } | null;
standupLoading: boolean;
askQuestion: string;
askAnswer: string | null;
askLoading: boolean;
actionPending: boolean;
pollTimer: ReturnType<typeof globalThis.setInterval> | null;
pollClient: GatewayBrowserClient | null;
requestUpdate: (() => void) | null;
};
const FRAME_PREVIEW_CACHE_LIMIT = 48;
const POLL_INTERVAL_MS = 30_000;
const logbookStates = new WeakMap<object, LogbookUiState>();
export function localDayKey(date = new Date()): string {
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${month}-${day}`;
}
export function shiftDay(day: string, deltaDays: number): string {
const base = new Date(`${day}T12:00:00`);
base.setDate(base.getDate() + deltaDays);
return localDayKey(base);
}
export function getLogbookState(host: object): LogbookUiState {
let state = logbookStates.get(host);
if (!state) {
state = {
day: localDayKey(),
dayPinned: false,
status: null,
days: [],
timeline: null,
loading: false,
error: null,
expandedCardIds: new Set(),
framePreviews: new Map(),
frameLoads: new Set(),
framePreviewFailed: new Set(),
standup: null,
standupLoading: false,
askQuestion: "",
askAnswer: null,
askLoading: false,
actionPending: false,
pollTimer: null,
pollClient: null,
requestUpdate: null,
};
logbookStates.set(host, state);
}
return state;
}
function notify(state: LogbookUiState): void {
state.requestUpdate?.();
}
function resetDayView(state: LogbookUiState, day: string): void {
state.day = day;
state.timeline = null;
state.standup = null;
state.askAnswer = null;
state.expandedCardIds = new Set();
}
export async function loadLogbook(
state: LogbookUiState,
client: GatewayBrowserClient | null,
opts?: { day?: string; today?: boolean; silent?: boolean },
): Promise<void> {
if (!client) {
return;
}
if (opts?.day) {
state.dayPinned = true;
if (opts.day !== state.day) {
resetDayView(state, opts.day);
}
} else if (opts?.today) {
state.dayPinned = false;
}
if (!opts?.silent) {
state.loading = true;
state.error = null;
notify(state);
}
try {
const [status, days, timeline] = await Promise.all([
client.request<LogbookStatusPayload>("logbook.status", {}),
client.request<LogbookDaysPayload>("logbook.days", {}),
client.request<LogbookTimelinePayload>("logbook.timeline", { day: state.day }),
]);
state.status = status;
state.days = days.days;
// Unpinned views follow the gateway's day: the browser clock can sit in
// another timezone than the capture host, and midnight rollover should
// advance the default view.
if (!state.dayPinned && status.today !== state.day) {
resetDayView(state, status.today);
state.timeline = await client.request<LogbookTimelinePayload>("logbook.timeline", {
day: status.today,
});
} else {
state.timeline = timeline;
}
state.error = null;
} catch (err) {
state.error = err instanceof Error ? err.message : String(err);
} finally {
state.loading = false;
notify(state);
}
}
/** Stops background polling; wired into tab-switch and disconnect cleanup. */
export function stopLogbookPolling(host: object): void {
const state = logbookStates.get(host);
if (state?.pollTimer) {
clearInterval(state.pollTimer);
state.pollTimer = null;
}
if (state) {
state.pollClient = null;
}
}
export function configureLogbookPolling(
state: LogbookUiState,
client: GatewayBrowserClient | null,
active: boolean,
): void {
if (!active || !client) {
if (state.pollTimer) {
clearInterval(state.pollTimer);
state.pollTimer = null;
}
state.pollClient = null;
return;
}
if (state.pollTimer && state.pollClient === client) {
return;
}
if (state.pollTimer) {
clearInterval(state.pollTimer);
}
state.pollClient = client;
state.pollTimer = setInterval(() => {
// Silent refresh keeps the timeline current while analysis batches land.
void loadLogbook(state, client, { silent: true });
}, POLL_INTERVAL_MS);
}
export async function loadLogbookFramePreview(
state: LogbookUiState,
client: GatewayBrowserClient | null,
frameId: number,
): Promise<void> {
if (
!client ||
state.framePreviews.has(frameId) ||
state.frameLoads.has(frameId) ||
state.framePreviewFailed.has(frameId)
) {
return;
}
state.frameLoads.add(frameId);
try {
const payload = await client.request<{ base64: string; format: string }>("logbook.frame", {
frameId,
});
if (state.framePreviews.size >= FRAME_PREVIEW_CACHE_LIMIT) {
const oldest = state.framePreviews.keys().next().value;
if (oldest !== undefined) {
state.framePreviews.delete(oldest);
}
}
state.framePreviews.set(frameId, `data:image/${payload.format};base64,${payload.base64}`);
} catch {
// Preview loads are cosmetic, but a missing frame (e.g. pruned by
// retention) must not re-fetch on every render, so remember the failure.
state.framePreviewFailed.add(frameId);
} finally {
state.frameLoads.delete(frameId);
notify(state);
}
}
export async function setLogbookCapturePaused(
state: LogbookUiState,
client: GatewayBrowserClient | null,
paused: boolean,
): Promise<void> {
if (!client || state.actionPending) {
return;
}
state.actionPending = true;
notify(state);
try {
state.status = await client.request<LogbookStatusPayload>("logbook.capture.set", { paused });
} catch (err) {
state.error = err instanceof Error ? err.message : String(err);
} finally {
state.actionPending = false;
notify(state);
}
}
export async function runLogbookAnalysisNow(
state: LogbookUiState,
client: GatewayBrowserClient | null,
): Promise<void> {
if (!client || state.actionPending) {
return;
}
state.actionPending = true;
notify(state);
try {
const result = await client.request<{ started: boolean; reason?: string }>(
"logbook.analyze.now",
{},
);
if (!result.started && result.reason) {
state.error = result.reason;
}
} catch (err) {
state.error = err instanceof Error ? err.message : String(err);
} finally {
state.actionPending = false;
notify(state);
void loadLogbook(state, client, { silent: true });
}
}
export async function loadLogbookStandup(
state: LogbookUiState,
client: GatewayBrowserClient | null,
refresh: boolean,
): Promise<void> {
if (!client || state.standupLoading) {
return;
}
state.standupLoading = true;
notify(state);
const requestedDay = state.day;
try {
const standup = await client.request<{ day: string; text: string; updatedMs: number }>(
"logbook.standup",
{ day: requestedDay, refresh },
);
if (state.day === requestedDay) {
state.standup = standup;
}
} catch (err) {
state.error = err instanceof Error ? err.message : String(err);
} finally {
state.standupLoading = false;
notify(state);
}
}
export async function askLogbook(
state: LogbookUiState,
client: GatewayBrowserClient | null,
): Promise<void> {
const question = state.askQuestion.trim();
if (!client || state.askLoading || question.length === 0) {
return;
}
state.askLoading = true;
state.askAnswer = null;
notify(state);
const requestedDay = state.day;
try {
const payload = await client.request<{ answer: string }>("logbook.ask", {
day: requestedDay,
question,
});
if (state.day === requestedDay) {
state.askAnswer = payload.answer;
}
} catch (err) {
state.error = err instanceof Error ? err.message : String(err);
} finally {
state.askLoading = false;
notify(state);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { render } from "lit";
import { afterEach, describe, expect, it } from "vitest";
import { setUiTimeFormatPreference } from "../../lib/format.ts";
import { getLogbookState } from "./logbook-controller.ts";
import { renderLogbook } from "./logbook-view.ts";
describe("Logbook view", () => {
afterEach(() => {
setUiTimeFormatPreference("auto");
});
it("renders timeline clocks in the capture host timezone", () => {
setUiTimeFormatPreference("24");
const host = {};
const state = getLogbookState(host);
state.day = "2026-01-01";
state.status = {
captureEnabled: true,
capturePaused: false,
captureIntervalSeconds: 30,
analysisIntervalMinutes: 15,
retentionDays: 30,
pendingFrames: 0,
analysisRunning: false,
visionModelSource: "missing",
today: "2026-01-01",
todayCards: 1,
timeZone: "America/Los_Angeles",
};
state.timeline = {
day: state.day,
cards: [
{
id: 1,
day: state.day,
startMs: Date.UTC(2026, 0, 2, 0, 30),
endMs: Date.UTC(2026, 0, 2, 1, 30),
title: "Work",
summary: "Summary",
detail: "",
category: "Coding",
distractions: [],
},
],
stats: { trackedMs: 0, distractionMs: 0, categories: [], apps: [] },
};
const container = document.createElement("div");
render(renderLogbook({ host, client: null, connected: false }), container);
expect(container.querySelector(".logbook-card__time")?.textContent?.trim()).toBe("16:3017:30");
});
});
+416
View File
@@ -0,0 +1,416 @@
// Control UI view renders the Logbook automatic work journal tab.
import { html, nothing, type TemplateResult } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { icons } from "../../components/icons.ts";
import { toSanitizedMarkdownHtml } from "../../components/markdown.ts";
import { t } from "../../i18n/index.ts";
import { formatTimeMs } from "../../lib/format.ts";
import {
askLogbook,
configureLogbookPolling,
getLogbookState,
loadLogbook,
loadLogbookFramePreview,
loadLogbookStandup,
localDayKey,
runLogbookAnalysisNow,
setLogbookCapturePaused,
shiftDay,
type LogbookCardPayload,
type LogbookStatusPayload,
type LogbookUiState,
} from "./logbook-controller.ts";
export type LogbookProps = {
host: object;
client: GatewayBrowserClient | null;
connected: boolean;
onRequestUpdate?: () => void;
};
function formatClock(ms: number, timeZone: string): string {
return formatTimeMs(ms, { hour: "2-digit", minute: "2-digit", timeZone }, "");
}
function formatDurationMs(ms: number): string {
const minutes = Math.round(ms / 60_000);
if (minutes < 60) {
return t("logbook.duration.minutes", { minutes: String(minutes) });
}
const hours = Math.floor(minutes / 60);
return t("logbook.duration.hours", { hours: String(hours), minutes: String(minutes % 60) });
}
/** Stable category hue so colors stay consistent across renders and days. */
function categoryHue(category: string): number {
let hash = 0;
for (let i = 0; i < category.length; i += 1) {
hash = (hash * 31 + category.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 360;
}
function renderStatusChips(status: LogbookStatusPayload): TemplateResult {
const capturing = status.captureEnabled && !status.capturePaused && !status.lastCaptureError;
const captureLabel = status.capturePaused
? t("logbook.status.paused")
: status.captureEnabled
? t("logbook.status.capturing", { seconds: String(status.captureIntervalSeconds) })
: t("logbook.status.disabled");
return html`
<div class="logbook__chips">
<span class="logbook__chip ${capturing ? "logbook__chip--ok" : "logbook__chip--warn"}">
<span class="logbook__chip-dot"></span>
${captureLabel}
</span>
${status.nodeName || status.nodeId
? html`<span class="logbook__chip" title=${t("logbook.status.nodeHelp")}>
${icons.monitor} ${status.nodeName ?? status.nodeId}
</span>`
: nothing}
${status.pendingFrames > 0
? html`<span class="logbook__chip" title=${t("logbook.status.pendingHelp")}>
${t("logbook.status.pending", { count: String(status.pendingFrames) })}
</span>`
: nothing}
${status.analysisRunning
? html`<span class="logbook__chip logbook__chip--busy"
>${t("logbook.status.analyzing")}</span
>`
: nothing}
${status.lastCaptureError
? html`<span class="logbook__chip logbook__chip--error" title=${status.lastCaptureError}>
${t("logbook.status.captureError")}
</span>`
: nothing}
${status.lastBatch?.status === "error"
? html`<span
class="logbook__chip logbook__chip--error"
title=${status.lastBatch.error ?? ""}
>
${t("logbook.status.batchError")}
</span>`
: nothing}
${status.visionModelSource === "missing"
? html`<span
class="logbook__chip logbook__chip--warn"
title=${t("logbook.status.modelMissingHelp")}
>
${t("logbook.status.modelMissing")}
</span>`
: nothing}
</div>
`;
}
function renderCard(
state: LogbookUiState,
client: GatewayBrowserClient | null,
card: LogbookCardPayload,
timeZone: string,
): TemplateResult {
const expanded = state.expandedCardIds.has(card.id);
const hue = categoryHue(card.category);
// Pruned keyframes look permanently absent; treating them as loading would
// re-request the missing frame on every render.
const keyframeId =
card.keyframeId !== undefined && !state.framePreviewFailed.has(card.keyframeId)
? card.keyframeId
: undefined;
const preview = keyframeId !== undefined ? state.framePreviews.get(keyframeId) : undefined;
if (expanded && keyframeId !== undefined && !preview) {
void loadLogbookFramePreview(state, client, keyframeId);
}
return html`
<article
class="logbook-card ${expanded ? "logbook-card--expanded" : ""}"
style="--logbook-hue: ${hue}"
>
<button
class="logbook-card__header"
type="button"
@click=${() => {
const next = new Set(state.expandedCardIds);
if (expanded) {
next.delete(card.id);
} else {
next.add(card.id);
}
state.expandedCardIds = next;
state.requestUpdate?.();
}}
>
<span class="logbook-card__time">
${formatClock(card.startMs, timeZone)}<span class="logbook-card__time-sep"></span
>${formatClock(card.endMs, timeZone)}
</span>
<span class="logbook-card__stripe" aria-hidden="true"></span>
<span class="logbook-card__heading">
<span class="logbook-card__title">${card.title}</span>
<span class="logbook-card__summary">${card.summary}</span>
</span>
<span class="logbook-card__meta">
<span class="logbook-card__category">${card.category}</span>
${card.appPrimary
? html`<span class="logbook-card__app">${card.appPrimary}</span>`
: nothing}
<span class="logbook-card__duration">${formatDurationMs(card.endMs - card.startMs)}</span>
</span>
</button>
${expanded
? html`
<div class="logbook-card__body">
${preview
? html`<img
class="logbook-card__keyframe"
src=${preview}
alt=${t("logbook.card.keyframeAlt")}
/>`
: keyframeId !== undefined
? html`<div class="logbook-card__keyframe logbook-card__keyframe--loading">
${t("common.loading")}
</div>`
: nothing}
${card.detail ? html`<p class="logbook-card__detail">${card.detail}</p>` : nothing}
${card.distractions.length > 0
? html`
<div class="logbook-card__distractions">
<span class="logbook-card__distractions-label">
${t("logbook.card.distractions")}
</span>
${card.distractions.map(
(distraction) => html`
<span class="logbook-card__distraction">
${formatClock(distraction.startMs, timeZone)} · ${distraction.title}
</span>
`,
)}
</div>
`
: nothing}
</div>
`
: nothing}
</article>
`;
}
function renderStats(state: LogbookUiState): TemplateResult | typeof nothing {
const stats = state.timeline?.stats;
if (!stats || stats.trackedMs <= 0) {
return nothing;
}
const focusMs = Math.max(0, stats.trackedMs - stats.distractionMs);
const focusPct = Math.round((focusMs / stats.trackedMs) * 100);
const maxCategoryMs = stats.categories[0]?.ms ?? 1;
return html`
<section class="card logbook-side__card">
<div class="card-title">${t("logbook.stats.title")}</div>
<div class="logbook-stats__focus">
<div class="logbook-stats__focus-bar">
<div class="logbook-stats__focus-fill" style="width: ${focusPct}%"></div>
</div>
<div class="logbook-stats__focus-legend">
<span>${t("logbook.stats.focus", { pct: String(focusPct) })}</span>
<span
>${t("logbook.stats.tracked", { duration: formatDurationMs(stats.trackedMs) })}</span
>
</div>
</div>
<div class="logbook-stats__categories">
${stats.categories.slice(0, 6).map(
(entry) => html`
<div
class="logbook-stats__category"
style="--logbook-hue: ${categoryHue(entry.category)}"
>
<span class="logbook-stats__category-name">${entry.category}</span>
<span class="logbook-stats__category-bar">
<span
class="logbook-stats__category-fill"
style="width: ${Math.max(6, Math.round((entry.ms / maxCategoryMs) * 100))}%"
></span>
</span>
<span class="logbook-stats__category-time">${formatDurationMs(entry.ms)}</span>
</div>
`,
)}
</div>
${stats.apps.length > 0
? html`
<div class="logbook-stats__apps">
${stats.apps
.slice(0, 5)
.map((app) => html`<span class="logbook-stats__app">${app.domain}</span>`)}
</div>
`
: nothing}
</section>
`;
}
function renderStandup(state: LogbookUiState, client: GatewayBrowserClient | null): TemplateResult {
return html`
<section class="card logbook-side__card">
<div class="logbook-side__card-header">
<div class="card-title">${t("logbook.standup.title")}</div>
<button
class="btn btn--small"
type="button"
?disabled=${state.standupLoading}
@click=${() => void loadLogbookStandup(state, client, state.standup !== null)}
>
${state.standupLoading
? t("common.loading")
: state.standup
? t("logbook.standup.refresh")
: t("logbook.standup.generate")}
</button>
</div>
${state.standup
? html`<div class="logbook-standup__body markdown-body">
${unsafeHTML(toSanitizedMarkdownHtml(state.standup.text))}
</div>`
: html`<div class="card-sub">${t("logbook.standup.empty")}</div>`}
</section>
`;
}
function renderAsk(state: LogbookUiState, client: GatewayBrowserClient | null): TemplateResult {
return html`
<section class="card logbook-side__card">
<div class="card-title">${t("logbook.ask.title")}</div>
<form
class="logbook-ask__form"
@submit=${(event: Event) => {
event.preventDefault();
void askLogbook(state, client);
}}
>
<input
class="logbook-ask__input"
type="text"
.value=${state.askQuestion}
placeholder=${t("logbook.ask.placeholder")}
@input=${(event: Event) => {
state.askQuestion = (event.target as HTMLInputElement).value;
}}
/>
<button class="btn btn--small" type="submit" ?disabled=${state.askLoading}>
${state.askLoading ? t("common.loading") : t("logbook.ask.submit")}
</button>
</form>
${state.askAnswer ? html`<p class="logbook-ask__answer">${state.askAnswer}</p>` : nothing}
</section>
`;
}
export function renderLogbook(props: LogbookProps) {
const state = getLogbookState(props.host);
state.requestUpdate = props.onRequestUpdate ?? null;
// The tab only renders while the plugin's descriptor is advertised, so
// enablement gating lives in the shell; connectivity is the only guard here.
const active = props.connected;
configureLogbookPolling(state, active ? props.client : null, active);
if (active && !state.timeline && !state.loading && !state.error) {
void loadLogbook(state, props.client);
}
// The gateway's day is authoritative; the browser clock only seeds the view
// until the first status response arrives.
const todayKey = state.status?.today ?? localDayKey();
const isToday = state.day === todayKey;
const status = state.status;
const cards = state.timeline?.cards ?? [];
return html`
<section class="logbook">
<header class="logbook__header">
<div class="logbook__daynav">
<button
class="btn btn--small"
type="button"
aria-label=${t("logbook.nav.previousDay")}
@click=${() => void loadLogbook(state, props.client, { day: shiftDay(state.day, -1) })}
>
</button>
<span class="logbook__day">${state.day}</span>
<button
class="btn btn--small"
type="button"
aria-label=${t("logbook.nav.nextDay")}
?disabled=${isToday}
@click=${() => void loadLogbook(state, props.client, { day: shiftDay(state.day, 1) })}
>
</button>
${!isToday
? html`<button
class="btn btn--small"
type="button"
@click=${() => void loadLogbook(state, props.client, { today: true })}
>
${t("logbook.nav.today")}
</button>`
: nothing}
</div>
${state.status ? renderStatusChips(state.status) : nothing}
<div class="logbook__actions">
${state.status
? html`<button
class="btn btn--small"
type="button"
?disabled=${state.actionPending || !state.status.captureEnabled}
@click=${() =>
void setLogbookCapturePaused(state, props.client, !state.status?.capturePaused)}
>
${state.status.capturePaused
? t("logbook.actions.resume")
: t("logbook.actions.pause")}
</button>`
: nothing}
<button
class="btn btn--small"
type="button"
?disabled=${state.actionPending}
@click=${() => void runLogbookAnalysisNow(state, props.client)}
>
${t("logbook.actions.analyzeNow")}
</button>
<button
class="btn btn--small"
type="button"
?disabled=${state.loading}
@click=${() => void loadLogbook(state, props.client)}
>
${icons.refresh}
</button>
</div>
</header>
${state.error ? html`<div class="callout danger" role="alert">${state.error}</div>` : nothing}
<div class="logbook__layout">
<div class="logbook__timeline">
${state.loading && cards.length === 0
? html`<div class="card-sub">${t("common.loading")}</div>`
: nothing}
${!state.loading && cards.length === 0 && !state.error
? html`
<div class="logbook__empty">
<div class="logbook__empty-title">${t("logbook.empty.title")}</div>
<div class="logbook__empty-sub">${t("logbook.empty.subtitle")}</div>
</div>
`
: nothing}
${status
? cards.map((card) => renderCard(state, props.client, card, status.timeZone))
: nothing}
</div>
<aside class="logbook__side">
${renderStats(state)} ${renderStandup(state, props.client)}
${renderAsk(state, props.client)}
</aside>
</div>
</section>
`;
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts";
import type { RouteId } from "../../app-route-paths.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import { getLogbookState } from "./logbook-controller.ts";
import { PluginPage } from "./plugin-page.ts";
describe("PluginPage", () => {
it("stops a bundled view when its advertised descriptor disappears", async () => {
const hello: GatewayHelloOk = {
type: "hello-ok",
protocol: 3,
auth: { role: "operator", scopes: ["operator.write"] },
controlUiTabs: [{ pluginId: "logbook", id: "logbook", label: "Logbook" }],
};
const client = {
request: vi.fn(async (method: string) => {
if (method === "logbook.status") {
return {
captureEnabled: true,
capturePaused: false,
captureIntervalSeconds: 30,
analysisIntervalMinutes: 15,
retentionDays: 30,
pendingFrames: 0,
analysisRunning: false,
visionModelSource: "missing",
today: "2026-07-05",
todayCards: 0,
timeZone: "UTC",
};
}
if (method === "logbook.days") {
return { days: [] };
}
return {
day: "2026-07-05",
cards: [],
stats: { trackedMs: 0, distractionMs: 0, categories: [], apps: [] },
};
}),
} as unknown as GatewayBrowserClient;
const snapshot: ApplicationGatewaySnapshot = {
client,
connected: true,
hello,
assistantAgentId: null,
sessionKey: "main",
lastError: null,
lastErrorCode: null,
};
const page = new PluginPage();
page.pluginId = "logbook";
page.tabId = "logbook";
(page as unknown as { context: ApplicationContext<RouteId> }).context = {
gateway: { snapshot, subscribe: () => () => undefined },
} as unknown as ApplicationContext<RouteId>;
document.body.append(page);
try {
await vi.waitFor(() => {
expect(getLogbookState(page).pollTimer).not.toBeNull();
});
hello.controlUiTabs = [];
page.requestUpdate();
await page.updateComplete;
expect(getLogbookState(page).pollTimer).toBeNull();
} finally {
page.remove();
}
});
});
+143
View File
@@ -0,0 +1,143 @@
import { consume } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { GatewayBrowserClient, GatewayControlUiPluginTab } from "../../api/gateway.ts";
import type { RouteId } from "../../app-route-paths.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { t } from "../../i18n/index.ts";
import { resolveEmbedSandbox } from "../../lib/chat/tool-display.ts";
import { pluginTabKey } from "./route.ts";
/**
* Bundled plugin tab views ship with the Control UI and render natively; every
* other tab either embeds the plugin-served panel (descriptor path) in a
* sandboxed frame or shows the unavailable card.
*/
type BundledPluginTabView = {
render: (props: {
host: object;
client: GatewayBrowserClient | null;
connected: boolean;
onRequestUpdate?: () => void;
}) => unknown;
stop: (host: object) => void;
};
// Keyed by pluginId/tabId: tab ids are only unique within their plugin.
const BUNDLED_TAB_VIEWS: Record<string, () => Promise<BundledPluginTabView>> = {
"logbook/logbook": async () => {
const [view, controller] = await Promise.all([
import("./logbook-view.ts"),
import("./logbook-controller.ts"),
]);
return { render: view.renderLogbook, stop: controller.stopLogbookPolling };
},
};
export class PluginPage extends LitElement {
override createRenderRoot() {
return this;
}
@property({ attribute: false }) pluginId = "";
@property({ attribute: false }) tabId = "";
@consume({ context: applicationContext, subscribe: false })
private context?: ApplicationContext<RouteId>;
@state() private bundledView: BundledPluginTabView | null = null;
private bundledViewId: string | null = null;
private stopGatewaySubscription: (() => void) | undefined;
override connectedCallback() {
super.connectedCallback();
this.style.display = "contents";
this.stopGatewaySubscription ??= this.context?.gateway.subscribe(() => this.requestUpdate());
}
override disconnectedCallback() {
this.stopGatewaySubscription?.();
this.stopGatewaySubscription = undefined;
this.stopBundledView();
super.disconnectedCallback();
}
private tabKey(): string {
return pluginTabKey({ pluginId: this.pluginId, id: this.tabId });
}
override willUpdate() {
const key = this.tabKey();
const hasBundledDescriptor = this.tabInfo() !== undefined && key in BUNDLED_TAB_VIEWS;
// Switching between plugin tabs reuses this element; the previous bundled
// view must stop its background polling before the next one renders. A
// descriptor can also disappear in place after disablement or scope loss.
if (this.bundledViewId !== null && (this.bundledViewId !== key || !hasBundledDescriptor)) {
this.stopBundledView();
}
if (this.bundledViewId === null && hasBundledDescriptor) {
this.bundledViewId = key;
void BUNDLED_TAB_VIEWS[key]().then((view) => {
if (this.bundledViewId === this.tabKey()) {
this.bundledView = view;
}
});
}
}
private stopBundledView() {
this.bundledView?.stop(this);
this.bundledView = null;
this.bundledViewId = null;
}
private tabInfo(): GatewayControlUiPluginTab | undefined {
const tabs = this.context?.gateway.snapshot.hello?.controlUiTabs ?? [];
return tabs.find((tab) => tab.pluginId === this.pluginId && tab.id === this.tabId);
}
override render() {
const context = this.context;
if (!context) {
return nothing;
}
// Only advertised tabs render: hello omits descriptors whose plugin is
// inactive or whose required scopes the connection lacks.
const info = this.tabInfo();
if (info && this.tabKey() in BUNDLED_TAB_VIEWS) {
if (!this.bundledView) {
return nothing;
}
const snapshot = context.gateway.snapshot;
return this.bundledView.render({
host: this,
client: snapshot.client,
connected: snapshot.connected,
onRequestUpdate: () => this.requestUpdate(),
});
}
if (info?.path) {
return html`
<section class="plugin-tab-embed">
<iframe
class="plugin-tab-embed__frame"
src=${info.path}
title=${info.label}
sandbox=${resolveEmbedSandbox(context.config.current.embedSandboxMode)}
></iframe>
</section>
`;
}
return html`
<section class="card lazy-view-state" role="status">
<div class="card-title">${t("pluginTabs.unavailableTitle")}</div>
<div class="card-sub">${t("pluginTabs.unavailableSubtitle")}</div>
</section>
`;
}
}
if (!customElements.get("openclaw-plugin-page")) {
customElements.define("openclaw-plugin-page", PluginPage);
}
+40
View File
@@ -0,0 +1,40 @@
import { definePage } from "@openclaw/uirouter";
import { html } from "lit";
export type PluginTabRef = { pluginId: string; id: string };
/** Reads the plugin tab reference from a `/plugin?plugin=<pluginId>&id=<tab>` search string. */
export function pluginTabRefFromSearch(search: string): PluginTabRef {
const params = new URLSearchParams(search);
return {
pluginId: params.get("plugin")?.trim() ?? "",
id: params.get("id")?.trim() ?? "",
};
}
export function pluginTabSearch(ref: PluginTabRef): string {
return `?plugin=${encodeURIComponent(ref.pluginId)}&id=${encodeURIComponent(ref.id)}`;
}
/** Stable key for one tab; ids are only unique per plugin, so both parts matter. */
export function pluginTabKey(ref: PluginTabRef): string {
return `${ref.pluginId}/${ref.id}`;
}
// One static route hosts every plugin-declared tab; the router only supports
// exact paths, so the tab reference travels in the query like chat sessions.
export const page = definePage({
id: "plugin",
path: "/plugin",
loaderDeps: (_context, location) => location.search,
loader: (_context, options) => pluginTabRefFromSearch(options.location.search),
component: () =>
import("./plugin-page.ts").then(() => ({
header: true,
render: (data: unknown) => {
const ref = (data ?? { pluginId: "", id: "" }) as PluginTabRef;
return html`<openclaw-plugin-page .pluginId=${ref.pluginId} .tabId=${ref.id}>
</openclaw-plugin-page>`;
},
})),
});
+1
View File
@@ -8,6 +8,7 @@
@import "./styles/config-quick.css";
@import "./styles/cron-quick-create.css";
@import "./styles/usage.css";
@import "./styles/logbook.css";
@import "./styles/dreams.css";
@import "./styles/workboard.css";
@import "./styles/skill-workshop.css";
+17
View File
@@ -6782,3 +6782,20 @@ details[open] > .ov-expandable-toggle::after {
text-indent: 0;
}
}
/* ---- Plugin tab embeds (plugin-served panels) ---- */
.plugin-tab-embed {
display: flex;
min-height: 0;
height: 100%;
}
.plugin-tab-embed__frame {
flex: 1;
min-height: 480px;
width: 100%;
border: none;
border-radius: 10px;
background: var(--card);
}
+411
View File
@@ -0,0 +1,411 @@
/* ===========================================
Logbook Tab - automatic work journal timeline
=========================================== */
.logbook {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.logbook__header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.logbook__daynav {
display: flex;
align-items: center;
gap: 6px;
}
.logbook__day {
font-variant-numeric: tabular-nums;
font-weight: 600;
padding: 0 4px;
}
.logbook__chips {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
flex: 1;
min-width: 0;
}
.logbook__chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
border-radius: 999px;
font-size: 11px;
color: var(--muted);
background: color-mix(in oklab, var(--panel) 80%, transparent);
white-space: nowrap;
}
.logbook__chip svg {
width: 12px;
height: 12px;
}
.logbook__chip-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted);
}
.logbook__chip--ok .logbook__chip-dot {
background: var(--ok);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--ok) 25%, transparent);
}
.logbook__chip--warn .logbook__chip-dot {
background: var(--warn);
}
.logbook__chip--busy {
color: var(--accent);
}
.logbook__chip--error {
color: var(--danger);
}
.logbook__actions {
display: flex;
align-items: center;
gap: 6px;
}
.logbook__layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 14px;
align-items: start;
}
@media (max-width: 980px) {
.logbook__layout {
grid-template-columns: minmax(0, 1fr);
}
}
/* ---- Timeline ---- */
.logbook__timeline {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.logbook__empty {
padding: 40px 20px;
text-align: center;
border: 1px dashed color-mix(in oklab, var(--muted) 35%, transparent);
border-radius: 12px;
}
.logbook__empty-title {
font-weight: 600;
margin-bottom: 4px;
}
.logbook__empty-sub {
color: var(--muted);
font-size: 12px;
}
.logbook-card {
border: 1px solid color-mix(in oklab, var(--muted) 18%, transparent);
border-radius: 10px;
background: var(--card);
overflow: hidden;
}
.logbook-card__header {
display: grid;
grid-template-columns: 92px 4px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.logbook-card__header:hover {
background: color-mix(in oklab, var(--panel) 60%, transparent);
}
.logbook-card__time {
font-variant-numeric: tabular-nums;
font-size: 12px;
color: var(--muted);
white-space: nowrap;
}
.logbook-card__time-sep {
opacity: 0.6;
padding: 0 2px;
}
.logbook-card__stripe {
width: 4px;
height: 100%;
min-height: 30px;
border-radius: 2px;
background: hsl(var(--logbook-hue, 200) 65% 55%);
}
.logbook-card__heading {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.logbook-card__title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logbook-card__summary {
color: var(--muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logbook-card--expanded .logbook-card__summary {
white-space: normal;
}
.logbook-card__meta {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.logbook-card__category {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 999px;
color: hsl(var(--logbook-hue, 200) 70% 70%);
background: hsl(var(--logbook-hue, 200) 60% 50% / 0.15);
}
.logbook-card__app {
font-size: 11px;
color: var(--muted);
}
.logbook-card__duration {
font-size: 11px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.logbook-card__body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0 12px 12px 118px;
}
.logbook-card__keyframe {
max-width: 100%;
border-radius: 8px;
border: 1px solid color-mix(in oklab, var(--muted) 20%, transparent);
}
.logbook-card__keyframe--loading {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
color: var(--muted);
font-size: 12px;
}
.logbook-card__detail {
margin: 0;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
}
.logbook-card__distractions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.logbook-card__distractions-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--warn);
}
.logbook-card__distraction {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
color: var(--muted);
background: color-mix(in oklab, var(--warn) 12%, transparent);
}
@media (max-width: 700px) {
.logbook-card__header {
grid-template-columns: 4px minmax(0, 1fr);
}
.logbook-card__time,
.logbook-card__meta {
display: none;
}
.logbook-card__body {
padding-left: 12px;
}
}
/* ---- Side panel ---- */
.logbook__side {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.logbook-side__card {
display: flex;
flex-direction: column;
gap: 10px;
}
.logbook-side__card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.logbook-stats__focus-bar {
height: 8px;
border-radius: 999px;
overflow: hidden;
background: color-mix(in oklab, var(--warn) 25%, transparent);
}
.logbook-stats__focus-fill {
height: 100%;
border-radius: 999px;
background: var(--ok);
}
.logbook-stats__focus-legend {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--muted);
margin-top: 4px;
}
.logbook-stats__categories {
display: flex;
flex-direction: column;
gap: 5px;
}
.logbook-stats__category {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
font-size: 11px;
}
.logbook-stats__category-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logbook-stats__category-bar {
height: 6px;
border-radius: 999px;
background: color-mix(in oklab, var(--panel) 85%, transparent);
overflow: hidden;
}
.logbook-stats__category-fill {
display: block;
height: 100%;
border-radius: 999px;
background: hsl(var(--logbook-hue, 200) 65% 55%);
}
.logbook-stats__category-time {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.logbook-stats__apps {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.logbook-stats__app {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
color: var(--muted);
background: color-mix(in oklab, var(--panel) 80%, transparent);
}
.logbook-standup__body {
font-size: 13px;
}
.logbook-ask__form {
display: flex;
gap: 6px;
}
.logbook-ask__input {
flex: 1;
min-width: 0;
}
.logbook-ask__answer {
margin: 0;
font-size: 13px;
line-height: 1.5;
padding: 10px;
border-radius: 8px;
background: color-mix(in oklab, var(--panel) 70%, transparent);
}