mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
feat(logbook): automatic work journal plugin with Control UI timeline tab (#99930)
* feat(logbook): automatic work journal plugin with a plugin-contributed Control UI tab Squash of PR #99930 work for rebase onto the Control UI route refactor: - extensions/logbook: Dayflow-style capture -> observations -> timeline cards pipeline with SQLite store, node capture commands, standup/ask, retention - plugin SDK/gateway seam: surface "tab" Control UI descriptors projected into hello-ok controlUiTabs (scope-filtered, deterministic order) - Control UI: dynamic plugin tabs with bundled Logbook view - docs, tests, labeler wiring * feat(ui): port plugin tabs and Logbook to the route-owned Control UI architecture - shared /plugin route carries the tab id in the query (?id=<tab>), matching the router's exact-path contract - openclaw-plugin-page renders bundled views (Logbook), sandboxed plugin frames (descriptor path), or the unavailable card - sidebar renders hello controlUiTabs after each group's static routes - Logbook view/controller live under ui/src/pages/plugin/ * fix(ui): namespace plugin tabs by pluginId to prevent cross-plugin tab id collisions * fix(logbook): prefer app capture nodes and rotate off failing nodes * fix(plugins): reject protocol-relative Control UI tab paths * fix(logbook): harden automatic journal * docs(changelog): remove maintainer self-credit * chore(ui): refresh locale metadata after rebase * fix(logbook): preserve analysis window boundaries * fix(logbook): align status privacy and timezone * fix(ui): stop hidden plugin tab polling
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- **Logbook work journal:** add a disabled-by-default bundled plugin that turns paired-node screen snapshots into a private timeline, daily standup, and timeline-grounded Q&A in a plugin-contributed Control UI tab. (#99930)
|
||||
- **Control UI message context:** reveal per-message token, context, and model details from the timestamp on hover or activation instead of showing a separate Context button.
|
||||
- **Control UI session titles:** reveal truncated recent-session names with a reduced-motion-safe hover animation.
|
||||
- **Control UI sidebar usage:** remove the provider usage quota row from the expanded sidebar while keeping usage details available in the chat composer and Usage page. Thanks @shakkernerd.
|
||||
|
||||
@@ -22,6 +22,7 @@ struct MacGatewayChatTransportMappingTests {
|
||||
server: [:],
|
||||
features: [:],
|
||||
snapshot: snapshot,
|
||||
controluitabs: nil,
|
||||
pluginsurfaceurls: nil,
|
||||
auth: [:],
|
||||
policy: [:])
|
||||
|
||||
@@ -125,6 +125,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
public let server: [String: AnyCodable]
|
||||
public let features: [String: AnyCodable]
|
||||
public let snapshot: Snapshot
|
||||
public let controluitabs: [[String: AnyCodable]]?
|
||||
public let pluginsurfaceurls: [String: AnyCodable]?
|
||||
public let auth: [String: AnyCodable]
|
||||
public let policy: [String: AnyCodable]
|
||||
@@ -135,6 +136,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
server: [String: AnyCodable],
|
||||
features: [String: AnyCodable],
|
||||
snapshot: Snapshot,
|
||||
controluitabs: [[String: AnyCodable]]?,
|
||||
pluginsurfaceurls: [String: AnyCodable]?,
|
||||
auth: [String: AnyCodable],
|
||||
policy: [String: AnyCodable])
|
||||
@@ -144,6 +146,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
self.server = server
|
||||
self.features = features
|
||||
self.snapshot = snapshot
|
||||
self.controluitabs = controluitabs
|
||||
self.pluginsurfaceurls = pluginsurfaceurls
|
||||
self.auth = auth
|
||||
self.policy = policy
|
||||
@@ -155,6 +158,7 @@ public struct HelloOk: Codable, Sendable {
|
||||
case server
|
||||
case features
|
||||
case snapshot
|
||||
case controluitabs = "controlUiTabs"
|
||||
case pluginsurfaceurls = "pluginSurfaceUrls"
|
||||
case auth
|
||||
case policy
|
||||
|
||||
@@ -1273,6 +1273,7 @@
|
||||
"plugins/codex-harness",
|
||||
"plugins/codex-native-plugins",
|
||||
"plugins/codex-computer-use",
|
||||
"plugins/logbook",
|
||||
"plugins/google-meet",
|
||||
"plugins/workboard",
|
||||
"plugins/webhooks",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
summary: "Optional automatic work journal built from periodic screen snapshots"
|
||||
read_when:
|
||||
- You want a Dayflow-style timeline of your day in the Control UI
|
||||
- You are enabling or configuring the bundled Logbook plugin
|
||||
- You want standup summaries or day recall grounded in screen activity
|
||||
title: "Logbook plugin"
|
||||
---
|
||||
|
||||
The Logbook plugin turns screen activity into an automatic work journal. It
|
||||
captures periodic screen snapshots from a paired node (for example the OpenClaw
|
||||
Mac app), summarizes them with a vision model into timestamped observations,
|
||||
and synthesizes those into timeline cards you can browse in the
|
||||
[Control UI](/web/control-ui). On top of the timeline it generates daily
|
||||
standup notes and answers questions about your day.
|
||||
|
||||
Everything stays local: snapshots and the timeline database live under the
|
||||
Gateway state directory. Only analysis batches are sent to the model you
|
||||
configure, so pick a local model if snapshots must never leave the machine.
|
||||
|
||||
## Default state
|
||||
|
||||
Logbook is a bundled plugin and is disabled by default. Screen capture is
|
||||
opt-in.
|
||||
|
||||
Enable it with:
|
||||
|
||||
```bash
|
||||
openclaw plugins enable logbook
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
Then open the dashboard and pick the Logbook tab:
|
||||
|
||||
```bash
|
||||
openclaw dashboard
|
||||
```
|
||||
|
||||
The Logbook tab is contributed through the plugin Control UI tab surface
|
||||
(`registerControlUiDescriptor` with `surface: "tab"`), so it appears in the
|
||||
sidebar only while the plugin is enabled on the connected gateway.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A connected node that can capture the screen. The macOS app node advertises
|
||||
`screen.snapshot` by default (see [Nodes](/nodes)); headless macOS node
|
||||
hosts (`openclaw node host run`) get a plugin-provided `logbook.snapshot`
|
||||
command backed by the system `screencapture` tool when Logbook is enabled.
|
||||
- A vision model whose media-understanding provider supports structured
|
||||
extraction (the bundled Codex plugin does, for example `codex/gpt-5.5`).
|
||||
Logbook resolves the model in order:
|
||||
1. `plugins.entries.logbook.config.visionModel` (`"provider/model"` ref)
|
||||
2. the first image-capable Codex entry under `tools.media.image.models` or
|
||||
`tools.media.models` (other media providers do not currently expose the
|
||||
structured extraction contract Logbook requires)
|
||||
- Timeline card synthesis, standup notes, and "ask your day" answers use the
|
||||
default agent model via the plugin LLM runtime.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Capture**: every `captureIntervalSeconds` (default 30s) Logbook invokes
|
||||
`screen.snapshot` on the capture node and stores a scaled JPEG frame.
|
||||
Consecutive identical frames are marked idle and excluded from analysis.
|
||||
2. **Observe**: once an analysis window (default 15 minutes) elapses, the
|
||||
frames are sent to the vision model, which returns timestamped activity
|
||||
observations ("VS Code: editing store.ts, fixing a type error").
|
||||
3. **Synthesize**: observations plus the last 45 minutes of existing cards are
|
||||
revised into timeline cards (10-60 minutes each) with a title, summary,
|
||||
category, main app, and any brief distractions.
|
||||
4. **Prune**: frames older than `retentionDays` (default 14) are deleted.
|
||||
Cards, observations, and standups are kept.
|
||||
|
||||
Frames and the timeline database live under `<state-dir>/logbook/`.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"logbook": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"captureIntervalSeconds": 30,
|
||||
"analysisIntervalMinutes": 15,
|
||||
"screenIndex": 0,
|
||||
"maxWidth": 1440,
|
||||
"nodeId": "my-mac",
|
||||
"visionModel": "codex/gpt-5.5",
|
||||
"retentionDays": 14,
|
||||
"captureEnabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All keys are optional. Leave `nodeId` unset to use the first connected node
|
||||
that supports `screen.snapshot`. Set `captureEnabled: false` to keep the
|
||||
timeline UI available without capturing; the dashboard also has a session-only
|
||||
pause toggle.
|
||||
|
||||
## Dashboard tab
|
||||
|
||||
- **Timeline**: expandable cards per activity with category colors, the main
|
||||
app, distraction chips, and a snapshot keyframe.
|
||||
- **Day at a glance**: focus ratio, category breakdown, top apps.
|
||||
- **Daily standup**: turns yesterday plus today into a ready-to-paste update.
|
||||
- **Ask your day**: natural-language questions answered from the tracked
|
||||
timeline ("when did I review the gateway PR?").
|
||||
- **Analyze now**: closes the current capture window immediately instead of
|
||||
waiting for the analysis interval.
|
||||
|
||||
## Gateway methods
|
||||
|
||||
Logbook registers Gateway RPC methods for the dashboard. `logbook.status`,
|
||||
`logbook.days`, and `logbook.timeline` return derived text and are readable
|
||||
with `operator.read`. Everything that returns raw screenshot pixels
|
||||
(`logbook.frames`, `logbook.frame`), spends model tokens (`logbook.standup`,
|
||||
`logbook.ask`), or mutates runtime state (`logbook.capture.set`,
|
||||
`logbook.analyze.now`) requires `operator.write`. The Control UI tab requires
|
||||
`operator.write` because the bundled view exposes those actions and raw frame
|
||||
previews; read-only clients may call the derived-text methods directly.
|
||||
|
||||
## Privacy notes
|
||||
|
||||
- Snapshots can contain anything on screen, including secrets. Frames never
|
||||
leave the machine except as model input for analysis batches.
|
||||
- Use a structured-extraction provider that runs locally, when available and
|
||||
explicitly configured, for a fully on-device pipeline.
|
||||
- Frames, the timeline database, and temporary captures are written with
|
||||
owner-only file permissions.
|
||||
- Adding `screen.snapshot` to `gateway.nodes.denyCommands` is the
|
||||
screen-capture kill switch: it blocks app-node capture and Logbook's own
|
||||
`logbook.snapshot` command alike.
|
||||
- Setting `tools.media.image.enabled: false` also stops Logbook from borrowing
|
||||
the media image models for analysis; only an explicit `visionModel` in the
|
||||
plugin config is used then.
|
||||
@@ -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(...)`
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// Logbook plugin entrypoint: automatic work journal built from screen snapshots.
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
type GatewayRequestHandlerOptions,
|
||||
} from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import {
|
||||
definePluginEntry,
|
||||
type OpenClawPluginApi,
|
||||
type OpenClawPluginNodeHostCommand,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { resolveLogbookConfig } from "./src/config.js";
|
||||
import { LogbookService } from "./src/service.js";
|
||||
import { dayKeyFor } from "./src/store.js";
|
||||
|
||||
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const logbookConfigSchema = {
|
||||
parse(value: unknown) {
|
||||
return resolveLogbookConfig(value);
|
||||
},
|
||||
};
|
||||
|
||||
function readDayParam(params: unknown): string {
|
||||
const day = (params as { day?: unknown } | undefined)?.day;
|
||||
if (day === undefined) {
|
||||
return dayKeyFor(Date.now());
|
||||
}
|
||||
if (typeof day !== "string" || !DAY_PATTERN.test(day)) {
|
||||
throw new Error("day must be YYYY-MM-DD");
|
||||
}
|
||||
return day;
|
||||
}
|
||||
|
||||
function readNumberParam(params: unknown, key: string): number {
|
||||
const value = (params as Record<string, unknown> | undefined)?.[key];
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`${key} must be a positive number`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const logbookNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
|
||||
{
|
||||
command: "logbook.snapshot",
|
||||
cap: "screen",
|
||||
dangerous: false,
|
||||
handle: async (paramsJSON) => {
|
||||
const { handleLogbookSnapshot } = await import("./src/node-host.js");
|
||||
let params: unknown;
|
||||
try {
|
||||
params = paramsJSON ? JSON.parse(paramsJSON) : undefined;
|
||||
} catch {
|
||||
params = undefined;
|
||||
}
|
||||
return JSON.stringify(await handleLogbookSnapshot(params));
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "logbook",
|
||||
name: "Logbook",
|
||||
description: "Automatic work journal built from periodic screen snapshots",
|
||||
configSchema: logbookConfigSchema,
|
||||
nodeHostCommands: logbookNodeHostCommands,
|
||||
register(api: OpenClawPluginApi) {
|
||||
const config = logbookConfigSchema.parse(api.pluginConfig);
|
||||
let service: LogbookService | null = null;
|
||||
|
||||
const requireService = () => {
|
||||
if (!service) {
|
||||
throw new Error("Logbook service is not running");
|
||||
}
|
||||
return service;
|
||||
};
|
||||
|
||||
const sendError = (respond: GatewayRequestHandlerOptions["respond"], err: unknown) => {
|
||||
const message = formatErrorMessage(err);
|
||||
respond(false, { error: message }, errorShape(ErrorCodes.UNAVAILABLE, message));
|
||||
};
|
||||
|
||||
const handle =
|
||||
(run: (params: unknown) => unknown) =>
|
||||
async ({ params, respond }: GatewayRequestHandlerOptions) => {
|
||||
try {
|
||||
respond(true, await run(params));
|
||||
} catch (err) {
|
||||
sendError(respond, err);
|
||||
}
|
||||
};
|
||||
|
||||
// Declares the dashboard tab; the Control UI renders it only while this
|
||||
// plugin is active, so no core code references the plugin id.
|
||||
api.session.controls.registerControlUiDescriptor({
|
||||
surface: "tab",
|
||||
id: "logbook",
|
||||
label: "Logbook",
|
||||
description: "Your day as a timeline, built from screen snapshots.",
|
||||
icon: "sun",
|
||||
group: "control",
|
||||
requiredScopes: ["operator.write"],
|
||||
});
|
||||
|
||||
// Adds logbook.snapshot to the default macOS node allowlist; without a
|
||||
// policy the gateway strips plugin commands from pairing surfaces.
|
||||
api.registerNodeInvokePolicy({
|
||||
commands: ["logbook.snapshot"],
|
||||
defaultPlatforms: ["macos"],
|
||||
handle: async (ctx) => {
|
||||
// Honor the operator's screen-capture kill switch: a screen.snapshot
|
||||
// deny must block this capture command too, not just the app node's.
|
||||
const denied = ctx.config.gateway?.nodes?.denyCommands ?? [];
|
||||
if (denied.includes("screen.snapshot")) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "SCREEN_CAPTURE_DENIED",
|
||||
message:
|
||||
"screen capture is denied by gateway.nodes.denyCommands (screen.snapshot); Logbook capture stays blocked until it is removed",
|
||||
};
|
||||
}
|
||||
return await ctx.invokeNode();
|
||||
},
|
||||
});
|
||||
|
||||
api.registerService({
|
||||
id: "logbook",
|
||||
start: (ctx) => {
|
||||
service = new LogbookService(config, {
|
||||
runtime: api.runtime,
|
||||
fullConfig: ctx.config,
|
||||
logger: ctx.logger,
|
||||
dataDir: path.join(ctx.stateDir, "logbook"),
|
||||
});
|
||||
service.start();
|
||||
},
|
||||
stop: () => {
|
||||
service?.stop();
|
||||
service = null;
|
||||
},
|
||||
});
|
||||
|
||||
// Unscoped plugin methods are authorized as operator.admin; explicit
|
||||
// scopes keep the tab usable for read/write-scoped Control UI sessions.
|
||||
const registerRead = (method: string, run: (params: unknown) => unknown) =>
|
||||
api.registerGatewayMethod(method, handle(run), { scope: "operator.read" });
|
||||
const registerWrite = (method: string, run: (params: unknown) => unknown) =>
|
||||
api.registerGatewayMethod(method, handle(run), { scope: "operator.write" });
|
||||
|
||||
// Raw frame bytes are the most sensitive payload (full screen contents),
|
||||
// so they require write scope while derived text stays readable.
|
||||
registerRead("logbook.status", () => requireService().status());
|
||||
|
||||
registerRead("logbook.days", () => ({ days: requireService().listDays() }));
|
||||
|
||||
registerRead("logbook.timeline", (params) => {
|
||||
const day = readDayParam(params);
|
||||
const svc = requireService();
|
||||
return { day, cards: svc.cardsForDay(day), stats: svc.dayStats(day) };
|
||||
});
|
||||
|
||||
registerWrite("logbook.frames", (params) => {
|
||||
const startMs = readNumberParam(params, "startMs");
|
||||
const endMs = readNumberParam(params, "endMs");
|
||||
const frames = requireService()
|
||||
.framesInRange(startMs, endMs)
|
||||
.map((frame) => ({ id: frame.id, capturedAtMs: frame.capturedAtMs, idle: frame.idle }));
|
||||
return { frames };
|
||||
});
|
||||
|
||||
registerWrite("logbook.frame", (params) => {
|
||||
const frameId = readNumberParam(params, "frameId");
|
||||
const frame = requireService().frameById(frameId);
|
||||
if (!frame) {
|
||||
throw new Error(`frame ${frameId} not found`);
|
||||
}
|
||||
return {
|
||||
frameId: frame.id,
|
||||
capturedAtMs: frame.capturedAtMs,
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
format: "jpeg",
|
||||
base64: readFileSync(frame.path).toString("base64"),
|
||||
};
|
||||
});
|
||||
|
||||
// Standup and ask spend model tokens; capture/analyze mutate runtime state.
|
||||
registerWrite("logbook.standup", (params) => {
|
||||
const refresh = (params as { refresh?: unknown } | undefined)?.refresh === true;
|
||||
return requireService().standup(readDayParam(params), refresh);
|
||||
});
|
||||
|
||||
registerWrite("logbook.ask", async (params) => {
|
||||
const question = (params as { question?: unknown } | undefined)?.question;
|
||||
if (typeof question !== "string" || question.trim().length === 0) {
|
||||
throw new Error("question is required");
|
||||
}
|
||||
const answer = await requireService().ask(readDayParam(params), question.trim());
|
||||
return { answer };
|
||||
});
|
||||
|
||||
registerWrite("logbook.capture.set", (params) => {
|
||||
const paused = (params as { paused?: unknown } | undefined)?.paused === true;
|
||||
const svc = requireService();
|
||||
svc.setCapturePaused(paused);
|
||||
return svc.status();
|
||||
});
|
||||
|
||||
registerWrite("logbook.analyze.now", () => requireService().analyzeNow());
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clockToMs,
|
||||
extractJsonPayload,
|
||||
parseCardsJson,
|
||||
parseObservationSegments,
|
||||
pickKeyframeId,
|
||||
revisionWindow,
|
||||
sampleFrames,
|
||||
selectBatchFrames,
|
||||
validateCardCoverage,
|
||||
} from "./analyze.js";
|
||||
|
||||
const DAY = "2026-07-03";
|
||||
const dayMs = (clock: string) => {
|
||||
const ms = clockToMs(DAY, clock);
|
||||
if (ms === null) {
|
||||
throw new Error(`bad clock ${clock}`);
|
||||
}
|
||||
return ms;
|
||||
};
|
||||
|
||||
describe("clockToMs", () => {
|
||||
it("parses 24h and 12h clocks on the local day", () => {
|
||||
expect(dayMs("13:05:30") - dayMs("13:05:00")).toBe(30_000);
|
||||
expect(dayMs("1:05 pm")).toBe(dayMs("13:05:00"));
|
||||
expect(dayMs("12:00 am")).toBe(dayMs("00:00:00"));
|
||||
});
|
||||
|
||||
it("rejects malformed input", () => {
|
||||
expect(clockToMs(DAY, "25:00:00")).toBeNull();
|
||||
expect(clockToMs(DAY, "half past nine")).toBeNull();
|
||||
expect(clockToMs("not-a-day", "10:00:00")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves local wall-clock time across a DST transition", () => {
|
||||
const moduleUrl = new URL("./analyze.ts", import.meta.url).href;
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"--eval",
|
||||
`const { clockToMs } = await import(${JSON.stringify(moduleUrl)}); process.stdout.write(JSON.stringify([clockToMs("2026-03-08", "10:00:00"), clockToMs("2026-03-08", "02:30:00")]));`,
|
||||
],
|
||||
{ encoding: "utf8", env: { ...process.env, TZ: "America/New_York" } },
|
||||
);
|
||||
|
||||
expect(JSON.parse(output)).toEqual([Date.UTC(2026, 2, 8, 14), null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractJsonPayload", () => {
|
||||
it("strips fences and surrounding prose", () => {
|
||||
expect(extractJsonPayload('```json\n{"a":1}\n```')).toBe('{"a":1}');
|
||||
expect(extractJsonPayload('Here you go:\n[{"a":1}]\nHope that helps!')).toBe('[{"a":1}]');
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseObservationSegments", () => {
|
||||
const startMs = dayMs("10:00:00");
|
||||
const endMs = dayMs("10:15:00");
|
||||
|
||||
it("parses and clamps segments into the batch window", () => {
|
||||
const raw = JSON.stringify({
|
||||
segments: [
|
||||
{ start: "09:55:00", end: "10:05:00", description: "VS Code: editing store.ts" },
|
||||
{ start: "10:05:00", end: "10:20:00", description: "Chrome: reviewing PR #99" },
|
||||
],
|
||||
});
|
||||
const segments = parseObservationSegments({ raw, day: DAY, startMs, endMs });
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(segments[0].startMs).toBe(startMs);
|
||||
expect(segments[1].endMs).toBe(endMs);
|
||||
});
|
||||
|
||||
it("returns empty on unparseable output", () => {
|
||||
expect(parseObservationSegments({ raw: "no json here", day: DAY, startMs, endMs })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCardsJson", () => {
|
||||
const windowStartMs = dayMs("10:00:00");
|
||||
const windowEndMs = dayMs("11:00:00");
|
||||
|
||||
const card = (overrides: Record<string, unknown> = {}) => ({
|
||||
startTime: "10:00:00",
|
||||
endTime: "10:30:00",
|
||||
category: "coding",
|
||||
title: "Working on logbook store",
|
||||
summary: "Implemented SQLite store",
|
||||
detailedSummary: "Added frames and cards tables.",
|
||||
distractions: [],
|
||||
appSites: { primary: "github.com" },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("accepts a valid card array and normalizes fields", () => {
|
||||
const result = parseCardsJson({
|
||||
raw: JSON.stringify([
|
||||
card({ category: "CODING", appSites: { primary: "https://GitHub.com/openclaw" } }),
|
||||
]),
|
||||
day: DAY,
|
||||
windowStartMs,
|
||||
windowEndMs,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.drafts[0].category).toBe("coding");
|
||||
expect(result.drafts[0].appPrimary).toBe("github.com");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps unknown categories to other", () => {
|
||||
const result = parseCardsJson({
|
||||
raw: JSON.stringify([card({ category: "quantum-vibes" })]),
|
||||
day: DAY,
|
||||
windowStartMs,
|
||||
windowEndMs,
|
||||
});
|
||||
expect(result.ok && result.drafts[0].category).toBe("other");
|
||||
});
|
||||
|
||||
it("trims sub-minute overlaps and rejects large ones", () => {
|
||||
const trimmed = parseCardsJson({
|
||||
raw: JSON.stringify([
|
||||
card({ startTime: "10:00:00", endTime: "10:30:30" }),
|
||||
card({ startTime: "10:30:00", endTime: "11:00:00", title: "Second" }),
|
||||
]),
|
||||
day: DAY,
|
||||
windowStartMs,
|
||||
windowEndMs,
|
||||
});
|
||||
expect(trimmed.ok).toBe(true);
|
||||
if (trimmed.ok) {
|
||||
expect(trimmed.drafts[1].startMs).toBe(trimmed.drafts[0].endMs);
|
||||
}
|
||||
|
||||
const rejected = parseCardsJson({
|
||||
raw: JSON.stringify([
|
||||
card({ startTime: "10:00:00", endTime: "10:45:00" }),
|
||||
card({ startTime: "10:30:00", endTime: "11:00:00", title: "Second" }),
|
||||
]),
|
||||
day: DAY,
|
||||
windowStartMs,
|
||||
windowEndMs,
|
||||
});
|
||||
expect(rejected.ok).toBe(false);
|
||||
if (!rejected.ok) {
|
||||
expect(rejected.error).toContain("overlap");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports actionable errors for the correction round-trip", () => {
|
||||
const result = parseCardsJson({
|
||||
raw: JSON.stringify([card({ startTime: "later that day" })]),
|
||||
day: DAY,
|
||||
windowStartMs,
|
||||
windowEndMs,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("startTime");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectBatchFrames", () => {
|
||||
const windowMs = 15 * 60_000;
|
||||
const t0 = dayMs("10:00:00");
|
||||
const frame = (id: number, offsetSec: number) => ({ id, capturedAtMs: t0 + offsetSec * 1000 });
|
||||
|
||||
it("keeps an in-progress window open", () => {
|
||||
const frames = [frame(1, 0), frame(2, 30), frame(3, 60)];
|
||||
expect(selectBatchFrames({ frames, windowMs, nowMs: t0 + 5 * 60_000 })).toBeNull();
|
||||
});
|
||||
|
||||
it("closes an elapsed window at its boundary so batches meet cleanly", () => {
|
||||
const frames = [frame(1, 0), frame(2, 30), frame(3, 60)];
|
||||
const selection = selectBatchFrames({ frames, windowMs, nowMs: t0 + windowMs + 1000 });
|
||||
expect(selection?.frameIds).toEqual([1, 2, 3]);
|
||||
expect(selection?.startMs).toBe(t0);
|
||||
expect(selection?.endMs).toBe(t0 + windowMs);
|
||||
});
|
||||
|
||||
it("keeps the boundary when the next frame starts the following window", () => {
|
||||
const frames = [frame(1, 0), frame(2, 30), frame(3, 60), frame(4, 15 * 60)];
|
||||
const selection = selectBatchFrames({ frames, windowMs, nowMs: t0 + windowMs + 1000 });
|
||||
expect(selection?.frameIds).toEqual([1, 2, 3]);
|
||||
expect(selection?.endMs).toBe(t0 + windowMs);
|
||||
});
|
||||
|
||||
it("splits on capture gaps without claiming the idle span", () => {
|
||||
const frames = [frame(1, 0), frame(2, 30), frame(3, 400)];
|
||||
const selection = selectBatchFrames({ frames, windowMs, nowMs: t0 + 60_000 });
|
||||
expect(selection?.frameIds).toEqual([1, 2]);
|
||||
expect(selection?.endMs).toBe(t0 + 30_000 + 1);
|
||||
});
|
||||
|
||||
it("force-closes an in-progress window at the last observed frame", () => {
|
||||
const frames = [frame(1, 0), frame(2, 30)];
|
||||
expect(selectBatchFrames({ frames, windowMs, nowMs: t0 + 60_000 })).toBeNull();
|
||||
const forced = selectBatchFrames({ frames, windowMs, nowMs: t0 + 60_000, force: true });
|
||||
expect(forced?.frameIds).toEqual([1, 2]);
|
||||
expect(forced?.endMs).toBe(t0 + 30_000 + 1);
|
||||
});
|
||||
|
||||
it("splits at local midnight so batch clocks stay on one day", () => {
|
||||
const nearMidnight = new Date(`${DAY}T23:59:30`).getTime();
|
||||
const frames = [
|
||||
{ id: 1, capturedAtMs: nearMidnight },
|
||||
{ id: 2, capturedAtMs: nearMidnight + 20_000 },
|
||||
{ id: 3, capturedAtMs: nearMidnight + 50_000 },
|
||||
];
|
||||
const selection = selectBatchFrames({ frames, windowMs, nowMs: nearMidnight + 60_000 });
|
||||
expect(selection?.frameIds).toEqual([1, 2]);
|
||||
expect(selection?.endMs).toBe(nearMidnight + 20_000 + 1);
|
||||
});
|
||||
|
||||
it("caps an elapsed window at midnight without a next-day frame", () => {
|
||||
const nearMidnight = new Date(`${DAY}T23:59:30`).getTime();
|
||||
const midnight = new Date(nearMidnight);
|
||||
midnight.setHours(24, 0, 0, 0);
|
||||
const frames = [
|
||||
{ id: 1, capturedAtMs: nearMidnight },
|
||||
{ id: 2, capturedAtMs: nearMidnight + 20_000 },
|
||||
];
|
||||
const selection = selectBatchFrames({ frames, windowMs, nowMs: nearMidnight + windowMs });
|
||||
expect(selection?.frameIds).toEqual([1, 2]);
|
||||
expect(selection?.endMs).toBe(midnight.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("sampleFrames", () => {
|
||||
it("keeps small sets and evenly samples large ones", () => {
|
||||
expect(sampleFrames([1, 2, 3], 16)).toEqual([1, 2, 3]);
|
||||
const sampled = sampleFrames(
|
||||
Array.from({ length: 100 }, (_, i) => i),
|
||||
16,
|
||||
);
|
||||
expect(sampled.length).toBeLessThanOrEqual(16);
|
||||
expect(sampled[0]).toBe(0);
|
||||
expect(sampled[sampled.length - 1]).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCardCoverage", () => {
|
||||
const window = { windowStartMs: dayMs("10:00:00"), windowEndMs: dayMs("11:00:00") };
|
||||
const span = (start: string, end: string) => ({ startMs: dayMs(start), endMs: dayMs(end) });
|
||||
|
||||
it("accepts drafts covering all required spans within tolerance", () => {
|
||||
const result = validateCardCoverage({
|
||||
drafts: [span("10:01:00", "10:30:00"), span("10:30:00", "10:59:00")],
|
||||
requiredSpans: [span("10:00:00", "11:00:00")],
|
||||
...window,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects partial outputs that would erase previous cards", () => {
|
||||
// Model returned only the new batch span, dropping the 10:00-10:30 card.
|
||||
const result = validateCardCoverage({
|
||||
drafts: [span("10:30:00", "11:00:00")],
|
||||
requiredSpans: [span("10:00:00", "10:30:00"), span("10:30:00", "11:00:00")],
|
||||
...window,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("not covered");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects drafts outside the revision window", () => {
|
||||
const result = validateCardCoverage({
|
||||
drafts: [span("09:00:00", "11:00:00")],
|
||||
requiredSpans: [span("10:00:00", "11:00:00")],
|
||||
...window,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("outside the revision window");
|
||||
}
|
||||
});
|
||||
|
||||
it("tolerates gaps inside required spans up to the tolerance", () => {
|
||||
const result = validateCardCoverage({
|
||||
drafts: [span("10:00:00", "10:29:00"), span("10:30:30", "11:00:00")],
|
||||
requiredSpans: [span("10:00:00", "11:00:00")],
|
||||
...window,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("revisionWindow / pickKeyframeId", () => {
|
||||
it("expands the window to cover previous draft cards", () => {
|
||||
const window = revisionWindow({
|
||||
batchStartMs: dayMs("10:30:00"),
|
||||
batchEndMs: dayMs("10:45:00"),
|
||||
previousCards: [
|
||||
{
|
||||
id: 1,
|
||||
day: DAY,
|
||||
startMs: dayMs("10:00:00"),
|
||||
endMs: dayMs("10:30:00"),
|
||||
title: "t",
|
||||
summary: "s",
|
||||
detail: "",
|
||||
category: "coding",
|
||||
distractions: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(window.startMs).toBe(dayMs("10:00:00"));
|
||||
expect(window.endMs).toBe(dayMs("10:45:00"));
|
||||
});
|
||||
|
||||
it("picks the frame closest to the card midpoint", () => {
|
||||
const frames = [
|
||||
{ id: 1, capturedAtMs: dayMs("10:00:00") },
|
||||
{ id: 2, capturedAtMs: dayMs("10:14:00") },
|
||||
{ id: 3, capturedAtMs: dayMs("10:29:00") },
|
||||
];
|
||||
const keyframe = pickKeyframeId(
|
||||
{ startMs: dayMs("10:00:00"), endMs: dayMs("10:30:00") },
|
||||
frames,
|
||||
);
|
||||
expect(keyframe).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
// Logbook analysis pipeline: frames -> observations -> revised timeline cards.
|
||||
// Pure parsing/validation lives here so tests can cover it without the SDK.
|
||||
import { CARD_CATEGORIES } from "./prompts.js";
|
||||
import { dayKeyFor } from "./store.js";
|
||||
import type { LogbookCard, LogbookCardDraft, LogbookDistraction } from "./types.js";
|
||||
|
||||
/** Cards within this window before a batch are treated as a revisable draft. */
|
||||
export const CARD_LOOKBACK_MS = 45 * 60 * 1000;
|
||||
/** Frame gap that splits one analysis window into separate batches. */
|
||||
export const BATCH_MAX_GAP_MS = 2 * 60 * 1000;
|
||||
/** Upper bound of images sent to the vision model per batch. */
|
||||
export const MAX_FRAMES_PER_CALL = 16;
|
||||
|
||||
export type ParsedSegment = { startMs: number; endMs: number; text: string };
|
||||
|
||||
/** Parses "HH:MM:SS" (or "H:MM", with optional am/pm) on a local day into epoch ms. */
|
||||
export function clockToMs(day: string, clock: string): number | null {
|
||||
const match = /^\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?\s*$/i.exec(clock);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
let hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
const seconds = Number(match[3] ?? "0");
|
||||
const meridiem = match[4]?.toLowerCase();
|
||||
if (meridiem === "pm" && hours < 12) {
|
||||
hours += 12;
|
||||
}
|
||||
if (meridiem === "am" && hours === 12) {
|
||||
hours = 0;
|
||||
}
|
||||
if (hours > 23 || minutes > 59 || seconds > 59) {
|
||||
return null;
|
||||
}
|
||||
const dayMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
|
||||
if (!dayMatch) {
|
||||
return null;
|
||||
}
|
||||
const year = Number(dayMatch[1]);
|
||||
const monthIndex = Number(dayMatch[2]) - 1;
|
||||
const dayOfMonth = Number(dayMatch[3]);
|
||||
const date = new Date(year, monthIndex, dayOfMonth, hours, minutes, seconds);
|
||||
// Component construction preserves wall-clock time across DST boundaries;
|
||||
// rejecting normalized fields also excludes invalid dates and skipped hours.
|
||||
if (
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== monthIndex ||
|
||||
date.getDate() !== dayOfMonth ||
|
||||
date.getHours() !== hours ||
|
||||
date.getMinutes() !== minutes ||
|
||||
date.getSeconds() !== seconds
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
/** Strips code fences and extracts the outermost JSON array/object from model text. */
|
||||
export function extractJsonPayload(raw: string): string {
|
||||
const cleaned = raw.replaceAll("```json", "").replaceAll("```", "").trim();
|
||||
const firstBracket = cleaned.search(/[[{]/);
|
||||
if (firstBracket < 0) {
|
||||
return cleaned;
|
||||
}
|
||||
const open = cleaned[firstBracket];
|
||||
const close = open === "[" ? "]" : "}";
|
||||
const lastClose = cleaned.lastIndexOf(close);
|
||||
if (lastClose > firstBracket) {
|
||||
return cleaned.slice(firstBracket, lastClose + 1);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function parseObservationSegments(params: {
|
||||
raw: string;
|
||||
day: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}): ParsedSegment[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(extractJsonPayload(params.raw));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const list = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed &&
|
||||
typeof parsed === "object" &&
|
||||
Array.isArray((parsed as { segments?: unknown }).segments)
|
||||
? (parsed as { segments: unknown[] }).segments
|
||||
: [];
|
||||
const segments: ParsedSegment[] = [];
|
||||
for (const entry of list) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const description = typeof record.description === "string" ? record.description.trim() : "";
|
||||
const startMs = typeof record.start === "string" ? clockToMs(params.day, record.start) : null;
|
||||
const endMs = typeof record.end === "string" ? clockToMs(params.day, record.end) : null;
|
||||
if (!description || startMs === null || endMs === null) {
|
||||
continue;
|
||||
}
|
||||
const clampedStart = Math.max(params.startMs, Math.min(startMs, params.endMs));
|
||||
const clampedEnd = Math.max(clampedStart, Math.min(endMs, params.endMs));
|
||||
segments.push({ startMs: clampedStart, endMs: clampedEnd, text: description });
|
||||
}
|
||||
return segments.toSorted((a, b) => a.startMs - b.startMs);
|
||||
}
|
||||
|
||||
type RawCard = {
|
||||
startTime?: unknown;
|
||||
endTime?: unknown;
|
||||
category?: unknown;
|
||||
title?: unknown;
|
||||
summary?: unknown;
|
||||
detailedSummary?: unknown;
|
||||
distractions?: unknown;
|
||||
appSites?: unknown;
|
||||
};
|
||||
|
||||
export type CardParseResult =
|
||||
| { ok: true; drafts: LogbookCardDraft[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function normalizeCategory(value: unknown): string {
|
||||
const category = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
return (CARD_CATEGORIES as readonly string[]).includes(category) ? category : "other";
|
||||
}
|
||||
|
||||
function normalizeDomain(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const domain = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.split(/[/?#]/)[0];
|
||||
return domain && domain.length <= 100 ? domain : undefined;
|
||||
}
|
||||
|
||||
function parseDistractions(day: string, value: unknown): LogbookDistraction[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const distractions: LogbookDistraction[] = [];
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const startMs = typeof record.startTime === "string" ? clockToMs(day, record.startTime) : null;
|
||||
const endMs = typeof record.endTime === "string" ? clockToMs(day, record.endTime) : null;
|
||||
const title = typeof record.title === "string" ? record.title.trim() : "";
|
||||
if (startMs === null || endMs === null || !title || endMs <= startMs) {
|
||||
continue;
|
||||
}
|
||||
distractions.push({ startMs, endMs, title });
|
||||
}
|
||||
return distractions;
|
||||
}
|
||||
|
||||
export function parseCardsJson(params: {
|
||||
raw: string;
|
||||
day: string;
|
||||
windowStartMs: number;
|
||||
windowEndMs: number;
|
||||
}): CardParseResult {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(extractJsonPayload(params.raw));
|
||||
} catch (err) {
|
||||
return { ok: false, error: `Output is not valid JSON: ${(err as Error).message}` };
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { ok: false, error: "Output must be a JSON array of cards." };
|
||||
}
|
||||
const drafts: LogbookCardDraft[] = [];
|
||||
const problems: string[] = [];
|
||||
parsed.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
problems.push(`Card ${index}: not an object.`);
|
||||
return;
|
||||
}
|
||||
const raw = entry as RawCard;
|
||||
const title = typeof raw.title === "string" ? raw.title.trim() : "";
|
||||
const summary = typeof raw.summary === "string" ? raw.summary.trim() : "";
|
||||
const startMs = typeof raw.startTime === "string" ? clockToMs(params.day, raw.startTime) : null;
|
||||
const endMs = typeof raw.endTime === "string" ? clockToMs(params.day, raw.endTime) : null;
|
||||
if (startMs === null || endMs === null) {
|
||||
problems.push(`Card ${index}: startTime/endTime must be HH:MM:SS local time.`);
|
||||
return;
|
||||
}
|
||||
if (endMs <= startMs) {
|
||||
problems.push(`Card ${index}: endTime must be after startTime.`);
|
||||
return;
|
||||
}
|
||||
if (!title || !summary) {
|
||||
problems.push(`Card ${index}: title and summary are required.`);
|
||||
return;
|
||||
}
|
||||
const appSites =
|
||||
raw.appSites && typeof raw.appSites === "object"
|
||||
? (raw.appSites as Record<string, unknown>)
|
||||
: {};
|
||||
drafts.push({
|
||||
day: params.day,
|
||||
startMs,
|
||||
endMs,
|
||||
title,
|
||||
summary,
|
||||
detail: typeof raw.detailedSummary === "string" ? raw.detailedSummary.trim() : "",
|
||||
category: normalizeCategory(raw.category),
|
||||
appPrimary: normalizeDomain(appSites.primary),
|
||||
appSecondary: normalizeDomain(appSites.secondary),
|
||||
distractions: parseDistractions(params.day, raw.distractions),
|
||||
keyframeId: undefined,
|
||||
});
|
||||
});
|
||||
if (problems.length > 0) {
|
||||
return { ok: false, error: problems.join("\n") };
|
||||
}
|
||||
if (drafts.length === 0) {
|
||||
return { ok: false, error: "Output contained no valid cards." };
|
||||
}
|
||||
const sorted = drafts.toSorted((a, b) => a.startMs - b.startMs);
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
const overlapMs = sorted[i - 1].endMs - sorted[i].startMs;
|
||||
if (overlapMs > 60 * 1000) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Cards ${i - 1} and ${i} overlap by ${Math.round(overlapMs / 60000)} minutes; adjacent cards must meet cleanly.`,
|
||||
};
|
||||
}
|
||||
if (overlapMs > 0) {
|
||||
// Trim sub-minute overlaps instead of round-tripping to the model again.
|
||||
sorted[i] = { ...sorted[i], startMs: sorted[i - 1].endMs };
|
||||
}
|
||||
}
|
||||
return { ok: true, drafts: sorted };
|
||||
}
|
||||
|
||||
/** Sub-minute slack so minute-rounded model times do not fail coverage checks. */
|
||||
export const COVERAGE_TOLERANCE_MS = 2 * 60 * 1000;
|
||||
|
||||
function formatClockForError(ms: number): string {
|
||||
const date = new Date(ms);
|
||||
const hh = String(date.getHours()).padStart(2, "0");
|
||||
const mm = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that drafts cover every required span before the destructive
|
||||
* window replacement. Without this, a syntactically valid but partial model
|
||||
* output would silently erase previously synthesized cards.
|
||||
*/
|
||||
export function validateCardCoverage(params: {
|
||||
drafts: Array<{ startMs: number; endMs: number }>;
|
||||
requiredSpans: Array<{ startMs: number; endMs: number }>;
|
||||
windowStartMs: number;
|
||||
windowEndMs: number;
|
||||
toleranceMs?: number;
|
||||
}): { ok: true } | { ok: false; error: string } {
|
||||
const tolerance = params.toleranceMs ?? COVERAGE_TOLERANCE_MS;
|
||||
const problems: string[] = [];
|
||||
for (const draft of params.drafts) {
|
||||
if (
|
||||
draft.startMs < params.windowStartMs - tolerance ||
|
||||
draft.endMs > params.windowEndMs + tolerance
|
||||
) {
|
||||
problems.push(
|
||||
`Card ${formatClockForError(draft.startMs)}-${formatClockForError(draft.endMs)} lies outside the revision window ${formatClockForError(params.windowStartMs)}-${formatClockForError(params.windowEndMs)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const covered = params.drafts
|
||||
.map((draft) => ({ startMs: draft.startMs, endMs: draft.endMs }))
|
||||
.toSorted((a, b) => a.startMs - b.startMs);
|
||||
for (const span of params.requiredSpans) {
|
||||
let cursor = span.startMs;
|
||||
for (const interval of covered) {
|
||||
if (interval.endMs <= cursor) {
|
||||
continue;
|
||||
}
|
||||
if (interval.startMs > cursor + tolerance) {
|
||||
break;
|
||||
}
|
||||
cursor = Math.max(cursor, interval.endMs);
|
||||
if (cursor >= span.endMs - tolerance) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cursor < span.endMs - tolerance) {
|
||||
problems.push(
|
||||
`Time ${formatClockForError(Math.max(cursor, span.startMs))}-${formatClockForError(span.endMs)} from the previous timeline is not covered; do not drop existing cards or observed time.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (problems.length > 0) {
|
||||
return { ok: false, error: problems.join("\n") };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Union of the revision window: previous draft cards plus the new batch range. */
|
||||
export function revisionWindow(params: {
|
||||
batchStartMs: number;
|
||||
batchEndMs: number;
|
||||
previousCards: LogbookCard[];
|
||||
}): { startMs: number; endMs: number } {
|
||||
let startMs = params.batchStartMs;
|
||||
let endMs = params.batchEndMs;
|
||||
for (const card of params.previousCards) {
|
||||
startMs = Math.min(startMs, card.startMs);
|
||||
endMs = Math.max(endMs, card.endMs);
|
||||
}
|
||||
return { startMs, endMs };
|
||||
}
|
||||
|
||||
/** Groups pending frames into one batch window, splitting on large gaps. */
|
||||
export function selectBatchFrames(params: {
|
||||
frames: Array<{ id: number; capturedAtMs: number }>;
|
||||
/** Close an in-progress window immediately instead of waiting for elapse. */
|
||||
force?: boolean;
|
||||
windowMs: number;
|
||||
nowMs: number;
|
||||
}): { frameIds: number[]; startMs: number; endMs: number } | null {
|
||||
if (params.frames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const first = params.frames[0];
|
||||
const firstDay = dayKeyFor(first.capturedAtMs);
|
||||
const nextDayStart = new Date(first.capturedAtMs);
|
||||
nextDayStart.setHours(24, 0, 0, 0);
|
||||
const windowEnd = Math.min(first.capturedAtMs + params.windowMs, nextDayStart.getTime());
|
||||
const selected: Array<{ id: number; capturedAtMs: number }> = [];
|
||||
let previousTs = first.capturedAtMs;
|
||||
let endedEarly = false;
|
||||
for (const frame of params.frames) {
|
||||
if (frame.capturedAtMs >= windowEnd) {
|
||||
endedEarly = dayKeyFor(frame.capturedAtMs) !== firstDay;
|
||||
break;
|
||||
}
|
||||
if (selected.length > 0 && frame.capturedAtMs - previousTs > BATCH_MAX_GAP_MS) {
|
||||
endedEarly = true;
|
||||
break;
|
||||
}
|
||||
// Batches never span local midnight: every downstream clock (observations,
|
||||
// cards, day keys) is parsed against the batch's single day.
|
||||
if (selected.length > 0 && dayKeyFor(frame.capturedAtMs) !== firstDay) {
|
||||
endedEarly = true;
|
||||
break;
|
||||
}
|
||||
selected.push(frame);
|
||||
previousTs = frame.capturedAtMs;
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const last = selected[selected.length - 1];
|
||||
// Only close a batch once its window has elapsed (or a gap/midnight ended
|
||||
// it), so a window in progress keeps accumulating frames; `force` closes an
|
||||
// in-progress window immediately (analyze now).
|
||||
const windowElapsed = params.nowMs >= windowEnd;
|
||||
if (!windowElapsed && !endedEarly && !params.force) {
|
||||
return null;
|
||||
}
|
||||
// A normally elapsed window ends at its boundary so consecutive batches meet
|
||||
// cleanly; ending at the last frame would leak one capture interval per
|
||||
// batch as a permanent timeline gap. Early or forced closures must not claim
|
||||
// time past the last observed frame (idle gap, next day, or the future).
|
||||
const endMs = windowElapsed && !endedEarly ? windowEnd : last.capturedAtMs + 1;
|
||||
return {
|
||||
frameIds: selected.map((frame) => frame.id),
|
||||
startMs: first.capturedAtMs,
|
||||
endMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Evenly samples frames so a batch stays within the per-call image budget. */
|
||||
export function sampleFrames<T>(frames: T[], max: number): T[] {
|
||||
if (frames.length <= max) {
|
||||
return frames;
|
||||
}
|
||||
const sampled: T[] = [];
|
||||
const step = (frames.length - 1) / (max - 1);
|
||||
for (let i = 0; i < max; i += 1) {
|
||||
sampled.push(frames[Math.round(i * step)]);
|
||||
}
|
||||
return [...new Set(sampled)];
|
||||
}
|
||||
|
||||
/** Picks the frame closest to a card's midpoint as its keyframe. */
|
||||
export function pickKeyframeId(
|
||||
card: { startMs: number; endMs: number },
|
||||
frames: Array<{ id: number; capturedAtMs: number }>,
|
||||
): number | undefined {
|
||||
if (frames.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const midpoint = card.startMs + (card.endMs - card.startMs) / 2;
|
||||
let best = frames[0];
|
||||
for (const frame of frames) {
|
||||
if (Math.abs(frame.capturedAtMs - midpoint) < Math.abs(best.capturedAtMs - midpoint)) {
|
||||
best = frame;
|
||||
}
|
||||
}
|
||||
return best.id;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Logbook plugin config resolution: clamps operator input into safe runtime bounds.
|
||||
|
||||
export type LogbookConfig = {
|
||||
captureEnabled: boolean;
|
||||
captureIntervalSeconds: number;
|
||||
analysisIntervalMinutes: number;
|
||||
nodeId?: string;
|
||||
screenIndex: number;
|
||||
maxWidth: number;
|
||||
visionModel?: string;
|
||||
retentionDays: number;
|
||||
};
|
||||
|
||||
const DEFAULTS = {
|
||||
captureEnabled: true,
|
||||
captureIntervalSeconds: 30,
|
||||
analysisIntervalMinutes: 15,
|
||||
screenIndex: 0,
|
||||
maxWidth: 1440,
|
||||
retentionDays: 14,
|
||||
} as const;
|
||||
|
||||
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const num = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
return Math.min(max, Math.max(min, Math.round(num)));
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function resolveLogbookConfig(raw: unknown): LogbookConfig {
|
||||
const value = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||
return {
|
||||
captureEnabled: value.captureEnabled !== false,
|
||||
captureIntervalSeconds: clampNumber(
|
||||
value.captureIntervalSeconds,
|
||||
DEFAULTS.captureIntervalSeconds,
|
||||
5,
|
||||
600,
|
||||
),
|
||||
analysisIntervalMinutes: clampNumber(
|
||||
value.analysisIntervalMinutes,
|
||||
DEFAULTS.analysisIntervalMinutes,
|
||||
3,
|
||||
120,
|
||||
),
|
||||
nodeId: optionalString(value.nodeId),
|
||||
screenIndex: clampNumber(value.screenIndex, DEFAULTS.screenIndex, 0, 16),
|
||||
maxWidth: clampNumber(value.maxWidth, DEFAULTS.maxWidth, 480, 3840),
|
||||
visionModel: optionalString(value.visionModel),
|
||||
retentionDays: clampNumber(value.retentionDays, DEFAULTS.retentionDays, 1, 365),
|
||||
};
|
||||
}
|
||||
|
||||
/** Splits a "provider/model" ref; model ids may themselves contain slashes. */
|
||||
export function parseModelRef(ref: string): { provider: string; model: string } | null {
|
||||
const slash = ref.indexOf("/");
|
||||
if (slash <= 0 || slash === ref.length - 1) {
|
||||
return null;
|
||||
}
|
||||
return { provider: ref.slice(0, slash), model: ref.slice(slash + 1) };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Logbook node-host command: screen capture for headless node hosts (macOS).
|
||||
// Nodes without the OpenClaw app (plain `openclaw node host run`) advertise
|
||||
// logbook.snapshot so capture works anywhere the plugin is enabled.
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export type LogbookSnapshotParams = {
|
||||
screenIndex?: number;
|
||||
maxWidth?: number;
|
||||
quality?: number;
|
||||
};
|
||||
|
||||
export type LogbookSnapshotPayload = { format: "jpeg"; base64: string } | { error: string };
|
||||
|
||||
function readParams(value: unknown): LogbookSnapshotParams {
|
||||
if (!value || typeof value !== "object") {
|
||||
return {};
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const num = (key: string) => {
|
||||
const candidate = record[key];
|
||||
return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : undefined;
|
||||
};
|
||||
return { screenIndex: num("screenIndex"), maxWidth: num("maxWidth"), quality: num("quality") };
|
||||
}
|
||||
|
||||
export async function handleLogbookSnapshot(rawParams: unknown): Promise<LogbookSnapshotPayload> {
|
||||
if (process.platform !== "darwin") {
|
||||
return { error: `logbook.snapshot is not supported on ${process.platform}` };
|
||||
}
|
||||
const params = readParams(rawParams);
|
||||
const screenIndex = Math.max(0, Math.round(params.screenIndex ?? 0));
|
||||
const maxWidth = params.maxWidth && params.maxWidth >= 480 ? Math.round(params.maxWidth) : 1440;
|
||||
const qualityPct = Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
10,
|
||||
Math.round(
|
||||
(params.quality && params.quality > 0 && params.quality <= 1 ? params.quality : 0.6) * 100,
|
||||
),
|
||||
),
|
||||
);
|
||||
// The shared helper rejects unsafe temp roots; the private subdirectory
|
||||
// keeps captures out of the broader OpenClaw temp namespace.
|
||||
const captureDir = path.join(resolvePreferredOpenClawTmpDir(), "logbook");
|
||||
await mkdir(captureDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(captureDir, 0o700);
|
||||
const filePath = path.join(captureDir, `logbook-snapshot-${randomUUID()}.jpg`);
|
||||
try {
|
||||
// Pre-create owner-only: screencapture truncates the existing inode, so
|
||||
// the capture never becomes world-readable even if the dir mode drifts.
|
||||
await writeFile(filePath, "", { mode: 0o600 });
|
||||
// -x: no capture sound; -C: include cursor; -D is 1-based display index.
|
||||
await execFileAsync("screencapture", [
|
||||
"-x",
|
||||
"-C",
|
||||
"-D",
|
||||
String(screenIndex + 1),
|
||||
"-t",
|
||||
"jpg",
|
||||
filePath,
|
||||
]);
|
||||
await execFileAsync("sips", [
|
||||
"--resampleHeightWidthMax",
|
||||
String(maxWidth),
|
||||
"-s",
|
||||
"format",
|
||||
"jpeg",
|
||||
"-s",
|
||||
"formatOptions",
|
||||
String(qualityPct),
|
||||
filePath,
|
||||
]);
|
||||
const buffer = await readFile(filePath);
|
||||
return { format: "jpeg", base64: buffer.toString("base64") };
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
await rm(filePath, { force: true });
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveLogbookConfig } from "./config.js";
|
||||
import { LogbookService } from "./service.js";
|
||||
|
||||
type NodeRecord = { nodeId: string; displayName?: string; commands: string[] };
|
||||
|
||||
const quietLogger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
};
|
||||
|
||||
function makeService(params: {
|
||||
nodes: NodeRecord[];
|
||||
invoke: (args: { nodeId: string; command: string }) => Promise<unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
fullConfig?: Record<string, unknown>;
|
||||
}) {
|
||||
const dataDir = realpathSync(mkdtempSync(path.join(tmpdir(), "logbook-service-test-")));
|
||||
const invoked: Array<{ nodeId: string; command: string }> = [];
|
||||
const runtime = {
|
||||
nodes: {
|
||||
list: async () => ({ nodes: params.nodes }),
|
||||
invoke: async (args: { nodeId: string; command: string }) => {
|
||||
invoked.push({ nodeId: args.nodeId, command: args.command });
|
||||
return await params.invoke(args);
|
||||
},
|
||||
},
|
||||
};
|
||||
const service = new LogbookService(
|
||||
resolveLogbookConfig({ captureEnabled: true, ...params.config }),
|
||||
{
|
||||
runtime: runtime as never,
|
||||
fullConfig: (params.fullConfig ?? {}) as never,
|
||||
logger: quietLogger as never,
|
||||
dataDir,
|
||||
},
|
||||
);
|
||||
service.start();
|
||||
const tick = () =>
|
||||
(service as unknown as { captureTick(): Promise<void> }).captureTick.call(service);
|
||||
return { service, invoked, tick, dataDir };
|
||||
}
|
||||
|
||||
const framePayload = {
|
||||
payload: { format: "jpeg", base64: Buffer.from("fake-jpeg").toString("base64") },
|
||||
};
|
||||
|
||||
describe("LogbookService capture node selection", () => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0)) {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers app nodes over headless node hosts regardless of node id order", async () => {
|
||||
const { service, invoked, tick, dataDir } = makeService({
|
||||
nodes: [
|
||||
{ nodeId: "a-headless", commands: ["logbook.snapshot"] },
|
||||
{ nodeId: "b-mac-app", commands: ["screen.snapshot"] },
|
||||
],
|
||||
invoke: async () => framePayload,
|
||||
});
|
||||
cleanups.push(() => {
|
||||
service.stop();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
await tick();
|
||||
expect(invoked).toEqual([{ nodeId: "b-mac-app", command: "screen.snapshot" }]);
|
||||
});
|
||||
|
||||
it("rotates to the next capture node after a failure instead of re-picking the broken one", async () => {
|
||||
const { service, invoked, tick, dataDir } = makeService({
|
||||
nodes: [
|
||||
{ nodeId: "a-broken", commands: ["logbook.snapshot"] },
|
||||
{ nodeId: "b-working", commands: ["logbook.snapshot"] },
|
||||
],
|
||||
invoke: async ({ nodeId }) => {
|
||||
if (nodeId === "a-broken") {
|
||||
return { payload: { error: "logbook.snapshot is not supported on linux" } };
|
||||
}
|
||||
return framePayload;
|
||||
},
|
||||
});
|
||||
cleanups.push(() => {
|
||||
service.stop();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
await tick();
|
||||
await tick();
|
||||
expect(invoked.map((call) => call.nodeId)).toEqual(["a-broken", "b-working"]);
|
||||
expect(service.status().lastCaptureError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogbookService vision model selection", () => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0)) {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("borrows only a media provider with structured extraction", () => {
|
||||
const { service, dataDir } = makeService({
|
||||
nodes: [],
|
||||
invoke: async () => framePayload,
|
||||
fullConfig: {
|
||||
tools: {
|
||||
media: {
|
||||
image: {
|
||||
models: [
|
||||
{ provider: "openai", model: "gpt-5.5", capabilities: ["image"] },
|
||||
{ provider: " Codex ", model: "gpt-5.5", capabilities: ["image"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanups.push(() => {
|
||||
service.stop();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
expect(service.status()).toMatchObject({
|
||||
visionModel: "codex/gpt-5.5",
|
||||
visionModelSource: "media-defaults",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a missing model when borrowed defaults cannot extract structured data", () => {
|
||||
const { service, dataDir } = makeService({
|
||||
nodes: [],
|
||||
invoke: async () => framePayload,
|
||||
fullConfig: {
|
||||
tools: {
|
||||
media: {
|
||||
models: [{ provider: "openai", model: "gpt-5.5", capabilities: ["image"] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
cleanups.push(() => {
|
||||
service.stop();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
expect(service.status()).toMatchObject({
|
||||
visionModel: undefined,
|
||||
visionModelSource: "missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LogbookService status", () => {
|
||||
it("returns the capture-host timezone without exposing the state path", () => {
|
||||
const { service, dataDir } = makeService({
|
||||
nodes: [],
|
||||
invoke: async () => framePayload,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(service.status()).toMatchObject({
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
});
|
||||
expect(service.status()).not.toHaveProperty("dataDir");
|
||||
} finally {
|
||||
service.stop();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,696 @@
|
||||
// Logbook background service: snapshot capture loop, batch analysis, retention.
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
OpenClawPluginApi,
|
||||
PluginLogger,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
CARD_LOOKBACK_MS,
|
||||
MAX_FRAMES_PER_CALL,
|
||||
parseCardsJson,
|
||||
parseObservationSegments,
|
||||
pickKeyframeId,
|
||||
revisionWindow,
|
||||
sampleFrames,
|
||||
selectBatchFrames,
|
||||
validateCardCoverage,
|
||||
} from "./analyze.js";
|
||||
import { parseModelRef, type LogbookConfig } from "./config.js";
|
||||
import {
|
||||
buildAskPrompt,
|
||||
buildCardsCorrectionPrompt,
|
||||
buildCardsPrompt,
|
||||
buildObservationInstructions,
|
||||
buildStandupPrompt,
|
||||
OBSERVATION_JSON_SCHEMA,
|
||||
} from "./prompts.js";
|
||||
import { dayKeyFor, LogbookStore } from "./store.js";
|
||||
import type { LogbookBatch, LogbookCard } from "./types.js";
|
||||
|
||||
const ANALYSIS_TICK_MS = 60 * 1000;
|
||||
const PRUNE_TICK_MS = 60 * 60 * 1000;
|
||||
const MODEL_MISSING_MESSAGE =
|
||||
"no vision model: set plugins.entries.logbook.config.visionModel or configure tools.media";
|
||||
const MODEL_MISSING_LOG_INTERVAL_MS = 10 * 60 * 1000;
|
||||
const CAPTURE_FAILURE_PAUSE_TICKS = 10;
|
||||
const CAPTURE_FAILURE_THRESHOLD = 3;
|
||||
const JPEG_QUALITY = 0.6;
|
||||
// Only Codex currently implements the structured image-extraction contract.
|
||||
// Borrowed defaults must not select a provider that will fail every batch.
|
||||
const STRUCTURED_MEDIA_PROVIDER = "codex";
|
||||
|
||||
type SnapshotPayload = {
|
||||
format?: string;
|
||||
base64?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** node.invoke responses wrap the node result in {payload, payloadJSON}. */
|
||||
function unwrapInvokePayload(raw: unknown): SnapshotPayload | null {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const envelope = raw as { payload?: unknown; payloadJSON?: string | null };
|
||||
if (envelope.payload && typeof envelope.payload === "object") {
|
||||
return envelope.payload as SnapshotPayload;
|
||||
}
|
||||
if (typeof envelope.payloadJSON === "string" && envelope.payloadJSON.length > 0) {
|
||||
try {
|
||||
return JSON.parse(envelope.payloadJSON) as SnapshotPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Tolerate transports that already deliver the bare node payload.
|
||||
return "base64" in envelope || "error" in envelope ? (envelope as SnapshotPayload) : null;
|
||||
}
|
||||
|
||||
/** Capture commands in preference order: app nodes first, headless node hosts second. */
|
||||
const CAPTURE_COMMANDS = ["screen.snapshot", "logbook.snapshot"] as const;
|
||||
|
||||
export type LogbookStatus = {
|
||||
captureEnabled: boolean;
|
||||
capturePaused: boolean;
|
||||
captureIntervalSeconds: number;
|
||||
analysisIntervalMinutes: number;
|
||||
retentionDays: number;
|
||||
nodeId?: string;
|
||||
nodeName?: string;
|
||||
lastCaptureAtMs?: number;
|
||||
lastCaptureError?: string;
|
||||
pendingFrames: number;
|
||||
analysisRunning: boolean;
|
||||
lastBatch?: Pick<LogbookBatch, "id" | "day" | "status" | "endMs" | "error">;
|
||||
visionModel?: string;
|
||||
visionModelSource: "config" | "media-defaults" | "missing";
|
||||
today: string;
|
||||
todayCards: number;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export class LogbookService {
|
||||
private store: LogbookStore | null = null;
|
||||
private captureTimer: NodeJS.Timeout | null = null;
|
||||
private analysisTimer: NodeJS.Timeout | null = null;
|
||||
private pruneTimer: NodeJS.Timeout | null = null;
|
||||
private captureInFlight = false;
|
||||
private analysisInFlight = false;
|
||||
private capturePaused = false;
|
||||
private captureFailures = 0;
|
||||
private captureBackoffTicks = 0;
|
||||
private lastCaptureAtMs: number | undefined;
|
||||
private lastCaptureError: string | undefined;
|
||||
private lastModelMissingLogMs = 0;
|
||||
private cachedNode: { nodeId: string; displayName?: string; command: string } | null = null;
|
||||
// Nodes whose captures failed this rotation; skipped until every candidate
|
||||
// has failed once, then retried so transient outages self-heal.
|
||||
private failedNodeIds = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly config: LogbookConfig,
|
||||
private readonly deps: {
|
||||
runtime: NonNullable<OpenClawPluginApi["runtime"]>;
|
||||
fullConfig: OpenClawConfig;
|
||||
logger: PluginLogger;
|
||||
dataDir: string;
|
||||
},
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
this.store = new LogbookStore(this.deps.dataDir);
|
||||
// Batches interrupted by a gateway restart go back to pending.
|
||||
this.store.resetRunningBatches();
|
||||
this.captureTimer = setInterval(() => {
|
||||
void this.captureTick();
|
||||
}, this.config.captureIntervalSeconds * 1000);
|
||||
this.captureTimer.unref?.();
|
||||
this.analysisTimer = setInterval(() => {
|
||||
void this.analysisTick();
|
||||
}, ANALYSIS_TICK_MS);
|
||||
this.analysisTimer.unref?.();
|
||||
this.pruneTimer = setInterval(() => {
|
||||
this.prune();
|
||||
}, PRUNE_TICK_MS);
|
||||
this.pruneTimer.unref?.();
|
||||
this.prune();
|
||||
this.deps.logger.info(
|
||||
`logbook: started (capture every ${this.config.captureIntervalSeconds}s, analysis window ${this.config.analysisIntervalMinutes}m, data ${this.deps.dataDir})`,
|
||||
);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const timer of [this.captureTimer, this.analysisTimer, this.pruneTimer]) {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}
|
||||
this.captureTimer = null;
|
||||
this.analysisTimer = null;
|
||||
this.pruneTimer = null;
|
||||
this.store?.close();
|
||||
this.store = null;
|
||||
}
|
||||
|
||||
private requireStore(): LogbookStore {
|
||||
if (!this.store) {
|
||||
throw new Error("Logbook service is not running");
|
||||
}
|
||||
return this.store;
|
||||
}
|
||||
|
||||
// ── Capture ────────────────────────────────────────────────────────
|
||||
|
||||
setCapturePaused(paused: boolean): void {
|
||||
this.capturePaused = paused;
|
||||
if (!paused) {
|
||||
this.captureBackoffTicks = 0;
|
||||
this.captureFailures = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveNode(): Promise<
|
||||
{ node: { nodeId: string; displayName?: string; command: string } } | { reason: string }
|
||||
> {
|
||||
if (this.cachedNode) {
|
||||
return { node: this.cachedNode };
|
||||
}
|
||||
const { nodes } = await this.deps.runtime.nodes.list({ connected: true });
|
||||
const captureCommand = (node: { commands?: string[] }) =>
|
||||
CAPTURE_COMMANDS.find((command) => (node.commands ?? []).includes(command));
|
||||
// App nodes (screen.snapshot) come first: plugin node-host commands are
|
||||
// advertised on every platform, but logbook.snapshot only captures on
|
||||
// macOS, so headless hosts are a fallback rather than the default pick.
|
||||
const commandRank = (node: { commands?: string[] }) =>
|
||||
CAPTURE_COMMANDS.indexOf(captureCommand(node) as (typeof CAPTURE_COMMANDS)[number]);
|
||||
const candidates = nodes
|
||||
.filter((node) => captureCommand(node) !== undefined)
|
||||
.toSorted((a, b) => commandRank(a) - commandRank(b) || a.nodeId.localeCompare(b.nodeId));
|
||||
const wanted = this.config.nodeId?.toLowerCase();
|
||||
// Failed nodes rotate to the back until everything has failed once;
|
||||
// without this, a broken node that sorts first is re-picked every tick.
|
||||
let pool = candidates.filter((node) => !this.failedNodeIds.has(node.nodeId));
|
||||
if (pool.length === 0) {
|
||||
this.failedNodeIds.clear();
|
||||
pool = candidates;
|
||||
}
|
||||
const picked = wanted
|
||||
? candidates.find(
|
||||
(node) =>
|
||||
node.nodeId.toLowerCase() === wanted || node.displayName?.toLowerCase() === wanted,
|
||||
)
|
||||
: pool[0];
|
||||
const command = picked ? captureCommand(picked) : undefined;
|
||||
if (!picked || !command) {
|
||||
const inventory =
|
||||
nodes
|
||||
.map(
|
||||
(node) =>
|
||||
`${node.displayName ?? node.nodeId}(${(node.commands ?? []).join("/") || "no commands"})`,
|
||||
)
|
||||
.join(", ") || "none";
|
||||
return {
|
||||
reason: `no connected node exposes ${CAPTURE_COMMANDS.join(" or ")}; connected: ${inventory}`,
|
||||
};
|
||||
}
|
||||
this.cachedNode = { nodeId: picked.nodeId, displayName: picked.displayName, command };
|
||||
return { node: this.cachedNode };
|
||||
}
|
||||
|
||||
private async captureTick(): Promise<void> {
|
||||
if (!this.config.captureEnabled || this.capturePaused || this.captureInFlight || !this.store) {
|
||||
return;
|
||||
}
|
||||
if (this.captureBackoffTicks > 0) {
|
||||
this.captureBackoffTicks -= 1;
|
||||
return;
|
||||
}
|
||||
this.captureInFlight = true;
|
||||
try {
|
||||
const resolved = await this.resolveNode();
|
||||
if ("reason" in resolved) {
|
||||
if (this.lastCaptureError !== resolved.reason) {
|
||||
this.deps.logger.warn(`logbook: ${resolved.reason}`);
|
||||
}
|
||||
this.lastCaptureError = resolved.reason;
|
||||
return;
|
||||
}
|
||||
const node = resolved.node;
|
||||
const invoked = await this.deps.runtime.nodes.invoke({
|
||||
nodeId: node.nodeId,
|
||||
command: node.command,
|
||||
params: {
|
||||
screenIndex: this.config.screenIndex,
|
||||
maxWidth: this.config.maxWidth,
|
||||
quality: JPEG_QUALITY,
|
||||
format: "jpeg",
|
||||
},
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const raw = unwrapInvokePayload(invoked);
|
||||
if (raw?.error) {
|
||||
throw new Error(raw.error);
|
||||
}
|
||||
const base64 = raw?.base64;
|
||||
if (!base64) {
|
||||
throw new Error(`${node.command} returned no image payload`);
|
||||
}
|
||||
const buffer = Buffer.from(base64, "base64");
|
||||
const capturedAtMs = Date.now();
|
||||
const day = dayKeyFor(capturedAtMs);
|
||||
const contentHash = createHash("sha256").update(buffer).digest("hex");
|
||||
// Unchanged consecutive frames mean the user is idle (or away); they are
|
||||
// stored for the filmstrip but excluded from analysis batches.
|
||||
const idle = this.store.lastFrame()?.contentHash === contentHash;
|
||||
const filePath = this.store.frameFilePath(day, capturedAtMs);
|
||||
// Screen captures can contain secrets; keep them owner-only.
|
||||
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(filePath, buffer, { mode: 0o600 });
|
||||
this.store.insertFrame({
|
||||
capturedAtMs,
|
||||
day,
|
||||
path: filePath,
|
||||
screenIndex: this.config.screenIndex,
|
||||
width: raw?.width,
|
||||
height: raw?.height,
|
||||
byteSize: buffer.byteLength,
|
||||
contentHash,
|
||||
idle,
|
||||
});
|
||||
this.lastCaptureAtMs = capturedAtMs;
|
||||
this.lastCaptureError = undefined;
|
||||
this.captureFailures = 0;
|
||||
this.failedNodeIds.clear();
|
||||
} catch (err) {
|
||||
this.captureFailures += 1;
|
||||
if (this.cachedNode) {
|
||||
this.failedNodeIds.add(this.cachedNode.nodeId);
|
||||
}
|
||||
this.cachedNode = null;
|
||||
this.lastCaptureError = err instanceof Error ? err.message : String(err);
|
||||
if (this.captureFailures >= CAPTURE_FAILURE_THRESHOLD) {
|
||||
this.captureBackoffTicks = CAPTURE_FAILURE_PAUSE_TICKS;
|
||||
this.deps.logger.warn(
|
||||
`logbook: capture failing (${this.lastCaptureError}); backing off for ${CAPTURE_FAILURE_PAUSE_TICKS} ticks`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.captureInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Analysis ───────────────────────────────────────────────────────
|
||||
|
||||
private resolveVisionModel(): {
|
||||
ref?: { provider: string; model: string; profile?: string; preferredProfile?: string };
|
||||
source: LogbookStatus["visionModelSource"];
|
||||
} {
|
||||
if (this.config.visionModel) {
|
||||
const ref = parseModelRef(this.config.visionModel);
|
||||
return ref ? { ref, source: "config" } : { source: "missing" };
|
||||
}
|
||||
const media = this.deps.fullConfig.tools?.media;
|
||||
// Operators who disabled image understanding must not have screenshots
|
||||
// routed to a provider via the borrowed media defaults.
|
||||
if (media?.image?.enabled === false) {
|
||||
return { source: "missing" };
|
||||
}
|
||||
const entries = [...(media?.image?.models ?? []), ...(media?.models ?? [])];
|
||||
for (const entry of entries) {
|
||||
const usable =
|
||||
entry.type !== "cli" &&
|
||||
entry.provider?.trim().toLowerCase() === STRUCTURED_MEDIA_PROVIDER &&
|
||||
typeof entry.model === "string" &&
|
||||
(!entry.capabilities || entry.capabilities.includes("image"));
|
||||
if (usable) {
|
||||
return {
|
||||
// Auth profile fields ride along so profile-scoped media credentials
|
||||
// keep working when Logbook borrows the media-understanding default.
|
||||
ref: {
|
||||
provider: STRUCTURED_MEDIA_PROVIDER,
|
||||
model: entry.model as string,
|
||||
profile: entry.profile,
|
||||
preferredProfile: entry.preferredProfile,
|
||||
},
|
||||
source: "media-defaults",
|
||||
};
|
||||
}
|
||||
}
|
||||
return { source: "missing" };
|
||||
}
|
||||
|
||||
async analyzeNow(): Promise<{ started: boolean; reason?: string }> {
|
||||
const store = this.requireStore();
|
||||
if (this.analysisInFlight) {
|
||||
return { started: false, reason: "analysis already running" };
|
||||
}
|
||||
if (!this.resolveVisionModel().ref) {
|
||||
return { started: false, reason: MODEL_MISSING_MESSAGE };
|
||||
}
|
||||
// Explicit user action is the retry path for failed batches; automatic
|
||||
// retries could loop model spend on a persistently failing batch.
|
||||
store.resetErrorBatches();
|
||||
if (!store.nextPendingBatch()) {
|
||||
const frames = store.unbatchedActiveFrames(2000);
|
||||
// Force-close the current window so "analyze now" needs no elapsed time.
|
||||
const selection = selectBatchFrames({
|
||||
frames,
|
||||
windowMs: this.config.analysisIntervalMinutes * 60_000,
|
||||
nowMs: Date.now(),
|
||||
force: true,
|
||||
});
|
||||
if (!selection) {
|
||||
return { started: false, reason: "no unanalyzed activity captured yet" };
|
||||
}
|
||||
store.createBatch({
|
||||
day: dayKeyFor(selection.startMs),
|
||||
startMs: selection.startMs,
|
||||
endMs: selection.endMs,
|
||||
frameIds: selection.frameIds,
|
||||
});
|
||||
}
|
||||
void this.analysisTick();
|
||||
return { started: true };
|
||||
}
|
||||
|
||||
private async analysisTick(): Promise<void> {
|
||||
if (this.analysisInFlight || !this.store) {
|
||||
return;
|
||||
}
|
||||
// Without a vision model, leave frames unbatched and batches pending so
|
||||
// everything analyzes once the operator configures one; erroring here
|
||||
// would permanently strand the assigned frames.
|
||||
if (!this.resolveVisionModel().ref) {
|
||||
const now = Date.now();
|
||||
if (now - this.lastModelMissingLogMs > MODEL_MISSING_LOG_INTERVAL_MS) {
|
||||
this.lastModelMissingLogMs = now;
|
||||
this.deps.logger.warn(`logbook: analysis paused; ${MODEL_MISSING_MESSAGE}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.analysisInFlight = true;
|
||||
try {
|
||||
this.enqueueElapsedWindow();
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const batch = this.store.nextPendingBatch();
|
||||
if (!batch) {
|
||||
return;
|
||||
}
|
||||
await this.runBatch(batch);
|
||||
}
|
||||
} catch (err) {
|
||||
this.deps.logger.error(`logbook: analysis tick failed: ${String(err)}`);
|
||||
} finally {
|
||||
this.analysisInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
private enqueueElapsedWindow(): void {
|
||||
const store = this.requireStore();
|
||||
// Windows close on elapsed wall-clock or on a capture gap; both cases are
|
||||
// resolved by selectBatchFrames against the oldest unbatched frame.
|
||||
while (true) {
|
||||
const frames = store.unbatchedActiveFrames(2000);
|
||||
const selection = selectBatchFrames({
|
||||
frames,
|
||||
windowMs: this.config.analysisIntervalMinutes * 60_000,
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
store.createBatch({
|
||||
day: dayKeyFor(selection.startMs),
|
||||
startMs: selection.startMs,
|
||||
endMs: selection.endMs,
|
||||
frameIds: selection.frameIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async runBatch(batch: LogbookBatch): Promise<void> {
|
||||
const store = this.requireStore();
|
||||
const vision = this.resolveVisionModel();
|
||||
if (!vision.ref) {
|
||||
// Stay pending: the analysis tick pauses until a model is configured.
|
||||
return;
|
||||
}
|
||||
store.setBatchStatus(
|
||||
batch.id,
|
||||
"running",
|
||||
undefined,
|
||||
`${vision.ref.provider}/${vision.ref.model}`,
|
||||
);
|
||||
try {
|
||||
const frames = store.batchFrames(batch.id);
|
||||
const sampled = sampleFrames(frames, MAX_FRAMES_PER_CALL);
|
||||
const images = sampled.map((frame) => ({
|
||||
type: "image" as const,
|
||||
buffer: readFileSync(frame.path),
|
||||
fileName: path.basename(frame.path),
|
||||
mime: "image/jpeg",
|
||||
}));
|
||||
const observationResult =
|
||||
await this.deps.runtime.mediaUnderstanding.extractStructuredWithModel({
|
||||
provider: vision.ref.provider,
|
||||
model: vision.ref.model,
|
||||
profile: vision.ref.profile,
|
||||
preferredProfile: vision.ref.preferredProfile,
|
||||
input: images,
|
||||
instructions: buildObservationInstructions({
|
||||
frameTimes: sampled.map((frame) => frame.capturedAtMs),
|
||||
startMs: batch.startMs,
|
||||
endMs: batch.endMs,
|
||||
}),
|
||||
schemaName: "logbook.observations",
|
||||
jsonSchema: OBSERVATION_JSON_SCHEMA,
|
||||
cfg: this.deps.fullConfig,
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
const segments = parseObservationSegments({
|
||||
raw: observationResult.text ?? "",
|
||||
day: batch.day,
|
||||
startMs: batch.startMs,
|
||||
endMs: batch.endMs,
|
||||
});
|
||||
if (segments.length === 0) {
|
||||
store.setBatchStatus(batch.id, "error", "vision model returned no usable segments");
|
||||
return;
|
||||
}
|
||||
store.replaceObservations(batch.id, batch.day, segments);
|
||||
await this.reviseCards(batch);
|
||||
store.setBatchStatus(batch.id, "done");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
store.setBatchStatus(batch.id, "error", message);
|
||||
this.deps.logger.warn(`logbook: batch ${batch.id} failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async reviseCards(batch: LogbookBatch): Promise<void> {
|
||||
const store = this.requireStore();
|
||||
const lookbackStart = batch.startMs - CARD_LOOKBACK_MS;
|
||||
const previousCards = store
|
||||
.cardsForDay(batch.day)
|
||||
.filter((card) => card.endMs > lookbackStart && card.startMs < batch.endMs);
|
||||
const observations = store.observationsInRange(
|
||||
batch.day,
|
||||
Math.min(lookbackStart, batch.startMs),
|
||||
batch.endMs,
|
||||
);
|
||||
const window = revisionWindow({
|
||||
batchStartMs: batch.startMs,
|
||||
batchEndMs: batch.endMs,
|
||||
previousCards,
|
||||
});
|
||||
const prompt = buildCardsPrompt({
|
||||
day: batch.day,
|
||||
observations,
|
||||
previousCards,
|
||||
windowStartMs: window.startMs,
|
||||
windowEndMs: window.endMs,
|
||||
});
|
||||
// Coverage is validated alongside parsing: a partial-but-valid output must
|
||||
// trigger the repair round-trip instead of erasing previous cards below.
|
||||
const requiredSpans = [
|
||||
...previousCards.map((card) => ({ startMs: card.startMs, endMs: card.endMs })),
|
||||
{ startMs: batch.startMs, endMs: batch.endMs },
|
||||
];
|
||||
const evaluate = (raw: string) => {
|
||||
const parsed = parseCardsJson({
|
||||
raw,
|
||||
day: batch.day,
|
||||
windowStartMs: window.startMs,
|
||||
windowEndMs: window.endMs,
|
||||
});
|
||||
if (!parsed.ok) {
|
||||
return parsed;
|
||||
}
|
||||
const coverage = validateCardCoverage({
|
||||
drafts: parsed.drafts,
|
||||
requiredSpans,
|
||||
windowStartMs: window.startMs,
|
||||
windowEndMs: window.endMs,
|
||||
});
|
||||
return coverage.ok ? parsed : { ok: false as const, error: coverage.error };
|
||||
};
|
||||
const first = await this.deps.runtime.llm.complete({
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
purpose: "logbook.cards",
|
||||
maxTokens: 4000,
|
||||
});
|
||||
let parsed = evaluate(first.text);
|
||||
if (!parsed.ok) {
|
||||
const retry = await this.deps.runtime.llm.complete({
|
||||
messages: [
|
||||
{ role: "user", content: prompt },
|
||||
{ role: "assistant", content: first.text },
|
||||
{ role: "user", content: buildCardsCorrectionPrompt(parsed.error) },
|
||||
],
|
||||
purpose: "logbook.cards.repair",
|
||||
maxTokens: 4000,
|
||||
});
|
||||
parsed = evaluate(retry.text);
|
||||
}
|
||||
if (!parsed.ok) {
|
||||
throw new Error(`card synthesis failed validation: ${parsed.error}`);
|
||||
}
|
||||
const windowFrames = store
|
||||
.framesInRange(window.startMs, window.endMs)
|
||||
.map((frame) => ({ id: frame.id, capturedAtMs: frame.capturedAtMs }));
|
||||
const drafts = parsed.drafts.map((draft) =>
|
||||
Object.assign(draft, { keyframeId: pickKeyframeId(draft, windowFrames) }),
|
||||
);
|
||||
store.replaceCardsInWindow(batch.day, window.startMs, window.endMs, drafts);
|
||||
}
|
||||
|
||||
// ── Q&A / standup ──────────────────────────────────────────────────
|
||||
|
||||
async standup(
|
||||
day: string,
|
||||
refresh: boolean,
|
||||
): Promise<{ day: string; text: string; updatedMs: number }> {
|
||||
const store = this.requireStore();
|
||||
if (!refresh) {
|
||||
const cached = store.getStandup(day);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
const previousDay = dayKeyFor(new Date(`${day}T12:00:00`).getTime() - 24 * 60 * 60 * 1000);
|
||||
const result = await this.deps.runtime.llm.complete({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: buildStandupPrompt({
|
||||
day,
|
||||
cards: store.cardsForDay(day),
|
||||
previousDayCards: store.cardsForDay(previousDay),
|
||||
}),
|
||||
},
|
||||
],
|
||||
purpose: "logbook.standup",
|
||||
maxTokens: 800,
|
||||
});
|
||||
store.saveStandup(day, result.text.trim());
|
||||
const saved = store.getStandup(day);
|
||||
if (!saved) {
|
||||
throw new Error("standup save failed");
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
async ask(day: string, question: string): Promise<string> {
|
||||
const store = this.requireStore();
|
||||
const observations = store.observationsInRange(day, 0, Number.MAX_SAFE_INTEGER).slice(-200);
|
||||
const result = await this.deps.runtime.llm.complete({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: buildAskPrompt({
|
||||
day,
|
||||
cards: store.cardsForDay(day),
|
||||
observations,
|
||||
question,
|
||||
}),
|
||||
},
|
||||
],
|
||||
purpose: "logbook.ask",
|
||||
maxTokens: 600,
|
||||
});
|
||||
return result.text.trim();
|
||||
}
|
||||
|
||||
// ── Introspection ──────────────────────────────────────────────────
|
||||
|
||||
cardsForDay(day: string): LogbookCard[] {
|
||||
return this.requireStore().cardsForDay(day);
|
||||
}
|
||||
|
||||
listDays(): ReturnType<LogbookStore["listDays"]> {
|
||||
return this.requireStore().listDays();
|
||||
}
|
||||
|
||||
dayStats(day: string): ReturnType<LogbookStore["dayStats"]> {
|
||||
return this.requireStore().dayStats(day);
|
||||
}
|
||||
|
||||
frameById(id: number): ReturnType<LogbookStore["frameById"]> {
|
||||
return this.requireStore().frameById(id);
|
||||
}
|
||||
|
||||
framesInRange(startMs: number, endMs: number): ReturnType<LogbookStore["framesInRange"]> {
|
||||
return this.requireStore().framesInRange(startMs, endMs);
|
||||
}
|
||||
|
||||
status(): LogbookStatus {
|
||||
const store = this.requireStore();
|
||||
const today = dayKeyFor(Date.now());
|
||||
const latestBatch = store.latestBatch();
|
||||
const vision = this.resolveVisionModel();
|
||||
return {
|
||||
captureEnabled: this.config.captureEnabled,
|
||||
capturePaused: this.capturePaused,
|
||||
captureIntervalSeconds: this.config.captureIntervalSeconds,
|
||||
analysisIntervalMinutes: this.config.analysisIntervalMinutes,
|
||||
retentionDays: this.config.retentionDays,
|
||||
nodeId: this.cachedNode?.nodeId ?? this.config.nodeId,
|
||||
nodeName: this.cachedNode?.displayName,
|
||||
lastCaptureAtMs: this.lastCaptureAtMs,
|
||||
lastCaptureError: this.lastCaptureError,
|
||||
pendingFrames: store.countUnbatchedActiveFrames(),
|
||||
analysisRunning: this.analysisInFlight,
|
||||
lastBatch: latestBatch
|
||||
? {
|
||||
id: latestBatch.id,
|
||||
day: latestBatch.day,
|
||||
status: latestBatch.status,
|
||||
endMs: latestBatch.endMs,
|
||||
error: latestBatch.error,
|
||||
}
|
||||
: undefined,
|
||||
visionModel: vision.ref ? `${vision.ref.provider}/${vision.ref.model}` : undefined,
|
||||
visionModelSource: vision.source,
|
||||
today,
|
||||
todayCards: store.cardsForDay(today).length,
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
}
|
||||
|
||||
private prune(): void {
|
||||
if (!this.store) {
|
||||
return;
|
||||
}
|
||||
const cutoff = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000;
|
||||
const removed = this.store.pruneFrames(cutoff);
|
||||
if (removed > 0) {
|
||||
this.deps.logger.info(
|
||||
`logbook: pruned ${removed} frames older than ${this.config.retentionDays}d`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { LogbookStore, dayKeyFor } from "./store.js";
|
||||
import type { LogbookCardDraft } from "./types.js";
|
||||
|
||||
const DAY = "2026-07-03";
|
||||
|
||||
function draft(overrides: Partial<LogbookCardDraft> = {}): LogbookCardDraft {
|
||||
const base = new Date(`${DAY}T10:00:00`).getTime();
|
||||
return {
|
||||
day: DAY,
|
||||
startMs: base,
|
||||
endMs: base + 30 * 60_000,
|
||||
title: "Card",
|
||||
summary: "Summary",
|
||||
detail: "",
|
||||
category: "coding",
|
||||
appPrimary: "github.com",
|
||||
appSecondary: undefined,
|
||||
distractions: [],
|
||||
keyframeId: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("LogbookStore", () => {
|
||||
let dir: string;
|
||||
let store: LogbookStore;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), "logbook-store-"));
|
||||
store = new LogbookStore(dir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const insertFrame = (capturedAtMs: number, opts?: { idle?: boolean; hash?: string }) => {
|
||||
const day = dayKeyFor(capturedAtMs);
|
||||
const filePath = store.frameFilePath(day, capturedAtMs);
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, "jpeg-bytes");
|
||||
return store.insertFrame({
|
||||
capturedAtMs,
|
||||
day,
|
||||
path: filePath,
|
||||
screenIndex: 0,
|
||||
byteSize: 9,
|
||||
contentHash: opts?.hash ?? `hash-${capturedAtMs}`,
|
||||
idle: opts?.idle ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
it("tracks unbatched active frames and excludes idle ones", () => {
|
||||
const t0 = Date.now();
|
||||
insertFrame(t0);
|
||||
insertFrame(t0 + 1000, { idle: true });
|
||||
insertFrame(t0 + 2000);
|
||||
expect(store.countUnbatchedActiveFrames()).toBe(2);
|
||||
const batchId = store.createBatch({
|
||||
day: dayKeyFor(t0),
|
||||
startMs: t0,
|
||||
endMs: t0 + 3000,
|
||||
frameIds: store.unbatchedActiveFrames(10).map((frame) => frame.id),
|
||||
});
|
||||
expect(store.countUnbatchedActiveFrames()).toBe(0);
|
||||
expect(store.batchFrames(batchId)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("resets running batches to pending on startup recovery", () => {
|
||||
const t0 = Date.now();
|
||||
insertFrame(t0);
|
||||
const batchId = store.createBatch({
|
||||
day: dayKeyFor(t0),
|
||||
startMs: t0,
|
||||
endMs: t0 + 1000,
|
||||
frameIds: [1],
|
||||
});
|
||||
store.setBatchStatus(batchId, "running");
|
||||
store.resetRunningBatches();
|
||||
expect(store.nextPendingBatch()?.id).toBe(batchId);
|
||||
});
|
||||
|
||||
it("replaces only cards overlapping the revision window", () => {
|
||||
const base = new Date(`${DAY}T09:00:00`).getTime();
|
||||
store.replaceCardsInWindow(DAY, base, base + 4 * 60 * 60_000, [
|
||||
draft({ startMs: base, endMs: base + 30 * 60_000, title: "Early" }),
|
||||
draft({ startMs: base + 60 * 60_000, endMs: base + 90 * 60_000, title: "Mid" }),
|
||||
]);
|
||||
// Revise only the window covering "Mid"; "Early" must survive untouched.
|
||||
store.replaceCardsInWindow(DAY, base + 50 * 60_000, base + 2 * 60 * 60_000, [
|
||||
draft({ startMs: base + 55 * 60_000, endMs: base + 95 * 60_000, title: "Mid revised" }),
|
||||
]);
|
||||
const titles = store.cardsForDay(DAY).map((card) => card.title);
|
||||
expect(titles).toEqual(["Early", "Mid revised"]);
|
||||
});
|
||||
|
||||
it("round-trips distractions and computes day stats", () => {
|
||||
const base = new Date(`${DAY}T10:00:00`).getTime();
|
||||
store.replaceCardsInWindow(DAY, base, base + 60 * 60_000, [
|
||||
draft({
|
||||
distractions: [{ startMs: base + 5 * 60_000, endMs: base + 10 * 60_000, title: "Twitter" }],
|
||||
}),
|
||||
]);
|
||||
const cards = store.cardsForDay(DAY);
|
||||
expect(cards[0].distractions).toEqual([
|
||||
{ startMs: base + 5 * 60_000, endMs: base + 10 * 60_000, title: "Twitter" },
|
||||
]);
|
||||
const stats = store.dayStats(DAY);
|
||||
expect(stats.trackedMs).toBe(30 * 60_000);
|
||||
expect(stats.distractionMs).toBe(5 * 60_000);
|
||||
expect(stats.categories[0]).toEqual({ category: "coding", ms: 30 * 60_000 });
|
||||
expect(stats.apps[0].domain).toBe("github.com");
|
||||
});
|
||||
|
||||
it("prunes old frame rows and files but keeps recent ones", () => {
|
||||
const now = Date.now();
|
||||
const oldId = insertFrame(now - 20 * 24 * 60 * 60_000);
|
||||
const newId = insertFrame(now);
|
||||
const oldPath = store.frameById(oldId)?.path ?? "";
|
||||
expect(store.pruneFrames(now - 14 * 24 * 60 * 60_000)).toBe(1);
|
||||
expect(store.frameById(oldId)).toBeNull();
|
||||
expect(existsSync(oldPath)).toBe(false);
|
||||
expect(store.frameById(newId)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("detaches pruned keyframes from surviving cards", () => {
|
||||
const now = Date.now();
|
||||
const oldId = insertFrame(now - 20 * 24 * 60 * 60_000);
|
||||
store.replaceCardsInWindow(DAY, 0, Number.MAX_SAFE_INTEGER, [draft({ keyframeId: oldId })]);
|
||||
store.pruneFrames(now - 14 * 24 * 60 * 60_000);
|
||||
expect(store.cardsForDay(DAY)[0]?.keyframeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("replaces observations on batch retry instead of appending", () => {
|
||||
const t0 = Date.now();
|
||||
const frameId = insertFrame(t0);
|
||||
const batchId = store.createBatch({
|
||||
day: DAY,
|
||||
startMs: t0,
|
||||
endMs: t0 + 1000,
|
||||
frameIds: [frameId],
|
||||
});
|
||||
store.replaceObservations(batchId, DAY, [{ startMs: t0, endMs: t0 + 500, text: "first run" }]);
|
||||
store.replaceObservations(batchId, DAY, [{ startMs: t0, endMs: t0 + 500, text: "retry run" }]);
|
||||
const observations = store.observationsInRange(DAY, 0, Number.MAX_SAFE_INTEGER);
|
||||
expect(observations).toHaveLength(1);
|
||||
expect(observations[0].text).toBe("retry run");
|
||||
});
|
||||
|
||||
it("requeues errored batches for explicit retry", () => {
|
||||
const t0 = Date.now();
|
||||
const frameId = insertFrame(t0);
|
||||
const batchId = store.createBatch({
|
||||
day: dayKeyFor(t0),
|
||||
startMs: t0,
|
||||
endMs: t0 + 1000,
|
||||
frameIds: [frameId],
|
||||
});
|
||||
store.setBatchStatus(batchId, "error", "boom");
|
||||
expect(store.nextPendingBatch()).toBeNull();
|
||||
expect(store.resetErrorBatches()).toBe(1);
|
||||
const requeued = store.nextPendingBatch();
|
||||
expect(requeued?.id).toBe(batchId);
|
||||
expect(requeued?.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps capture data owner-only on disk", () => {
|
||||
const mode = (p: string) => statSync(p).mode & 0o777;
|
||||
expect(mode(dir)).toBe(0o700);
|
||||
expect(mode(store.framesDir)).toBe(0o700);
|
||||
expect(mode(path.join(dir, "logbook.sqlite"))).toBe(0o600);
|
||||
});
|
||||
|
||||
it("stores and updates standups", () => {
|
||||
store.saveStandup(DAY, "## Done\n- shipped");
|
||||
store.saveStandup(DAY, "## Done\n- shipped more");
|
||||
expect(store.getStandup(DAY)?.text).toContain("shipped more");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,605 @@
|
||||
// Logbook SQLite store: frames on disk, everything else in one plugin-owned DB.
|
||||
// Uses node:sqlite prepared statements directly (extension-local store, same
|
||||
// pattern as memory-core/imessage); the shared Kysely helpers are core-only.
|
||||
import { chmodSync, mkdirSync, rmdirSync, rmSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
LogbookBatch,
|
||||
LogbookBatchStatus,
|
||||
LogbookCard,
|
||||
LogbookCardDraft,
|
||||
LogbookDayStats,
|
||||
LogbookDistraction,
|
||||
LogbookFrame,
|
||||
LogbookObservation,
|
||||
} from "./types.js";
|
||||
|
||||
type SqliteModule = typeof import("node:sqlite");
|
||||
type Database = import("node:sqlite").DatabaseSync;
|
||||
|
||||
function loadNodeSqlite(): SqliteModule {
|
||||
const req = createRequire(import.meta.url);
|
||||
return req("node:sqlite") as SqliteModule;
|
||||
}
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS frames (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
captured_at_ms INTEGER NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
screen_index INTEGER NOT NULL DEFAULT 0,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
byte_size INTEGER NOT NULL DEFAULT 0,
|
||||
content_hash TEXT NOT NULL,
|
||||
idle INTEGER NOT NULL DEFAULT 0,
|
||||
batch_id INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_logbook_frames_day ON frames (day, captured_at_ms);
|
||||
CREATE INDEX IF NOT EXISTS idx_logbook_frames_unbatched ON frames (batch_id) WHERE batch_id IS NULL;
|
||||
CREATE TABLE IF NOT EXISTS batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
day TEXT NOT NULL,
|
||||
start_ms INTEGER NOT NULL,
|
||||
end_ms INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
error TEXT,
|
||||
frame_count INTEGER NOT NULL DEFAULT 0,
|
||||
model TEXT,
|
||||
created_ms INTEGER NOT NULL,
|
||||
updated_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_logbook_batches_day ON batches (day, start_ms);
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id INTEGER NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
start_ms INTEGER NOT NULL,
|
||||
end_ms INTEGER NOT NULL,
|
||||
text TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_logbook_observations_day ON observations (day, start_ms);
|
||||
CREATE TABLE IF NOT EXISTS cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
day TEXT NOT NULL,
|
||||
start_ms INTEGER NOT NULL,
|
||||
end_ms INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT 'other',
|
||||
app_primary TEXT,
|
||||
app_secondary TEXT,
|
||||
distractions TEXT NOT NULL DEFAULT '[]',
|
||||
keyframe_id INTEGER,
|
||||
updated_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_logbook_cards_day ON cards (day, start_ms);
|
||||
CREATE TABLE IF NOT EXISTS standups (
|
||||
day TEXT PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
updated_ms INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
type FrameRow = {
|
||||
id: number;
|
||||
captured_at_ms: number;
|
||||
day: string;
|
||||
path: string;
|
||||
screen_index: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
byte_size: number;
|
||||
idle: number;
|
||||
};
|
||||
|
||||
type CardRow = {
|
||||
id: number;
|
||||
day: string;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
category: string;
|
||||
app_primary: string | null;
|
||||
app_secondary: string | null;
|
||||
distractions: string;
|
||||
keyframe_id: number | null;
|
||||
};
|
||||
|
||||
function toFrame(row: FrameRow): LogbookFrame {
|
||||
return {
|
||||
id: row.id,
|
||||
capturedAtMs: row.captured_at_ms,
|
||||
day: row.day,
|
||||
path: row.path,
|
||||
screenIndex: row.screen_index,
|
||||
width: row.width ?? undefined,
|
||||
height: row.height ?? undefined,
|
||||
byteSize: row.byte_size,
|
||||
idle: row.idle === 1,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDistractions(raw: string): LogbookDistraction[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter(
|
||||
(entry): entry is LogbookDistraction =>
|
||||
entry !== null &&
|
||||
typeof entry === "object" &&
|
||||
typeof (entry as LogbookDistraction).title === "string" &&
|
||||
typeof (entry as LogbookDistraction).startMs === "number" &&
|
||||
typeof (entry as LogbookDistraction).endMs === "number",
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toCard(row: CardRow): LogbookCard {
|
||||
return {
|
||||
id: row.id,
|
||||
day: row.day,
|
||||
startMs: row.start_ms,
|
||||
endMs: row.end_ms,
|
||||
title: row.title,
|
||||
summary: row.summary,
|
||||
detail: row.detail,
|
||||
category: row.category,
|
||||
appPrimary: row.app_primary ?? undefined,
|
||||
appSecondary: row.app_secondary ?? undefined,
|
||||
distractions: parseDistractions(row.distractions),
|
||||
keyframeId: row.keyframe_id ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Formats an epoch-ms timestamp as a local-time YYYY-MM-DD day key. */
|
||||
export function dayKeyFor(ms: number): string {
|
||||
const date = new Date(ms);
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dayOfMonth = String(date.getDate()).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${month}-${dayOfMonth}`;
|
||||
}
|
||||
|
||||
export class LogbookStore {
|
||||
private readonly db: Database;
|
||||
readonly framesDir: string;
|
||||
|
||||
constructor(readonly dataDir: string) {
|
||||
// Frames and the DB hold raw screen contents; keep everything owner-only
|
||||
// even when the surrounding state dir is more permissive.
|
||||
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(dataDir, 0o700);
|
||||
this.framesDir = path.join(dataDir, "frames");
|
||||
mkdirSync(this.framesDir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(this.framesDir, 0o700);
|
||||
const { DatabaseSync } = loadNodeSqlite();
|
||||
const dbPath = path.join(dataDir, "logbook.sqlite");
|
||||
this.db = new DatabaseSync(dbPath);
|
||||
// WAL/SHM sidecars inherit the main DB file's permissions.
|
||||
chmodSync(dbPath, 0o600);
|
||||
this.db.exec("PRAGMA journal_mode = WAL");
|
||||
this.db.exec("PRAGMA busy_timeout = 1000");
|
||||
this.db.exec(SCHEMA);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
frameFilePath(day: string, capturedAtMs: number): string {
|
||||
return path.join(this.framesDir, day, `${capturedAtMs}.jpg`);
|
||||
}
|
||||
|
||||
insertFrame(params: {
|
||||
capturedAtMs: number;
|
||||
day: string;
|
||||
path: string;
|
||||
screenIndex: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
byteSize: number;
|
||||
contentHash: string;
|
||||
idle: boolean;
|
||||
}): number {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`INSERT INTO frames (captured_at_ms, day, path, screen_index, width, height, byte_size, content_hash, idle)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
params.capturedAtMs,
|
||||
params.day,
|
||||
params.path,
|
||||
params.screenIndex,
|
||||
params.width ?? null,
|
||||
params.height ?? null,
|
||||
params.byteSize,
|
||||
params.contentHash,
|
||||
params.idle ? 1 : 0,
|
||||
);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
lastFrame(): { capturedAtMs: number; contentHash: string } | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT captured_at_ms, content_hash FROM frames ORDER BY captured_at_ms DESC LIMIT 1`,
|
||||
)
|
||||
.get() as { captured_at_ms: number; content_hash: string } | undefined;
|
||||
return row ? { capturedAtMs: row.captured_at_ms, contentHash: row.content_hash } : null;
|
||||
}
|
||||
|
||||
unbatchedActiveFrames(limit: number): LogbookFrame[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, captured_at_ms, day, path, screen_index, width, height, byte_size, idle
|
||||
FROM frames WHERE batch_id IS NULL AND idle = 0
|
||||
ORDER BY captured_at_ms ASC LIMIT ?`,
|
||||
)
|
||||
.all(limit) as FrameRow[];
|
||||
return rows.map(toFrame);
|
||||
}
|
||||
|
||||
countUnbatchedActiveFrames(): number {
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM frames WHERE batch_id IS NULL AND idle = 0`)
|
||||
.get() as { n: number };
|
||||
return row.n;
|
||||
}
|
||||
|
||||
frameById(id: number): LogbookFrame | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, captured_at_ms, day, path, screen_index, width, height, byte_size, idle
|
||||
FROM frames WHERE id = ?`,
|
||||
)
|
||||
.get(id) as FrameRow | undefined;
|
||||
return row ? toFrame(row) : null;
|
||||
}
|
||||
|
||||
framesInRange(startMs: number, endMs: number): LogbookFrame[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, captured_at_ms, day, path, screen_index, width, height, byte_size, idle
|
||||
FROM frames WHERE captured_at_ms >= ? AND captured_at_ms < ?
|
||||
ORDER BY captured_at_ms ASC`,
|
||||
)
|
||||
.all(startMs, endMs) as FrameRow[];
|
||||
return rows.map(toFrame);
|
||||
}
|
||||
|
||||
createBatch(params: { day: string; startMs: number; endMs: number; frameIds: number[] }): number {
|
||||
const now = Date.now();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`INSERT INTO batches (day, start_ms, end_ms, status, frame_count, created_ms, updated_ms)
|
||||
VALUES (?, ?, ?, 'pending', ?, ?, ?)`,
|
||||
)
|
||||
.run(params.day, params.startMs, params.endMs, params.frameIds.length, now, now);
|
||||
const batchId = Number(result.lastInsertRowid);
|
||||
const assign = this.db.prepare(`UPDATE frames SET batch_id = ? WHERE id = ?`);
|
||||
for (const frameId of params.frameIds) {
|
||||
assign.run(batchId, frameId);
|
||||
}
|
||||
return batchId;
|
||||
}
|
||||
|
||||
setBatchStatus(
|
||||
batchId: number,
|
||||
status: LogbookBatchStatus,
|
||||
error?: string,
|
||||
model?: string,
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE batches SET status = ?, error = ?, model = COALESCE(?, model), updated_ms = ? WHERE id = ?`,
|
||||
)
|
||||
.run(status, error ?? null, model ?? null, Date.now(), batchId);
|
||||
}
|
||||
|
||||
latestBatch(): LogbookBatch | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, day, start_ms, end_ms, status, error, frame_count, model
|
||||
FROM batches ORDER BY id DESC LIMIT 1`,
|
||||
)
|
||||
.get() as
|
||||
| {
|
||||
id: number;
|
||||
day: string;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
status: LogbookBatchStatus;
|
||||
error: string | null;
|
||||
frame_count: number;
|
||||
model: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
day: row.day,
|
||||
startMs: row.start_ms,
|
||||
endMs: row.end_ms,
|
||||
status: row.status,
|
||||
error: row.error ?? undefined,
|
||||
frameCount: row.frame_count,
|
||||
model: row.model ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Requeues batches stuck in `running` after a crash so frames are not orphaned. */
|
||||
resetRunningBatches(): void {
|
||||
this.db
|
||||
.prepare(`UPDATE batches SET status = 'pending', updated_ms = ? WHERE status = 'running'`)
|
||||
.run(Date.now());
|
||||
}
|
||||
|
||||
/** Requeues failed batches for an explicit user-driven retry (analyze now). */
|
||||
resetErrorBatches(): number {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE batches SET status = 'pending', error = NULL, updated_ms = ? WHERE status = 'error'`,
|
||||
)
|
||||
.run(Date.now());
|
||||
return Number(result.changes);
|
||||
}
|
||||
|
||||
nextPendingBatch(): LogbookBatch | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, day, start_ms, end_ms, status, error, frame_count, model
|
||||
FROM batches WHERE status = 'pending' ORDER BY start_ms ASC LIMIT 1`,
|
||||
)
|
||||
.get() as
|
||||
| {
|
||||
id: number;
|
||||
day: string;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
status: LogbookBatchStatus;
|
||||
error: string | null;
|
||||
frame_count: number;
|
||||
model: string | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
day: row.day,
|
||||
startMs: row.start_ms,
|
||||
endMs: row.end_ms,
|
||||
status: row.status,
|
||||
error: row.error ?? undefined,
|
||||
frameCount: row.frame_count,
|
||||
model: row.model ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
batchFrames(batchId: number): LogbookFrame[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, captured_at_ms, day, path, screen_index, width, height, byte_size, idle
|
||||
FROM frames WHERE batch_id = ? ORDER BY captured_at_ms ASC`,
|
||||
)
|
||||
.all(batchId) as FrameRow[];
|
||||
return rows.map(toFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a batch's observations atomically. Batch retries (analyze now
|
||||
* after an error) rerun the vision stage, so appending would duplicate
|
||||
* evidence into card synthesis, standups, and ask answers.
|
||||
*/
|
||||
replaceObservations(
|
||||
batchId: number,
|
||||
day: string,
|
||||
segments: Array<{ startMs: number; endMs: number; text: string }>,
|
||||
): void {
|
||||
this.db.exec("BEGIN");
|
||||
try {
|
||||
this.db.prepare(`DELETE FROM observations WHERE batch_id = ?`).run(batchId);
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO observations (batch_id, day, start_ms, end_ms, text) VALUES (?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const segment of segments) {
|
||||
insert.run(batchId, day, segment.startMs, segment.endMs, segment.text);
|
||||
}
|
||||
this.db.exec("COMMIT");
|
||||
} catch (err) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
observationsInRange(day: string, startMs: number, endMs: number): LogbookObservation[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, batch_id, day, start_ms, end_ms, text FROM observations
|
||||
WHERE day = ? AND end_ms > ? AND start_ms < ? ORDER BY start_ms ASC`,
|
||||
)
|
||||
.all(day, startMs, endMs) as Array<{
|
||||
id: number;
|
||||
batch_id: number;
|
||||
day: string;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
text: string;
|
||||
}>;
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
batchId: row.batch_id,
|
||||
day: row.day,
|
||||
startMs: row.start_ms,
|
||||
endMs: row.end_ms,
|
||||
text: row.text,
|
||||
}));
|
||||
}
|
||||
|
||||
cardsForDay(day: string): LogbookCard[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, day, start_ms, end_ms, title, summary, detail, category, app_primary, app_secondary, distractions, keyframe_id
|
||||
FROM cards WHERE day = ? ORDER BY start_ms ASC`,
|
||||
)
|
||||
.all(day) as CardRow[];
|
||||
return rows.map(toCard);
|
||||
}
|
||||
|
||||
cardById(id: number): LogbookCard | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, day, start_ms, end_ms, title, summary, detail, category, app_primary, app_secondary, distractions, keyframe_id
|
||||
FROM cards WHERE id = ?`,
|
||||
)
|
||||
.get(id) as CardRow | undefined;
|
||||
return row ? toCard(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces cards overlapping [startMs, endMs) for a day in one transaction.
|
||||
* The analysis lookback treats recent cards as a revisable draft, so partial
|
||||
* writes here would surface as duplicated or missing timeline segments.
|
||||
*/
|
||||
replaceCardsInWindow(
|
||||
day: string,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
drafts: LogbookCardDraft[],
|
||||
): void {
|
||||
const now = Date.now();
|
||||
this.db.exec("BEGIN");
|
||||
try {
|
||||
this.db
|
||||
.prepare(`DELETE FROM cards WHERE day = ? AND end_ms > ? AND start_ms < ?`)
|
||||
.run(day, startMs, endMs);
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO cards (day, start_ms, end_ms, title, summary, detail, category, app_primary, app_secondary, distractions, keyframe_id, updated_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const draft of drafts) {
|
||||
insert.run(
|
||||
draft.day,
|
||||
draft.startMs,
|
||||
draft.endMs,
|
||||
draft.title,
|
||||
draft.summary,
|
||||
draft.detail,
|
||||
draft.category,
|
||||
draft.appPrimary ?? null,
|
||||
draft.appSecondary ?? null,
|
||||
JSON.stringify(draft.distractions),
|
||||
draft.keyframeId ?? null,
|
||||
now,
|
||||
);
|
||||
}
|
||||
this.db.exec("COMMIT");
|
||||
} catch (err) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
listDays(): Array<{ day: string; cards: number; firstMs: number; lastMs: number }> {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT day, COUNT(*) AS cards, MIN(start_ms) AS first_ms, MAX(end_ms) AS last_ms
|
||||
FROM cards GROUP BY day ORDER BY day DESC`,
|
||||
)
|
||||
.all() as Array<{ day: string; cards: number; first_ms: number; last_ms: number }>;
|
||||
return rows.map((row) => ({
|
||||
day: row.day,
|
||||
cards: row.cards,
|
||||
firstMs: row.first_ms,
|
||||
lastMs: row.last_ms,
|
||||
}));
|
||||
}
|
||||
|
||||
dayStats(day: string): LogbookDayStats {
|
||||
const cards = this.cardsForDay(day);
|
||||
const categories = new Map<string, number>();
|
||||
const apps = new Map<string, number>();
|
||||
let trackedMs = 0;
|
||||
let distractionMs = 0;
|
||||
for (const card of cards) {
|
||||
const duration = Math.max(0, card.endMs - card.startMs);
|
||||
trackedMs += duration;
|
||||
categories.set(card.category, (categories.get(card.category) ?? 0) + duration);
|
||||
if (card.appPrimary) {
|
||||
apps.set(card.appPrimary, (apps.get(card.appPrimary) ?? 0) + duration);
|
||||
}
|
||||
for (const distraction of card.distractions) {
|
||||
distractionMs += Math.max(0, distraction.endMs - distraction.startMs);
|
||||
}
|
||||
}
|
||||
const byMsDesc = (a: { ms: number }, b: { ms: number }) => b.ms - a.ms;
|
||||
return {
|
||||
trackedMs,
|
||||
distractionMs,
|
||||
categories: [...categories.entries()]
|
||||
.map(([category, ms]) => ({ category, ms }))
|
||||
.toSorted(byMsDesc),
|
||||
apps: [...apps.entries()].map(([domain, ms]) => ({ domain, ms })).toSorted(byMsDesc),
|
||||
};
|
||||
}
|
||||
|
||||
getStandup(day: string): { day: string; text: string; updatedMs: number } | null {
|
||||
const row = this.db
|
||||
.prepare(`SELECT day, text, updated_ms FROM standups WHERE day = ?`)
|
||||
.get(day) as { day: string; text: string; updated_ms: number } | undefined;
|
||||
return row ? { day: row.day, text: row.text, updatedMs: row.updated_ms } : null;
|
||||
}
|
||||
|
||||
saveStandup(day: string, text: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO standups (day, text, updated_ms) VALUES (?, ?, ?)
|
||||
ON CONFLICT(day) DO UPDATE SET text = excluded.text, updated_ms = excluded.updated_ms`,
|
||||
)
|
||||
.run(day, text, Date.now());
|
||||
}
|
||||
|
||||
/** Deletes frame rows and files older than the retention window. */
|
||||
pruneFrames(olderThanMs: number): number {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT id, path, day FROM frames WHERE captured_at_ms < ?`)
|
||||
.all(olderThanMs) as Array<{ id: number; path: string; day: string }>;
|
||||
if (rows.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const days = new Set<string>();
|
||||
for (const row of rows) {
|
||||
rmSync(row.path, { force: true });
|
||||
days.add(row.day);
|
||||
}
|
||||
// Cards outlive their frames; a dangling keyframe_id would make the UI
|
||||
// retry a preview fetch forever, so detach it before the rows disappear.
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE cards SET keyframe_id = NULL
|
||||
WHERE keyframe_id IN (SELECT id FROM frames WHERE captured_at_ms < ?)`,
|
||||
)
|
||||
.run(olderThanMs);
|
||||
this.db.prepare(`DELETE FROM frames WHERE captured_at_ms < ?`).run(olderThanMs);
|
||||
for (const day of days) {
|
||||
// Best-effort: removes now-empty day directories, keeps non-empty ones.
|
||||
try {
|
||||
rmdirSync(path.join(this.framesDir, day));
|
||||
} catch {}
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Shared Logbook domain shapes used by the store, pipeline, and gateway methods.
|
||||
|
||||
export type LogbookFrame = {
|
||||
id: number;
|
||||
capturedAtMs: number;
|
||||
day: string;
|
||||
path: string;
|
||||
screenIndex: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
byteSize: number;
|
||||
idle: boolean;
|
||||
};
|
||||
|
||||
export type LogbookBatchStatus = "pending" | "running" | "done" | "error";
|
||||
|
||||
export type LogbookBatch = {
|
||||
id: number;
|
||||
day: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
status: LogbookBatchStatus;
|
||||
error?: string;
|
||||
frameCount: number;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type LogbookObservation = {
|
||||
id: number;
|
||||
batchId: number;
|
||||
day: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type LogbookDistraction = {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type LogbookCard = {
|
||||
id: number;
|
||||
day: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
category: string;
|
||||
appPrimary?: string;
|
||||
appSecondary?: string;
|
||||
distractions: LogbookDistraction[];
|
||||
keyframeId?: number;
|
||||
};
|
||||
|
||||
export type LogbookCardDraft = Omit<LogbookCard, "id">;
|
||||
|
||||
export type LogbookDayStats = {
|
||||
trackedMs: number;
|
||||
distractionMs: number;
|
||||
categories: Array<{ category: string; ms: number }>;
|
||||
apps: Array<{ domain: string; ms: number }>;
|
||||
};
|
||||
@@ -101,6 +101,24 @@ export const HelloOkSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
snapshot: SnapshotSchema,
|
||||
// Additive: plugin-declared Control UI tabs (surface "tab" descriptors).
|
||||
controlUiTabs: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object(
|
||||
{
|
||||
pluginId: NonEmptyString,
|
||||
id: NonEmptyString,
|
||||
label: NonEmptyString,
|
||||
description: Type.Optional(Type.String()),
|
||||
icon: Type.Optional(Type.String()),
|
||||
path: Type.Optional(Type.String()),
|
||||
group: Type.Optional(Type.Union([Type.Literal("control"), Type.Literal("agent")])),
|
||||
order: Type.Optional(Type.Number()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
),
|
||||
pluginSurfaceUrls: Type.Optional(Type.Record(NonEmptyString, NonEmptyString)),
|
||||
auth: Type.Object(
|
||||
{
|
||||
|
||||
Generated
+9
@@ -982,6 +982,15 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
extensions/logbook:
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
openclaw:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/matrix:
|
||||
dependencies:
|
||||
'@matrix-org/matrix-sdk-crypto-nodejs':
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginControlUiDescriptor } from "../plugins/host-hooks.js";
|
||||
import { projectControlUiPluginTabs } from "./control-ui-plugin-tabs.js";
|
||||
|
||||
function tabDescriptor(
|
||||
overrides: Partial<PluginControlUiDescriptor> = {},
|
||||
): PluginControlUiDescriptor {
|
||||
return {
|
||||
id: "logbook",
|
||||
surface: "tab",
|
||||
label: "Logbook",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("projectControlUiPluginTabs", () => {
|
||||
it("projects only tab descriptors", () => {
|
||||
const tabs = projectControlUiPluginTabs(
|
||||
[
|
||||
{ pluginId: "logbook", descriptor: tabDescriptor() },
|
||||
{ pluginId: "other", descriptor: tabDescriptor({ id: "run-panel", surface: "run" }) },
|
||||
],
|
||||
["operator.admin"],
|
||||
);
|
||||
expect(tabs.map((tab) => tab.id)).toEqual(["logbook"]);
|
||||
expect(tabs[0].pluginId).toBe("logbook");
|
||||
});
|
||||
|
||||
it("hides tabs whose required scopes are not granted", () => {
|
||||
const entries = [
|
||||
{
|
||||
pluginId: "logbook",
|
||||
descriptor: tabDescriptor({ requiredScopes: ["operator.write"] }),
|
||||
},
|
||||
{
|
||||
pluginId: "adminy",
|
||||
descriptor: tabDescriptor({
|
||||
id: "adminy",
|
||||
label: "Admin",
|
||||
requiredScopes: ["operator.admin"],
|
||||
}),
|
||||
},
|
||||
];
|
||||
expect(projectControlUiPluginTabs(entries, ["operator.read"])).toEqual([]);
|
||||
// Admin implies write for visibility.
|
||||
expect(projectControlUiPluginTabs(entries, ["operator.write"]).map((tab) => tab.id)).toEqual([
|
||||
"logbook",
|
||||
]);
|
||||
expect(projectControlUiPluginTabs(entries, ["operator.admin"]).map((tab) => tab.id)).toEqual([
|
||||
"adminy",
|
||||
"logbook",
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders deterministically by order, label, then id", () => {
|
||||
const tabs = projectControlUiPluginTabs(
|
||||
[
|
||||
{ pluginId: "b", descriptor: tabDescriptor({ id: "beta", label: "Beta" }) },
|
||||
{ pluginId: "a", descriptor: tabDescriptor({ id: "alpha", label: "Alpha", order: 5 }) },
|
||||
{ pluginId: "c", descriptor: tabDescriptor({ id: "zed", label: "Beta" }) },
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(tabs.map((tab) => tab.id)).toEqual(["beta", "zed", "alpha"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Projects plugin "tab" Control UI descriptors into the hello payload so the
|
||||
// dashboard renders plugin tabs without hardcoding plugin ids in core.
|
||||
import type { PluginControlUiDescriptor } from "../plugins/host-hooks.js";
|
||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { authorizeOperatorScopesForRequiredScope } from "./method-scopes.js";
|
||||
|
||||
export type ControlUiPluginTab = {
|
||||
pluginId: string;
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
path?: string;
|
||||
group?: "control" | "agent";
|
||||
order?: number;
|
||||
};
|
||||
|
||||
type ControlUiDescriptorEntry = {
|
||||
pluginId: string;
|
||||
descriptor: PluginControlUiDescriptor;
|
||||
};
|
||||
|
||||
/** Pure projection of tab descriptors visible to the presented scopes. */
|
||||
export function projectControlUiPluginTabs(
|
||||
entries: readonly ControlUiDescriptorEntry[],
|
||||
scopes: readonly string[],
|
||||
): ControlUiPluginTab[] {
|
||||
const tabs: ControlUiPluginTab[] = [];
|
||||
for (const entry of entries) {
|
||||
const descriptor = entry.descriptor;
|
||||
if (descriptor.surface !== "tab") {
|
||||
continue;
|
||||
}
|
||||
const visible = (descriptor.requiredScopes ?? []).every(
|
||||
(scope) => authorizeOperatorScopesForRequiredScope(scope, scopes).allowed,
|
||||
);
|
||||
if (!visible) {
|
||||
continue;
|
||||
}
|
||||
tabs.push({
|
||||
pluginId: entry.pluginId,
|
||||
id: descriptor.id,
|
||||
label: descriptor.label,
|
||||
description: descriptor.description,
|
||||
icon: descriptor.icon,
|
||||
path: descriptor.path,
|
||||
group: descriptor.group,
|
||||
order: descriptor.order,
|
||||
});
|
||||
}
|
||||
// Deterministic ordering keeps hello payloads stable across connects.
|
||||
return tabs.toSorted(
|
||||
(left, right) =>
|
||||
(left.order ?? 0) - (right.order ?? 0) ||
|
||||
left.label.localeCompare(right.label) ||
|
||||
left.id.localeCompare(right.id),
|
||||
);
|
||||
}
|
||||
|
||||
/** Lists active plugins' tab descriptors visible to the presented scopes. */
|
||||
export function listControlUiPluginTabs(scopes: readonly string[]): ControlUiPluginTab[] {
|
||||
const registry = getActivePluginRegistry();
|
||||
return projectControlUiPluginTabs(registry?.controlUiDescriptors ?? [], scopes);
|
||||
}
|
||||
@@ -109,6 +109,7 @@ import { verifyAgentRuntimeIdentityToken } from "../../agent-runtime-identity-to
|
||||
import { AUTH_RATE_LIMIT_SCOPE_NODE_PAIRING, type AuthRateLimiter } from "../../auth-rate-limit.js";
|
||||
import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js";
|
||||
import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js";
|
||||
import { listControlUiPluginTabs } from "../../control-ui-plugin-tabs.js";
|
||||
import { normalizeDeviceMetadataForAuth } from "../../device-auth.js";
|
||||
import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../../method-scopes.js";
|
||||
import type { GatewayMethodRegistry } from "../../methods/registry.js";
|
||||
@@ -2097,6 +2098,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
snapshot.stateVersion.health = getHealthVersion();
|
||||
}
|
||||
const helloOkAuthScopes = deviceToken ? deviceToken.scopes : scopes;
|
||||
const controlUiTabs = listControlUiPluginTabs(helloOkAuthScopes);
|
||||
const helloOk = {
|
||||
type: "hello-ok",
|
||||
protocol: PROTOCOL_VERSION,
|
||||
@@ -2106,6 +2108,7 @@ export function attachGatewayWsMessageHandler(params: GatewayWsMessageHandlerPar
|
||||
},
|
||||
features: { methods: gatewayMethods, events },
|
||||
snapshot,
|
||||
...(controlUiTabs.length > 0 ? { controlUiTabs } : {}),
|
||||
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
|
||||
auth: {
|
||||
role,
|
||||
|
||||
@@ -45,6 +45,7 @@ const EXPECTED_BUNDLED_STARTUP_PLUGIN_IDS = [
|
||||
"google-meet",
|
||||
"llm-task",
|
||||
"lobster",
|
||||
"logbook",
|
||||
"memory-wiki",
|
||||
"ollama",
|
||||
"openshell",
|
||||
|
||||
@@ -90,14 +90,28 @@ export type PluginToolMetadataRegistration = {
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type PluginControlUiTabGroup = "control" | "agent";
|
||||
|
||||
export type PluginControlUiDescriptor = {
|
||||
id: string;
|
||||
surface: "session" | "tool" | "run" | "settings";
|
||||
/** "tab" adds a Control UI sidebar tab; other surfaces attach to existing views. */
|
||||
surface: "session" | "tool" | "run" | "settings" | "tab";
|
||||
label: string;
|
||||
description?: string;
|
||||
placement?: string;
|
||||
schema?: PluginJsonValue;
|
||||
requiredScopes?: OperatorScope[];
|
||||
/** Icon name hint for tab descriptors; unknown names fall back to a generic icon. */
|
||||
icon?: string;
|
||||
/**
|
||||
* Gateway HTTP path (e.g. /plugins/<id>/panel) rendered in a sandboxed frame
|
||||
* when the Control UI has no bundled view for this tab.
|
||||
*/
|
||||
path?: string;
|
||||
/** Sidebar group for tab descriptors; defaults to "control". */
|
||||
group?: PluginControlUiTabGroup;
|
||||
/** Sort order among plugin tabs; lower renders first. */
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type PluginSessionActionContext = {
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -173,6 +173,17 @@ function isTrustedRetryEndpoint(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export type GatewayControlUiPluginTab = {
|
||||
pluginId: string;
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
path?: string;
|
||||
group?: "control" | "agent";
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type GatewayHelloOk = {
|
||||
type: "hello-ok";
|
||||
protocol: number;
|
||||
@@ -188,6 +199,7 @@ export type GatewayHelloOk = {
|
||||
scopes: string[];
|
||||
issuedAtMs?: number;
|
||||
};
|
||||
controlUiTabs?: GatewayControlUiPluginTab[];
|
||||
pluginSurfaceUrls?: Record<string, string>;
|
||||
policy?: { tickIntervalMs?: number };
|
||||
};
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -61,6 +61,7 @@ const NAVIGATION_ICONS: NavigationItem = {
|
||||
debug: "bug",
|
||||
logs: "scrollText",
|
||||
dreams: "moon",
|
||||
plugin: "puzzle",
|
||||
};
|
||||
|
||||
export function isSettingsNavigationRoute(routeId: NavigationRouteId): boolean {
|
||||
@@ -159,6 +160,7 @@ const NAVIGATION_COPY: Record<NavigationRouteId, { titleKey: string; subtitleKey
|
||||
debug: { titleKey: "tabs.debug", subtitleKey: "subtitles.debug" },
|
||||
logs: { titleKey: "tabs.logs", subtitleKey: "subtitles.logs" },
|
||||
dreams: { titleKey: "tabs.dreams", subtitleKey: "subtitles.dreams" },
|
||||
plugin: { titleKey: "tabs.plugin", subtitleKey: "subtitles.plugin" },
|
||||
};
|
||||
|
||||
export function titleForRoute(routeId: NavigationRouteId): string {
|
||||
|
||||
@@ -24,6 +24,7 @@ export const APP_ROUTE_DEFINITIONS = {
|
||||
skills: { path: "/skills" },
|
||||
cron: { path: "/cron" },
|
||||
nodes: { path: "/nodes" },
|
||||
plugin: { path: "/plugin" },
|
||||
dreams: { path: "/dreaming", aliases: ["/dreams"] },
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { page as instancesPage } from "./pages/instances/route.ts";
|
||||
import { page as logsPage } from "./pages/logs/route.ts";
|
||||
import { page as nodesPage } from "./pages/nodes/route.ts";
|
||||
import { page as overviewPage } from "./pages/overview/route.ts";
|
||||
import { page as pluginPage } from "./pages/plugin/route.ts";
|
||||
import { page as sessionsPage } from "./pages/sessions/route.ts";
|
||||
import { page as skillWorkshopPage } from "./pages/skill-workshop/route.ts";
|
||||
import { page as skillsPage } from "./pages/skills/route.ts";
|
||||
@@ -50,6 +51,7 @@ export const APP_ROUTE_TREE = [
|
||||
cronPage,
|
||||
nodesPage,
|
||||
dreamsPage,
|
||||
pluginPage,
|
||||
] as const;
|
||||
|
||||
const appRoutes = APP_ROUTE_TREE as readonly AppRoute[];
|
||||
|
||||
@@ -26,6 +26,7 @@ import { searchForSession } from "../lib/sessions/index.ts";
|
||||
import { resolveAgentIdFromSessionKey } from "../lib/sessions/session-key.ts";
|
||||
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../lib/string-coerce.ts";
|
||||
import { renderDevicePairSetup } from "../pages/nodes/view-pairing.ts";
|
||||
import { pluginTabKey, pluginTabRefFromSearch } from "../pages/plugin/route.ts";
|
||||
import { bootstrapApplication, type ApplicationRuntime } from "./bootstrap.ts";
|
||||
import {
|
||||
applicationContext,
|
||||
@@ -605,6 +606,11 @@ class OpenClawShell extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
const activeRoute = this.routeState.routeId ?? "chat";
|
||||
// Plugin tabs share one route; the search picks the active item.
|
||||
const activePluginTabId =
|
||||
activeRoute === "plugin"
|
||||
? pluginTabKey(pluginTabRefFromSearch(this.routeState.location?.search ?? ""))
|
||||
: "";
|
||||
const navDrawerOpen = this.navDrawerOpen && !this.onboarding;
|
||||
const navCollapsed = this.navCollapsed && !navDrawerOpen;
|
||||
return html`
|
||||
@@ -648,6 +654,7 @@ class OpenClawShell extends LitElement {
|
||||
<openclaw-app-sidebar
|
||||
.basePath=${context.basePath}
|
||||
.activeRouteId=${activeRoute}
|
||||
.activePluginTabId=${activePluginTabId}
|
||||
.enabledRouteIds=${APP_ROUTE_IDS}
|
||||
.sessionKey=${this.activeSessionKey}
|
||||
.collapsed=${navCollapsed}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { consume } from "@lit/context";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { keyed } from "lit/directives/keyed.js";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import type { GatewayBrowserClient, GatewayControlUiPluginTab } from "../api/gateway.ts";
|
||||
import type { SessionsListResult } from "../api/types.ts";
|
||||
import {
|
||||
cancelRoutePreload,
|
||||
@@ -45,7 +45,8 @@ import {
|
||||
resolvePreferredSessionForAgent,
|
||||
resolveSessionAgentFilterOptions,
|
||||
} from "../lib/sessions/session-options.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import { pluginTabKey, pluginTabSearch } from "../pages/plugin/route.ts";
|
||||
import { icons, type IconName } from "./icons.ts";
|
||||
|
||||
type SidebarRecentSession = {
|
||||
key: string;
|
||||
@@ -77,6 +78,7 @@ export class AppSidebar extends LitElement {
|
||||
|
||||
@property({ attribute: false }) basePath = "";
|
||||
@property({ attribute: false }) activeRouteId?: NavigationRouteId;
|
||||
@property({ attribute: false }) activePluginTabId = "";
|
||||
@property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[];
|
||||
@property({ attribute: false }) collapsed = false;
|
||||
@property({ attribute: false }) connected = false;
|
||||
@@ -406,6 +408,39 @@ export class AppSidebar extends LitElement {
|
||||
: link;
|
||||
}
|
||||
|
||||
/** Plugin-declared tabs (hello controlUiTabs) render after a group's static routes. */
|
||||
private pluginTabsForGroup(groupLabel: string): GatewayControlUiPluginTab[] {
|
||||
const tabs = this.context?.gateway.snapshot.hello?.controlUiTabs ?? [];
|
||||
return tabs.filter((tab) => (tab.group ?? "control") === groupLabel);
|
||||
}
|
||||
|
||||
private renderPluginTab(tab: GatewayControlUiPluginTab) {
|
||||
const ref = { pluginId: tab.pluginId, id: tab.id };
|
||||
const search = pluginTabSearch(ref);
|
||||
const href = `${pathForRoute("plugin", this.basePath)}${search}`;
|
||||
const active = this.activeRouteId === "plugin" && this.activePluginTabId === pluginTabKey(ref);
|
||||
const iconName = tab.icon && Object.hasOwn(icons, tab.icon) ? (tab.icon as IconName) : "puzzle";
|
||||
const link = html`
|
||||
<a
|
||||
href=${href}
|
||||
class="nav-item ${active ? "nav-item--active" : ""}"
|
||||
@click=${(event: MouseEvent) => {
|
||||
if (!shouldHandleNavigationClick(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.onNavigate?.("plugin", { search });
|
||||
}}
|
||||
>
|
||||
<span class="nav-item__icon" aria-hidden="true">${icons[iconName]}</span>
|
||||
${!this.collapsed ? html`<span class="nav-item__text">${tab.label}</span>` : nothing}
|
||||
</a>
|
||||
`;
|
||||
return this.collapsed
|
||||
? html`<openclaw-tooltip .content=${tab.label}>${link}</openclaw-tooltip>`
|
||||
: link;
|
||||
}
|
||||
|
||||
private renderRecentSession(session: SidebarRecentSession) {
|
||||
const context = this.context;
|
||||
const archiveAllowed = canArchiveSessionRow(
|
||||
@@ -712,6 +747,9 @@ export class AppSidebar extends LitElement {
|
||||
: nothing}
|
||||
<div class="nav-section__items">
|
||||
${group.routes.map((routeId) => this.renderRoute(routeId))}
|
||||
${this.pluginTabsForGroup(group.label).map((tab) =>
|
||||
this.renderPluginTab(tab),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+1
@@ -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"}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
-7
@@ -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",
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+5
@@ -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"}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
}
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -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",
|
||||
|
||||
Generated
+62
@@ -486,6 +486,7 @@ export const zh_TW: TranslationMap = {
|
||||
debug: "調試",
|
||||
logs: "日誌",
|
||||
dreams: "夢境",
|
||||
plugin: "外掛程式",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "工作區、工具、身份。",
|
||||
@@ -511,6 +512,7 @@ export const zh_TW: TranslationMap = {
|
||||
debug: "快照、事件、RPC。",
|
||||
logs: "實時網關日誌。",
|
||||
dreams: "睡眠期間的記憶整合。",
|
||||
plugin: "外掛程式提供的面板。",
|
||||
},
|
||||
skillWorkshop: {
|
||||
header: {
|
||||
@@ -570,6 +572,66 @@ export const zh_TW: TranslationMap = {
|
||||
truncated: "Log output truncated; showing latest chunk.",
|
||||
empty: "No log entries.",
|
||||
},
|
||||
pluginTabs: {
|
||||
unavailableTitle: "Plugin panel unavailable",
|
||||
unavailableSubtitle:
|
||||
"The plugin that owns this tab is not active on the connected gateway, or it did not provide a panel.",
|
||||
},
|
||||
logbook: {
|
||||
duration: {
|
||||
minutes: "{minutes}m",
|
||||
hours: "{hours}h {minutes}m",
|
||||
},
|
||||
nav: {
|
||||
previousDay: "Previous day",
|
||||
nextDay: "Next day",
|
||||
today: "Today",
|
||||
},
|
||||
status: {
|
||||
capturing: "Capturing every {seconds}s",
|
||||
paused: "Capture paused",
|
||||
disabled: "Capture off",
|
||||
nodeHelp: "Node providing screen snapshots.",
|
||||
pending: "{count} frames queued",
|
||||
pendingHelp: "Snapshots waiting for the next analysis batch.",
|
||||
analyzing: "Analyzing…",
|
||||
captureError: "Capture error",
|
||||
batchError: "Analysis error",
|
||||
modelMissing: "No vision model",
|
||||
modelMissingHelp:
|
||||
"Set plugins.entries.logbook.config.visionModel (for example codex/gpt-5.5) or configure tools.media models.",
|
||||
},
|
||||
actions: {
|
||||
pause: "Pause",
|
||||
resume: "Resume",
|
||||
analyzeNow: "Analyze now",
|
||||
},
|
||||
empty: {
|
||||
title: "Nothing on the timeline yet.",
|
||||
subtitle:
|
||||
"Logbook is collecting snapshots; cards appear after the first analysis batch completes.",
|
||||
},
|
||||
card: {
|
||||
keyframeAlt: "Screen snapshot from this activity",
|
||||
distractions: "Distractions",
|
||||
},
|
||||
stats: {
|
||||
title: "Day at a glance",
|
||||
focus: "{pct}% focus",
|
||||
tracked: "{duration} tracked",
|
||||
},
|
||||
standup: {
|
||||
title: "Daily standup",
|
||||
generate: "Generate",
|
||||
refresh: "Regenerate",
|
||||
empty: "Turn today's timeline into a ready-to-paste standup update.",
|
||||
},
|
||||
ask: {
|
||||
title: "Ask your day",
|
||||
placeholder: "When did I review the gateway PR?",
|
||||
submit: "Ask",
|
||||
},
|
||||
},
|
||||
workboard: {
|
||||
disabledHelpStart: "Workboard 已停用。啟用",
|
||||
enableConfigKey: "plugins.entries.workboard.enabled = true",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import {
|
||||
askLogbook,
|
||||
configureLogbookPolling,
|
||||
getLogbookState,
|
||||
loadLogbookStandup,
|
||||
stopLogbookPolling,
|
||||
} from "./logbook-controller.ts";
|
||||
|
||||
function clientWithRequest(
|
||||
request: (method: string, params: unknown) => Promise<unknown>,
|
||||
): GatewayBrowserClient {
|
||||
return { request } as GatewayBrowserClient;
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("Logbook controller", () => {
|
||||
const hosts: object[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const host of hosts.splice(0)) {
|
||||
stopLogbookPolling(host);
|
||||
}
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("rebinds polling when the gateway client changes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
const state = getLogbookState(host);
|
||||
const firstRequest = vi.fn(async () => ({}));
|
||||
const secondRequest = vi.fn(async () => ({}));
|
||||
|
||||
configureLogbookPolling(state, clientWithRequest(firstRequest), true);
|
||||
configureLogbookPolling(state, clientWithRequest(secondRequest), true);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(firstRequest).not.toHaveBeenCalled();
|
||||
expect(secondRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a standup response after the selected day changes", async () => {
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
const state = getLogbookState(host);
|
||||
state.day = "2026-07-04";
|
||||
const pending = deferred<unknown>();
|
||||
const request = loadLogbookStandup(
|
||||
state,
|
||||
clientWithRequest(() => pending.promise),
|
||||
false,
|
||||
);
|
||||
|
||||
state.day = "2026-07-05";
|
||||
pending.resolve({ day: "2026-07-04", text: "Old day", updatedMs: 1 });
|
||||
await request;
|
||||
|
||||
expect(state.standup).toBeNull();
|
||||
});
|
||||
|
||||
it("discards an ask response after the selected day changes", async () => {
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
const state = getLogbookState(host);
|
||||
state.day = "2026-07-04";
|
||||
state.askQuestion = "What did I do?";
|
||||
const pending = deferred<unknown>();
|
||||
const request = askLogbook(
|
||||
state,
|
||||
clientWithRequest(() => pending.promise),
|
||||
);
|
||||
|
||||
state.day = "2026-07-05";
|
||||
pending.resolve({ answer: "Old day" });
|
||||
await request;
|
||||
|
||||
expect(state.askAnswer).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
// Control UI controller for the Logbook tab: state, gateway calls, polling.
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
|
||||
export type LogbookStatusPayload = {
|
||||
captureEnabled: boolean;
|
||||
capturePaused: boolean;
|
||||
captureIntervalSeconds: number;
|
||||
analysisIntervalMinutes: number;
|
||||
retentionDays: number;
|
||||
nodeId?: string;
|
||||
nodeName?: string;
|
||||
lastCaptureAtMs?: number;
|
||||
lastCaptureError?: string;
|
||||
pendingFrames: number;
|
||||
analysisRunning: boolean;
|
||||
lastBatch?: { id: number; day: string; status: string; endMs: number; error?: string };
|
||||
visionModel?: string;
|
||||
visionModelSource: "config" | "media-defaults" | "missing";
|
||||
today: string;
|
||||
todayCards: number;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export type LogbookDistractionPayload = { startMs: number; endMs: number; title: string };
|
||||
|
||||
export type LogbookCardPayload = {
|
||||
id: number;
|
||||
day: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
category: string;
|
||||
appPrimary?: string;
|
||||
appSecondary?: string;
|
||||
distractions: LogbookDistractionPayload[];
|
||||
keyframeId?: number;
|
||||
};
|
||||
|
||||
export type LogbookDayStatsPayload = {
|
||||
trackedMs: number;
|
||||
distractionMs: number;
|
||||
categories: Array<{ category: string; ms: number }>;
|
||||
apps: Array<{ domain: string; ms: number }>;
|
||||
};
|
||||
|
||||
export type LogbookTimelinePayload = {
|
||||
day: string;
|
||||
cards: LogbookCardPayload[];
|
||||
stats: LogbookDayStatsPayload;
|
||||
};
|
||||
|
||||
export type LogbookDaysPayload = {
|
||||
days: Array<{ day: string; cards: number; firstMs: number; lastMs: number }>;
|
||||
};
|
||||
|
||||
export type LogbookUiState = {
|
||||
day: string;
|
||||
/** True once the user navigated to a specific day; unpinned views follow the gateway's today. */
|
||||
dayPinned: boolean;
|
||||
status: LogbookStatusPayload | null;
|
||||
days: LogbookDaysPayload["days"];
|
||||
timeline: LogbookTimelinePayload | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
expandedCardIds: Set<number>;
|
||||
framePreviews: Map<number, string>;
|
||||
frameLoads: Set<number>;
|
||||
framePreviewFailed: Set<number>;
|
||||
standup: { day: string; text: string; updatedMs: number } | null;
|
||||
standupLoading: boolean;
|
||||
askQuestion: string;
|
||||
askAnswer: string | null;
|
||||
askLoading: boolean;
|
||||
actionPending: boolean;
|
||||
pollTimer: ReturnType<typeof globalThis.setInterval> | null;
|
||||
pollClient: GatewayBrowserClient | null;
|
||||
requestUpdate: (() => void) | null;
|
||||
};
|
||||
|
||||
const FRAME_PREVIEW_CACHE_LIMIT = 48;
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
const logbookStates = new WeakMap<object, LogbookUiState>();
|
||||
|
||||
export function localDayKey(date = new Date()): string {
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function shiftDay(day: string, deltaDays: number): string {
|
||||
const base = new Date(`${day}T12:00:00`);
|
||||
base.setDate(base.getDate() + deltaDays);
|
||||
return localDayKey(base);
|
||||
}
|
||||
|
||||
export function getLogbookState(host: object): LogbookUiState {
|
||||
let state = logbookStates.get(host);
|
||||
if (!state) {
|
||||
state = {
|
||||
day: localDayKey(),
|
||||
dayPinned: false,
|
||||
status: null,
|
||||
days: [],
|
||||
timeline: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
expandedCardIds: new Set(),
|
||||
framePreviews: new Map(),
|
||||
frameLoads: new Set(),
|
||||
framePreviewFailed: new Set(),
|
||||
standup: null,
|
||||
standupLoading: false,
|
||||
askQuestion: "",
|
||||
askAnswer: null,
|
||||
askLoading: false,
|
||||
actionPending: false,
|
||||
pollTimer: null,
|
||||
pollClient: null,
|
||||
requestUpdate: null,
|
||||
};
|
||||
logbookStates.set(host, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function notify(state: LogbookUiState): void {
|
||||
state.requestUpdate?.();
|
||||
}
|
||||
|
||||
function resetDayView(state: LogbookUiState, day: string): void {
|
||||
state.day = day;
|
||||
state.timeline = null;
|
||||
state.standup = null;
|
||||
state.askAnswer = null;
|
||||
state.expandedCardIds = new Set();
|
||||
}
|
||||
|
||||
export async function loadLogbook(
|
||||
state: LogbookUiState,
|
||||
client: GatewayBrowserClient | null,
|
||||
opts?: { day?: string; today?: boolean; silent?: boolean },
|
||||
): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
if (opts?.day) {
|
||||
state.dayPinned = true;
|
||||
if (opts.day !== state.day) {
|
||||
resetDayView(state, opts.day);
|
||||
}
|
||||
} else if (opts?.today) {
|
||||
state.dayPinned = false;
|
||||
}
|
||||
if (!opts?.silent) {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
notify(state);
|
||||
}
|
||||
try {
|
||||
const [status, days, timeline] = await Promise.all([
|
||||
client.request<LogbookStatusPayload>("logbook.status", {}),
|
||||
client.request<LogbookDaysPayload>("logbook.days", {}),
|
||||
client.request<LogbookTimelinePayload>("logbook.timeline", { day: state.day }),
|
||||
]);
|
||||
state.status = status;
|
||||
state.days = days.days;
|
||||
// Unpinned views follow the gateway's day: the browser clock can sit in
|
||||
// another timezone than the capture host, and midnight rollover should
|
||||
// advance the default view.
|
||||
if (!state.dayPinned && status.today !== state.day) {
|
||||
resetDayView(state, status.today);
|
||||
state.timeline = await client.request<LogbookTimelinePayload>("logbook.timeline", {
|
||||
day: status.today,
|
||||
});
|
||||
} else {
|
||||
state.timeline = timeline;
|
||||
}
|
||||
state.error = null;
|
||||
} catch (err) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
state.loading = false;
|
||||
notify(state);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops background polling; wired into tab-switch and disconnect cleanup. */
|
||||
export function stopLogbookPolling(host: object): void {
|
||||
const state = logbookStates.get(host);
|
||||
if (state?.pollTimer) {
|
||||
clearInterval(state.pollTimer);
|
||||
state.pollTimer = null;
|
||||
}
|
||||
if (state) {
|
||||
state.pollClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function configureLogbookPolling(
|
||||
state: LogbookUiState,
|
||||
client: GatewayBrowserClient | null,
|
||||
active: boolean,
|
||||
): void {
|
||||
if (!active || !client) {
|
||||
if (state.pollTimer) {
|
||||
clearInterval(state.pollTimer);
|
||||
state.pollTimer = null;
|
||||
}
|
||||
state.pollClient = null;
|
||||
return;
|
||||
}
|
||||
if (state.pollTimer && state.pollClient === client) {
|
||||
return;
|
||||
}
|
||||
if (state.pollTimer) {
|
||||
clearInterval(state.pollTimer);
|
||||
}
|
||||
state.pollClient = client;
|
||||
state.pollTimer = setInterval(() => {
|
||||
// Silent refresh keeps the timeline current while analysis batches land.
|
||||
void loadLogbook(state, client, { silent: true });
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export async function loadLogbookFramePreview(
|
||||
state: LogbookUiState,
|
||||
client: GatewayBrowserClient | null,
|
||||
frameId: number,
|
||||
): Promise<void> {
|
||||
if (
|
||||
!client ||
|
||||
state.framePreviews.has(frameId) ||
|
||||
state.frameLoads.has(frameId) ||
|
||||
state.framePreviewFailed.has(frameId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state.frameLoads.add(frameId);
|
||||
try {
|
||||
const payload = await client.request<{ base64: string; format: string }>("logbook.frame", {
|
||||
frameId,
|
||||
});
|
||||
if (state.framePreviews.size >= FRAME_PREVIEW_CACHE_LIMIT) {
|
||||
const oldest = state.framePreviews.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
state.framePreviews.delete(oldest);
|
||||
}
|
||||
}
|
||||
state.framePreviews.set(frameId, `data:image/${payload.format};base64,${payload.base64}`);
|
||||
} catch {
|
||||
// Preview loads are cosmetic, but a missing frame (e.g. pruned by
|
||||
// retention) must not re-fetch on every render, so remember the failure.
|
||||
state.framePreviewFailed.add(frameId);
|
||||
} finally {
|
||||
state.frameLoads.delete(frameId);
|
||||
notify(state);
|
||||
}
|
||||
}
|
||||
|
||||
export async function setLogbookCapturePaused(
|
||||
state: LogbookUiState,
|
||||
client: GatewayBrowserClient | null,
|
||||
paused: boolean,
|
||||
): Promise<void> {
|
||||
if (!client || state.actionPending) {
|
||||
return;
|
||||
}
|
||||
state.actionPending = true;
|
||||
notify(state);
|
||||
try {
|
||||
state.status = await client.request<LogbookStatusPayload>("logbook.capture.set", { paused });
|
||||
} catch (err) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
state.actionPending = false;
|
||||
notify(state);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runLogbookAnalysisNow(
|
||||
state: LogbookUiState,
|
||||
client: GatewayBrowserClient | null,
|
||||
): Promise<void> {
|
||||
if (!client || state.actionPending) {
|
||||
return;
|
||||
}
|
||||
state.actionPending = true;
|
||||
notify(state);
|
||||
try {
|
||||
const result = await client.request<{ started: boolean; reason?: string }>(
|
||||
"logbook.analyze.now",
|
||||
{},
|
||||
);
|
||||
if (!result.started && result.reason) {
|
||||
state.error = result.reason;
|
||||
}
|
||||
} catch (err) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
state.actionPending = false;
|
||||
notify(state);
|
||||
void loadLogbook(state, client, { silent: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadLogbookStandup(
|
||||
state: LogbookUiState,
|
||||
client: GatewayBrowserClient | null,
|
||||
refresh: boolean,
|
||||
): Promise<void> {
|
||||
if (!client || state.standupLoading) {
|
||||
return;
|
||||
}
|
||||
state.standupLoading = true;
|
||||
notify(state);
|
||||
const requestedDay = state.day;
|
||||
try {
|
||||
const standup = await client.request<{ day: string; text: string; updatedMs: number }>(
|
||||
"logbook.standup",
|
||||
{ day: requestedDay, refresh },
|
||||
);
|
||||
if (state.day === requestedDay) {
|
||||
state.standup = standup;
|
||||
}
|
||||
} catch (err) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
state.standupLoading = false;
|
||||
notify(state);
|
||||
}
|
||||
}
|
||||
|
||||
export async function askLogbook(
|
||||
state: LogbookUiState,
|
||||
client: GatewayBrowserClient | null,
|
||||
): Promise<void> {
|
||||
const question = state.askQuestion.trim();
|
||||
if (!client || state.askLoading || question.length === 0) {
|
||||
return;
|
||||
}
|
||||
state.askLoading = true;
|
||||
state.askAnswer = null;
|
||||
notify(state);
|
||||
const requestedDay = state.day;
|
||||
try {
|
||||
const payload = await client.request<{ answer: string }>("logbook.ask", {
|
||||
day: requestedDay,
|
||||
question,
|
||||
});
|
||||
if (state.day === requestedDay) {
|
||||
state.askAnswer = payload.answer;
|
||||
}
|
||||
} catch (err) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
state.askLoading = false;
|
||||
notify(state);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
// Control UI view renders the Logbook automatic work journal tab.
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import { toSanitizedMarkdownHtml } from "../../components/markdown.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { formatTimeMs } from "../../lib/format.ts";
|
||||
import {
|
||||
askLogbook,
|
||||
configureLogbookPolling,
|
||||
getLogbookState,
|
||||
loadLogbook,
|
||||
loadLogbookFramePreview,
|
||||
loadLogbookStandup,
|
||||
localDayKey,
|
||||
runLogbookAnalysisNow,
|
||||
setLogbookCapturePaused,
|
||||
shiftDay,
|
||||
type LogbookCardPayload,
|
||||
type LogbookStatusPayload,
|
||||
type LogbookUiState,
|
||||
} from "./logbook-controller.ts";
|
||||
|
||||
export type LogbookProps = {
|
||||
host: object;
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
onRequestUpdate?: () => void;
|
||||
};
|
||||
|
||||
function formatClock(ms: number, timeZone: string): string {
|
||||
return formatTimeMs(ms, { hour: "2-digit", minute: "2-digit", timeZone }, "");
|
||||
}
|
||||
|
||||
function formatDurationMs(ms: number): string {
|
||||
const minutes = Math.round(ms / 60_000);
|
||||
if (minutes < 60) {
|
||||
return t("logbook.duration.minutes", { minutes: String(minutes) });
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return t("logbook.duration.hours", { hours: String(hours), minutes: String(minutes % 60) });
|
||||
}
|
||||
|
||||
/** Stable category hue so colors stay consistent across renders and days. */
|
||||
function categoryHue(category: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < category.length; i += 1) {
|
||||
hash = (hash * 31 + category.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash) % 360;
|
||||
}
|
||||
|
||||
function renderStatusChips(status: LogbookStatusPayload): TemplateResult {
|
||||
const capturing = status.captureEnabled && !status.capturePaused && !status.lastCaptureError;
|
||||
const captureLabel = status.capturePaused
|
||||
? t("logbook.status.paused")
|
||||
: status.captureEnabled
|
||||
? t("logbook.status.capturing", { seconds: String(status.captureIntervalSeconds) })
|
||||
: t("logbook.status.disabled");
|
||||
return html`
|
||||
<div class="logbook__chips">
|
||||
<span class="logbook__chip ${capturing ? "logbook__chip--ok" : "logbook__chip--warn"}">
|
||||
<span class="logbook__chip-dot"></span>
|
||||
${captureLabel}
|
||||
</span>
|
||||
${status.nodeName || status.nodeId
|
||||
? html`<span class="logbook__chip" title=${t("logbook.status.nodeHelp")}>
|
||||
${icons.monitor} ${status.nodeName ?? status.nodeId}
|
||||
</span>`
|
||||
: nothing}
|
||||
${status.pendingFrames > 0
|
||||
? html`<span class="logbook__chip" title=${t("logbook.status.pendingHelp")}>
|
||||
${t("logbook.status.pending", { count: String(status.pendingFrames) })}
|
||||
</span>`
|
||||
: nothing}
|
||||
${status.analysisRunning
|
||||
? html`<span class="logbook__chip logbook__chip--busy"
|
||||
>${t("logbook.status.analyzing")}</span
|
||||
>`
|
||||
: nothing}
|
||||
${status.lastCaptureError
|
||||
? html`<span class="logbook__chip logbook__chip--error" title=${status.lastCaptureError}>
|
||||
${t("logbook.status.captureError")}
|
||||
</span>`
|
||||
: nothing}
|
||||
${status.lastBatch?.status === "error"
|
||||
? html`<span
|
||||
class="logbook__chip logbook__chip--error"
|
||||
title=${status.lastBatch.error ?? ""}
|
||||
>
|
||||
${t("logbook.status.batchError")}
|
||||
</span>`
|
||||
: nothing}
|
||||
${status.visionModelSource === "missing"
|
||||
? html`<span
|
||||
class="logbook__chip logbook__chip--warn"
|
||||
title=${t("logbook.status.modelMissingHelp")}
|
||||
>
|
||||
${t("logbook.status.modelMissing")}
|
||||
</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCard(
|
||||
state: LogbookUiState,
|
||||
client: GatewayBrowserClient | null,
|
||||
card: LogbookCardPayload,
|
||||
timeZone: string,
|
||||
): TemplateResult {
|
||||
const expanded = state.expandedCardIds.has(card.id);
|
||||
const hue = categoryHue(card.category);
|
||||
// Pruned keyframes look permanently absent; treating them as loading would
|
||||
// re-request the missing frame on every render.
|
||||
const keyframeId =
|
||||
card.keyframeId !== undefined && !state.framePreviewFailed.has(card.keyframeId)
|
||||
? card.keyframeId
|
||||
: undefined;
|
||||
const preview = keyframeId !== undefined ? state.framePreviews.get(keyframeId) : undefined;
|
||||
if (expanded && keyframeId !== undefined && !preview) {
|
||||
void loadLogbookFramePreview(state, client, keyframeId);
|
||||
}
|
||||
return html`
|
||||
<article
|
||||
class="logbook-card ${expanded ? "logbook-card--expanded" : ""}"
|
||||
style="--logbook-hue: ${hue}"
|
||||
>
|
||||
<button
|
||||
class="logbook-card__header"
|
||||
type="button"
|
||||
@click=${() => {
|
||||
const next = new Set(state.expandedCardIds);
|
||||
if (expanded) {
|
||||
next.delete(card.id);
|
||||
} else {
|
||||
next.add(card.id);
|
||||
}
|
||||
state.expandedCardIds = next;
|
||||
state.requestUpdate?.();
|
||||
}}
|
||||
>
|
||||
<span class="logbook-card__time">
|
||||
${formatClock(card.startMs, timeZone)}<span class="logbook-card__time-sep">–</span
|
||||
>${formatClock(card.endMs, timeZone)}
|
||||
</span>
|
||||
<span class="logbook-card__stripe" aria-hidden="true"></span>
|
||||
<span class="logbook-card__heading">
|
||||
<span class="logbook-card__title">${card.title}</span>
|
||||
<span class="logbook-card__summary">${card.summary}</span>
|
||||
</span>
|
||||
<span class="logbook-card__meta">
|
||||
<span class="logbook-card__category">${card.category}</span>
|
||||
${card.appPrimary
|
||||
? html`<span class="logbook-card__app">${card.appPrimary}</span>`
|
||||
: nothing}
|
||||
<span class="logbook-card__duration">${formatDurationMs(card.endMs - card.startMs)}</span>
|
||||
</span>
|
||||
</button>
|
||||
${expanded
|
||||
? html`
|
||||
<div class="logbook-card__body">
|
||||
${preview
|
||||
? html`<img
|
||||
class="logbook-card__keyframe"
|
||||
src=${preview}
|
||||
alt=${t("logbook.card.keyframeAlt")}
|
||||
/>`
|
||||
: keyframeId !== undefined
|
||||
? html`<div class="logbook-card__keyframe logbook-card__keyframe--loading">
|
||||
${t("common.loading")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${card.detail ? html`<p class="logbook-card__detail">${card.detail}</p>` : nothing}
|
||||
${card.distractions.length > 0
|
||||
? html`
|
||||
<div class="logbook-card__distractions">
|
||||
<span class="logbook-card__distractions-label">
|
||||
${t("logbook.card.distractions")}
|
||||
</span>
|
||||
${card.distractions.map(
|
||||
(distraction) => html`
|
||||
<span class="logbook-card__distraction">
|
||||
${formatClock(distraction.startMs, timeZone)} · ${distraction.title}
|
||||
</span>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStats(state: LogbookUiState): TemplateResult | typeof nothing {
|
||||
const stats = state.timeline?.stats;
|
||||
if (!stats || stats.trackedMs <= 0) {
|
||||
return nothing;
|
||||
}
|
||||
const focusMs = Math.max(0, stats.trackedMs - stats.distractionMs);
|
||||
const focusPct = Math.round((focusMs / stats.trackedMs) * 100);
|
||||
const maxCategoryMs = stats.categories[0]?.ms ?? 1;
|
||||
return html`
|
||||
<section class="card logbook-side__card">
|
||||
<div class="card-title">${t("logbook.stats.title")}</div>
|
||||
<div class="logbook-stats__focus">
|
||||
<div class="logbook-stats__focus-bar">
|
||||
<div class="logbook-stats__focus-fill" style="width: ${focusPct}%"></div>
|
||||
</div>
|
||||
<div class="logbook-stats__focus-legend">
|
||||
<span>${t("logbook.stats.focus", { pct: String(focusPct) })}</span>
|
||||
<span
|
||||
>${t("logbook.stats.tracked", { duration: formatDurationMs(stats.trackedMs) })}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logbook-stats__categories">
|
||||
${stats.categories.slice(0, 6).map(
|
||||
(entry) => html`
|
||||
<div
|
||||
class="logbook-stats__category"
|
||||
style="--logbook-hue: ${categoryHue(entry.category)}"
|
||||
>
|
||||
<span class="logbook-stats__category-name">${entry.category}</span>
|
||||
<span class="logbook-stats__category-bar">
|
||||
<span
|
||||
class="logbook-stats__category-fill"
|
||||
style="width: ${Math.max(6, Math.round((entry.ms / maxCategoryMs) * 100))}%"
|
||||
></span>
|
||||
</span>
|
||||
<span class="logbook-stats__category-time">${formatDurationMs(entry.ms)}</span>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${stats.apps.length > 0
|
||||
? html`
|
||||
<div class="logbook-stats__apps">
|
||||
${stats.apps
|
||||
.slice(0, 5)
|
||||
.map((app) => html`<span class="logbook-stats__app">${app.domain}</span>`)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStandup(state: LogbookUiState, client: GatewayBrowserClient | null): TemplateResult {
|
||||
return html`
|
||||
<section class="card logbook-side__card">
|
||||
<div class="logbook-side__card-header">
|
||||
<div class="card-title">${t("logbook.standup.title")}</div>
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
?disabled=${state.standupLoading}
|
||||
@click=${() => void loadLogbookStandup(state, client, state.standup !== null)}
|
||||
>
|
||||
${state.standupLoading
|
||||
? t("common.loading")
|
||||
: state.standup
|
||||
? t("logbook.standup.refresh")
|
||||
: t("logbook.standup.generate")}
|
||||
</button>
|
||||
</div>
|
||||
${state.standup
|
||||
? html`<div class="logbook-standup__body markdown-body">
|
||||
${unsafeHTML(toSanitizedMarkdownHtml(state.standup.text))}
|
||||
</div>`
|
||||
: html`<div class="card-sub">${t("logbook.standup.empty")}</div>`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAsk(state: LogbookUiState, client: GatewayBrowserClient | null): TemplateResult {
|
||||
return html`
|
||||
<section class="card logbook-side__card">
|
||||
<div class="card-title">${t("logbook.ask.title")}</div>
|
||||
<form
|
||||
class="logbook-ask__form"
|
||||
@submit=${(event: Event) => {
|
||||
event.preventDefault();
|
||||
void askLogbook(state, client);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
class="logbook-ask__input"
|
||||
type="text"
|
||||
.value=${state.askQuestion}
|
||||
placeholder=${t("logbook.ask.placeholder")}
|
||||
@input=${(event: Event) => {
|
||||
state.askQuestion = (event.target as HTMLInputElement).value;
|
||||
}}
|
||||
/>
|
||||
<button class="btn btn--small" type="submit" ?disabled=${state.askLoading}>
|
||||
${state.askLoading ? t("common.loading") : t("logbook.ask.submit")}
|
||||
</button>
|
||||
</form>
|
||||
${state.askAnswer ? html`<p class="logbook-ask__answer">${state.askAnswer}</p>` : nothing}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderLogbook(props: LogbookProps) {
|
||||
const state = getLogbookState(props.host);
|
||||
state.requestUpdate = props.onRequestUpdate ?? null;
|
||||
// The tab only renders while the plugin's descriptor is advertised, so
|
||||
// enablement gating lives in the shell; connectivity is the only guard here.
|
||||
const active = props.connected;
|
||||
configureLogbookPolling(state, active ? props.client : null, active);
|
||||
if (active && !state.timeline && !state.loading && !state.error) {
|
||||
void loadLogbook(state, props.client);
|
||||
}
|
||||
|
||||
// The gateway's day is authoritative; the browser clock only seeds the view
|
||||
// until the first status response arrives.
|
||||
const todayKey = state.status?.today ?? localDayKey();
|
||||
const isToday = state.day === todayKey;
|
||||
const status = state.status;
|
||||
const cards = state.timeline?.cards ?? [];
|
||||
return html`
|
||||
<section class="logbook">
|
||||
<header class="logbook__header">
|
||||
<div class="logbook__daynav">
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
aria-label=${t("logbook.nav.previousDay")}
|
||||
@click=${() => void loadLogbook(state, props.client, { day: shiftDay(state.day, -1) })}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span class="logbook__day">${state.day}</span>
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
aria-label=${t("logbook.nav.nextDay")}
|
||||
?disabled=${isToday}
|
||||
@click=${() => void loadLogbook(state, props.client, { day: shiftDay(state.day, 1) })}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
${!isToday
|
||||
? html`<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
@click=${() => void loadLogbook(state, props.client, { today: true })}
|
||||
>
|
||||
${t("logbook.nav.today")}
|
||||
</button>`
|
||||
: nothing}
|
||||
</div>
|
||||
${state.status ? renderStatusChips(state.status) : nothing}
|
||||
<div class="logbook__actions">
|
||||
${state.status
|
||||
? html`<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
?disabled=${state.actionPending || !state.status.captureEnabled}
|
||||
@click=${() =>
|
||||
void setLogbookCapturePaused(state, props.client, !state.status?.capturePaused)}
|
||||
>
|
||||
${state.status.capturePaused
|
||||
? t("logbook.actions.resume")
|
||||
: t("logbook.actions.pause")}
|
||||
</button>`
|
||||
: nothing}
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
?disabled=${state.actionPending}
|
||||
@click=${() => void runLogbookAnalysisNow(state, props.client)}
|
||||
>
|
||||
${t("logbook.actions.analyzeNow")}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
?disabled=${state.loading}
|
||||
@click=${() => void loadLogbook(state, props.client)}
|
||||
>
|
||||
${icons.refresh}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
${state.error ? html`<div class="callout danger" role="alert">${state.error}</div>` : nothing}
|
||||
<div class="logbook__layout">
|
||||
<div class="logbook__timeline">
|
||||
${state.loading && cards.length === 0
|
||||
? html`<div class="card-sub">${t("common.loading")}</div>`
|
||||
: nothing}
|
||||
${!state.loading && cards.length === 0 && !state.error
|
||||
? html`
|
||||
<div class="logbook__empty">
|
||||
<div class="logbook__empty-title">${t("logbook.empty.title")}</div>
|
||||
<div class="logbook__empty-sub">${t("logbook.empty.subtitle")}</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
${status
|
||||
? cards.map((card) => renderCard(state, props.client, card, status.timeZone))
|
||||
: nothing}
|
||||
</div>
|
||||
<aside class="logbook__side">
|
||||
${renderStats(state)} ${renderStandup(state, props.client)}
|
||||
${renderAsk(state, props.client)}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts";
|
||||
import type { RouteId } from "../../app-route-paths.ts";
|
||||
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
|
||||
import { getLogbookState } from "./logbook-controller.ts";
|
||||
import { PluginPage } from "./plugin-page.ts";
|
||||
|
||||
describe("PluginPage", () => {
|
||||
it("stops a bundled view when its advertised descriptor disappears", async () => {
|
||||
const hello: GatewayHelloOk = {
|
||||
type: "hello-ok",
|
||||
protocol: 3,
|
||||
auth: { role: "operator", scopes: ["operator.write"] },
|
||||
controlUiTabs: [{ pluginId: "logbook", id: "logbook", label: "Logbook" }],
|
||||
};
|
||||
const client = {
|
||||
request: vi.fn(async (method: string) => {
|
||||
if (method === "logbook.status") {
|
||||
return {
|
||||
captureEnabled: true,
|
||||
capturePaused: false,
|
||||
captureIntervalSeconds: 30,
|
||||
analysisIntervalMinutes: 15,
|
||||
retentionDays: 30,
|
||||
pendingFrames: 0,
|
||||
analysisRunning: false,
|
||||
visionModelSource: "missing",
|
||||
today: "2026-07-05",
|
||||
todayCards: 0,
|
||||
timeZone: "UTC",
|
||||
};
|
||||
}
|
||||
if (method === "logbook.days") {
|
||||
return { days: [] };
|
||||
}
|
||||
return {
|
||||
day: "2026-07-05",
|
||||
cards: [],
|
||||
stats: { trackedMs: 0, distractionMs: 0, categories: [], apps: [] },
|
||||
};
|
||||
}),
|
||||
} as unknown as GatewayBrowserClient;
|
||||
const snapshot: ApplicationGatewaySnapshot = {
|
||||
client,
|
||||
connected: true,
|
||||
hello,
|
||||
assistantAgentId: null,
|
||||
sessionKey: "main",
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
};
|
||||
const page = new PluginPage();
|
||||
page.pluginId = "logbook";
|
||||
page.tabId = "logbook";
|
||||
(page as unknown as { context: ApplicationContext<RouteId> }).context = {
|
||||
gateway: { snapshot, subscribe: () => () => undefined },
|
||||
} as unknown as ApplicationContext<RouteId>;
|
||||
|
||||
document.body.append(page);
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(getLogbookState(page).pollTimer).not.toBeNull();
|
||||
});
|
||||
|
||||
hello.controlUiTabs = [];
|
||||
page.requestUpdate();
|
||||
await page.updateComplete;
|
||||
|
||||
expect(getLogbookState(page).pollTimer).toBeNull();
|
||||
} finally {
|
||||
page.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { GatewayBrowserClient, GatewayControlUiPluginTab } from "../../api/gateway.ts";
|
||||
import type { RouteId } from "../../app-route-paths.ts";
|
||||
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { resolveEmbedSandbox } from "../../lib/chat/tool-display.ts";
|
||||
import { pluginTabKey } from "./route.ts";
|
||||
|
||||
/**
|
||||
* Bundled plugin tab views ship with the Control UI and render natively; every
|
||||
* other tab either embeds the plugin-served panel (descriptor path) in a
|
||||
* sandboxed frame or shows the unavailable card.
|
||||
*/
|
||||
type BundledPluginTabView = {
|
||||
render: (props: {
|
||||
host: object;
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
onRequestUpdate?: () => void;
|
||||
}) => unknown;
|
||||
stop: (host: object) => void;
|
||||
};
|
||||
|
||||
// Keyed by pluginId/tabId: tab ids are only unique within their plugin.
|
||||
const BUNDLED_TAB_VIEWS: Record<string, () => Promise<BundledPluginTabView>> = {
|
||||
"logbook/logbook": async () => {
|
||||
const [view, controller] = await Promise.all([
|
||||
import("./logbook-view.ts"),
|
||||
import("./logbook-controller.ts"),
|
||||
]);
|
||||
return { render: view.renderLogbook, stop: controller.stopLogbookPolling };
|
||||
},
|
||||
};
|
||||
|
||||
export class PluginPage extends LitElement {
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@property({ attribute: false }) pluginId = "";
|
||||
@property({ attribute: false }) tabId = "";
|
||||
|
||||
@consume({ context: applicationContext, subscribe: false })
|
||||
private context?: ApplicationContext<RouteId>;
|
||||
|
||||
@state() private bundledView: BundledPluginTabView | null = null;
|
||||
|
||||
private bundledViewId: string | null = null;
|
||||
private stopGatewaySubscription: (() => void) | undefined;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.style.display = "contents";
|
||||
this.stopGatewaySubscription ??= this.context?.gateway.subscribe(() => this.requestUpdate());
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.stopGatewaySubscription?.();
|
||||
this.stopGatewaySubscription = undefined;
|
||||
this.stopBundledView();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private tabKey(): string {
|
||||
return pluginTabKey({ pluginId: this.pluginId, id: this.tabId });
|
||||
}
|
||||
|
||||
override willUpdate() {
|
||||
const key = this.tabKey();
|
||||
const hasBundledDescriptor = this.tabInfo() !== undefined && key in BUNDLED_TAB_VIEWS;
|
||||
// Switching between plugin tabs reuses this element; the previous bundled
|
||||
// view must stop its background polling before the next one renders. A
|
||||
// descriptor can also disappear in place after disablement or scope loss.
|
||||
if (this.bundledViewId !== null && (this.bundledViewId !== key || !hasBundledDescriptor)) {
|
||||
this.stopBundledView();
|
||||
}
|
||||
if (this.bundledViewId === null && hasBundledDescriptor) {
|
||||
this.bundledViewId = key;
|
||||
void BUNDLED_TAB_VIEWS[key]().then((view) => {
|
||||
if (this.bundledViewId === this.tabKey()) {
|
||||
this.bundledView = view;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private stopBundledView() {
|
||||
this.bundledView?.stop(this);
|
||||
this.bundledView = null;
|
||||
this.bundledViewId = null;
|
||||
}
|
||||
|
||||
private tabInfo(): GatewayControlUiPluginTab | undefined {
|
||||
const tabs = this.context?.gateway.snapshot.hello?.controlUiTabs ?? [];
|
||||
return tabs.find((tab) => tab.pluginId === this.pluginId && tab.id === this.tabId);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const context = this.context;
|
||||
if (!context) {
|
||||
return nothing;
|
||||
}
|
||||
// Only advertised tabs render: hello omits descriptors whose plugin is
|
||||
// inactive or whose required scopes the connection lacks.
|
||||
const info = this.tabInfo();
|
||||
if (info && this.tabKey() in BUNDLED_TAB_VIEWS) {
|
||||
if (!this.bundledView) {
|
||||
return nothing;
|
||||
}
|
||||
const snapshot = context.gateway.snapshot;
|
||||
return this.bundledView.render({
|
||||
host: this,
|
||||
client: snapshot.client,
|
||||
connected: snapshot.connected,
|
||||
onRequestUpdate: () => this.requestUpdate(),
|
||||
});
|
||||
}
|
||||
if (info?.path) {
|
||||
return html`
|
||||
<section class="plugin-tab-embed">
|
||||
<iframe
|
||||
class="plugin-tab-embed__frame"
|
||||
src=${info.path}
|
||||
title=${info.label}
|
||||
sandbox=${resolveEmbedSandbox(context.config.current.embedSandboxMode)}
|
||||
></iframe>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<section class="card lazy-view-state" role="status">
|
||||
<div class="card-title">${t("pluginTabs.unavailableTitle")}</div>
|
||||
<div class="card-sub">${t("pluginTabs.unavailableSubtitle")}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-plugin-page")) {
|
||||
customElements.define("openclaw-plugin-page", PluginPage);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { definePage } from "@openclaw/uirouter";
|
||||
import { html } from "lit";
|
||||
|
||||
export type PluginTabRef = { pluginId: string; id: string };
|
||||
|
||||
/** Reads the plugin tab reference from a `/plugin?plugin=<pluginId>&id=<tab>` search string. */
|
||||
export function pluginTabRefFromSearch(search: string): PluginTabRef {
|
||||
const params = new URLSearchParams(search);
|
||||
return {
|
||||
pluginId: params.get("plugin")?.trim() ?? "",
|
||||
id: params.get("id")?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginTabSearch(ref: PluginTabRef): string {
|
||||
return `?plugin=${encodeURIComponent(ref.pluginId)}&id=${encodeURIComponent(ref.id)}`;
|
||||
}
|
||||
|
||||
/** Stable key for one tab; ids are only unique per plugin, so both parts matter. */
|
||||
export function pluginTabKey(ref: PluginTabRef): string {
|
||||
return `${ref.pluginId}/${ref.id}`;
|
||||
}
|
||||
|
||||
// One static route hosts every plugin-declared tab; the router only supports
|
||||
// exact paths, so the tab reference travels in the query like chat sessions.
|
||||
export const page = definePage({
|
||||
id: "plugin",
|
||||
path: "/plugin",
|
||||
loaderDeps: (_context, location) => location.search,
|
||||
loader: (_context, options) => pluginTabRefFromSearch(options.location.search),
|
||||
component: () =>
|
||||
import("./plugin-page.ts").then(() => ({
|
||||
header: true,
|
||||
render: (data: unknown) => {
|
||||
const ref = (data ?? { pluginId: "", id: "" }) as PluginTabRef;
|
||||
return html`<openclaw-plugin-page .pluginId=${ref.pluginId} .tabId=${ref.id}>
|
||||
</openclaw-plugin-page>`;
|
||||
},
|
||||
})),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user