feat: initial Lam Agent extension (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-10-03 20:32:40 +00:00
parent 1d39aabb50
commit 8b507e7953
36 changed files with 7136 additions and 2 deletions
+134
View File
@@ -0,0 +1,134 @@
import { MAX_TOOL_ITERATIONS } from "./constants";
import { streamChat } from "./ollama";
import { executeTool, toolLabel, type ToolContext } from "./tools";
import type { ApiMessage, ToolCall, ToolCallRecord, ToolDefinition } from "./types";
/**
* The tool-calling loop.
*
* Kept free of any React or Vicinae imports so the whole thing can be driven
* from a plain unit test with a stubbed `fetch`.
*/
export interface AgentEvents {
onContentDelta?: (delta: string) => void;
onThinkingDelta?: (delta: string) => void;
onToolCall?: (record: ToolCallRecord) => void;
/** A short line for the UI's activity indicator, e.g. "Searching the web". */
onStatus?: (status: string) => void;
}
export interface AgentOptions {
host: string;
apiKey: string;
model: string;
/** Omit or pass "off" to leave reasoning disabled. */
think?: string;
messages: ApiMessage[];
tools: ToolDefinition[];
toolContext: Omit<ToolContext, "signal" | "now"> & { now?: Date };
signal?: AbortSignal;
events?: AgentEvents;
}
export interface AgentResult {
content: string;
thinking: string;
toolCalls: ToolCallRecord[];
}
/**
* Picks the authoritative tool-call list out of a stream.
*
* Ollama sends tool calls complete in the final streamed message rather than
* dribbling them out across chunks the way OpenAI does, so the last non-empty
* array wins. Exported because the assumption is worth pinning down in a test:
* if Ollama ever starts streaming partial calls, this is the one place to fix.
*/
export function pickToolCalls(
previous: ToolCall[] | undefined,
incoming: ToolCall[] | undefined,
): ToolCall[] | undefined {
return incoming && incoming.length > 0 ? incoming : previous;
}
function normalizeToolCall(call: ToolCall): { name: string; arguments: Record<string, unknown> } {
const name = typeof call.function?.name === "string" ? call.function.name : "";
const rawArgs = call.function?.arguments;
const args = rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs) ? rawArgs : {};
return { name, arguments: args as Record<string, unknown> };
}
export async function runAgent(options: AgentOptions): Promise<AgentResult> {
const conversation: ApiMessage[] = [...options.messages];
const toolCalls: ToolCallRecord[] = [];
let thinking = "";
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
let content = "";
let streamedThinking = "";
let pending: ToolCall[] | undefined;
for await (const chunk of streamChat({
host: options.host,
apiKey: options.apiKey,
model: options.model,
messages: conversation,
tools: options.tools.length > 0 ? options.tools : undefined,
think: options.think,
signal: options.signal,
})) {
if (chunk.thinking) {
streamedThinking += chunk.thinking;
options.events?.onThinkingDelta?.(chunk.thinking);
}
if (chunk.content) {
content += chunk.content;
options.events?.onContentDelta?.(chunk.content);
}
pending = pickToolCalls(pending, chunk.toolCalls);
}
thinking += streamedThinking;
// No tool calls means this was the model's answer. Earlier iterations only
// produced a preamble, which is intentionally discarded rather than shown
// as a half-answer.
if (!pending || pending.length === 0) {
return { content, thinking, toolCalls };
}
conversation.push({ role: "assistant", content, tool_calls: pending });
for (const rawCall of pending) {
const { name, arguments: args } = normalizeToolCall(rawCall);
if (!name) {
conversation.push({ role: "tool", content: "Malformed tool call: missing function name.", tool_name: "unknown" });
toolCalls.push({ name: "unknown", arguments: {}, result: "Malformed tool call.", isError: true });
continue;
}
options.events?.onStatus?.(toolLabel(name));
const result = await executeTool(name, args, {
host: options.toolContext.host,
apiKey: options.toolContext.apiKey,
now: options.toolContext.now,
signal: options.signal,
});
const record: ToolCallRecord = { name, arguments: args, result: result.content, isError: result.isError };
toolCalls.push(record);
options.events?.onToolCall?.(record);
conversation.push({ role: "tool", content: result.content, tool_name: name });
}
}
return {
content: `_Stopped after ${MAX_TOOL_ITERATIONS} rounds of tool calls without a final answer._`,
thinking,
toolCalls,
};
}
+238
View File
@@ -0,0 +1,238 @@
import { Action, ActionPanel, Icon, Keyboard, List } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useChat } from "../hooks/useChat";
import { buildTurnMarkdown, pendingMarkdown, previewText } from "../markdown";
import { getLamPreferences } from "../preferences";
import { listSessions, loadSession } from "../sessions";
import type { ChatTurn, Session, SessionSummary } from "../types";
import { formatRelativeTime, truncate } from "../utils";
import { ComposeForm } from "./ComposeForm";
/**
* The chat surface.
*
* Layout notes, because they are consequences of how Vicinae renders lists
* rather than free choices:
*
* - The search bar doubles as the composer. It is the only always-focused text
* input in a List, which is what makes "hit a hotkey and just start typing"
* work. It is single-line, hence the separate ⌘↩ composer.
* - `filtering` is off. This list is a transcript, not a set of search results,
* so the builtin fuzzy filter would hide turns as the user types a question.
* - Turns render newest first. The answer the user just asked for should be the
* thing nearest the search bar, not something they have to scroll to.
* - Tab reaches the session dropdown in the search bar accessory, which is how
* "Tab to move through previous conversations" is wired.
*/
/** Sentinel value for the dropdown row that starts a fresh conversation. */
const NEW_SESSION_VALUE = "__new__";
export interface ChatViewProps {
/** Reopens an existing conversation instead of starting a new one. */
initialSession?: Session;
/** Text handed over by a deeplink or fallback command. */
initialPrompt?: string;
}
function detailMarkdown(turn: ChatTurn, live: boolean, streamed: string, streamedThinking: string, status?: string): string {
if (live && !streamed && !turn.response) {
return pendingMarkdown(turn.prompt, status);
}
return buildTurnMarkdown({
prompt: turn.prompt,
response: live ? streamed : turn.response,
thinking: live ? streamedThinking : turn.thinking,
toolCalls: turn.toolCalls,
error: live ? undefined : turn.error,
model: turn.model,
createdAt: turn.createdAt,
isStreaming: live,
});
}
export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
const preferences = useMemo(() => getLamPreferences(), []);
const chat = useChat({ preferences, initialSession });
const [input, setInput] = useState(initialPrompt ?? "");
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const refreshSessions = useCallback(() => {
void listSessions()
.then(setSessions)
.catch(() => setSessions([]));
}, []);
// Refreshed after each turn so the Tab dropdown picks up the newly saved chat.
useEffect(() => {
refreshSessions();
}, [refreshSessions, chat.session.updatedAt]);
const send = useCallback(
(text: string) => {
chat.ask(text);
setInput("");
},
[chat.ask],
);
const stopAction = (
<Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />
);
const sendAction = (
<Action title="Send" icon={Icon.SpeechBubble} onAction={() => send(input)} />
);
const composeAction = (
<Action.Push
title="Compose Longer Message"
icon={Icon.Text}
shortcut={{ key: "enter", modifiers: ["cmd"] }}
target={<ComposeForm initialValue={input} onSubmit={send} />}
/>
);
const newChatAction = (
<Action
title="New Chat"
icon={Icon.Plus}
shortcut={Keyboard.Shortcut.Common.New}
onAction={chat.newSession}
/>
);
/**
* Every row carries the same primary action. Enter therefore always sends the
* current search text, whichever row happens to be selected, which is what
* makes the whole window feel like a single input box.
*/
const rowActions = (turn: ChatTurn) => (
<ActionPanel>
{chat.isStreaming ? stopAction : sendAction}
{composeAction}
<Action.CopyToClipboard
title="Copy Answer"
content={turn.response}
shortcut={Keyboard.Shortcut.Common.Copy}
/>
<Action.CopyToClipboard title="Copy Question" content={turn.prompt} />
{newChatAction}
</ActionPanel>
);
const dropdownItems = useMemo(() => {
const hasCurrent = sessions.some((summary) => summary.id === chat.session.id);
const withCurrent: SessionSummary[] = hasCurrent
? sessions
: [
{
id: chat.session.id,
title: chat.session.title,
model: chat.session.model,
createdAt: chat.session.createdAt,
updatedAt: chat.session.updatedAt,
turnCount: chat.session.turns.length,
preview: "",
},
...sessions,
];
return withCurrent;
}, [sessions, chat.session]);
const orderedTurns = useMemo(() => [...chat.session.turns].reverse(), [chat.session.turns]);
return (
<List
isLoading={chat.isStreaming}
isShowingDetail
filtering={false}
searchText={input}
onSearchTextChange={setInput}
navigationTitle="Lam"
searchBarPlaceholder={
chat.isStreaming ? "Lam is thinking — press ⏎ to stop" : "Ask Lam anything…"
}
searchBarAccessory={
<List.Dropdown
tooltip="Switch conversation"
placeholder="New chat"
value={chat.session.id}
onChange={(value) => {
if (value === NEW_SESSION_VALUE) {
chat.newSession();
return;
}
void loadSession(value).then((session) => {
if (session) chat.replaceSession(session);
});
}}
>
<List.Dropdown.Item title="+ New chat" value={NEW_SESSION_VALUE} />
<List.Dropdown.Section title="Recent">
{dropdownItems.map((summary) => (
<List.Dropdown.Item
key={summary.id}
title={truncate(summary.title, 44)}
value={summary.id}
/>
))}
</List.Dropdown.Section>
</List.Dropdown>
}
actions={
<ActionPanel>
{chat.isStreaming ? stopAction : sendAction}
{composeAction}
{newChatAction}
</ActionPanel>
}
>
{orderedTurns.length === 0 ? (
<List.EmptyView
icon={Icon.Stars}
title="Ask Lam anything"
description="Type a question and press Return. Use ⌘↩ for a longer, multi-line message."
/>
) : (
orderedTurns.map((turn, index) => {
const live = index === 0 && chat.isStreaming;
const accessories: List.Item.Accessory[] = [{ text: formatRelativeTime(turn.createdAt) }];
if (turn.toolCalls.length > 0) {
accessories.unshift({ tag: `${turn.toolCalls.length} tools` });
}
return (
<List.Item
key={turn.id}
id={turn.id}
icon={live ? Icon.ArrowClockwise : Icon.SpeechBubble}
title={truncate(turn.prompt, 72)}
subtitle={
live ? (chat.status ?? "Thinking…") : previewText(turn.response || turn.error || "", 90)
}
accessories={accessories}
detail={
<List.Item.Detail
markdown={detailMarkdown(
turn,
live,
chat.streamingResponse,
chat.streamingThinking,
chat.status,
)}
isLoading={live}
/>
}
actions={rowActions(turn)}
/>
);
})
)}
</List>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useNavigation } from "@vicinae/api";
import { Form, Action, ActionPanel, Icon } from "@vicinae/api";
import { useState } from "react";
/**
* The multi-line composer.
*
* This exists because the launcher's search bar is single-line and there is no
* API to change that. Pressing ⌘↩ in the chat view pushes this view, where a
* real multi-line text area gives the behaviour people expect from a chat box:
* Return inserts a newline, and ⌘↩ sends.
*/
export interface ComposeFormProps {
initialValue: string;
onSubmit: (text: string) => void;
}
export function ComposeForm({ initialValue, onSubmit }: ComposeFormProps) {
const { pop } = useNavigation();
const [error, setError] = useState<string | undefined>(undefined);
return (
<Form
navigationTitle="Compose message"
actions={
<ActionPanel>
<Action.SubmitForm
title="Send"
icon={Icon.SpeechBubble}
shortcut={{ key: "enter", modifiers: ["cmd"] }}
onSubmit={(values) => {
const text = String(values.prompt ?? "").trim();
if (!text) {
setError("Write something first.");
return;
}
// Pop before handing off so the chat view is already visible when
// streaming starts, rather than showing a stale composer.
pop();
onSubmit(text);
}}
/>
<Action title="Cancel" onAction={pop} />
</ActionPanel>
}
>
<Form.TextArea
id="prompt"
title="Message"
placeholder="Ask Lam anything. Return starts a new line."
defaultValue={initialValue}
error={error}
autoFocus
/>
<Form.Description title="Keyboard" text="Return — new line · ⌘↩ — send" />
</Form>
);
}
+33
View File
@@ -0,0 +1,33 @@
/** Tunable constants for Lam Agent. Anything a future contributor might want to change lives here. */
export const DEFAULT_HOST = "https://ollama.com";
export const DEFAULT_MODEL = "gpt-oss:120b";
/** LocalStorage keys. The index is small; each session body gets its own key so saving one never rewrites the others. */
export const STORAGE_INDEX_KEY = "lam.sessions.index";
export const STORAGE_SESSION_PREFIX = "lam.session.";
/** How many times the model may call tools before Lam gives up and reports back. */
export const MAX_TOOL_ITERATIONS = 5;
/** Oldest sessions past this count are evicted, along with their storage keys. */
export const MAX_SESSIONS = 100;
/** Defence against a single runaway session bloating LocalStorage. */
export const MAX_TURNS_PER_SESSION = 200;
export const MAX_STORED_CHARS_PER_TURN = 20_000;
/** How much of a prompt becomes the session title in the session list. */
export const TITLE_MAX_LENGTH = 60;
export const DEFAULT_SYSTEM_PROMPT = `You are Lam, a concise assistant built into the Vicinae launcher on the user's desktop.
- Lead with the answer, then add supporting detail. Do not pad with pleasantries.
- You render inside a narrow launcher window: keep paragraphs short and prefer tight markdown lists. Avoid wide tables and large headings.
- Call the tools you are given when they would make your answer more accurate. Anything time-sensitive, or after your training cutoff, should be checked with a tool rather than guessed.
- Never invent tool output. If a tool fails, say so plainly and answer with what you already know.
- When you are unsure, say what you are unsure about instead of hedging vaguely.`;
/** Extra instruction appended when the user has web tools enabled and the question looks time-sensitive. */
export const RECENCY_HINT =
"If the answer depends on current events, prices, versions, or releases, use web_search before answering.";
+30
View File
@@ -0,0 +1,30 @@
import { DEFAULT_SYSTEM_PROMPT, RECENCY_HINT } from "./constants";
import type { ApiMessage, ChatTurn } from "./types";
/**
* Turns stored turns into the message array sent to the model.
*
* Only completed question/answer pairs are replayed. The intermediate tool
* messages from a previous turn are intentionally dropped: they are noise once
* the answer is written, and replaying them would re-send large page contents
* on every subsequent request.
*/
export function buildMessages(systemPrompt: string, turns: ChatTurn[], prompt: string): ApiMessage[] {
const messages: ApiMessage[] = [{ role: "system", content: systemPrompt }];
for (const turn of turns) {
if (!turn.response) continue;
messages.push({ role: "user", content: turn.prompt });
messages.push({ role: "assistant", content: turn.response });
}
messages.push({ role: "user", content: prompt });
return messages;
}
/** Uses the user's prompt if they set one, otherwise Lam's default. */
export function composeSystemPrompt(customPrompt: string, webEnabled: boolean): string {
const parts = [customPrompt.trim() || DEFAULT_SYSTEM_PROMPT];
if (webEnabled) parts.push(RECENCY_HINT);
return parts.join("\n\n");
}
+268
View File
@@ -0,0 +1,268 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { runAgent } from "../agent";
import { buildMessages, composeSystemPrompt } from "../history";
import { OllamaError } from "../ollama";
import type { ResolvedPreferences } from "../preferences";
import { createSession, createTurn, saveSession, withTitleFromFirstTurn } from "../sessions";
import { toolsFor } from "../tools";
import type { Session } from "../types";
import { errorMessage, isAbortError } from "../utils";
/**
* The chat state machine.
*
* Kept out of the component so `chat.tsx` stays a layout file, and so the
* streaming behaviour has one obvious home.
*
* Two details are worth calling out:
*
* 1. Streaming deltas are accumulated in refs and flushed to React state on a
* timer. A long answer arrives as thousands of tokens, and re-rendering the
* whole transcript per token makes the launcher visibly janky.
* 2. The live session is mirrored in a ref. `ask` needs the session as it is at
* call time, and reading it from state inside a stable callback would close
* over a stale value.
*/
/** Coalescing window for streamed deltas. Short enough to feel live, long enough to stay smooth. */
const FLUSH_INTERVAL_MS = 60;
export interface UseChatOptions {
preferences: ResolvedPreferences;
/** Restores an existing conversation, e.g. when reopening from the sessions command. */
initialSession?: Session;
}
export interface UseChat {
session: Session;
isStreaming: boolean;
streamingResponse: string;
streamingThinking: string;
/** Short activity line such as "Searching the web", shown while a turn is in flight. */
status?: string;
error?: string;
ask: (prompt: string) => void;
cancel: () => void;
replaceSession: (session: Session) => void;
newSession: () => void;
setModel: (model: string) => void;
}
export function useChat(options: UseChatOptions): UseChat {
const [session, setSession] = useState<Session>(
() => options.initialSession ?? createSession(options.preferences.model),
);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingResponse, setStreamingResponse] = useState("");
const [streamingThinking, setStreamingThinking] = useState("");
const [status, setStatus] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const sessionRef = useRef(session);
const preferencesRef = useRef(options.preferences);
const abortRef = useRef<AbortController | null>(null);
const liveRef = useRef(false);
const responseRef = useRef("");
const thinkingRef = useRef("");
const statusRef = useRef<string | undefined>(undefined);
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
preferencesRef.current = options.preferences;
/** Updates state and the ref together, so `ask` never reads a stale session. */
const commit = useCallback((next: Session) => {
sessionRef.current = next;
setSession(next);
}, []);
const clearFlushTimer = useCallback(() => {
if (flushTimerRef.current !== null) {
clearTimeout(flushTimerRef.current);
flushTimerRef.current = null;
}
}, []);
const scheduleFlush = useCallback(() => {
if (flushTimerRef.current !== null) return;
flushTimerRef.current = setTimeout(() => {
flushTimerRef.current = null;
if (!liveRef.current) return;
setStreamingResponse(responseRef.current);
setStreamingThinking(thinkingRef.current);
}, FLUSH_INTERVAL_MS);
}, []);
const flushNow = useCallback(() => {
clearFlushTimer();
setStreamingResponse(responseRef.current);
setStreamingThinking(thinkingRef.current);
}, [clearFlushTimer]);
const setStatusValue = useCallback((value: string | undefined) => {
if (statusRef.current === value) return;
statusRef.current = value;
setStatus(value);
}, []);
const cancel = useCallback(() => {
abortRef.current?.abort();
}, []);
/** Aborts any in-flight request when the command unmounts, so no stream survives the view. */
useEffect(() => {
return () => {
liveRef.current = false;
abortRef.current?.abort();
if (flushTimerRef.current !== null) clearTimeout(flushTimerRef.current);
};
}, []);
const ask = useCallback(
(rawPrompt: string) => {
const prompt = rawPrompt.trim();
if (!prompt || liveRef.current) return;
const preferences = preferencesRef.current;
const base = sessionRef.current;
// The turn is inserted immediately with an empty response so the UI shows
// the question while the answer streams in beneath it.
const turn = createTurn(prompt, preferences.model);
const started = withTitleFromFirstTurn({
...base,
model: preferences.model,
turns: [...base.turns, turn],
});
commit(started);
responseRef.current = "";
thinkingRef.current = "";
setStreamingResponse("");
setStreamingThinking("");
setError(undefined);
setStatusValue("Thinking");
liveRef.current = true;
setIsStreaming(true);
const controller = new AbortController();
abortRef.current = controller;
const messages = buildMessages(
composeSystemPrompt(preferences.systemPrompt, preferences.webEnabled),
base.turns,
prompt,
);
void (async () => {
let failure: string | undefined;
let toolCalls = turn.toolCalls;
try {
const result = await runAgent({
host: preferences.host,
apiKey: preferences.apiKey,
model: preferences.model,
think: preferences.think,
messages,
tools: toolsFor({ webEnabled: preferences.webEnabled }),
toolContext: { host: preferences.host, apiKey: preferences.apiKey },
signal: controller.signal,
events: {
onContentDelta: (delta) => {
responseRef.current += delta;
setStatusValue(undefined);
scheduleFlush();
},
onThinkingDelta: (delta) => {
thinkingRef.current += delta;
scheduleFlush();
},
onStatus: (value) => setStatusValue(value),
onToolCall: (record) => {
toolCalls = [...toolCalls, record];
},
},
});
responseRef.current = result.content;
thinkingRef.current = result.thinking;
toolCalls = result.toolCalls;
} catch (thrown) {
if (isAbortError(thrown)) {
failure = "Cancelled.";
} else {
const hint = thrown instanceof OllamaError ? thrown.hint : undefined;
failure = hint ? `${errorMessage(thrown)} ${hint}` : errorMessage(thrown);
}
}
const finished = {
...turn,
response: responseRef.current,
thinking: thinkingRef.current,
toolCalls,
error: failure,
};
const updated = withTitleFromFirstTurn({
...started,
turns: [...base.turns, finished],
updatedAt: Date.now(),
});
liveRef.current = false;
abortRef.current = null;
commit(updated);
setIsStreaming(false);
setStatusValue(undefined);
setError(failure);
flushNow();
// Persistence is best-effort: a storage failure should not lose the
// answer that is already on screen.
void saveSession(updated).catch(() => undefined);
})();
},
[commit, flushNow, scheduleFlush, setStatusValue],
);
const replaceSession = useCallback(
(next: Session) => {
abortRef.current?.abort();
liveRef.current = false;
setIsStreaming(false);
setStatusValue(undefined);
setError(undefined);
setStreamingResponse("");
setStreamingThinking("");
commit(next);
},
[commit, setStatusValue],
);
const newSession = useCallback(() => {
replaceSession(createSession(preferencesRef.current.model));
}, [replaceSession]);
const setModel = useCallback(
(model: string) => {
commit({ ...sessionRef.current, model, updatedAt: Date.now() });
},
[commit],
);
return {
session,
isStreaming,
streamingResponse,
streamingThinking,
status,
error,
ask,
cancel,
replaceSession,
newSession,
setModel,
};
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Time and place, derived locally.
*
* `get_location` is intentionally timezone-derived rather than a GPS or IP
* lookup. It answers the questions people actually ask a launcher ("what time
* is it there", "is it late in London") without shipping the user's IP address
* to a third-party geolocation service on every tool call. It is also pure, so
* it is cheap to test.
*/
export interface LocationContext {
timezone: string;
utcOffset: string;
locale: string;
region?: string;
city?: string;
}
/** Splits an IANA zone like "Asia/Manila" or "America/Argentina/Salta" into readable parts. */
export function describeTimezone(timeZone: string): { region?: string; city?: string } {
const parts = timeZone.split("/").filter(Boolean);
if (parts.length < 2) return {};
const region = parts[0];
const city = parts[parts.length - 1].replace(/_/g, " ");
return { region, city: city || undefined };
}
/** "GMT+08:00" on a modern V8; falls back to a short name if `longOffset` is unavailable. */
export function utcOffsetLabel(date: Date, timeZone: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "longOffset" }).formatToParts(
date,
);
const label = parts.find((part) => part.type === "timeZoneName")?.value;
if (label) return label;
} catch {
// Unsupported timeZone name: fall through to the short form below.
}
try {
const parts = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "short" }).formatToParts(date);
return parts.find((part) => part.type === "timeZoneName")?.value ?? "UTC";
} catch {
return "UTC";
}
}
function resolveTimeZone(timeZone?: string): string {
if (timeZone) return timeZone;
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return "UTC";
}
}
export function getLocationContext(date: Date = new Date(), timeZone?: string): LocationContext {
const zone = resolveTimeZone(timeZone);
let locale = "en-US";
try {
locale = Intl.DateTimeFormat().resolvedOptions().locale;
} catch {
// Keep the fallback locale.
}
return {
timezone: zone,
utcOffset: utcOffsetLabel(date, zone),
locale,
...describeTimezone(zone),
};
}
/** A full, unambiguous rendering of "now", e.g. "Saturday, 3 October 2026 at 20:11:04 GMT+08:00". */
export function formatNow(date: Date, timeZone?: string): string {
const zone = resolveTimeZone(timeZone);
try {
return new Intl.DateTimeFormat("en-US", {
timeZone: zone,
dateStyle: "full",
timeStyle: "long",
}).format(date);
} catch {
return date.toISOString();
}
}
/** The weekday name, which models frequently get wrong when left to arithmetic. */
export function weekdayName(date: Date, timeZone?: string): string {
const zone = resolveTimeZone(timeZone);
try {
return new Intl.DateTimeFormat("en-US", { timeZone: zone, weekday: "long" }).format(date);
} catch {
return new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(date);
}
}
+98
View File
@@ -0,0 +1,98 @@
import type { ChatTurn } from "./types";
/**
* Renders a turn as markdown for the detail panel.
*
* Kept as a pure function so the transcript layout can be tested without
* mounting a component, and so the same renderer can serve both a finished
* turn and one that is still streaming.
*/
export const STREAMING_CURSOR = "▍";
export interface TurnMarkdownInput {
prompt: string;
response: string;
thinking?: string;
toolCalls?: ChatTurn["toolCalls"];
error?: string;
model?: string;
createdAt?: number;
isStreaming?: boolean;
}
/** Prefixes every line so a multi-line prompt renders as one continuous quote block. */
function quote(text: string): string {
return text
.split("\n")
.map((line) => `> ${line}`.trimEnd())
.join("\n");
}
export function buildTurnMarkdown(input: TurnMarkdownInput): string {
const sections: string[] = [];
sections.push(quote(input.prompt));
sections.push("");
const body = input.response.trim() || (input.isStreaming ? "_Thinking…_" : "_No response._");
sections.push(input.isStreaming ? `${body} ${STREAMING_CURSOR}` : body);
if (input.error) {
sections.push("", `**Error** — ${input.error}`);
}
const toolCalls = input.toolCalls ?? [];
if (toolCalls.length > 0) {
const lines = toolCalls.map((call) => `- \`${call.name}\`${call.isError ? " — failed" : ""}`);
sections.push("", "**Tools used**", ...lines);
}
if (input.thinking && input.thinking.trim().length > 0) {
sections.push("", "---", "", "**Reasoning**", "", quote(input.thinking.trim()));
}
const footer: string[] = [];
if (input.model) footer.push(`\`${input.model}\``);
if (input.createdAt) footer.push(new Date(input.createdAt).toLocaleString());
if (footer.length > 0) {
sections.push("", "---", "", footer.join(" · "));
}
return sections.join("\n");
}
/** The placeholder shown while the first tokens are still in flight. */
export function pendingMarkdown(prompt: string, status?: string): string {
const lines = [quote(prompt), ""];
lines.push(status ? `_${status}…_` : "_Thinking…_");
return lines.join("\n");
}
/** A short one-line preview of an answer, for a collapsed row. */
export function previewText(text: string, max: number): string {
const collapsed = text.replace(/\s+/g, " ").trim();
if (collapsed.length <= max) return collapsed;
return `${collapsed.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
}
/**
* Flattens a whole conversation into plain text for the clipboard.
*
* This has to load the full transcript rather than reuse the index summary —
* copying a truncated preview and calling it "the conversation" would be a
* quiet lie.
*/
export function sessionToPlainText(turns: ChatTurn[]): string {
if (turns.length === 0) return "";
return turns
.map((turn) => {
const header = `## ${turn.prompt}`;
const body = turn.response.trim() || (turn.error ? "" : "_(no response)_");
const failure = turn.error ? `\n\n**[error]** ${turn.error}` : "";
return `${header}\n\n${body}${failure}`;
})
.join("\n\n---\n\n");
}
+290
View File
@@ -0,0 +1,290 @@
import { DEFAULT_HOST } from "./constants";
import type { ApiMessage, ModelInfo, ToolCall, ToolDefinition } from "./types";
/**
* Thin client for the Ollama HTTP API.
*
* Everything here is plain `fetch`, deliberately: the extension runs in Node
* inside Vicinae's extension runtime, so there is no reason to pull in the
* `ollama` npm package and its own transport for four endpoints.
*/
/** An error with enough context for the UI to render something actionable rather than a raw status code. */
export class OllamaError extends Error {
readonly status?: number;
readonly hint?: string;
constructor(message: string, options: { status?: number; hint?: string } = {}) {
super(message);
this.name = "OllamaError";
this.status = options.status;
this.hint = options.hint;
}
}
/** Accepts `https://ollama.com/`, `http://localhost:11434`, or an empty preference, and yields a base URL with no trailing slash. */
export function normalizeHost(host: string | undefined): string {
const trimmed = (host ?? "").trim();
return (trimmed || DEFAULT_HOST).replace(/\/+$/, "");
}
function authHeaders(apiKey: string): Record<string, string> {
const key = apiKey.trim();
return key ? { Authorization: `Bearer ${key}` } : {};
}
async function safeText(response: Response): Promise<string> {
try {
return await response.text();
} catch {
return "";
}
}
function describeHttpError(status: number, bodyText: string, host: string): OllamaError {
const detail = bodyText.trim().slice(0, 300);
switch (status) {
case 401:
case 403:
return new OllamaError("Ollama rejected the API key.", {
status,
hint: "Check the Ollama API Key preference. Create a new key at https://ollama.com/settings/keys.",
});
case 404:
return new OllamaError(`Ollama returned 404 from ${host}.`, {
status,
hint: "The model name may be wrong, or this host may not expose that endpoint. Local Ollama servers do not provide the web tools.",
});
case 429:
return new OllamaError("Ollama is rate limiting this key.", {
status,
hint: "Wait a moment and retry, or switch to a lighter model.",
});
default:
return new OllamaError(`Ollama request failed with HTTP ${status}.`, { status, hint: detail });
}
}
/**
* Splits newline-delimited JSON into parsed objects.
*
* Exported for tests. Ollama streams NDJSON, and a network chunk can land
* anywhere: mid-line, or between the bytes of a multi-byte UTF-8 character.
* Both are handled here by buffering until a newline arrives and letting
* TextDecoder hold incomplete sequences back.
*/
export async function* parseNdjsonStream<T>(stream: ReadableStream<Uint8Array>): AsyncGenerator<T> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) yield JSON.parse(line) as T;
newlineIndex = buffer.indexOf("\n");
}
}
// Flush whatever the decoder is still holding, then the final unterminated line.
buffer += decoder.decode();
const tail = buffer.trim();
if (tail) yield JSON.parse(tail) as T;
} finally {
try {
reader.releaseLock();
} catch {
// The stream was already torn down, typically because the request was aborted.
}
}
}
interface OllamaChatEvent {
message?: {
role?: string;
content?: string;
thinking?: string;
tool_calls?: ToolCall[];
};
done?: boolean;
done_reason?: string;
error?: string;
}
export interface ChatStreamChunk {
content?: string;
thinking?: string;
toolCalls?: ToolCall[];
done: boolean;
doneReason?: string;
}
export interface StreamChatOptions {
host: string;
apiKey: string;
model: string;
messages: ApiMessage[];
tools?: ToolDefinition[];
/** Omit, or pass "off", to leave reasoning disabled. */
think?: string | boolean;
signal?: AbortSignal;
}
/**
* Streams a chat completion.
*
* Because this is an async generator, the request is not issued until the
* caller starts iterating, which lets the UI attach its loading state first.
*/
export async function* streamChat(options: StreamChatOptions): AsyncGenerator<ChatStreamChunk> {
const host = normalizeHost(options.host);
const body: Record<string, unknown> = {
model: options.model,
messages: options.messages,
stream: true,
};
if (options.tools && options.tools.length > 0) body.tools = options.tools;
if (options.think && options.think !== "off") body.think = options.think;
const response = await fetch(`${host}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
body: JSON.stringify(body),
signal: options.signal,
});
if (!response.ok) {
throw describeHttpError(response.status, await safeText(response), host);
}
if (!response.body) {
throw new OllamaError("Ollama returned an empty response body.");
}
for await (const event of parseNdjsonStream<OllamaChatEvent>(response.body)) {
if (event.error) {
throw new OllamaError(event.error);
}
yield {
// Ollama sends empty strings for absent fields; normalise to undefined so
// callers can use a simple truthiness check.
content: event.message?.content || undefined,
thinking: event.message?.thinking || undefined,
toolCalls: event.message?.tool_calls,
done: Boolean(event.done),
doneReason: event.done_reason,
};
}
}
export interface ListModelsOptions {
host: string;
apiKey: string;
signal?: AbortSignal;
}
/** Lists the models this API key can reach. On Ollama Cloud this reflects the account's entitlements. */
export async function listModels(options: ListModelsOptions): Promise<ModelInfo[]> {
const host = normalizeHost(options.host);
const response = await fetch(`${host}/api/tags`, {
headers: authHeaders(options.apiKey),
signal: options.signal,
});
if (!response.ok) {
throw describeHttpError(response.status, await safeText(response), host);
}
const payload = (await response.json()) as {
models?: Array<{ name?: string; model?: string; details?: { parameter_size?: string } }>;
};
return (payload.models ?? [])
.map((model) => ({
name: model.name ?? model.model ?? "",
parameterSize: model.details?.parameter_size,
}))
.filter((model) => model.name.length > 0)
.sort((a, b) => a.name.localeCompare(b.name));
}
export interface WebSearchResult {
title: string;
url: string;
content: string;
}
export interface WebSearchOptions {
host: string;
apiKey: string;
query: string;
maxResults?: number;
signal?: AbortSignal;
}
export async function webSearch(options: WebSearchOptions): Promise<WebSearchResult[]> {
const host = normalizeHost(options.host);
const body: Record<string, unknown> = { query: options.query };
if (typeof options.maxResults === "number") body.max_results = options.maxResults;
const response = await fetch(`${host}/api/web_search`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
body: JSON.stringify(body),
signal: options.signal,
});
if (!response.ok) {
throw describeHttpError(response.status, await safeText(response), host);
}
const payload = (await response.json()) as { results?: WebSearchResult[] };
return payload.results ?? [];
}
export interface WebFetchResult {
title: string;
content: string;
links: string[];
}
export interface WebFetchOptions {
host: string;
apiKey: string;
url: string;
signal?: AbortSignal;
}
export async function webFetch(options: WebFetchOptions): Promise<WebFetchResult> {
const host = normalizeHost(options.host);
const response = await fetch(`${host}/api/web_fetch`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
body: JSON.stringify({ url: options.url }),
signal: options.signal,
});
if (!response.ok) {
throw describeHttpError(response.status, await safeText(response), host);
}
const payload = (await response.json()) as Partial<WebFetchResult>;
return {
title: payload.title ?? options.url,
content: payload.content ?? "",
links: payload.links ?? [],
};
}
+54
View File
@@ -0,0 +1,54 @@
import { getPreferenceValues } from "@vicinae/api";
import { DEFAULT_HOST, DEFAULT_MODEL } from "./constants";
/**
* Preferences, read and normalised.
*
* Declared locally rather than relying on the `vicinae-env.d.ts` that the
* `vici` toolchain generates, so this file typechecks and unit-tests without a
* codegen step. The manifest in `package.json` remains the source of truth; if
* a preference is added there, mirror it here.
*/
export interface LamPreferences {
apiKey?: string;
model?: string;
ollamaHost?: string;
systemPrompt?: string;
webSearchEnabled?: boolean;
thinking?: string;
}
/** Preferences with every optional value filled in, so callers never branch on undefined. */
export interface ResolvedPreferences {
apiKey: string;
model: string;
host: string;
systemPrompt: string;
webEnabled: boolean;
/** Undefined when reasoning is off, which is what the API client expects. */
think?: string;
}
export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
const thinking = (raw.thinking ?? "").trim();
return {
apiKey: (raw.apiKey ?? "").trim(),
model: (raw.model ?? "").trim() || DEFAULT_MODEL,
host: (raw.ollamaHost ?? "").trim() || DEFAULT_HOST,
systemPrompt: (raw.systemPrompt ?? "").trim(),
// Defaults to on: the preference is a checkbox the user has to actively untick.
webEnabled: raw.webSearchEnabled ?? true,
think: thinking && thinking !== "off" ? thinking : undefined,
};
}
export function getLamPreferences(): ResolvedPreferences {
return resolvePreferences(getPreferenceValues<LamPreferences>());
}
/** True when the extension cannot work until the user supplies a key. */
export function needsSetup(preferences: ResolvedPreferences): boolean {
return preferences.apiKey.length === 0;
}
+49
View File
@@ -0,0 +1,49 @@
import { z } from "zod";
/**
* Schemas guarding everything read back from LocalStorage.
*
* Stored data outlives the code that wrote it, so this is the boundary where an
* older or hand-edited payload gets rejected instead of crashing a render.
*/
export const ToolCallRecordSchema = z.object({
name: z.string(),
arguments: z.record(z.string(), z.unknown()),
result: z.string(),
isError: z.boolean(),
});
export const ChatTurnSchema = z.object({
id: z.string(),
prompt: z.string(),
response: z.string(),
thinking: z.string().default(""),
model: z.string(),
createdAt: z.number(),
error: z.string().optional(),
toolCalls: z.array(ToolCallRecordSchema).default([]),
});
export const SessionSchema = z.object({
id: z.string(),
title: z.string(),
model: z.string(),
createdAt: z.number(),
updatedAt: z.number(),
turns: z.array(ChatTurnSchema).default([]),
});
export const SessionSummarySchema = z.object({
id: z.string(),
title: z.string(),
model: z.string(),
createdAt: z.number(),
updatedAt: z.number(),
turnCount: z.number(),
preview: z.string(),
});
export const SessionIndexSchema = z.array(SessionSummarySchema);
export type StoredSession = z.infer<typeof SessionSchema>;
+182
View File
@@ -0,0 +1,182 @@
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 { 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)}`;
}
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()): ChatTurn {
return {
id: newId(),
prompt,
response: "",
thinking: "",
model,
createdAt: now,
toolCalls: [],
};
}
/** 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) };
}
+242
View File
@@ -0,0 +1,242 @@
import { formatNow, getLocationContext, weekdayName } from "./location";
import { webFetch, webSearch } from "./ollama";
import type { ToolDefinition } from "./types";
import { errorMessage } from "./utils";
/**
* The tools Lam can call.
*
* All four are read-only. That is a deliberate boundary for this version: an
* assistant that can silently act on the user's machine needs a confirmation
* story first, and this release is chat-only. Anything with a side effect
* (opening URLs, clipboard access, launching apps) belongs behind an approval
* step that does not exist yet.
*/
export interface ToolContext {
host: string;
apiKey: string;
signal?: AbortSignal;
/** Injectable clock so tests are deterministic. */
now?: Date;
}
export interface ToolResult {
content: string;
isError: boolean;
}
/** Search snippets are truncated so a chatty result set cannot swallow the model's context window. */
const MAX_SNIPPET_CHARS = 1200;
const MAX_FETCH_CHARS = 8000;
export const TOOL_DEFINITIONS: ToolDefinition[] = [
{
type: "function",
function: {
name: "get_datetime",
description:
"Get the user's current local date and time, including the weekday, timezone, and UTC offset. Use this whenever an answer depends on what today's date is or what time it is now.",
parameters: { type: "object", properties: {}, required: [] },
},
},
{
type: "function",
function: {
name: "get_location",
description:
"Get the user's approximate location context: timezone, UTC offset, locale, region, and city. Use it for local questions such as weather, business hours, or travel timing. This is derived from the device timezone, not GPS, so treat it as a region rather than an address.",
parameters: { type: "object", properties: {}, required: [] },
},
},
{
type: "function",
function: {
name: "web_search",
description:
"Search the web for current information. Use it for recent events, prices, releases, documentation changes, or anything that may have happened after your training cutoff. Returns a list of titles, URLs, and content snippets.",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "The search query. Be specific." },
max_results: {
type: "integer",
description: "How many results to return, between 1 and 10. Defaults to 5.",
},
},
required: ["query"],
},
},
},
{
type: "function",
function: {
name: "web_fetch",
description:
"Fetch a single web page and return its title, main content as markdown, and the links on the page. Use it after web_search to read a promising result in full, or directly when the user supplies a URL.",
parameters: {
type: "object",
properties: {
url: { type: "string", description: "Absolute URL to fetch, including the scheme." },
},
required: ["url"],
},
},
},
];
/** Tools that need Ollama Cloud. A local Ollama server does not implement these endpoints. */
export const WEB_TOOL_NAMES = new Set(["web_search", "web_fetch"]);
/** Human-readable labels for the activity line in the UI. */
export const TOOL_LABELS: Record<string, string> = {
get_datetime: "Checking the time",
get_location: "Checking your location",
web_search: "Searching the web",
web_fetch: "Reading a page",
};
export function toolLabel(name: string): string {
return TOOL_LABELS[name] ?? `Running ${name}`;
}
/** Filters out the web tools when the user has turned them off. */
export function toolsFor(options: { webEnabled: boolean }): ToolDefinition[] {
return TOOL_DEFINITIONS.filter((tool) => options.webEnabled || !WEB_TOOL_NAMES.has(tool.function.name));
}
function asString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function asCount(value: unknown, min: number, max: number): number | undefined {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) return undefined;
return Math.min(max, Math.max(min, Math.trunc(parsed)));
}
function isWebTool(name: string): boolean {
return WEB_TOOL_NAMES.has(name);
}
/**
* Runs one tool call and always resolves.
*
* Tool failures are returned as `isError: true` rather than thrown, because the
* model should get a chance to recover, retry with different arguments, or
* explain the failure to the user. Throwing here would abort the whole turn
* over something the model can often route around.
*/
export async function executeTool(
name: string,
args: Record<string, unknown>,
context: ToolContext,
): Promise<ToolResult> {
try {
switch (name) {
case "get_datetime":
return { content: describeNow(context.now), isError: false };
case "get_location":
return { content: describeLocation(context.now), isError: false };
case "web_search":
return await runWebSearch(args, context);
case "web_fetch":
return await runWebFetch(args, context);
default:
return { content: `Unknown tool "${name}".`, isError: true };
}
} catch (error) {
const suffix = isWebTool(name)
? " The web tools require an Ollama Cloud host and an API key with web search enabled."
: "";
return { content: `${errorMessage(error)}${suffix}`, isError: true };
}
}
function describeNow(now?: Date): string {
const date = now ?? new Date();
const location = getLocationContext(date);
return [
`Current date and time: ${formatNow(date, location.timezone)}`,
`Weekday: ${weekdayName(date, location.timezone)}`,
`Timezone: ${location.timezone} (${location.utcOffset})`,
`Unix timestamp: ${Math.floor(date.getTime() / 1000)}`,
`ISO 8601 (UTC): ${date.toISOString()}`,
].join("\n");
}
function describeLocation(now?: Date): string {
const date = now ?? new Date();
const location = getLocationContext(date);
const lines = [
`Timezone: ${location.timezone}`,
`UTC offset: ${location.utcOffset}`,
`Locale: ${location.locale}`,
];
if (location.city) lines.push(`City: ${location.city}`);
if (location.region) lines.push(`Region: ${location.region}`);
lines.push("Note: derived from the device timezone. It indicates region, not a precise address.");
return lines.join("\n");
}
async function runWebSearch(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
const query = asString(args.query);
if (!query) {
return { content: 'web_search requires a "query" string.', isError: true };
}
const results = await webSearch({
host: context.host,
apiKey: context.apiKey,
query,
maxResults: asCount(args.max_results, 1, 10),
signal: context.signal,
});
if (results.length === 0) {
return { content: `No web results for "${query}".`, isError: false };
}
const rendered = results
.map((result, index) => {
const snippet = result.content.replace(/\s+/g, " ").trim().slice(0, MAX_SNIPPET_CHARS);
return `${index + 1}. ${result.title}\n ${result.url}\n ${snippet}`;
})
.join("\n\n");
return { content: `Web results for "${query}":\n\n${rendered}`, isError: false };
}
async function runWebFetch(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
const url = asString(args.url);
if (!url) {
return { content: 'web_fetch requires a "url" string.', isError: true };
}
const page = await webFetch({
host: context.host,
apiKey: context.apiKey,
url,
signal: context.signal,
});
const content = page.content.slice(0, MAX_FETCH_CHARS);
const truncated = page.content.length > MAX_FETCH_CHARS ? "\n\n[content truncated]" : "";
const links = page.links.slice(0, 20);
const sections = [`Title: ${page.title}`, `URL: ${url}`, "", content + truncated];
if (links.length > 0) {
sections.push("", `Links on page:\n${links.map((link) => `- ${link}`).join("\n")}`);
}
return { content: sections.join("\n"), isError: false };
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Shared domain types for Lam Agent.
*
* These are deliberately split into two families:
*
* - `ApiMessage` and friends mirror exactly what Ollama's `/api/chat` accepts.
* - `Session`, `ChatTurn` and friends are Lam's own storage model.
*
* Keeping them separate matters because the storage model is derived from the
* API model, not identical to it: the tool-calling messages that Ollama needs
* mid-turn are not persisted, so a reloaded session replays cleanly as plain
* user/assistant pairs.
*/
export type ChatRole = "system" | "user" | "assistant" | "tool";
export interface ToolCall {
function: {
name: string;
arguments: Record<string, unknown>;
};
}
export interface ToolDefinition {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
/** A message in the shape Ollama's `/api/chat` endpoint expects. */
export interface ApiMessage {
role: ChatRole;
content: string;
tool_calls?: ToolCall[];
/** Only set on `role: "tool"` messages, naming the tool that produced this content. */
tool_name?: string;
}
/** What a tool actually returned during a turn, kept for display and debugging. */
export interface ToolCallRecord {
name: string;
arguments: Record<string, unknown>;
result: string;
isError: boolean;
}
/** One question and its answer. The unit the transcript is rendered from. */
export interface ChatTurn {
id: string;
prompt: string;
response: string;
thinking: string;
model: string;
createdAt: number;
/** Set when the turn failed, so a stored session can show what went wrong. */
error?: string;
toolCalls: ToolCallRecord[];
}
export interface Session {
id: string;
title: string;
model: string;
createdAt: number;
updatedAt: number;
turns: ChatTurn[];
}
/** The lightweight record kept in the session index, so listing sessions never loads full transcripts. */
export interface SessionSummary {
id: string;
title: string;
model: string;
createdAt: number;
updatedAt: number;
turnCount: number;
preview: string;
}
/** A model as reported by `GET /api/tags`. */
export interface ModelInfo {
name: string;
parameterSize?: string;
}
+56
View File
@@ -0,0 +1,56 @@
/** Small pure helpers shared across the extension. Kept dependency-free so they are trivial to test. */
/**
* Collapses whitespace and clips to `max` characters, adding an ellipsis when clipped.
* Used for list rows and session titles, where a stray newline would break the layout.
*/
export function truncate(text: string, max: number): string {
const collapsed = text.replace(/\s+/g, " ").trim();
if (collapsed.length <= max) return collapsed;
return `${collapsed.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
}
/** "just now", "12m ago", "3h ago", then an absolute date once it is older than a day. */
export function formatRelativeTime(timestamp: number, now: number = Date.now()): string {
const deltaSeconds = Math.round((now - timestamp) / 1000);
if (!Number.isFinite(deltaSeconds) || deltaSeconds < 0) return "just now";
if (deltaSeconds < 45) return "just now";
const minutes = Math.round(deltaSeconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
/** Pulls a human-readable message out of anything that can be thrown. */
export function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
/** True when a rejected promise came from an AbortController rather than a real failure. */
export function isAbortError(error: unknown): boolean {
return error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted"));
}
/**
* Builds the first line of a prompt into a session title.
* Prompt text often starts with markdown decoration, which reads badly in a list row.
*/
export function titleFromPrompt(prompt: string, max: number): string {
const firstLine = prompt.split("\n").find((line) => line.trim().length > 0) ?? prompt;
const cleaned = firstLine.replace(/^[#>*\-\s]+/, "").trim();
return truncate(cleaned || "New chat", max);
}