Files
lam-agent-vicinae/src/lib/sessions.ts
T
adrianbonpin 5e083d8142 feat: snip a region into chat, and make links clickable
Two changes: screenshot input gated on the model really reporting vision,
and a fix for URLs that arrived as code.

Links
- A fence whose body is all URLs, a lone `https://...` inline span, and a
  line that is only a URL are rewritten into [url](url) markdown links.
  Vicinae attaches only the table and strikethrough GFM extensions, so
  autolink is off and a bare URL is dead text; clickability comes from
  MarkdownModel turning CMARK_NODE_LINK into an <a href>. A fenced URL was
  a dark panel with a Copy button and no way to open it.
- promoteStandaloneCodeSpans was itself promoting a lone URL span into a
  fence, so it made this worse. Link restoration now runs first.
- The default prompt says never to fence a URL.
- Anything that is not one of those three shapes is copied through, so a
  curl invocation containing an address is never rewritten.

Screenshots
- capture.ts shells out to an auto-detected tool: grimblast, grim + slurp,
  spectacle, maim, scrot, flameshot, or screencapture on macOS. There is
  no screenshot call in @vicinae/api and Clipboard moves no binary, so the
  extension owns the capture and reads the file it wrote rather than
  guessing at the clipboard. Cancel is detected by whether an image landed
  on disk, because the tools disagree on exit codes.
- In chat: "Snip a Region" in the action panel (cmd+shift+S) attaches to
  the current conversation. Standalone: "Snip to Lam" starts a new one.
- vision.ts caches the /api/show capability answer per host and model. A
  failed check is "unknown", never "no", so a dropped connection is not
  reported as a fact about the user's model. Actions still fail closed.
- Only the path is stored, base64 is read at send time, and the image goes
  on its own turn only. Capture is never a model-callable tool.

Verified: 244 tests, tsc, eslint, and vici build (both entrypoints).
2026-10-04 15:13:15 +08:00

194 lines
6.0 KiB
TypeScript

