UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,652 lines 61.3 kB
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 };