openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,652 lines • 61.3 kB
JavaScript
import { l as normalizeOptionalString } from "../../string-coerce-CIXf7egm.js";
import { t as formatErrorMessage } from "../../errors-Db3Ymjlb.js";
import { t as openNodeSqliteDatabase, u as runSqliteImmediateTransactionSync } from "../../node-sqlite-BpQX3W0e.js";
import { a as getNodeSqliteKysely, i as executeSqliteQueryTakeFirstSync, r as executeSqliteQuerySync, t as compileSqliteQueryBindings } from "../../kysely-sync-COmh4HWh.js";
import { t as migrateSqliteSchemaToStrict } from "../../sqlite-strict-Cpho4I9M.js";
import { t as configureSqliteConnectionPragmas } from "../../sqlite-wal-B4waQq_w.js";
import { t as ErrorCodes } from "../../gateway-error-details-w0nAGBBp.js";
import { d as errorShape } from "../../error-codes-Bo8q2D1o.js";
import { t as canonicalizeBase64 } from "../../base64-Vw7DZYSc.js";
import "../../sqlite-runtime-BpLlraG0.js";
import "../../error-runtime-Bz9Tw57Z.js";
import { t as expectDefined } from "../../expect-runtime-CJBt0Gq2.js";
import "../../string-coerce-runtime-GQa0ehRA.js";
import { t as definePluginEntry } from "../../plugin-entry-zfBGJaNO.js";
import "../../media-runtime--XhyE_WP.js";
import "../../gateway-runtime-BkyVf3uU.js";
import "../../plugin-state-runtime-CeL_xbVf.js";
import { chmodSync, mkdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
//#region extensions/logbook/src/config.ts
const DEFAULTS = {
captureEnabled: true,
captureIntervalSeconds: 30,
analysisIntervalMinutes: 15,
screenIndex: 0,
maxWidth: 1440,
retentionDays: 14
};
function clampNumber(value, fallback, min, max) {
return Math.min(max, Math.max(min, Math.round(typeof value === "number" && Number.isFinite(value) ? value : fallback)));
}
function resolveLogbookConfig(raw) {
const value = raw && typeof raw === "object" ? raw : {};
return {
captureEnabled: value.captureEnabled !== false,
captureIntervalSeconds: clampNumber(value.captureIntervalSeconds, DEFAULTS.captureIntervalSeconds, 5, 600),
analysisIntervalMinutes: clampNumber(value.analysisIntervalMinutes, DEFAULTS.analysisIntervalMinutes, 3, 120),
nodeId: normalizeOptionalString(value.nodeId),
screenIndex: clampNumber(value.screenIndex, DEFAULTS.screenIndex, 0, 16),
maxWidth: clampNumber(value.maxWidth, DEFAULTS.maxWidth, 480, 3840),
visionModel: normalizeOptionalString(value.visionModel),
retentionDays: clampNumber(value.retentionDays, DEFAULTS.retentionDays, 1, 365)
};
}
/** Splits a "provider/model" ref; model ids may themselves contain slashes. */
function parseModelRef(ref) {
const slash = ref.indexOf("/");
if (slash <= 0 || slash === ref.length - 1) return null;
return {
provider: ref.slice(0, slash),
model: ref.slice(slash + 1)
};
}
//#endregion
//#region extensions/logbook/src/prompts.ts
const CARD_MIN_MINUTES = 10;
const CARD_MAX_MINUTES = 60;
const CARD_CATEGORIES = [
"coding",
"review",
"writing",
"research",
"comms",
"meetings",
"design",
"ops",
"browsing",
"media",
"other"
];
function formatClock(ms) {
const date = new Date(ms);
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
}
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" }
}
}
} }
};
function buildObservationInstructions(params) {
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");
}
function buildCardsPrompt(params) {
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");
}
function buildCardsCorrectionPrompt(validationError) {
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");
}
function buildStandupPrompt(params) {
const render = (cards) => 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");
}
function buildAskPrompt(params) {
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");
}
//#endregion
//#region extensions/logbook/src/store.ts
const LOGBOOK_SCHEMA_VERSION = 1;
const LOGBOOK_SQLITE_BUSY_TIMEOUT_MS = 5e3;
const SCHEMA = `
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' CHECK (status IN ('pending', 'running', 'done', 'error')),
error TEXT,
frame_count INTEGER NOT NULL DEFAULT 0,
model TEXT,
created_ms INTEGER NOT NULL,
updated_ms INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_logbook_batches_day ON batches (day, start_ms);
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 CHECK (idle IN (0, 1)),
batch_id INTEGER REFERENCES batches(id) ON DELETE SET NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_logbook_frames_day ON frames (day, captured_at_ms);
CREATE INDEX IF NOT EXISTS idx_logbook_frames_captured_at ON frames (captured_at_ms);
CREATE INDEX IF NOT EXISTS idx_logbook_frames_unbatched ON frames (batch_id) WHERE batch_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_logbook_frames_batch ON frames (batch_id, captured_at_ms) WHERE batch_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id INTEGER NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
day TEXT NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
text TEXT NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_logbook_observations_day ON observations (day, start_ms);
CREATE INDEX IF NOT EXISTS idx_logbook_observations_batch ON observations (batch_id);
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 REFERENCES frames(id) ON DELETE SET NULL,
updated_ms INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_logbook_cards_day ON cards (day, start_ms);
CREATE INDEX IF NOT EXISTS idx_logbook_cards_keyframe ON cards (keyframe_id) WHERE keyframe_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS standups (
day TEXT PRIMARY KEY,
text TEXT NOT NULL,
updated_ms INTEGER NOT NULL
) STRICT;
`;
function toFrame(row) {
return {
id: row.id,
capturedAtMs: row.captured_at_ms,
day: row.day,
path: row.path,
screenIndex: row.screen_index,
width: row.width ?? void 0,
height: row.height ?? void 0,
byteSize: row.byte_size,
idle: row.idle === 1
};
}
function toBatch(row) {
return {
id: row.id,
day: row.day,
startMs: row.start_ms,
endMs: row.end_ms,
status: row.status,
error: row.error ?? void 0,
frameCount: row.frame_count,
model: row.model ?? void 0
};
}
function parseDistractions$1(raw) {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((entry) => entry !== null && typeof entry === "object" && typeof entry.title === "string" && typeof entry.startMs === "number" && typeof entry.endMs === "number");
} catch {
return [];
}
}
function toCard(row) {
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 ?? void 0,
appSecondary: row.app_secondary ?? void 0,
distractions: parseDistractions$1(row.distractions),
keyframeId: row.keyframe_id ?? void 0
};
}
/** Formats an epoch-ms timestamp as a local-time YYYY-MM-DD day key. */
function dayKeyFor(ms) {
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}`;
}
var LogbookStore = class {
constructor(dataDir) {
this.dataDir = dataDir;
mkdirSync(dataDir, {
recursive: true,
mode: 448
});
chmodSync(dataDir, 448);
this.framesDir = path.join(dataDir, "frames");
mkdirSync(this.framesDir, {
recursive: true,
mode: 448
});
chmodSync(this.framesDir, 448);
const dbPath = path.join(dataDir, "logbook.sqlite");
const db = openNodeSqliteDatabase(dbPath);
let walMaintenance;
try {
chmodSync(dbPath, 384);
walMaintenance = configureSqliteConnectionPragmas(db, {
busyTimeoutMs: LOGBOOK_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: "logbook",
databasePath: dbPath,
foreignKeys: true,
synchronous: "NORMAL"
});
const versionRow = db.prepare("PRAGMA user_version").get();
const schemaVersion = Number(versionRow?.user_version ?? 0);
if (schemaVersion > LOGBOOK_SCHEMA_VERSION) throw new Error(`Logbook database uses newer schema version ${schemaVersion}; this build supports ${LOGBOOK_SCHEMA_VERSION}`);
db.exec(SCHEMA);
if (schemaVersion < LOGBOOK_SCHEMA_VERSION) {
migrateSqliteSchemaToStrict(db, SCHEMA, { databaseLabel: dbPath });
db.exec(`PRAGMA user_version = ${LOGBOOK_SCHEMA_VERSION};`);
}
} catch (error) {
walMaintenance?.close();
db.close();
throw error;
}
if (!walMaintenance) {
db.close();
throw new Error("Logbook SQLite maintenance failed to initialize");
}
this.db = db;
this.walMaintenance = walMaintenance;
this.query = getNodeSqliteKysely(db);
this.framesQuery = this.query.selectFrom("frames").select([
"id",
"captured_at_ms",
"day",
"path",
"screen_index",
"width",
"height",
"byte_size",
"idle"
]).orderBy("captured_at_ms", "asc").orderBy("id", "asc");
this.batchesQuery = this.query.selectFrom("batches").select([
"id",
"day",
"start_ms",
"end_ms",
"status",
"error",
"frame_count",
"model"
]);
this.cardsQuery = this.query.selectFrom("cards");
}
close() {
this.walMaintenance.close();
this.db.close();
}
frameFilePath(day, capturedAtMs) {
return path.join(this.framesDir, day, `${capturedAtMs}.jpg`);
}
insertFrame(params) {
const { compiled, bind } = compileSqliteQueryBindings((p) => this.query.insertInto("frames").values({
captured_at_ms: p((row) => row.capturedAtMs),
day: p((row) => row.day),
path: p((row) => row.path),
screen_index: p((row) => row.screenIndex),
width: p((row) => row.width ?? null),
height: p((row) => row.height ?? null),
byte_size: p((row) => row.byteSize),
content_hash: p((row) => row.contentHash),
idle: p((row) => row.idle ? 1 : 0)
}));
const result = this.db.prepare(compiled.sql).run(...bind(params));
return Number(result.lastInsertRowid);
}
lastFrame() {
const row = executeSqliteQueryTakeFirstSync(this.db, this.query.selectFrom("frames").select(["captured_at_ms", "content_hash"]).orderBy("captured_at_ms", "desc").orderBy("id", "desc").limit(1));
return row ? {
capturedAtMs: row.captured_at_ms,
contentHash: row.content_hash
} : null;
}
unbatchedActiveFrames(limit) {
return executeSqliteQuerySync(this.db, this.framesQuery.where("batch_id", "is", null).where("idle", "=", 0).limit(limit)).rows.map(toFrame);
}
countUnbatchedActiveFrames() {
const row = executeSqliteQueryTakeFirstSync(this.db, this.query.selectFrom("frames").select((eb) => eb.fn.countAll().as("n")).where("batch_id", "is", null).where("idle", "=", 0));
return expectDefined(row, "Logbook unbatched frame count").n;
}
frameById(id) {
const row = executeSqliteQueryTakeFirstSync(this.db, this.framesQuery.where("id", "=", id));
return row ? toFrame(row) : null;
}
framesInRange(startMs, endMs) {
return executeSqliteQuerySync(this.db, this.framesQuery.where("captured_at_ms", ">=", startMs).where("captured_at_ms", "<", endMs)).rows.map(toFrame);
}
createBatch(params) {
if (params.frameIds.length === 0) throw new Error("Logbook batch requires at least one frame");
const now = Date.now();
const batch = compileSqliteQueryBindings((p) => this.query.insertInto("batches").values({
day: p((row) => row.day),
start_ms: p((row) => row.startMs),
end_ms: p((row) => row.endMs),
status: "pending",
frame_count: p((row) => row.frameIds.length),
created_ms: now,
updated_ms: now
}));
const insertBatch = this.db.prepare(batch.compiled.sql);
const assignment = compileSqliteQueryBindings((p) => this.query.updateTable("frames").set({ batch_id: p((row) => row.batchId) }).where("id", "=", p((row) => row.frameId)).where("batch_id", "is", null));
const assignFrame = this.db.prepare(assignment.compiled.sql);
return runSqliteImmediateTransactionSync(this.db, () => {
const result = insertBatch.run(...batch.bind(params));
const batchId = Number(result.lastInsertRowid);
for (const frameId of params.frameIds) if (assignFrame.run(...assignment.bind({
batchId,
frameId
})).changes !== 1) throw new Error(`Logbook frame ${frameId} is missing or already batched`);
return batchId;
}, {
busyTimeoutMs: LOGBOOK_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: "logbook",
operationLabel: "logbook.batch.create"
});
}
setBatchStatus(batchId, status, error, model) {
const { compiled, bind } = compileSqliteQueryBindings((p) => this.query.updateTable("batches").set((eb) => ({
status,
error: error ?? null,
model: eb.fn.coalesce(eb.val(model ?? null), "model"),
updated_ms: p(() => Date.now())
})).where("id", "=", batchId));
this.db.prepare(compiled.sql).run(...bind());
}
latestBatch() {
const row = executeSqliteQueryTakeFirstSync(this.db, this.batchesQuery.orderBy("id", "desc").limit(1));
return row ? toBatch(row) : null;
}
/** Requeues batches stuck in `running` after a crash so frames are not orphaned. */
resetRunningBatches() {
const { compiled, bind } = compileSqliteQueryBindings((p) => this.query.updateTable("batches").set({
status: "pending",
updated_ms: p(() => Date.now())
}).where("status", "=", "running"));
this.db.prepare(compiled.sql).run(...bind());
}
/** Requeues failed batches for an explicit user-driven retry (analyze now). */
resetErrorBatches() {
const { compiled, bind } = compileSqliteQueryBindings((p) => this.query.updateTable("batches").set({
status: "pending",
error: null,
updated_ms: p(() => Date.now())
}).where("status", "=", "error"));
const result = this.db.prepare(compiled.sql).run(...bind());
return Number(result.changes);
}
nextPendingBatch() {
const row = executeSqliteQueryTakeFirstSync(this.db, this.batchesQuery.where("status", "=", "pending").orderBy("start_ms", "asc").orderBy("id", "asc").limit(1));
return row ? toBatch(row) : null;
}
batchFrames(batchId) {
return executeSqliteQuerySync(this.db, this.framesQuery.where("batch_id", "=", batchId)).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, day, segments) {
const deletion = compileSqliteQueryBindings(() => this.query.deleteFrom("observations").where("batch_id", "=", batchId));
const deleteBatch = this.db.prepare(deletion.compiled.sql);
const observation = compileSqliteQueryBindings((p) => this.query.insertInto("observations").values({
batch_id: batchId,
day,
start_ms: p((row) => row.startMs),
end_ms: p((row) => row.endMs),
text: p((row) => row.text)
}));
const insert = this.db.prepare(observation.compiled.sql);
runSqliteImmediateTransactionSync(this.db, () => {
deleteBatch.run(...deletion.bind());
for (const segment of segments) insert.run(...observation.bind(segment));
}, {
busyTimeoutMs: LOGBOOK_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: "logbook",
operationLabel: "logbook.observations.replace"
});
}
observationsInRange(day, startMs, endMs, tailLimit) {
const direction = tailLimit === void 0 ? "asc" : "desc";
let query = this.query.selectFrom("observations").selectAll().where("day", "=", day).where("end_ms", ">", startMs).where("start_ms", "<", endMs).orderBy("start_ms", direction).orderBy("id", direction);
if (tailLimit !== void 0) query = query.limit(tailLimit);
const rows = executeSqliteQuerySync(this.db, query).rows;
if (tailLimit !== void 0) rows.reverse();
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, window) {
let query = this.cardsQuery.selectAll().where("day", "=", day).orderBy("start_ms", "asc").orderBy("id", "asc");
if (window) query = query.where("end_ms", ">", window.startMs).where("start_ms", "<", window.endMs);
return executeSqliteQuerySync(this.db, query).rows.map(toCard);
}
countCardsForDay(day) {
const row = executeSqliteQueryTakeFirstSync(this.db, this.cardsQuery.select((eb) => eb.fn.countAll().as("count")).where("day", "=", day));
return expectDefined(row, "Logbook card count").count;
}
/**
* 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, startMs, endMs, drafts) {
const now = Date.now();
const deletion = compileSqliteQueryBindings(() => this.query.deleteFrom("cards").where("day", "=", day).where("end_ms", ">", startMs).where("start_ms", "<", endMs));
const deleteWindow = this.db.prepare(deletion.compiled.sql);
const card = compileSqliteQueryBindings((p) => this.query.insertInto("cards").values({
day: p((row) => row.day),
start_ms: p((row) => row.startMs),
end_ms: p((row) => row.endMs),
title: p((row) => row.title),
summary: p((row) => row.summary),
detail: p((row) => row.detail),
category: p((row) => row.category),
app_primary: p((row) => row.appPrimary ?? null),
app_secondary: p((row) => row.appSecondary ?? null),
distractions: p((row) => JSON.stringify(row.distractions)),
keyframe_id: p((row) => row.keyframeId ?? null),
updated_ms: now
}));
const insert = this.db.prepare(card.compiled.sql);
runSqliteImmediateTransactionSync(this.db, () => {
deleteWindow.run(...deletion.bind());
for (const draft of drafts) insert.run(...card.bind(draft));
}, {
busyTimeoutMs: LOGBOOK_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: "logbook",
operationLabel: "logbook.cards.replace"
});
}
listDays() {
return executeSqliteQuerySync(this.db, this.cardsQuery.select((eb) => [
"day",
eb.fn.countAll().as("cards"),
eb.fn.min("start_ms").as("first_ms"),
eb.fn.max("end_ms").as("last_ms")
]).groupBy("day").orderBy("day", "desc")).rows.map((row) => ({
day: row.day,
cards: row.cards,
firstMs: row.first_ms,
lastMs: row.last_ms
}));
}
timelineForDay(day) {
const cards = this.cardsForDay(day);
const categories = /* @__PURE__ */ new Map();
const apps = /* @__PURE__ */ new Map();
let trackedMs = 0;
let distractionMs = 0;
for (const card of cards) {
const duration = Math.max(0, card.endMs - card.startMs);
trackedMs += duration;
categories.set(card.category, (categories.get(card.category) ?? 0) + duration);
if (card.appPrimary) apps.set(card.appPrimary, (apps.get(card.appPrimary) ?? 0) + duration);
for (const distraction of card.distractions) distractionMs += Math.max(0, distraction.endMs - distraction.startMs);
}
const byMsDesc = (a, b) => b.ms - a.ms;
return {
day,
cards,
stats: {
trackedMs,
distractionMs,
categories: [...categories.entries()].map(([category, ms]) => ({
category,
ms
})).toSorted(byMsDesc),
apps: [...apps.entries()].map(([domain, ms]) => ({
domain,
ms
})).toSorted(byMsDesc)
}
};
}
getStandup(day) {
const row = executeSqliteQueryTakeFirstSync(this.db, this.query.selectFrom("standups").selectAll().where("day", "=", day));
return row ? {
day: row.day,
text: row.text,
updatedMs: row.updated_ms
} : null;
}
saveStandup(day, text) {
const { compiled, bind } = compileSqliteQueryBindings((p) => this.query.insertInto("standups").values({
day,
text,
updated_ms: p(() => Date.now())
}).onConflict((conflict) => conflict.column("day").doUpdateSet((eb) => ({
text: eb.ref("excluded.text"),
updated_ms: eb.ref("excluded.updated_ms")
}))));
this.db.prepare(compiled.sql).run(...bind());
}
/** Deletes frame rows and files older than the retention window. */
pruneFrames(olderThanMs) {
const rows = executeSqliteQuerySync(this.db, this.query.selectFrom("frames").select([
"id",
"path",
"day"
]).where("captured_at_ms", "<", olderThanMs)).rows;
if (rows.length === 0) return 0;
const days = /* @__PURE__ */ new Set();
for (const row of rows) {
rmSync(row.path, { force: true });
days.add(row.day);
}
const currentPath = compileSqliteQueryBindings((p) => this.query.selectFrom("frames").select("path").where("id", "=", p((id) => id)));
const selectCurrent = this.db.prepare(currentPath.compiled.sql);
const deletion = compileSqliteQueryBindings((p) => this.query.deleteFrom("frames").where("id", "=", p((id) => id)));
const deleteById = this.db.prepare(deletion.compiled.sql);
const deleted = runSqliteImmediateTransactionSync(this.db, () => {
let count = 0;
for (const row of rows) {
const current = selectCurrent.get(...currentPath.bind(row.id));
if (!current) continue;
if (current.path !== row.path) throw new Error(`Logbook frame ${row.id} changed path while pruning`);
count += Number(deleteById.run(...deletion.bind(row.id)).changes);
}
return count;
}, {
busyTimeoutMs: LOGBOOK_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: "logbook",
operationLabel: "logbook.frames.prune"
});
for (const day of days) try {
rmdirSync(path.join(this.framesDir, day));
} catch {}
return deleted;
}
};
//#endregion
//#region extensions/logbook/src/analyze.ts
/** Cards within this window before a batch are treated as a revisable draft. */
const CARD_LOOKBACK_MS = 27e5;
/** Frame gap that splits one analysis window into separate batches. */
const BATCH_MAX_GAP_MS = 12e4;
/** Parses "HH:MM:SS" (or "H:MM", with optional am/pm) on a local day into epoch ms. */
function clockToMs(day, clock) {
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 && (hours < 1 || hours > 12)) return null;
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);
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. */
function extractJsonPayload(raw) {
const cleaned = raw.replaceAll("```json", "").replaceAll("```", "").trim();
const firstBracket = cleaned.search(/[[{]/);
if (firstBracket < 0) return cleaned;
const close = cleaned[firstBracket] === "[" ? "]" : "}";
const lastClose = cleaned.lastIndexOf(close);
if (lastClose > firstBracket) return cleaned.slice(firstBracket, lastClose + 1);
return cleaned;
}
function parseObservationSegments(params) {
let parsed;
try {
parsed = JSON.parse(extractJsonPayload(params.raw));
} catch {
return [];
}
const list = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.segments) ? parsed.segments : [];
const segments = [];
for (const entry of list) {
if (!entry || typeof entry !== "object") continue;
const record = entry;
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);
}
function normalizeCategory(value) {
const category = typeof value === "string" ? value.trim().toLowerCase() : "";
return CARD_CATEGORIES.includes(category) ? category : "other";
}
function normalizeDomain(value) {
if (typeof value !== "string") return;
const domain = value.trim().toLowerCase().replace(/^https?:\/\//, "").split(/[/?#]/)[0];
return domain && domain.length <= 100 ? domain : void 0;
}
function parseDistractions(day, value) {
if (!Array.isArray(value)) return [];
const distractions = [];
for (const entry of value) {
if (!entry || typeof entry !== "object") continue;
const record = entry;
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;
}
function parseCardsJson(params) {
let parsed;
try {
parsed = JSON.parse(extractJsonPayload(params.raw));
} catch (err) {
return {
ok: false,
error: `Output is not valid JSON: ${err.message}`
};
}
if (!Array.isArray(parsed)) return {
ok: false,
error: "Output must be a JSON array of cards."
};
const drafts = [];
const problems = [];
parsed.forEach((entry, index) => {
if (!entry || typeof entry !== "object") {
problems.push(`Card ${index}: not an object.`);
return;
}
const raw = entry;
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 : {};
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: void 0
});
});
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);
const normalized = [];
for (const current of sorted) {
const previous = normalized.at(-1);
if (!previous) {
normalized.push(current);
continue;
}
const overlapMs = previous.endMs - current.startMs;
if (overlapMs > 6e4) return {
ok: false,
error: `Cards ${normalized.length - 1} and ${normalized.length} overlap by ${Math.round(overlapMs / 6e4)} minutes; adjacent cards must meet cleanly.`
};
if (overlapMs > 0) normalized.push({
...current,
startMs: previous.endMs
});
else normalized.push(current);
}
return {
ok: true,
drafts: normalized
};
}
/** Sub-minute slack so minute-rounded model times do not fail coverage checks. */
const COVERAGE_TOLERANCE_MS = 12e4;
function formatClockForError(ms) {
const date = new Date(ms);
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}
/**
* 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.
*/
function validateCardCoverage(params) {
const tolerance = params.toleranceMs ?? COVERAGE_TOLERANCE_MS;
const problems = [];
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. */
function revisionWindow(params) {
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. */
function selectBatchFrames(params) {
if (params.frames.length === 0) return null;
const first = expectDefined(params.frames[0], "first pending Logbook frame");
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 = [];
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;
}
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 = expectDefined(selected.at(-1), "last selected Logbook frame");
const windowElapsed = params.nowMs >= windowEnd;
if (!windowElapsed && !endedEarly && !params.force) return null;
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. */
function sampleFrames(frames, max) {
if (max <= 0) return [];
if (frames.length <= max) return frames;
if (max === 1) return [expectDefined(frames[0], "first Logbook frame sample")];
const sampled = [];
const step = (frames.length - 1) / (max - 1);
for (let i = 0; i < max; i += 1) sampled.push(expectDefined(frames[Math.round(i * step)], "sampled Logbook frame"));
return [...new Set(sampled)];
}
/** Picks the frame closest to a card's midpoint as its keyframe. */
function pickKeyframeId(card, frames) {
if (frames.length === 0) return;
const midpoint = card.startMs + (card.endMs - card.startMs) / 2;
let best = expectDefined(frames[0], "first Logbook keyframe candidate");
for (const frame of frames) if (Math.abs(frame.capturedAtMs - midpoint) < Math.abs(best.capturedAtMs - midpoint)) best = frame;
return best.id;
}
//#endregion
//#region extensions/logbook/src/service.ts
const ANALYSIS_TICK_MS = 6e4;
const PRUNE_TICK_MS = 36e5;
const MODEL_MISSING_MESSAGE = "no vision model: set plugins.entries.logbook.config.visionModel or configure tools.media";
const MODEL_MISSING_LOG_INTERVAL_MS = 6e5;
const CAPTURE_FAILURE_PAUSE_TICKS = 10;
const CAPTURE_FAILURE_THRESHOLD = 3;
const JPEG_QUALITY = .6;
const STRUCTURED_MEDIA_PROVIDER = "codex";
/** node.invoke responses wrap the node result in {payload, payloadJSON}. */
function unwrapInvokePayload(raw) {
if (!raw || typeof raw !== "object") return null;
const envelope = raw;
if (envelope.payload && typeof envelope.payload === "object") return envelope.payload;
if (typeof envelope.payloadJSON === "string" && envelope.payloadJSON.length > 0) try {
return JSON.parse(envelope.payloadJSON);
} catch {
return null;
}
return "base64" in envelope || "error" in envelope ? envelope : null;
}
/** Capture commands in preference order: app nodes first, headless node hosts second. */
const CAPTURE_COMMANDS = ["screen.snapshot", "logbook.snapshot"];
var LogbookService = class {
constructor(config, deps) {
this.config = config;
this.deps = deps;
this.store = null;
this.operations = /* @__PURE__ */ new Set();
this.captureTimer = null;
this.analysisTimer = null;
this.pruneTimer = null;
this.captureInFlight = false;
this.analysisInFlight = false;
this.capturePaused = false;
this.captureFailures = 0;
this.captureBackoffTicks = 0;
this.lastModelMissingLogMs = 0;
this.cachedNode = null;
this.failedNodeIds = /* @__PURE__ */ new Set();
}
start() {
this.stopping = void 0;
this.store = new LogbookStore(this.deps.dataDir);
this.store.resetRunningBatches();
this.captureTimer = setInterval(() => {
this.captureTick();
}, this.config.captureIntervalSeconds * 1e3);
this.captureTimer.unref?.();
this.analysisTimer = setInterval(() => {
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() {
if (this.stopping) return this.stopping;
for (const timer of [
this.captureTimer,
this.analysisTimer,
this.pruneTimer
]) if (timer) clearInterval(timer);
this.captureTimer = null;
this.analysisTimer = null;
this.pruneTimer = null;
const store = this.store;
this.stopping = Promise.allSettled(this.operations).then(() => {
store?.close();
this.store = null;
});
return this.stopping;
}
trackOperation(run) {
const operation = Promise.resolve().then(run);
this.operations.add(operation);
const settled = () => this.operations.delete(operation);
operation.then(settled, settled);
return operation;
}
requireStore() {
if (this.stopping || !this.store) throw new Error("Logbook service is not running");
return this.store;
}
setCapturePaused(paused) {
this.capturePaused = paused;
if (!paused) {
this.captureBackoffTicks = 0;
this.captureFailures = 0;
}
}
async resolveNode() {
if (this.cachedNode) return { node: this.cachedNode };
const { nodes } = await this.deps.runtime.nodes.list({ connected: true });
const captureCommand = (node) => CAPTURE_COMMANDS.find((command) => (node.commands ?? []).includes(command));
const commandRank = (node) => CAPTURE_COMMANDS.indexOf(captureCommand(node));
const candidates = nodes.filter((node) => captureCommand(node) !== void 0).toSorted((a, b) => commandRank(a) - commandRank(b) || a.nodeId.localeCompare(b.nodeId));
const wanted = this.config.nodeId?.toLowerCase();
let pool = candidates.filter((node) => !this.failedNodeIds.has(node.nodeId));
if (pool.length === 0) {
this.failedNodeIds.clear();
pool = candidates;
}
const picked = wanted ? candidates.find((node) => node.nodeId.toLowerCase() === wanted || node.displayName?.toLowerCase() === wanted) : pool[0];
const command = picked ? captureCommand(picked) : void 0;
if (!picked || !command) {
const inventory = nodes.map((node) => `${node.displayName ?? node.nodeId}(${(node.commands ?? []).join("/") || "no commands"})`).join(", ") || "none";
return { reason: `no connected node exposes ${CAPTURE_COMMANDS.join(" or ")}; connected: ${inventory}` };
}
this.cachedNode = {
nodeId: picked.nodeId,
displayName: picked.displayName,
command
};
return { node: this.cachedNode };
}
async captureTick() {
const store = this.store;
if (this.stopping || !this.config.captureEnabled || this.capturePaused || this.captureInFlight || !store) return;
if (this.captureBackoffTicks > 0) {
this.captureBackoffTicks -= 1;
return;
}
this.captureInFlight = true;
return this.trackOperation(async () => {
try {
const resolved = await this.resolveNode();
if ("reason" in resolved) {
if (this.lastCaptureError !== resolved.reason) this.deps.logger.warn(`logbook: ${resolved.reason}`);
this.lastCaptureError = resolved.reason;
return;
}
const node = resolved.node;
const raw = unwrapInvokePayload(await this.deps.runtime.nodes.invoke({
nodeId: node.nodeId,
command: node.command,
params: {
screenIndex: this.config.screenIndex,
maxWidth: this.config.maxWidth,
quality: JPEG_QUALITY,
format: "jpeg"
},
timeoutMs: 3e4
}));
if (raw?.error) throw new Error(raw.error);
const rawBase64 = raw?.base64;
if (rawBase64 === void 0 || rawBase64 === "") throw new Error(`${node.command} returned no image payload`);
if (typeof rawBase64 !== "string") throw new Error(`${node.command} returned invalid image payload`);
const base64 = canonicalizeBase64(rawBase64);
if (!base64) throw new Error(`${node.command} returned invalid image payload`);
const buffer = Buffer.from(base64, "base64");
const capturedAtMs = Date.now();
const day = dayKeyFor(capturedAtMs);
const contentHash = createHash("sha256").update(buffer).digest("hex");
const idle = store.lastFrame()?.contentHash === contentHash;
const filePath = store.frameFilePath(day, capturedAtMs);
mkdirSync(path.dirname(filePath), {
recursive: true,
mode: 448
});
writeFileSync(filePath, buffer, { mode: 384 });
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 = void 0;
this.captureFailures = 0;
this.failedNodeIds.clear();
} catch (err) {
this.captureFailures += 1;
if (this.cachedNode) this.failedNodeIds.add(this.cachedNode.nodeId);
this.cachedNode = null;
this.lastCaptureError = 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;
}
});
}
resolveVisionModel() {
if (this.config.visionModel) {
const ref = parseModelRef(this.config.visionModel);
return ref ? {
ref,
source: "config"
} : { source: "missing" };
}
const media = this.deps.fullConfig.tools?.media;
if (media?.image?.enabled === false) return { source: "missing" };
const entries = media?.models ?? [];
for (const entry of entries) if (entry.type !== "cli" && entry.provider?.trim().toLowerCase() === STRUCTURED_MEDIA_PROVIDER && typeof entry.model === "string" && (!entry.capabilities || entry.capabilities.includes("image"))) return {
ref: {
provider: STRUCTURED_MEDIA_PROVIDER,
model: entry.model,
profile: entry.profile,
preferredProfile: entry.preferredProfile
},
source: "media-defaults"
};
return { source: "missing" };
}
async analyzeNow() {
const store = this.requireStore();
if (this.analysisInFlight) return {
started: false,
reason: "analysis already running"
};
if (!this.resolveVisionModel().ref) return {
started: false,
reason: MODEL_MISSING_MESSAGE
};
store.resetErrorBatches();
if (!store.nextPendingBatch()) {
if (!this.enqueueNextBatch(store, true)) return {
started: false,
reason: "no unanalyzed activity captured yet"
};
}
this.analysisTick();
return { started: true };
}
async analysisTick() {
const store = this.store;
if (this.stopping || this.analysisInFlight || !store) return;
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;
return this.trackOperation(async () => {
try {
if (this.stopping) return;
this.enqueueElapsedWindow(store);
for (let i = 0; i < 4 && !this.stopping; i += 1) {
const batch = store.nextPendingBatch();
if (!batch) return;
await this.runBatch(store, batch);
}
} catch (err) {
this.deps.logger.error(`logbook: analysis tick failed: ${String(err)}`);
} finally {
this.analysisInFlight = false;
}
});
}
enqueueNextBatch(store, force = false) {
const selection = selectBatchFrames({
frames: store.unbatchedActiveFrames(2e3),
windowMs: this.config.analysisIntervalMinutes * 6e4,
nowMs: Date.now(),
force
});
if (!selection) return false;
store.createBatch({
day: dayKeyFor(selection.startMs),
startMs: selection.startMs,
endMs: selection.endMs,
frameIds: selection.frameIds
});
return true;
}
enqueueElapsedWindow(store) {
while (this.enqueueNextBatch(store));
}
async runBatch(store, batch) {
const vision = this.resolveVisionModel();
if (!vision.ref) return;
store.setBatchStatus(batch.id, "running", void 0, `${vision.ref.provider}/${vision.ref.model}`);
try {
const sampled = sampleFrames(store.batchFrames(batch.id), 16);
const images = sampled.map((frame) => ({
type: "image",
buffer: readFileSync(frame.path),
fileName: path.basename(frame.path),
mime: "image/jpeg"
}));
const segments = parseObservationSegments({
raw: (await this.deps.runtime.mediaUnderstanding.extractStructuredWithModel({
provider: vision.ref.provider,
model: vision.ref.model,
profile: vision.ref.profile,
preferredProfile: vision.ref.preferredProfile,
input: images,
instructions: buildObservationInstructions({
frameTimes: sampled.map((frame) => frame.capturedAtMs),
startMs: batch.startMs,
endMs: batch.endMs
}),
schemaName: "logbook.observations",
jsonSchema: OBSERVATION_JSON_SCHEMA,
cfg: this.deps.fullConfig,
timeoutMs: 18e4
})).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(store, 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}`);
}
}
async reviseCards(store, batch) {
const lookbackStart = batch.startMs - CARD_LOOKBACK_MS;
const previousCards = store.cardsForDay(batch.day, {
startMs: lookbackStart,
endMs: 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
});
const requiredSpans = [...previousCards.map((card) => ({
startMs: card.startMs,
endMs: card.endMs
})), {
startMs: batch.startMs,
endMs: batch.endMs
}];
const evaluate = (raw) => {
const parsed = parseCardsJson({
raw,
day: batch.day,
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,
error: coverage.error
};
};
const first = await this.deps.runtime.llm.complete({
messages: [{
role: "user",
content: prompt
}],
purpose: "logbook.cards",
maxTokens: 4e3
});
let parsed = evaluate(first.text);
if (!parsed.ok) parsed = evaluate((await this.deps.runtime.llm.complete({
messages: [
{
role: "user",
content: prompt
},
{
role: "assistant",
content: first.text
},
{
role: "user",
content: buildCardsCorrectionPrompt(parsed.error)
}
],
purpose: "logbook.cards.repair",
maxTokens: 4e3
})).text);
if (!parsed.ok) throw new Error(`card synthesis failed validation: ${parsed.error}`);
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);
}
async standup(day, refresh) {
const store = this.requireStore();
return this.trackOperation(async () => {
if (!refresh) {
const cached = store.getStandup(day);
if (cached) return cached;
}
const previousDay = dayKeyFor((/* @__PURE__ */ new Date(`${day}T12:00:00`)).getTime() - 864e5);
const result = await this.deps.runtime.llm.complete({
messages: [{
role: "user",
content: buildStandupPrompt({
day,
cards: 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, question) {
const store = this.requireStore();
const observations = store.observationsInRange(day, 0, Number.MAX_SAFE_INTEGER, 200);
return (await this.deps.runtime.llm.complete({
messages: [{
role: "user",
content: buildAskPrompt({
day,
cards: store.cardsForDay(day),
observations,
question
})
}],
purpose: "logbook.ask",
maxTokens: 600
})).text.trim();
}
timelineForDay(day) {
return this.requireStore().timelineForDay(day);
}
listDays() {
return this.requireStore().listDays();
}
frameById(id) {
return this.requireStore().frameById(id);
}
framesInRange(startMs, endMs) {
return this.requireStore().framesInRange(startMs, endMs);
}
status() {
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
} : void 0,
visionModel: vision.ref ? `${vision.ref.provider}/${vision.ref.model}` : void 0,
visionModelSource: vision.source,
today,
todayCards: store.countCardsForDay(today),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
};
}
prune() {
if (!this.store) return;
const cutoff = Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1e3;
const removed = this.store.pruneFrames(cutoff);
if (removed > 0) this.deps.logger.info(`logbook: pruned ${removed} frames older than ${this.config.retentionDays}d`);
}
};
//#endregion
//#region extensions/logbook/index.ts
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const logbookConfigSchema = { parse(value) {
return resolveLogbookConfig(value);
} };
function readDayParam(params) {
const day = params?.day;
if (day === void 0) return dayKeyFor(Date.now());
if (typeof day !== "string" || !DAY_PATTERN.test(day)) throw new Error("day must be YYYY-MM-DD");
return day;
}
function readNumberParam(params, key) {
const value = params?.[key];
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${key} must be a positive number`);
return value;
}
var logbook_default = definePluginEntry({
id: "logbook",
name: "Logbook",
description: "Automatic work journal built from periodic screen snapshots",
configSchema: logbookConfigSchema,
nodeHostCommands: [{
command: "logbook.snapshot",
cap: "screen",
dangerous: false,
handle: async (paramsJSON) => {
const { handleLogbookSnapshot } = await import("../../node-host-Dtjs6vyd.js");
let params;
try {
params = paramsJSON ? JSON.parse(paramsJSON) : void 0;
} catch {
params = void 0;
}
return JSON.stringify(await handleLogbookSnapshot(params));
}
}],
register(api) {
const config = logbookConfigSchema.parse(api.pluginConfig);
let service = null;
let stopping;
let retired = false;
const stopService = () => {
const current = service;
service = null;
return stopping ??= current?.stop();
};
const requireService = () => {
if (!service) throw new Error("Logbook service is not running");
return service;
};
const sendError = (respond, err) => {
const message = formatErrorMessage(err);
respond(false, { error: message }, errorShape(ErrorCodes.UNAVAILABLE, message));
};
const handle = (run) => async ({ params, respond }) => {
try {
respond(true, await run(params));
} catch (err) {
sendError(respond, err);
}
};
api.session.controls.registerControlUiDescriptor({
surface: "tab",
id: "logbook",
label: "Logbook",
description: "Your day as a timeline, built from screen snapshots.",
icon: "sun",
group: "control",
requiredScopes: ["operator.write"]
});
api.registerNodeInvokePolicy({
commands: ["logbook.snapshot"],
defaultPlatforms: ["macos"],
handle: async (ctx) => {
if ((ctx.config.gateway?.nodes?.commands?.deny ?? []).includes("screen.snapshot")) return {
ok: false,
code: "SCREEN_CAPTURE_DENIED",
message: "screen capture is denied by gateway.nodes.commands.deny (screen.snapshot); Logbook capture stays blocked until it is removed"
};
return await ctx.invokeNode();
}
});
api.registerService({
id: "logbook",
start: (ctx) => {
if (retired) throw new Error("Logbook plugin runtime has been retired");
stopping = void 0;
service = new LogbookService(config, {
runtime: api.runtime,
fullConfig: ctx.config,
logger: ctx.logger,
dataDir: path.join(ctx.stateDir, "logbook")
});
service.start();
},
stop: stopService
});
api.lifecycle.registerRuntimeLifecycle({
id: "logbook-service",
cleanup: ({ reason, sessionKey, runId }) => {
if (sessionKey === void 0 && runId === void 0 && (reason === "restart" || reason === "disable")) {
retired = true;
return stopService();
}
}
});
const registerRead = (method, run) => api.registerGatewayMethod(method, handle(run), { scope: "operator.read" });
const registerWrite = (method, run) => api.registerGatewayMethod(method, handle(run), { scope: "operator.write" });
api.registerGatewayMethod("logbook.status", handle(() => requireService().status()), {
scope: "operator.read",
profileAccess: "independent"
});
registerRead("logbook.days", () => ({ days: requireService().listDays() }));
registerRead("logbook.timeline", (params) => requireService().timelineForDay(readDayParam(params)));
registerWrite("logbook.frames", (params) => {
const startMs = readNumberParam(params, "startMs");
const endMs = readNumberParam(params, "endMs");
return { frames: requireService().framesInRange(startMs, endMs).map((frame) => ({
id: frame.id,
capturedAtMs: frame.capturedAtMs,
idle: frame.idle
})) };
});
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")
};
});
registerWrite("logbook.standup", (params) => {
const refresh = params?.refresh === true;
return requireService().standup(readDayParam(params), refresh);
});
registerWrite("logbook.ask", async (params) => {
const question = params?.question;
if (typeof question !== "string" || question.trim().length === 0) throw new Error("question is required");
return { answer: await requireService().ask(readDayParam(params), question.trim()) };
});
registerWrite("logbook.capture.set", (params) => {
const paused = params?.paused === true;
const svc = requireService();
svc.setCapturePaused(paused);
return svc.status();
});
registerWrite("logbook.analyze.now", () => requireService().analyzeNow());
}
});
//#endregion
export { logbook_default as default };