import { LocalStorage } from "@vicinae/api";
import { MAX_SESSIONS, MAX_STORED_CHARS_PER_TURN, MAX_TURNS_PER_SESSION, STORAGE_INDEX_KEY, STORAGE_SESSION_PREFIX, TITLE_MAX_LENGTH } from "./constants";
import { SessionIndexSchema, SessionSchema } from "./schemas";
import type { Attachment, ChatTurn, Session, SessionSummary } from "./types";
import { titleFromPrompt, truncate } from "./utils";
/**
* Session persistence on top of Vicinae's LocalStorage.
*
* LocalStorage only accepts strings, numbers, and booleans, so everything is
* JSON-encoded. An index holding small summaries is stored separately from the
* session bodies: listing sessions is a hot path, and it should not require
* decoding every transcript.
*
* A session body is one key, so saving a turn rewrites only that session.
* Transcripts are capped twice over, per turn and per session, because
* LocalStorage has no documented ceiling and a runaway loop should not be able
* to wedge the extension.
*/
function newId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/** Builds the lightweight index record kept in the session list. */
export function summarizeSession(session: Session): SessionSummary {
return summarize(session);
}
function sessionKey(id: string): string {
return `${STORAGE_SESSION_PREFIX}${id}`;
}
function summarize(session: Session): SessionSummary {
const lastTurn = session.turns[session.turns.length - 1];
return {
id: session.id,
title: session.title,
model: session.model,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
turnCount: session.turns.length,
preview: lastTurn ? truncate(lastTurn.response || lastTurn.prompt, TITLE_MAX_LENGTH * 2) : "",
};
}
export function createSession(model: string, now: number = Date.now()): Session {
return {
id: newId(),
title: "New chat",
model,
createdAt: now,
updatedAt: now,
turns: [],
};
}
/** Creates the turn record for a prompt that is about to be sent. */
export function createTurn(
prompt: string,
model: string,
now: number = Date.now(),
attachments: Attachment[] = [],
): ChatTurn {
return {
id: newId(),
prompt,
response: "",
thinking: "",
model,
createdAt: now,
toolCalls: [],
attachments,
};
}
/** Drops the oldest turns once a session exceeds its cap, keeping the most recent context. */
export function capTurns(turns: ChatTurn[]): ChatTurn[] {
return turns.length <= MAX_TURNS_PER_SESSION ? turns : turns.slice(turns.length - MAX_TURNS_PER_SESSION);
}
/** Trims a single turn's stored text so one enormous answer cannot dominate storage. */
export function capTurn(turn: ChatTurn): ChatTurn {
const cap = (text: string): string =>
text.length <= MAX_STORED_CHARS_PER_TURN ? text : `${text.slice(0, MAX_STORED_CHARS_PER_TURN)}\n\n[truncated]`;
return {
...turn,
prompt: cap(turn.prompt),
response: cap(turn.response),
thinking: cap(turn.thinking),
toolCalls: turn.toolCalls.map((call) => ({ ...call, result: cap(call.result) })),
};
}
export async function listSessions(): Promise<SessionSummary[]> {
try {
const raw = await LocalStorage.getItem<string>(STORAGE_INDEX_KEY);
if (!raw) return [];
const parsed = SessionIndexSchema.safeParse(JSON.parse(raw));
if (!parsed.success) return [];
return [...parsed.data].sort((a, b) => b.updatedAt - a.updatedAt);
} catch {
// A corrupt index should degrade to "no sessions", never to a crashed command.
return [];
}
}
async function writeIndex(summaries: SessionSummary[]): Promise<void> {
await LocalStorage.setItem(STORAGE_INDEX_KEY, JSON.stringify(summaries));
}
export async function loadSession(id: string): Promise<Session | undefined> {
try {
const raw = await LocalStorage.getItem<string>(sessionKey(id));
if (!raw) return undefined;
const parsed = SessionSchema.safeParse(JSON.parse(raw));
if (!parsed.success) return undefined;
return {
...parsed.data,
turns: parsed.data.turns.map((turn) => ({ ...turn, toolCalls: turn.toolCalls ?? [] })),
};
} catch {
return undefined;
}
}
/**
* Persists a session and refreshes its index entry, evicting the oldest
* sessions past {@link MAX_SESSIONS} along with their storage keys.
*/
export async function saveSession(session: Session): Promise<void> {
const capped: Session = {
...session,
turns: capTurns(session.turns).map(capTurn),
};
await LocalStorage.setItem(sessionKey(capped.id), JSON.stringify(capped));
const summaries = await listSessions();
const others = summaries.filter((summary) => summary.id !== capped.id);
const merged = [summarize(capped), ...others].sort((a, b) => b.updatedAt - a.updatedAt);
const kept = merged.slice(0, MAX_SESSIONS);
const evicted = merged.slice(MAX_SESSIONS);
await writeIndex(kept);
for (const summary of evicted) {
try {
await LocalStorage.removeItem(sessionKey(summary.id));
} catch {
// Losing an eviction just leaves an orphaned key; not worth failing the save.
}
}
}
export async function deleteSession(id: string): Promise<void> {
const summaries = await listSessions();
await writeIndex(summaries.filter((summary) => summary.id !== id));
try {
await LocalStorage.removeItem(sessionKey(id));
} catch {
// Already gone.
}
}
export async function clearSessions(): Promise<void> {
const summaries = await listSessions();
for (const summary of summaries) {
try {
await LocalStorage.removeItem(sessionKey(summary.id));
} catch {
// Keep going: clearing as much as possible beats failing outright.
}
}
await LocalStorage.removeItem(STORAGE_INDEX_KEY);
}
/** Recomputes a session's title from its first prompt, once there is a prompt worth naming it after. */
export function withTitleFromFirstTurn(session: Session): Session {
if (session.turns.length === 0) return session;
if (session.title !== "New chat") return session;
return { ...session, title: titleFromPrompt(session.turns[0].prompt, TITLE_MAX_LENGTH) };
}