Something went wrong. Try again.
last activity notifications for opencode
Something went wrong. Try again.
4.8 kB · 136 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137/** * Server companion: persist significant conversational gaps immediately * before the prompt that resumes a session. */
import { Plugin } from "@opencode-ai/plugin/effect";import type { Context } from "@opencode-ai/plugin/effect/plugin";import { Session } from "@opencode-ai/schema/session";import type { SessionInbox } from "@opencode-ai/schema/session-inbox";import type { SessionMessage } from "@opencode-ai/schema/session-message";import { DateTime, Effect, Fiber } from "effect";
import { resolveConfig } from "../config/config.ts";import { renderTimeShift, timeShift } from "../time/shift.ts";
const PROMPT_PREFIX = "session-prompt/";
interface PromptEvent { readonly sessionID: string; readonly messageID: string; readonly delivery: SessionInbox.Delivery;}
interface PromptRecord { readonly atMs: number;}
const sessionID = (value: string): Session.ID => value as Session.ID;const messageID = (value: string): SessionMessage.ID => value as SessionMessage.ID;const promptKey = (id: string) => PROMPT_PREFIX + id;
function syntheticID(id: string): SessionMessage.ID { return messageID(`msg_lastact_${id.replace(/^msg_/, "")}`);}
function promptRecord(value: unknown): PromptRecord | undefined { if (value === null || typeof value !== "object") return; const atMs = (value as Record<string, unknown>).atMs; if (typeof atMs !== "number" || !Number.isFinite(atMs) || atMs <= 0) return; return { atMs };}
/** * Prefer actual conversational evidence over `time.updated`: selections, * renames, and earlier prompt hooks may advance `updated` immediately before * this hook, but they must not erase a long gap since the last turn/prompt. */function previousActivity(input: { readonly createdMs: number; readonly updatedMs: number; readonly idleMs?: number; readonly promptMs?: number;}): number | undefined { const conversational = [input.idleMs, input.promptMs].filter( (value): value is number => value !== undefined, ); if (conversational.length > 0) return Math.max(...conversational); return input.updatedMs > input.createdMs ? input.updatedMs : undefined;}
const recordPromptGap = Effect.fn("Lastact.recordPromptGap")(function* (input: { readonly ctx: Context; readonly event: PromptEvent; readonly gapThresholdMs: number;}) { const { ctx, event, gapThresholdMs } = input; const info = yield* ctx.session.get({ sessionID: sessionID(event.sessionID) }); const stored = promptRecord(yield* ctx.storage.get(promptKey(event.sessionID))); const nowMs = Date.now(); const previousMs = previousActivity({ createdMs: DateTime.toEpochMillis(info.time.created), updatedMs: DateTime.toEpochMillis(info.time.updated), idleMs: info.time.idle === undefined ? undefined : DateTime.toEpochMillis(info.time.idle), promptMs: stored?.atMs, }); const shift = timeShift(previousMs, nowMs, gapThresholdMs);
if (shift !== undefined) { const text = renderTimeShift(shift); yield* ctx.session.synthetic({ id: syntheticID(event.messageID), sessionID: sessionID(event.sessionID), text, description: text, metadata: { source: "opencode-lastact", kind: "time-shift", from: new Date(shift.fromMs).toISOString(), to: new Date(shift.toMs).toISOString(), durationMs: shift.durationMs, }, delivery: event.delivery, resume: false, }); }
// This is written after synthetic admission so a failed marker is retried. yield* ctx.storage.set(promptKey(event.sessionID), { atMs: nowMs });});
export default Plugin.define({ id: "rektide.opencode-lastact", tui: true, effect: Effect.fn("Lastact")(function* (ctx: Context) { const config = resolveConfig(ctx.options); if (!config.enabled) return;
// Prompt hooks are not exactly-once. Serialize each session so the first // resumed prompt records the gap and overlapping prompts observe its // stored prompt clock instead of emitting duplicates. const tails = new Map<string, Fiber.Fiber<void>>(); yield* ctx.session.hook("prompt", (event) => { const key = event.sessionID as string; const previous = tails.get(key); let fiber: Fiber.Fiber<void>; const run = Effect.gen(function* () { if (previous !== undefined) yield* Fiber.await(previous); yield* recordPromptGap({ ctx, event: event as unknown as PromptEvent, gapThresholdMs: config.gapThresholdMs, }); }).pipe( Effect.orDie, Effect.ensuring( Effect.sync(() => { if (tails.get(key) === fiber) tails.delete(key); }), ), ); fiber = Effect.runFork(run); tails.set(key, fiber); return Fiber.join(fiber); }); }),});