diff --git a/.github/labeler.yml b/.github/labeler.yml index db1ea25106f..c46dd3bdb29 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7548de05a..cb35302e54c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift index bf8d4d40a13..b2112105b4a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacGatewayChatTransportMappingTests.swift @@ -22,6 +22,7 @@ struct MacGatewayChatTransportMappingTests { server: [:], features: [:], snapshot: snapshot, + controluitabs: nil, pluginsurfaceurls: nil, auth: [:], policy: [:]) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 3d0e4a6b65c..995f636b724 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 diff --git a/docs/docs.json b/docs/docs.json index 99d73a99292..4ee563c8e94 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1273,6 +1273,7 @@ "plugins/codex-harness", "plugins/codex-native-plugins", "plugins/codex-computer-use", + "plugins/logbook", "plugins/google-meet", "plugins/workboard", "plugins/webhooks", diff --git a/docs/docs_map.md b/docs/docs_map.md index 11b49504208..8144116af97 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/plugins/logbook.md b/docs/plugins/logbook.md new file mode 100644 index 00000000000..20ffeaeefd1 --- /dev/null +++ b/docs/plugins/logbook.md @@ -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 `/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. diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 1c68b29b9a2..ffba10c8067 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -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(...)` diff --git a/extensions/logbook/index.ts b/extensions/logbook/index.ts new file mode 100644 index 00000000000..4e6800d3316 --- /dev/null +++ b/extensions/logbook/index.ts @@ -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 | 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()); + }, +}); diff --git a/extensions/logbook/openclaw.plugin.json b/extensions/logbook/openclaw.plugin.json new file mode 100644 index 00000000000..0ed9477cc26 --- /dev/null +++ b/extensions/logbook/openclaw.plugin.json @@ -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 + } + } + } +} diff --git a/extensions/logbook/package.json b/extensions/logbook/package.json new file mode 100644 index 00000000000..a6211a6230e --- /dev/null +++ b/extensions/logbook/package.json @@ -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" + ] + } +} diff --git a/extensions/logbook/src/analyze.test.ts b/extensions/logbook/src/analyze.test.ts new file mode 100644 index 00000000000..1def6e1e436 --- /dev/null +++ b/extensions/logbook/src/analyze.test.ts @@ -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 = {}) => ({ + 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); + }); +}); diff --git a/extensions/logbook/src/analyze.ts b/extensions/logbook/src/analyze.ts new file mode 100644 index 00000000000..f8027e75caf --- /dev/null +++ b/extensions/logbook/src/analyze.ts @@ -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; + 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; + 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) + : {}; + 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(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; +} diff --git a/extensions/logbook/src/config.ts b/extensions/logbook/src/config.ts new file mode 100644 index 00000000000..a98a28aefac --- /dev/null +++ b/extensions/logbook/src/config.ts @@ -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; + 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) }; +} diff --git a/extensions/logbook/src/node-host.ts b/extensions/logbook/src/node-host.ts new file mode 100644 index 00000000000..6b775025763 --- /dev/null +++ b/extensions/logbook/src/node-host.ts @@ -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; + 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 { + 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 }); + } +} diff --git a/extensions/logbook/src/prompts.ts b/extensions/logbook/src/prompts.ts new file mode 100644 index 00000000000..225a5237a8a --- /dev/null +++ b/extensions/logbook/src/prompts.ts @@ -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"); +} diff --git a/extensions/logbook/src/service.test.ts b/extensions/logbook/src/service.test.ts new file mode 100644 index 00000000000..a7369f627f8 --- /dev/null +++ b/extensions/logbook/src/service.test.ts @@ -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; + config?: Record; + fullConfig?: Record; +}) { + 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 }).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 }); + } + }); +}); diff --git a/extensions/logbook/src/service.ts b/extensions/logbook/src/service.ts new file mode 100644 index 00000000000..15c303ad56a --- /dev/null +++ b/extensions/logbook/src/service.ts @@ -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; + 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(); + + constructor( + private readonly config: LogbookConfig, + private readonly deps: { + runtime: NonNullable; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.requireStore().listDays(); + } + + dayStats(day: string): ReturnType { + return this.requireStore().dayStats(day); + } + + frameById(id: number): ReturnType { + return this.requireStore().frameById(id); + } + + framesInRange(startMs: number, endMs: number): ReturnType { + 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`, + ); + } + } +} diff --git a/extensions/logbook/src/store.test.ts b/extensions/logbook/src/store.test.ts new file mode 100644 index 00000000000..4554e546cd2 --- /dev/null +++ b/extensions/logbook/src/store.test.ts @@ -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 { + 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"); + }); +}); diff --git a/extensions/logbook/src/store.ts b/extensions/logbook/src/store.ts new file mode 100644 index 00000000000..95703362019 --- /dev/null +++ b/extensions/logbook/src/store.ts @@ -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(); + const apps = new Map(); + 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(); + 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; + } +} diff --git a/extensions/logbook/src/types.ts b/extensions/logbook/src/types.ts new file mode 100644 index 00000000000..67863049afa --- /dev/null +++ b/extensions/logbook/src/types.ts @@ -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; + +export type LogbookDayStats = { + trackedMs: number; + distractionMs: number; + categories: Array<{ category: string; ms: number }>; + apps: Array<{ domain: string; ms: number }>; +}; diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index 7035883045d..bba7504a7ee 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -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( { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4350dff5c36..5af40b02ffe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/src/gateway/control-ui-plugin-tabs.test.ts b/src/gateway/control-ui-plugin-tabs.test.ts new file mode 100644 index 00000000000..0d7f0e8872e --- /dev/null +++ b/src/gateway/control-ui-plugin-tabs.test.ts @@ -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 { + 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"]); + }); +}); diff --git a/src/gateway/control-ui-plugin-tabs.ts b/src/gateway/control-ui-plugin-tabs.ts new file mode 100644 index 00000000000..db46b6900d8 --- /dev/null +++ b/src/gateway/control-ui-plugin-tabs.ts @@ -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); +} diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index a383b749fe6..03130fc15e6 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -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, diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 18cb054ab86..7756fd9cbe1 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -45,6 +45,7 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [ "google-meet", "llm-task", "lobster", + "logbook", "memory-wiki", "ollama", "openshell", diff --git a/src/plugins/host-hooks.ts b/src/plugins/host-hooks.ts index ac3513bd5ac..7fc3eabb500 100644 --- a/src/plugins/host-hooks.ts +++ b/src/plugins/host-hooks.ts @@ -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//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 = { diff --git a/src/plugins/registry.control-ui.test.ts b/src/plugins/registry.control-ui.test.ts index 3bda087905a..571b9fc6496 100644 --- a/src/plugins/registry.control-ui.test.ts +++ b/src/plugins/registry.control-ui.test.ts @@ -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"), + }), + ); + }); }); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 24301c597e2..4198e9b4405 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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, diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 00000000000..c2658d7d1b3 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index 58558ee32ea..d8e0debc278 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -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; policy?: { tickIntervalMs?: number }; }; diff --git a/ui/src/app-navigation.test.ts b/ui/src/app-navigation.test.ts index 2212fa40b17..87e67e33233 100644 --- a/ui/src/app-navigation.test.ts +++ b/ui/src/app-navigation.test.ts @@ -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"]); diff --git a/ui/src/app-navigation.ts b/ui/src/app-navigation.ts index aa7096f004b..795bf726b47 100644 --- a/ui/src/app-navigation.ts +++ b/ui/src/app-navigation.ts @@ -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 (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` + { + if (!shouldHandleNavigationClick(event)) { + return; + } + event.preventDefault(); + this.onNavigate?.("plugin", { search }); + }} + > + + ${!this.collapsed ? html`${tab.label}` : nothing} + + `; + return this.collapsed + ? html`${link}` + : link; + } + private renderRecentSession(session: SidebarRecentSession) { const context = this.context; const archiveAllowed = canArchiveSessionRow( @@ -712,6 +747,9 @@ export class AppSidebar extends LitElement { : nothing} `; diff --git a/ui/src/i18n/.i18n/ar.meta.json b/ui/src/i18n/.i18n/ar.meta.json index d2adffaa897..15e637a2975 100644 --- a/ui/src/i18n/.i18n/ar.meta.json +++ b/ui/src/i18n/.i18n/ar.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index a9b4c438b01..639a66d1809 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index 9db9038cd62..08c17dfda46 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/fa.meta.json b/ui/src/i18n/.i18n/fa.meta.json index 5a8e51e7d3c..087157f3b70 100644 --- a/ui/src/i18n/.i18n/fa.meta.json +++ b/ui/src/i18n/.i18n/fa.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index acb1d6ac881..2a0a02d098d 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/hi.meta.json b/ui/src/i18n/.i18n/hi.meta.json index c86bea0c8ec..d9c9822b85e 100644 --- a/ui/src/i18n/.i18n/hi.meta.json +++ b/ui/src/i18n/.i18n/hi.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/hi.tm.jsonl b/ui/src/i18n/.i18n/hi.tm.jsonl index f2369276f3b..eb51a6e4ca2 100644 --- a/ui/src/i18n/.i18n/hi.tm.jsonl +++ b/ui/src/i18n/.i18n/hi.tm.jsonl @@ -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"} diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index 122d540c017..cfaf3f2f170 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/it.meta.json b/ui/src/i18n/.i18n/it.meta.json index 1fe67443249..1d3c4fd3911 100644 --- a/ui/src/i18n/.i18n/it.meta.json +++ b/ui/src/i18n/.i18n/it.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index 5a2511d07ee..7cf1e7a614b 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index 3f6a36d0c30..c7dc27e6b74 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/nl.meta.json b/ui/src/i18n/.i18n/nl.meta.json index ee1b985b947..7a1f40987e3 100644 --- a/ui/src/i18n/.i18n/nl.meta.json +++ b/ui/src/i18n/.i18n/nl.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index b6d139a58cc..ce74a6c35b3 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index 176e2e60a3d..cf4cfce4d39 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index 4b82799925a..18903d5d259 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -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", diff --git a/ui/src/i18n/.i18n/ru.meta.json b/ui/src/i18n/.i18n/ru.meta.json index 776562a7072..65e383e8094 100644 --- a/ui/src/i18n/.i18n/ru.meta.json +++ b/ui/src/i18n/.i18n/ru.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/ru.tm.jsonl b/ui/src/i18n/.i18n/ru.tm.jsonl index cefa048a950..6833f1b553c 100644 --- a/ui/src/i18n/.i18n/ru.tm.jsonl +++ b/ui/src/i18n/.i18n/ru.tm.jsonl @@ -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"} diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index fcc264231a2..ecbb696c334 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index 51e1682a35f..015cc1034d0 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index 8b8ec27b88a..22efc7f7676 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/vi.meta.json b/ui/src/i18n/.i18n/vi.meta.json index 74d7b81532a..6e0390cf09f 100644 --- a/ui/src/i18n/.i18n/vi.meta.json +++ b/ui/src/i18n/.i18n/vi.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index 635784d7c17..dab4727ad23 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -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 } diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index 2dea631c036..51b0fb3d84b 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -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 } diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index 555d47f7da6..71e1ffd526d 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -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", diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index 34becf3b522..0b21fc923de 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -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", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 78df6bba5fb..caf969b662b 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index 254de2d5194..c0c8e57361a 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -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", diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index d2e01fb5b39..0fbfd1a519a 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -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", diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 46a3d24f91d..c97422d6f1e 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -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", diff --git a/ui/src/i18n/locales/hi.ts b/ui/src/i18n/locales/hi.ts index ee19595f27a..ada462c2c53 100644 --- a/ui/src/i18n/locales/hi.ts +++ b/ui/src/i18n/locales/hi.ts @@ -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", diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index d6c25e39e16..39618da59cc 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -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", diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index 9278f7a8317..9400a715e89 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -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", diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index ba8910efd0f..837ca7ce8ce 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -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", diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index 7bbcd3bdc74..9dac1e1deed 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -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", diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index 79313a90138..270d191d964 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -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", diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index 0efdabba042..9f20ff5f8fa 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -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", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 44da2975980..f8a8c170d2c 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/ui/src/i18n/locales/ru.ts b/ui/src/i18n/locales/ru.ts index a2f492bb513..945dca0504f 100644 --- a/ui/src/i18n/locales/ru.ts +++ b/ui/src/i18n/locales/ru.ts @@ -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", diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index 771cadaafe3..7ba3a8ae47a 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -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", diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index 595d065707e..1c150085e29 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -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", diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index d4f4d52f9ee..2c940b7c530 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -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", diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index 50e26819b24..8264d0ece91 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -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", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 5bab0714009..5514ff2af94 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -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", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 737c5d63994..bb118ad8f95 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -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", diff --git a/ui/src/pages/plugin/logbook-controller.test.ts b/ui/src/pages/plugin/logbook-controller.test.ts new file mode 100644 index 00000000000..c3f0f1c9968 --- /dev/null +++ b/ui/src/pages/plugin/logbook-controller.test.ts @@ -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, +): GatewayBrowserClient { + return { request } as GatewayBrowserClient; +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((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(); + 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(); + const request = askLogbook( + state, + clientWithRequest(() => pending.promise), + ); + + state.day = "2026-07-05"; + pending.resolve({ answer: "Old day" }); + await request; + + expect(state.askAnswer).toBeNull(); + }); +}); diff --git a/ui/src/pages/plugin/logbook-controller.ts b/ui/src/pages/plugin/logbook-controller.ts new file mode 100644 index 00000000000..2c57660c249 --- /dev/null +++ b/ui/src/pages/plugin/logbook-controller.ts @@ -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; + framePreviews: Map; + frameLoads: Set; + framePreviewFailed: Set; + standup: { day: string; text: string; updatedMs: number } | null; + standupLoading: boolean; + askQuestion: string; + askAnswer: string | null; + askLoading: boolean; + actionPending: boolean; + pollTimer: ReturnType | null; + pollClient: GatewayBrowserClient | null; + requestUpdate: (() => void) | null; +}; + +const FRAME_PREVIEW_CACHE_LIMIT = 48; +const POLL_INTERVAL_MS = 30_000; + +const logbookStates = new WeakMap(); + +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 { + 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("logbook.status", {}), + client.request("logbook.days", {}), + client.request("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("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 { + 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 { + if (!client || state.actionPending) { + return; + } + state.actionPending = true; + notify(state); + try { + state.status = await client.request("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 { + 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 { + 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 { + 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); + } +} diff --git a/ui/src/pages/plugin/logbook-view.test.ts b/ui/src/pages/plugin/logbook-view.test.ts new file mode 100644 index 00000000000..8cf496a2ebd --- /dev/null +++ b/ui/src/pages/plugin/logbook-view.test.ts @@ -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:30–17:30"); + }); +}); diff --git a/ui/src/pages/plugin/logbook-view.ts b/ui/src/pages/plugin/logbook-view.ts new file mode 100644 index 00000000000..265c588f3ff --- /dev/null +++ b/ui/src/pages/plugin/logbook-view.ts @@ -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` +
+ + + ${captureLabel} + + ${status.nodeName || status.nodeId + ? html` + ${icons.monitor} ${status.nodeName ?? status.nodeId} + ` + : nothing} + ${status.pendingFrames > 0 + ? html` + ${t("logbook.status.pending", { count: String(status.pendingFrames) })} + ` + : nothing} + ${status.analysisRunning + ? html`${t("logbook.status.analyzing")}` + : nothing} + ${status.lastCaptureError + ? html` + ${t("logbook.status.captureError")} + ` + : nothing} + ${status.lastBatch?.status === "error" + ? html` + ${t("logbook.status.batchError")} + ` + : nothing} + ${status.visionModelSource === "missing" + ? html` + ${t("logbook.status.modelMissing")} + ` + : nothing} +
+ `; +} + +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` +
+ + ${expanded + ? html` +
+ ${preview + ? html`${t("logbook.card.keyframeAlt")}` + : keyframeId !== undefined + ? html`
+ ${t("common.loading")} +
` + : nothing} + ${card.detail ? html`

${card.detail}

` : nothing} + ${card.distractions.length > 0 + ? html` +
+ + ${t("logbook.card.distractions")} + + ${card.distractions.map( + (distraction) => html` + + ${formatClock(distraction.startMs, timeZone)} · ${distraction.title} + + `, + )} +
+ ` + : nothing} +
+ ` + : nothing} +
+ `; +} + +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` +
+
${t("logbook.stats.title")}
+
+
+
+
+
+ ${t("logbook.stats.focus", { pct: String(focusPct) })} + ${t("logbook.stats.tracked", { duration: formatDurationMs(stats.trackedMs) })} +
+
+
+ ${stats.categories.slice(0, 6).map( + (entry) => html` +
+ ${entry.category} + + + + ${formatDurationMs(entry.ms)} +
+ `, + )} +
+ ${stats.apps.length > 0 + ? html` +
+ ${stats.apps + .slice(0, 5) + .map((app) => html`${app.domain}`)} +
+ ` + : nothing} +
+ `; +} + +function renderStandup(state: LogbookUiState, client: GatewayBrowserClient | null): TemplateResult { + return html` +
+
+
${t("logbook.standup.title")}
+ +
+ ${state.standup + ? html`
+ ${unsafeHTML(toSanitizedMarkdownHtml(state.standup.text))} +
` + : html`
${t("logbook.standup.empty")}
`} +
+ `; +} + +function renderAsk(state: LogbookUiState, client: GatewayBrowserClient | null): TemplateResult { + return html` +
+
${t("logbook.ask.title")}
+
{ + event.preventDefault(); + void askLogbook(state, client); + }} + > + { + state.askQuestion = (event.target as HTMLInputElement).value; + }} + /> + +
+ ${state.askAnswer ? html`

${state.askAnswer}

` : nothing} +
+ `; +} + +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` +
+
+
+ + ${state.day} + + ${!isToday + ? html`` + : nothing} +
+ ${state.status ? renderStatusChips(state.status) : nothing} +
+ ${state.status + ? html`` + : nothing} + + +
+
+ ${state.error ? html`` : nothing} +
+
+ ${state.loading && cards.length === 0 + ? html`
${t("common.loading")}
` + : nothing} + ${!state.loading && cards.length === 0 && !state.error + ? html` +
+
${t("logbook.empty.title")}
+
${t("logbook.empty.subtitle")}
+
+ ` + : nothing} + ${status + ? cards.map((card) => renderCard(state, props.client, card, status.timeZone)) + : nothing} +
+ +
+
+ `; +} diff --git a/ui/src/pages/plugin/plugin-page.test.ts b/ui/src/pages/plugin/plugin-page.test.ts new file mode 100644 index 00000000000..477da9ace45 --- /dev/null +++ b/ui/src/pages/plugin/plugin-page.test.ts @@ -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 }).context = { + gateway: { snapshot, subscribe: () => () => undefined }, + } as unknown as ApplicationContext; + + 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(); + } + }); +}); diff --git a/ui/src/pages/plugin/plugin-page.ts b/ui/src/pages/plugin/plugin-page.ts new file mode 100644 index 00000000000..321b4c9084f --- /dev/null +++ b/ui/src/pages/plugin/plugin-page.ts @@ -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 Promise> = { + "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; + + @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` +
+ +
+ `; + } + return html` +
+
${t("pluginTabs.unavailableTitle")}
+
${t("pluginTabs.unavailableSubtitle")}
+
+ `; + } +} + +if (!customElements.get("openclaw-plugin-page")) { + customElements.define("openclaw-plugin-page", PluginPage); +} diff --git a/ui/src/pages/plugin/route.ts b/ui/src/pages/plugin/route.ts new file mode 100644 index 00000000000..5cb1e07a261 --- /dev/null +++ b/ui/src/pages/plugin/route.ts @@ -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=&id=` 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` + `; + }, + })), +}); diff --git a/ui/src/styles.css b/ui/src/styles.css index 6e6558a1c19..3f6780bdf12 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -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"; diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 9a5ba693285..ad0f1975a27 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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); +} diff --git a/ui/src/styles/logbook.css b/ui/src/styles/logbook.css new file mode 100644 index 00000000000..4ed75d986c6 --- /dev/null +++ b/ui/src/styles/logbook.css @@ -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); +}