feat: strip speaker labels from the transcript, add a plain-prose prompt #9
@@ -97,7 +97,7 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
|
|||||||
| **Ollama API Key** | — | Required. Stored by Vicinae as a password preference. |
|
| **Ollama API Key** | — | Required. Stored by Vicinae as a password preference. |
|
||||||
| **Model** | — | Required. Any model your key can reach. |
|
| **Model** | — | Required. Any model your key can reach. |
|
||||||
| **Ollama Host** | `https://ollama.com` | Set to `http://localhost:11434` for a local server. |
|
| **Ollama Host** | `https://ollama.com` | Set to `http://localhost:11434` for a local server. |
|
||||||
| **System Prompt** | Lam's default | Replaces the built-in prompt entirely. |
|
| **System Prompt** | Lam's default | Replaces the built-in prompt entirely. The default asks for plain prose: no em dashes, no generated-text tells, no restating the question. |
|
||||||
| **Web Tools** | on | Enables `web_search` and `web_fetch`. |
|
| **Web Tools** | on | Enables `web_search` and `web_fetch`. |
|
||||||
| **Reasoning Effort** | Off | `low`/`medium`/`high` for models that support thinking. |
|
| **Reasoning Effort** | Off | `low`/`medium`/`high` for models that support thinking. |
|
||||||
| **Show Tool Calls** | on | Lists the tools Lam called under each answer. |
|
| **Show Tool Calls** | on | Lists the tools Lam called under each answer. |
|
||||||
@@ -156,7 +156,7 @@ A few decisions worth knowing about before you change things:
|
|||||||
|
|
||||||
**The transcript reads newest message first.** The search bar sits at the top of the window, so the most recent message — an answer, once one has been written — is the first line under it rather than below the fold. Within a single exchange that means the answer appears above the question it responds to. `buildThread` produces that order, and `buildThreadMarkdown` regroups messages back into exchanges so the horizontal rule separates one exchange from the next instead of dividing every message.
|
**The transcript reads newest message first.** The search bar sits at the top of the window, so the most recent message — an answer, once one has been written — is the first line under it rather than below the fold. Within a single exchange that means the answer appears above the question it responds to. `buildThread` produces that order, and `buildThreadMarkdown` regroups messages back into exchanges so the horizontal rule separates one exchange from the next instead of dividing every message.
|
||||||
|
|
||||||
**The transcript is text-only.** Speaker labels are plain `You` / `Lam`, with the question dimmed as a blockquote and the answer as body text. Icons are not possible there: `MdImage` renders images block-level, centred, and capped at 200px tall, and every candidate source was tried — `icon://person`, a raw icon slug, and a `file://` asset path — and each fell back to rendering its alt text as a caption.
|
**The transcript is text-only, and nothing in it is bold except an error.** The answer is what gets read, so it is plain body text carrying no label; the question is a blockquote, which the renderer draws dimmed, italic, and with a left rule. A `**You**` / `**Lam**` header on each exchange gave both speakers identical weight and made the answer sit behind chrome. Tool calls collapse to one italic line (_Tools: `web_search`_) rather than a bold header plus a list, so they stay below the answer too. Raycast solves the same problem with alignment, but markdown offers no alignment control.
|
||||||
|
|
||||||
**Streamed deltas are buffered in refs and flushed to state on a timer** (`FLUSH_INTERVAL_MS`). Re-rendering the transcript per token makes the launcher visibly janky.
|
**Streamed deltas are buffered in refs and flushed to state on a timer** (`FLUSH_INTERVAL_MS`). Re-rendering the transcript per token makes the launcher visibly janky.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
ASSISTANT_LABEL,
|
|
||||||
STREAMING_CURSOR,
|
STREAMING_CURSOR,
|
||||||
USER_LABEL,
|
|
||||||
buildThreadMarkdown,
|
buildThreadMarkdown,
|
||||||
quote,
|
quote,
|
||||||
sessionToPlainText,
|
sessionToPlainText,
|
||||||
@@ -38,12 +36,29 @@ describe("buildThreadMarkdown", () => {
|
|||||||
message({ role: "assistant", text: "4" }),
|
message({ role: "assistant", text: "4" }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(markdown).toContain("**You**");
|
|
||||||
expect(markdown).toContain("> What is 2+2?");
|
expect(markdown).toContain("> What is 2+2?");
|
||||||
expect(markdown).toContain("**Lam**");
|
|
||||||
expect(markdown).toContain("4");
|
expect(markdown).toContain("4");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels no speaker, so only the answer carries weight", () => {
|
||||||
|
// A `**You**` / `**Lam**` header on each exchange gave both speakers the
|
||||||
|
// same visual weight and pushed the answer behind chrome.
|
||||||
|
const markdown = buildThreadMarkdown([
|
||||||
|
message({ role: "user", text: "q" }),
|
||||||
|
message({ role: "assistant", text: "a" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(markdown).not.toContain("You");
|
||||||
|
expect(markdown).not.toContain("Lam");
|
||||||
|
// Nothing bold at all, other than an error.
|
||||||
|
expect(markdown).not.toContain("**");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("identifies the question by styling alone", () => {
|
||||||
|
const markdown = buildThreadMarkdown([message({ role: "user", text: "just a question" })]);
|
||||||
|
expect(markdown.trim()).toBe("> just a question");
|
||||||
|
});
|
||||||
|
|
||||||
it("groups an answer with its question, dividing only between exchanges", () => {
|
it("groups an answer with its question, dividing only between exchanges", () => {
|
||||||
const markdown = buildThreadMarkdown([
|
const markdown = buildThreadMarkdown([
|
||||||
message({ id: "t2:assistant", role: "assistant", text: "second answer" }),
|
message({ id: "t2:assistant", role: "assistant", text: "second answer" }),
|
||||||
@@ -59,17 +74,6 @@ describe("buildThreadMarkdown", () => {
|
|||||||
expect(markdown.indexOf("second question")).toBeLessThan(markdown.indexOf("first answer"));
|
expect(markdown.indexOf("second question")).toBeLessThan(markdown.indexOf("first answer"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("labels each speaker, in order", () => {
|
|
||||||
const markdown = buildThreadMarkdown([
|
|
||||||
message({ role: "user", text: "q" }),
|
|
||||||
message({ role: "assistant", text: "a" }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(markdown).toContain(`**${USER_LABEL}**`);
|
|
||||||
expect(markdown).toContain(`**${ASSISTANT_LABEL}**`);
|
|
||||||
expect(markdown.indexOf(USER_LABEL)).toBeLessThan(markdown.indexOf(ASSISTANT_LABEL));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders no images, because markdown cannot resolve them", () => {
|
it("renders no images, because markdown cannot resolve them", () => {
|
||||||
// Images render block-level and centred at up to 200px (`MdImage.qml`), and
|
// Images render block-level and centred at up to 200px (`MdImage.qml`), and
|
||||||
// every candidate icon source fails. Keep the transcript text-only.
|
// every candidate icon source fails. Keep the transcript text-only.
|
||||||
@@ -123,7 +127,7 @@ describe("buildThreadMarkdown", () => {
|
|||||||
expect(markdown).toContain("Ollama rejected the API key.");
|
expect(markdown).toContain("Ollama rejected the API key.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists tools that were used, flagging failures", () => {
|
it("lists tools on one quiet line, flagging failures", () => {
|
||||||
const markdown = buildThreadMarkdown([
|
const markdown = buildThreadMarkdown([
|
||||||
message({
|
message({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -135,8 +139,9 @@ describe("buildThreadMarkdown", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(markdown).toContain("`web_search`");
|
expect(markdown).toContain("_Tools: `web_search`, `web_fetch` (failed)_");
|
||||||
expect(markdown).toContain("`web_fetch` — failed");
|
// Italic, not a bold header, so it stays below the answer.
|
||||||
|
expect(markdown).not.toContain("**Tools");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits tools and reasoning when the preferences are off", () => {
|
it("omits tools and reasoning when the preferences are off", () => {
|
||||||
@@ -151,7 +156,7 @@ describe("buildThreadMarkdown", () => {
|
|||||||
|
|
||||||
const hidden = buildThreadMarkdown(messages, { showToolCalls: false, showReasoning: false });
|
const hidden = buildThreadMarkdown(messages, { showToolCalls: false, showReasoning: false });
|
||||||
|
|
||||||
expect(hidden).not.toContain("Tools used");
|
expect(hidden).not.toContain("_Tools:");
|
||||||
expect(hidden).not.toContain("web_search");
|
expect(hidden).not.toContain("web_search");
|
||||||
expect(hidden).not.toContain("Reasoning");
|
expect(hidden).not.toContain("Reasoning");
|
||||||
expect(hidden).not.toContain("let me think");
|
expect(hidden).not.toContain("let me think");
|
||||||
@@ -170,11 +175,11 @@ describe("buildThreadMarkdown", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const toolsOnly = buildThreadMarkdown(messages, { showToolCalls: true, showReasoning: false });
|
const toolsOnly = buildThreadMarkdown(messages, { showToolCalls: true, showReasoning: false });
|
||||||
expect(toolsOnly).toContain("Tools used");
|
expect(toolsOnly).toContain("_Tools:");
|
||||||
expect(toolsOnly).not.toContain("Reasoning");
|
expect(toolsOnly).not.toContain("Reasoning");
|
||||||
|
|
||||||
const reasoningOnly = buildThreadMarkdown(messages, { showToolCalls: false, showReasoning: true });
|
const reasoningOnly = buildThreadMarkdown(messages, { showToolCalls: false, showReasoning: true });
|
||||||
expect(reasoningOnly).not.toContain("Tools used");
|
expect(reasoningOnly).not.toContain("_Tools:");
|
||||||
expect(reasoningOnly).toContain("Reasoning");
|
expect(reasoningOnly).toContain("Reasoning");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,12 +203,25 @@ describe("sessionToPlainText", () => {
|
|||||||
it("keeps every question and answer", () => {
|
it("keeps every question and answer", () => {
|
||||||
const text = sessionToPlainText([turn("q1", "a1"), turn("q2", "a2")]);
|
const text = sessionToPlainText([turn("q1", "a1"), turn("q2", "a2")]);
|
||||||
|
|
||||||
expect(text).toContain("## q1");
|
expect(text).toContain("q1");
|
||||||
expect(text).toContain("a1");
|
expect(text).toContain("a1");
|
||||||
expect(text).toContain("## q2");
|
expect(text).toContain("q2");
|
||||||
expect(text).toContain("a2");
|
expect(text).toContain("a2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels the speakers, because the transcript's styling does not survive a clipboard", () => {
|
||||||
|
const text = sessionToPlainText([turn("q1", "a1")]);
|
||||||
|
|
||||||
|
expect(text).toContain("**You**");
|
||||||
|
expect(text).toContain("**Lam**");
|
||||||
|
expect(text.indexOf("q1")).toBeLessThan(text.indexOf("a1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads oldest first, unlike the transcript", () => {
|
||||||
|
const text = sessionToPlainText([turn("first", "a1"), turn("second", "a2")]);
|
||||||
|
expect(text.indexOf("first")).toBeLessThan(text.indexOf("second"));
|
||||||
|
});
|
||||||
|
|
||||||
it("includes the failure when a turn errored", () => {
|
it("includes the failure when a turn errored", () => {
|
||||||
expect(sessionToPlainText([turn("q", "", "Ollama rejected the API key.")])).toContain(
|
expect(sessionToPlainText([turn("q", "", "Ollama rejected the API key.")])).toContain(
|
||||||
"Ollama rejected the API key.",
|
"Ollama rejected the API key.",
|
||||||
|
|||||||
+15
-6
@@ -20,13 +20,22 @@ export const MAX_STORED_CHARS_PER_TURN = 20_000;
|
|||||||
/** How much of a prompt becomes the session title in the session list. */
|
/** How much of a prompt becomes the session title in the session list. */
|
||||||
export const TITLE_MAX_LENGTH = 60;
|
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.
|
export const DEFAULT_SYSTEM_PROMPT = `You are Lam, a concise assistant in the Vicinae launcher on the user's desktop.
|
||||||
|
|
||||||
- Lead with the answer, then add supporting detail. Do not pad with pleasantries.
|
Answer first, detail after. No preamble.
|
||||||
- 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.
|
Write plainly:
|
||||||
- Never invent tool output. If a tool fails, say so plainly and answer with what you already know.
|
- Never use an em dash. Use a comma, a colon, or a full stop.
|
||||||
- When you are unsure, say what you are unsure about instead of hedging vaguely.`;
|
- No generated-text tells: "it's not just X, it's Y", "not only... but also", "the real question is". No openers like "Great question" or "Certainly". No "I hope this helps".
|
||||||
|
- Avoid delve, tapestry, landscape, leverage, robust, seamless, realm, testament.
|
||||||
|
- Do not restate the question or narrate your process.
|
||||||
|
- Bold only the direct answer to a factual question, never for emphasis in prose.
|
||||||
|
|
||||||
|
You render in a narrow window: keep paragraphs short, prefer compact lists, and skip large headings and wide tables.
|
||||||
|
|
||||||
|
Call a tool when it makes the answer more accurate. Anything time-sensitive or past your cutoff should be checked rather than guessed, and never invent tool output. If a tool fails, say so and answer with what you know.
|
||||||
|
|
||||||
|
Say what you are unsure about rather than hedging.`;
|
||||||
|
|
||||||
/** Extra instruction appended when the user has web tools enabled and the question looks time-sensitive. */
|
/** Extra instruction appended when the user has web tools enabled and the question looks time-sensitive. */
|
||||||
export const RECENCY_HINT =
|
export const RECENCY_HINT =
|
||||||
|
|||||||
+34
-36
@@ -6,28 +6,21 @@ import type { ChatTurn, ThreadMessage } from "./types";
|
|||||||
* Kept as pure functions so the transcript layout can be tested without
|
* Kept as pure functions so the transcript layout can be tested without
|
||||||
* mounting a component.
|
* mounting a component.
|
||||||
*
|
*
|
||||||
* The user/assistant distinction is carried by markdown itself: user messages
|
* ## Emphasis
|
||||||
* render as blockquotes, which the renderer draws in a dimmed italic style,
|
*
|
||||||
* while answers render as normal body text. Raycast's chat uses right-aligned
|
* Nothing here is bold except an error. The answer is what is being read, so it
|
||||||
* bubbles for the same job, but markdown offers no alignment control, so the
|
* is plain body text and carries no label. The question is a blockquote, which
|
||||||
* dim/bright split is the available equivalent.
|
* the renderer draws dimmed, italic, and with a left rule. Putting a `**You**`
|
||||||
|
* and a `**Lam**` header on each exchange gave both speakers the same visual
|
||||||
|
* weight and made the reader scan past chrome to reach the answer.
|
||||||
|
*
|
||||||
|
* Raycast solves the same problem with alignment — right-aligned bubbles for
|
||||||
|
* the user — but markdown offers no alignment control, so styling is the only
|
||||||
|
* lever available.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const STREAMING_CURSOR = "▍";
|
export const STREAMING_CURSOR = "▍";
|
||||||
|
|
||||||
/**
|
|
||||||
* Speaker labels.
|
|
||||||
*
|
|
||||||
* Plain text, not icons. Vicinae's markdown renderer draws images as
|
|
||||||
* block-level, centred elements capped at 200px tall (`MdImage.qml`), and none
|
|
||||||
* of the candidate sources resolve inside markdown — `icon://person`, a raw
|
|
||||||
* icon slug, and a `file://` asset path were all tried and each one fell back
|
|
||||||
* to rendering the alt text as a caption. An icon therefore cannot sit inline
|
|
||||||
* beside a message the way a text label can.
|
|
||||||
*/
|
|
||||||
export const USER_LABEL = "You";
|
|
||||||
export const ASSISTANT_LABEL = "Lam";
|
|
||||||
|
|
||||||
/** Which optional sections the transcript includes. Driven by preferences. */
|
/** Which optional sections the transcript includes. Driven by preferences. */
|
||||||
export interface TranscriptOptions {
|
export interface TranscriptOptions {
|
||||||
showToolCalls: boolean;
|
showToolCalls: boolean;
|
||||||
@@ -44,26 +37,31 @@ export function quote(text: string): string {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMessage(message: ThreadMessage, options: TranscriptOptions): string {
|
/** One italic line, well below the weight of the answer it belongs to. */
|
||||||
if (message.role === "user") {
|
function renderTools(message: ThreadMessage): string {
|
||||||
return `**${USER_LABEL}**\n\n${quote(message.text)}`;
|
const names = message.toolCalls
|
||||||
|
.map((call) => `\`${call.name}\`${call.isError ? " (failed)" : ""}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return `_Tools: ${names}_`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderMessage(message: ThreadMessage, options: TranscriptOptions): string {
|
||||||
|
// The question is identified by its styling alone.
|
||||||
|
if (message.role === "user") return quote(message.text);
|
||||||
|
|
||||||
// While a tool is running there is no answer text yet, so the activity line
|
// While a tool is running there is no answer text yet, so the activity line
|
||||||
// stands in and the user can see what Lam is actually doing.
|
// stands in and the user can see what Lam is actually doing.
|
||||||
const placeholder = message.status ? `_${message.status}…_` : "_Thinking…_";
|
const placeholder = message.status ? `_${message.status}…_` : "_Thinking…_";
|
||||||
const body = message.text.trim() || (message.isStreaming ? placeholder : "_No response._");
|
const body = message.text.trim() || (message.isStreaming ? placeholder : "_No response._");
|
||||||
const sections = [`**${ASSISTANT_LABEL}**`, "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
|
const sections = [message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
|
||||||
|
|
||||||
if (message.error) {
|
// The one thing allowed to break the plain-text rule, because it means
|
||||||
sections.push("", `**Error** — ${message.error}`);
|
// something went wrong and should not be skimmed past.
|
||||||
}
|
if (message.error) sections.push("", `**Error** — ${message.error}`);
|
||||||
|
|
||||||
if (options.showToolCalls && message.toolCalls.length > 0) {
|
if (options.showToolCalls && message.toolCalls.length > 0) {
|
||||||
const lines = message.toolCalls.map(
|
sections.push("", renderTools(message));
|
||||||
(call) => `- \`${call.name}\`${call.isError ? " — failed" : ""}`,
|
|
||||||
);
|
|
||||||
sections.push("", "**Tools used**", ...lines);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.showReasoning && message.thinking.trim().length > 0) {
|
if (options.showReasoning && message.thinking.trim().length > 0) {
|
||||||
@@ -85,7 +83,8 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
|
|||||||
* Messages are grouped back into exchanges for layout: an answer and the
|
* Messages are grouped back into exchanges for layout: an answer and the
|
||||||
* question it responds to belong to the same block, so the horizontal rule only
|
* question it responds to belong to the same block, so the horizontal rule only
|
||||||
* separates one exchange from the next rather than dividing every single
|
* separates one exchange from the next rather than dividing every single
|
||||||
* message.
|
* message. That rule earns its place with newest-first ordering, where the
|
||||||
|
* boundary between two answers would otherwise be easy to miss.
|
||||||
*/
|
*/
|
||||||
export function buildThreadMarkdown(
|
export function buildThreadMarkdown(
|
||||||
messages: ThreadMessage[],
|
messages: ThreadMessage[],
|
||||||
@@ -122,19 +121,18 @@ export function buildThreadMarkdown(
|
|||||||
* Unlike the transcript this reads oldest-first: as a standalone document, a
|
* Unlike the transcript this reads oldest-first: as a standalone document, a
|
||||||
* conversation is easier to read in the order it happened.
|
* conversation is easier to read in the order it happened.
|
||||||
*
|
*
|
||||||
* This has to load the full transcript rather than reuse the index summary —
|
* This needs labels because the transcript's styling — blockquotes and dimmed
|
||||||
* copying a truncated preview and calling it "the conversation" would be a
|
* text — is gone by the time it reaches a clipboard.
|
||||||
* quiet lie.
|
|
||||||
*/
|
*/
|
||||||
export function sessionToPlainText(turns: ChatTurn[]): string {
|
export function sessionToPlainText(turns: ChatTurn[]): string {
|
||||||
if (turns.length === 0) return "";
|
if (turns.length === 0) return "";
|
||||||
|
|
||||||
return turns
|
return turns
|
||||||
.map((turn) => {
|
.map((turn) => {
|
||||||
const header = `## ${turn.prompt}`;
|
const question = `**You**\n\n${turn.prompt}`;
|
||||||
const body = turn.response.trim() || (turn.error ? "" : "_(no response)_");
|
const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_");
|
||||||
const failure = turn.error ? `\n\n**[error]** ${turn.error}` : "";
|
const failure = turn.error ? `\n\n**[error]** ${turn.error}` : "";
|
||||||
return `${header}\n\n${body}${failure}`;
|
return `${question}\n\n**Lam**\n\n${answer}${failure}`;
|
||||||
})
|
})
|
||||||
.join("\n\n---\n\n");
|
.join("\n\n---\n\n");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user