1378 lines
48 KiB
Markdown
1378 lines
48 KiB
Markdown
# Source Citations Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Give every source a turn consults one number, shared by the model and the reader, and render the model's `[n]` markers as links with a sources list under the answer.
|
|
|
|
**Architecture:** A turn-scoped source list is created in `runAgent` and threaded through `ToolContext`, so the web tools print the numbers the model cites and return the new entries with their result. Those entries ride on the existing `ToolCallRecord` into storage and into the transcript, where a new citation pass inside the render pipeline turns markers into links and reports which numbers were cited. The spec is `docs/superpowers/specs/2026-10-07-source-citations-design.md`.
|
|
|
|
**Tech Stack:** TypeScript, React (Vicinae extension API), vitest, zod, prettier, eslint. Commands: `npm run typecheck`, `npm run lint:code`, `npm test`, `npx prettier --check <files>`.
|
|
|
|
**Verified context:**
|
|
|
|
- Vicinae cannot render footnotes: `cmark_parser_new(CMARK_OPT_DEFAULT)` with only the `table` and `strikethrough` extensions attached (`vicinaehq/vicinae`, `src/server/src/ui/quick/markdown-model.cpp:330-340`).
|
|
- `splitSegments(markdown): Segment[]` in `src/lib/render.ts` returns `{ fence: boolean; language: string; lines: string[]; raw?: string[] }`.
|
|
- `mapOutsideCodeSpans(text, transform)` in `src/lib/code.ts:163` applies a transform outside code spans.
|
|
- `markdownLink(label, destination)` in `src/lib/code.ts:389` escapes square brackets in the label, so `markdownLink("[1]", url)` produces `[\[1\]](url)`, which the renderer displays as a clickable `[1]`.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
| File | Responsibility |
|
|
| ---------------------- | --------------------------------------------------------------- |
|
|
| `src/lib/types.ts` | Modify: gains `Source`, the stored shape of one numbered source |
|
|
| `src/lib/citations.ts` | Create: numbering, collection, marker linking, list rendering |
|
|
| `src/lib/tools.ts` | Modify: register sources, print numbers, return new entries |
|
|
| `src/lib/agent.ts` | Modify: one list per turn, stored on each record |
|
|
| `src/lib/schemas.ts` | Modify: accept the new optional field |
|
|
| `src/lib/render.ts` | Modify: citation pass, returns text plus the cited set |
|
|
| `src/lib/markdown.ts` | Modify: append the list, carry it into the plain-text export |
|
|
| `src/lib/constants.ts` | Modify: the citation instruction |
|
|
| `README.md` | Modify: what the feature does |
|
|
|
|
---
|
|
|
|
### Task 1: The source type and the turn's numbering
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/types.ts` (after `ToolCallRecord`)
|
|
- Create: `src/lib/citations.ts`
|
|
- Create: `src/__tests__/citations.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `src/__tests__/citations.test.ts`:
|
|
|
|
```ts
|
|
import { describe, expect, it } from "vitest";
|
|
import { createSourceList } from "../lib/citations";
|
|
|
|
describe("createSourceList", () => {
|
|
it("numbers sources from one in the order they were found", () => {
|
|
const list = createSourceList();
|
|
expect(list.add("First", "https://first.example").index).toBe(1);
|
|
expect(list.add("Second", "https://second.example").index).toBe(2);
|
|
});
|
|
|
|
it("gives a URL the number it already has, so a re-fetch cites the same page", () => {
|
|
const list = createSourceList();
|
|
list.add("First", "https://first.example");
|
|
const again = list.add("First again", "https://first.example");
|
|
|
|
expect(again.index).toBe(1);
|
|
// Nothing new was added, which is how the caller knows not to store it twice.
|
|
expect(again.source).toBeUndefined();
|
|
});
|
|
|
|
it("returns the entry only the first time, so a turn stores it once", () => {
|
|
const list = createSourceList();
|
|
expect(list.add("First", "https://first.example").source).toEqual({
|
|
index: 1,
|
|
title: "First",
|
|
url: "https://first.example",
|
|
});
|
|
});
|
|
|
|
it("ignores surrounding whitespace when matching a URL", () => {
|
|
const list = createSourceList();
|
|
list.add("First", "https://first.example");
|
|
expect(list.add("First", " https://first.example ").index).toBe(1);
|
|
});
|
|
|
|
it("falls back to the URL as the title when there is none", () => {
|
|
const list = createSourceList();
|
|
expect(list.add(" ", "https://first.example").source?.title).toBe("https://first.example");
|
|
});
|
|
|
|
it("gives a result with no usable URL no number at all", () => {
|
|
// Zero rather than the next number: there is nothing to link to, so the tool
|
|
// prints no marker and the model cannot cite a page that has no address.
|
|
const list = createSourceList();
|
|
expect(list.add("Title", " ").index).toBe(0);
|
|
expect(list.add("Next", "https://next.example").index).toBe(1);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `npx vitest run src/__tests__/citations.test.ts`
|
|
|
|
Expected: FAIL, `Failed to resolve import "../lib/citations"`.
|
|
|
|
- [ ] **Step 3: Add `Source` to the domain types**
|
|
|
|
In `src/lib/types.ts`, directly above `ToolCallRecord`:
|
|
|
|
```ts
|
|
/**
|
|
* One numbered source, as a web tool found it.
|
|
*
|
|
* The number is the whole point: it is assigned once per turn, printed to the
|
|
* model in the tool result, and rendered as the marker the reader clicks. The
|
|
* model and the reader therefore agree on what `[3]` means without either of
|
|
* them being asked to remember a URL.
|
|
*/
|
|
export interface Source {
|
|
/** The number the model cites. One-based, and never reused for a different page. */
|
|
index: number;
|
|
/** Page title, or the URL when the page had none. */
|
|
title: string;
|
|
url: string;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Write the module**
|
|
|
|
Create `src/lib/citations.ts`:
|
|
|
|
```ts
|
|
import { markdownLink } from "./code";
|
|
import type { Source, ToolCallRecord } from "./types";
|
|
|
|
/**
|
|
* Numbered sources for one turn, and the citation markers that point at them.
|
|
*
|
|
* Vicinae has no footnote support to lean on: it parses markdown with
|
|
* `CMARK_OPT_DEFAULT` and attaches only the table and strikethrough extensions,
|
|
* so a `[^1]` written by a model arrives in the pane as literal text. Everything
|
|
* a footnote would do therefore has to happen here, on the way to the renderer.
|
|
*/
|
|
|
|
/** What a turn uses to hand out numbers, one per page it consults. */
|
|
export interface SourceList {
|
|
/**
|
|
* The number for a URL, registering it the first time it is seen.
|
|
*
|
|
* `index` is 0 for a result with no usable address, which is the caller's
|
|
* signal to print no marker at all. `source` comes back only when this call
|
|
* registered something new, so a caller records each page exactly once.
|
|
*/
|
|
add(title: string, url: string): { index: number; source?: Source };
|
|
}
|
|
|
|
/**
|
|
* A fresh list, numbering from one.
|
|
*
|
|
* Scoped to a turn rather than a conversation: a new question starts again at
|
|
* one, which is what lets a marker mean one thing for the whole answer beside
|
|
* it, and lets the transcript list sources per answer rather than per session.
|
|
*/
|
|
export function createSourceList(): SourceList {
|
|
const byUrl = new Map<string, Source>();
|
|
let next = 1;
|
|
|
|
return {
|
|
add(title, url) {
|
|
const address = url.trim();
|
|
if (address.length === 0) return { index: 0 };
|
|
|
|
const known = byUrl.get(address);
|
|
if (known) return { index: known.index };
|
|
|
|
const source: Source = { index: next, title: title.trim() || address, url: address };
|
|
next += 1;
|
|
byUrl.set(address, source);
|
|
return { index: source.index, source };
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
Note: `ToolCallRecord` is imported for the tasks that follow; if `npm run lint:code` complains about an unused import at this step, drop it here and add it in Task 2.
|
|
|
|
- [ ] **Step 5: Run the test to verify it passes**
|
|
|
|
Run: `npx vitest run src/__tests__/citations.test.ts`
|
|
|
|
Expected: PASS, 6 tests.
|
|
|
|
- [ ] **Step 6: Typecheck and commit**
|
|
|
|
```bash
|
|
npm run typecheck && npx prettier --check src/lib/citations.ts src/lib/types.ts src/__tests__/citations.test.ts
|
|
git add src/lib/citations.ts src/lib/types.ts src/__tests__/citations.test.ts
|
|
git commit -m "feat: number the sources a turn consults"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Flattening a turn's records, and reading back what was cited
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/citations.ts`
|
|
- Modify: `src/__tests__/citations.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `src/__tests__/citations.test.ts`:
|
|
|
|
```ts
|
|
import { citedIndices, collectSources } from "../lib/citations";
|
|
import type { ToolCallRecord } from "../lib/types";
|
|
|
|
/** One record, with only the fields the source helpers read. */
|
|
function call(sources?: ToolCallRecord["sources"]): ToolCallRecord {
|
|
return { name: "web_search", arguments: {}, result: "", isError: false, sources };
|
|
}
|
|
|
|
describe("collectSources", () => {
|
|
it("flattens a turn's records into one list in number order", () => {
|
|
const collected = collectSources([
|
|
call([{ index: 1, title: "First", url: "https://first.example" }]),
|
|
call([{ index: 2, title: "Second", url: "https://second.example" }]),
|
|
]);
|
|
|
|
expect(collected.map((source) => source.index)).toEqual([1, 2]);
|
|
});
|
|
|
|
it("keeps the first record's version of a number", () => {
|
|
// A number is assigned once, so two records carrying it describe the same
|
|
// page, and the first one seen is the one that named it.
|
|
const collected = collectSources([
|
|
call([{ index: 1, title: "First", url: "https://first.example" }]),
|
|
call([{ index: 1, title: "Duplicate", url: "https://first.example" }]),
|
|
]);
|
|
|
|
expect(collected).toEqual([{ index: 1, title: "First", url: "https://first.example" }]);
|
|
});
|
|
|
|
it("sorts by number even when the records arrive out of order", () => {
|
|
const collected = collectSources([
|
|
call([{ index: 3, title: "Third", url: "https://third.example" }]),
|
|
call([{ index: 1, title: "First", url: "https://first.example" }]),
|
|
]);
|
|
|
|
expect(collected.map((source) => source.index)).toEqual([1, 3]);
|
|
});
|
|
|
|
it("is empty for a turn that consulted nothing", () => {
|
|
expect(collectSources([])).toEqual([]);
|
|
expect(collectSources([call()])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("citedIndices", () => {
|
|
const sources = [
|
|
{ index: 1, title: "First", url: "https://first.example" },
|
|
{ index: 2, title: "Second", url: "https://second.example" },
|
|
];
|
|
|
|
it("finds the numbers an answer cites, in either marker shape", () => {
|
|
expect([...citedIndices("See [1] and [^2].", sources)].sort()).toEqual([1, 2]);
|
|
});
|
|
|
|
it("ignores a number that is not a source", () => {
|
|
expect([...citedIndices("See [9].", sources)]).toEqual([]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/citations.test.ts`
|
|
|
|
Expected: FAIL, `citedIndices is not a function`.
|
|
|
|
- [ ] **Step 3: Implement both helpers**
|
|
|
|
Add to `src/lib/citations.ts`, after `createSourceList`:
|
|
|
|
```ts
|
|
/**
|
|
* Every source a turn consulted, in number order.
|
|
*
|
|
* A record holds only what its own call added, so the turn's list is the
|
|
* flattening of its records. That keeps the running total from being stored
|
|
* once per later call, and it means a list can be rebuilt from a stored turn
|
|
* without re-reading a tool result string and hoping its format held.
|
|
*/
|
|
export function collectSources(calls: ToolCallRecord[]): Source[] {
|
|
const byIndex = new Map<number, Source>();
|
|
|
|
for (const call of calls) {
|
|
for (const source of call.sources ?? []) {
|
|
if (!byIndex.has(source.index)) byIndex.set(source.index, source);
|
|
}
|
|
}
|
|
|
|
return [...byIndex.values()].sort((left, right) => left.index - right.index);
|
|
}
|
|
|
|
/**
|
|
* The numbers an answer cites that are actually sources.
|
|
*
|
|
* A plain scan, without the fence awareness the linking pass has, because its
|
|
* one caller is the clipboard export: what a copied document needs is the list
|
|
* of pages the answer leaned on, and a `[1]` inside a snippet there costs a
|
|
* reader nothing.
|
|
*/
|
|
export function citedIndices(answer: string, sources: Source[]): Set<number> {
|
|
const known = new Set(sources.map((source) => source.index));
|
|
const cited = new Set<number>();
|
|
|
|
for (const match of answer.matchAll(/\[\^?(\d+(?:\s*,\s*\d+)*)\]/g)) {
|
|
for (const part of match[1].split(",")) {
|
|
const index = Number(part.trim());
|
|
if (known.has(index)) cited.add(index);
|
|
}
|
|
}
|
|
|
|
return cited;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/citations.test.ts`
|
|
|
|
Expected: PASS, 13 tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
npm run typecheck
|
|
git add src/lib/citations.ts src/__tests__/citations.test.ts
|
|
git commit -m "feat: collect a turn's sources and read back what was cited"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Linking the markers
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/citations.ts`
|
|
- Modify: `src/__tests__/citations.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `src/__tests__/citations.test.ts`:
|
|
|
|
```ts
|
|
import { linkCitations } from "../lib/citations";
|
|
|
|
describe("linkCitations", () => {
|
|
const sources = [
|
|
{ index: 1, title: "First", url: "https://first.example" },
|
|
{ index: 3, title: "Third", url: "https://third.example" },
|
|
];
|
|
|
|
it("turns a marker into a link whose label is the marker", () => {
|
|
const result = linkCitations("Rain is likely [1].", sources);
|
|
|
|
expect(result.text).toBe("Rain is likely [\\[1\\]](https://first.example).");
|
|
expect([...result.cited]).toEqual([1]);
|
|
});
|
|
|
|
it("reads the footnote shape as the same thing", () => {
|
|
expect(linkCitations("Rain [^1].", sources).text).toBe("Rain [\\[1\\]](https://first.example).");
|
|
});
|
|
|
|
it("links a list of markers and keeps the comma", () => {
|
|
const result = linkCitations("Both [1, 3] agree.", sources);
|
|
|
|
expect(result.text).toBe(
|
|
"Both [\\[1\\]](https://first.example), [\\[3\\]](https://third.example) agree.",
|
|
);
|
|
expect([...result.cited].sort()).toEqual([1, 3]);
|
|
});
|
|
|
|
it("leaves a marker alone when any number in it is not a source", () => {
|
|
// Half a link reads as a citation that works, which is worse than the marker
|
|
// the model wrote: the reader cannot tell which half is missing.
|
|
const result = linkCitations("See [1, 9].", sources);
|
|
|
|
expect(result.text).toBe("See [1, 9].");
|
|
expect([...result.cited]).toEqual([]);
|
|
});
|
|
|
|
it("leaves a marker alone when the destination is not one this window should open", () => {
|
|
const unsafe = [{ index: 1, title: "Nasty", url: "javascript:alert(1)" }];
|
|
const result = linkCitations("See [1].", unsafe);
|
|
|
|
expect(result.text).toBe("See [1].");
|
|
expect([...result.cited]).toEqual([]);
|
|
});
|
|
|
|
it("leaves a destination containing whitespace as text", () => {
|
|
const odd = [{ index: 1, title: "Odd", url: "https://first.example/a b" }];
|
|
expect(linkCitations("See [1].", odd).text).toBe("See [1].");
|
|
});
|
|
|
|
it("does not touch a link the model wrote", () => {
|
|
const result = linkCitations("See [1](https://elsewhere.example).", sources);
|
|
expect(result.text).toBe("See [1](https://elsewhere.example).");
|
|
});
|
|
|
|
it("does not touch a reference definition line", () => {
|
|
expect(linkCitations("[1]: https://elsewhere.example", sources).text).toBe(
|
|
"[1]: https://elsewhere.example",
|
|
);
|
|
});
|
|
|
|
it("drops a footnote definition for a source it already lists", () => {
|
|
const answer = "Rain [^1].\n\n[^1]: First, https://first.example";
|
|
expect(linkCitations(answer, sources).text).toBe("Rain [\\[1\\]](https://first.example).\n");
|
|
});
|
|
|
|
it("keeps a definition for a number it knows nothing about", () => {
|
|
const answer = "Rain [^1].\n\n[^7]: Something else";
|
|
expect(linkCitations(answer, sources).text).toBe(
|
|
"Rain [\\[1\\]](https://first.example).\n\n[^7]: Something else",
|
|
);
|
|
});
|
|
|
|
it("leaves a marker inside a code span as the text it is", () => {
|
|
expect(linkCitations("Type `curl [1]` now.", sources).text).toBe("Type `curl [1]` now.");
|
|
});
|
|
|
|
it("changes nothing when there are no sources", () => {
|
|
expect(linkCitations("See [1].", []).text).toBe("See [1].");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/citations.test.ts`
|
|
|
|
Expected: FAIL, `linkCitations is not a function`.
|
|
|
|
- [ ] **Step 3: Implement the pass**
|
|
|
|
Add to `src/lib/citations.ts`. Add `mapOutsideCodeSpans` to the existing import from `./code` so it reads `import { mapOutsideCodeSpans, markdownLink } from "./code";`, then:
|
|
|
|
```ts
|
|
/** What linking did to one run of prose. */
|
|
export interface CitationResult {
|
|
text: string;
|
|
/** The numbers that became links, which is what the list filters on. */
|
|
cited: Set<number>;
|
|
}
|
|
|
|
/**
|
|
* A citation marker the model wrote, in the two shapes it reaches for.
|
|
*
|
|
* The trailing guards are what keep this off markdown the model wrote on
|
|
* purpose: `](` starts a link, and `]:` is a reference definition. The digit
|
|
* list allows `[1, 3]`, which is how a sentence resting on two sources is
|
|
* usually written.
|
|
*/
|
|
const CITATION_MARKER = /\[\^?(\d+(?:\s*,\s*\d+)*)\](?![(:])/g;
|
|
|
|
/** A footnote definition line, which is `[^1]: title, url` on a line of its own. */
|
|
const FOOTNOTE_DEFINITION = /^\s*\[\^?(\d+)\]:\s*\S/;
|
|
|
|
/**
|
|
* A destination this window can safely be asked to open, or nothing.
|
|
*
|
|
* A search result is untrusted input, so the scheme is checked here for the same
|
|
* reason `SAFE_URL` in `html.ts` checks it: a clickable `javascript:` link would
|
|
* be a real vulnerability rather than a rendering quirk. The rest of the test is
|
|
* characters that would break the link or smuggle a second one out of it.
|
|
*/
|
|
function linkableUrl(url: string): string | undefined {
|
|
const address = url.trim();
|
|
if (!/^https?:\/\//i.test(address)) return undefined;
|
|
if (/[\s<>\\]/.test(address)) return undefined;
|
|
if (/[\u0000-\u001f\u007f]/.test(address)) return undefined;
|
|
return address;
|
|
}
|
|
|
|
/**
|
|
* Drops a definition line for a source the turn already lists.
|
|
*
|
|
* The entry is rendered from what the tool returned, so the model's copy of the
|
|
* same thing is a duplicate line rather than information. One for a number we
|
|
* know nothing about is left alone, because then it is the only place that text
|
|
* exists.
|
|
*/
|
|
function stripDefinitions(text: string, known: Set<number>): string {
|
|
return text
|
|
.split("\n")
|
|
.filter((line) => {
|
|
const definition = FOOTNOTE_DEFINITION.exec(line);
|
|
return !definition || !known.has(Number(definition[1]));
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
/**
|
|
* `[1]`, `[^1]` and `[1, 3]` as links, plus the numbers that became links.
|
|
*
|
|
* One run of prose only: fences are the caller's business, because the caller
|
|
* already walks the document in segments for the maths pass and there is no
|
|
* reason for two ideas of where code starts.
|
|
*/
|
|
export function linkCitations(text: string, sources: Source[]): CitationResult {
|
|
const cited = new Set<number>();
|
|
if (sources.length === 0) return { text, cited };
|
|
|
|
const byIndex = new Map(sources.map((source) => [source.index, source]));
|
|
const prose = stripDefinitions(text, new Set(byIndex.keys()));
|
|
|
|
const linked = mapOutsideCodeSpans(prose, (chunk) =>
|
|
chunk.replace(CITATION_MARKER, (whole: string, digits: string) => {
|
|
const wanted = digits.split(",").map((part) => Number(part.trim()));
|
|
const links: string[] = [];
|
|
|
|
for (const index of wanted) {
|
|
const source = byIndex.get(index);
|
|
const href = source ? linkableUrl(source.url) : undefined;
|
|
// All or nothing, and nothing if any destination is unusable: a marker
|
|
// that is partly a link reads as a citation that works.
|
|
if (!href) return whole;
|
|
links.push(markdownLink(`[${index}]`, href));
|
|
}
|
|
|
|
for (const index of wanted) cited.add(index);
|
|
return links.join(", ");
|
|
}),
|
|
);
|
|
|
|
return { text: linked, cited };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/citations.test.ts`
|
|
|
|
Expected: PASS, 26 tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
npm run typecheck && npm run lint:code
|
|
git add src/lib/citations.ts src/__tests__/citations.test.ts
|
|
git commit -m "feat: link the citation markers a model writes"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: The list under the answer
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/citations.ts`
|
|
- Modify: `src/__tests__/citations.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `src/__tests__/citations.test.ts`:
|
|
|
|
```ts
|
|
import { renderSources } from "../lib/citations";
|
|
|
|
describe("renderSources", () => {
|
|
const sources = [
|
|
{ index: 1, title: "First", url: "https://first.example" },
|
|
{ index: 3, title: "Third", url: "https://third.example" },
|
|
];
|
|
|
|
it("lists the sources the answer cited, numbered as the model numbered them", () => {
|
|
const list = renderSources(sources, new Set([3]), false);
|
|
|
|
expect(list).toBe("_Sources_\n\n- [\\[3\\]](https://third.example) Third");
|
|
});
|
|
|
|
it("lists everything when the answer cited nothing, once the turn has ended", () => {
|
|
// Nothing to filter on, so the only thing left to show is what was consulted.
|
|
expect(renderSources(sources, new Set(), false)).toBe(
|
|
"_Sources_\n\n- [\\[1\\]](https://first.example) First\n- [\\[3\\]](https://third.example) Third",
|
|
);
|
|
});
|
|
|
|
it("stays away while an answer is still arriving", () => {
|
|
// Otherwise the list appears in full at the first token and then shrinks as
|
|
// citations arrive, which reads as a glitch rather than as settling.
|
|
expect(renderSources(sources, new Set(), true)).toBe("");
|
|
});
|
|
|
|
it("lists nothing when there was nothing to list", () => {
|
|
expect(renderSources([], new Set(), false)).toBe("");
|
|
});
|
|
|
|
it("keeps an entry whose destination cannot be linked", () => {
|
|
const unsafe = [{ index: 1, title: "Nasty", url: "javascript:alert(1)" }];
|
|
expect(renderSources(unsafe, new Set(), false)).toBe("_Sources_\n\n- [1] Nasty");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/citations.test.ts`
|
|
|
|
Expected: FAIL, `renderSources is not a function`.
|
|
|
|
- [ ] **Step 3: Implement the list**
|
|
|
|
Add to `src/lib/citations.ts`:
|
|
|
|
```ts
|
|
/**
|
|
* The sources under an answer, or nothing when there is nothing to say.
|
|
*
|
|
* The marker is the link, matching the inline marker character for character, so
|
|
* one token means one thing in both places. Numbers are the model's rather than
|
|
* the reader's: `[3]` is the third source the turn found whether or not the
|
|
* answer cited the two before it, which keeps the transcript agreeing with the
|
|
* tool output and with anything the model said about its numbering.
|
|
*/
|
|
export function renderSources(sources: Source[], cited: Set<number>, streaming: boolean): string {
|
|
// While an answer is arriving the cited set is still filling up, so the list
|
|
// waits rather than showing everything and then dropping most of it.
|
|
const shown =
|
|
cited.size > 0 ? sources.filter((source) => cited.has(source.index)) : streaming ? [] : sources;
|
|
if (shown.length === 0) return "";
|
|
|
|
const entries = shown.map((source) => {
|
|
const href = linkableUrl(source.url);
|
|
return `- ${href ? markdownLink(`[${source.index}]`, href) : `[${source.index}]`} ${source.title}`;
|
|
});
|
|
|
|
return ["_Sources_", "", ...entries].join("\n");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/citations.test.ts`
|
|
|
|
Expected: PASS, 31 tests.
|
|
|
|
- [ ] **Step 5: Format, check and commit**
|
|
|
|
```bash
|
|
npx prettier --write src/lib/citations.ts src/__tests__/citations.test.ts
|
|
npm run typecheck && npm run lint:code && npx vitest run
|
|
git add src/lib/citations.ts src/__tests__/citations.test.ts
|
|
git commit -m "feat: render the sources list under an answer"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: The web tools print the numbers
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/tools.ts` (`ToolContext`, `ToolResult`, `runWebSearch`, `runWebFetch`)
|
|
- Modify: `src/__tests__/tools.test.ts`
|
|
|
|
- [ ] **Step 1: Update the existing format assertions and add the new tests**
|
|
|
|
In `src/__tests__/tools.test.ts`, in the `web_search` describe, change the numbering assertions:
|
|
|
|
```ts
|
|
expect(result.content).toContain("[1] First");
|
|
expect(result.content).toContain("https://first.example");
|
|
expect(result.content).toContain("[2] Second");
|
|
```
|
|
|
|
In the `web_fetch` describe, change:
|
|
|
|
```ts
|
|
expect(result.content).toContain("[1] Example");
|
|
```
|
|
|
|
and, for the page with no title:
|
|
|
|
```ts
|
|
expect(result.content).toContain("[1] https://untitled.example");
|
|
```
|
|
|
|
Then append this describe to the file:
|
|
|
|
```ts
|
|
describe("sources", () => {
|
|
it("keeps numbering across the calls in one turn", async () => {
|
|
// Two searches in a turn must not each start at 1, or a marker in the answer
|
|
// would point at two different pages.
|
|
const list = createSourceList();
|
|
|
|
const first = await executeTool("web_search", { query: "one" }, { ...baseContext(), sources: list });
|
|
const second = await executeTool("web_search", { query: "two" }, { ...baseContext(), sources: list });
|
|
|
|
expect(first.content).toContain("[1] First");
|
|
expect(second.content).toContain("[3] Third");
|
|
expect(second.sources?.map((source) => source.index)).toEqual([3, 4]);
|
|
});
|
|
|
|
it("gives a page its number once, however it was reached", async () => {
|
|
const list = createSourceList();
|
|
await executeTool("web_search", { query: "one" }, { ...baseContext(), sources: list });
|
|
const page = await executeTool(
|
|
"web_fetch",
|
|
{ url: "https://first.example" },
|
|
{ ...baseContext(), sources: list },
|
|
);
|
|
|
|
expect(page.content).toContain("[1] First");
|
|
// Nothing new: the search had already numbered it.
|
|
expect(page.sources).toEqual([]);
|
|
});
|
|
|
|
it("numbers from one when the caller keeps no list of its own", async () => {
|
|
const result = await executeTool("web_search", { query: "one" }, baseContext());
|
|
expect(result.content).toContain("[1] First");
|
|
});
|
|
});
|
|
```
|
|
|
|
Use whichever context helper and `webSearch` stub the existing tests in the file already build; the assertions above are the part that matters. Add `createSourceList` to the imports from `../lib/citations`.
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/tools.test.ts`
|
|
|
|
Expected: FAIL. The old assertions expect `1. First`, and `sources` is undefined.
|
|
|
|
- [ ] **Step 3: Extend the context and result types**
|
|
|
|
In `src/lib/tools.ts`, add to the imports:
|
|
|
|
```ts
|
|
import { createSourceList, type SourceList } from "./citations";
|
|
import type { Source } from "./types";
|
|
```
|
|
|
|
Add to `ToolContext`, after `ipPlace`:
|
|
|
|
```ts
|
|
/**
|
|
* The turn's source list, when the caller keeps one.
|
|
*
|
|
* Optional, so a caller with no interest in citations still gets the same
|
|
* numbered output: a list is made on the spot when none is supplied.
|
|
*/
|
|
sources?: SourceList;
|
|
```
|
|
|
|
Add to `ToolResult`:
|
|
|
|
```ts
|
|
/**
|
|
* The sources this call added, for the transcript to list under the answer.
|
|
*
|
|
* Only what this call found, not the turn's running total: the full list is
|
|
* the flattening of a turn's records, so storing the total here would write
|
|
* the same page once per later call.
|
|
*/
|
|
sources?: Source[];
|
|
```
|
|
|
|
- [ ] **Step 4: Number the search results**
|
|
|
|
In `runWebSearch`, replace the `rendered` block with:
|
|
|
|
```ts
|
|
const list = context.sources ?? createSourceList();
|
|
const added: Source[] = [];
|
|
|
|
const rendered = results
|
|
.map((result) => {
|
|
const collapsed = result.content.replace(/\s+/g, " ").trim();
|
|
// Mark the cut rather than silently dropping the tail: the model should
|
|
// know a snippet was trimmed, and this matches what web_fetch does.
|
|
const snippet =
|
|
collapsed.length > MAX_SNIPPET_CHARS
|
|
? `${collapsed.slice(0, MAX_SNIPPET_CHARS)} …[truncated]`
|
|
: collapsed;
|
|
|
|
const { index, source } = list.add(result.title, result.url);
|
|
if (source) added.push(source);
|
|
|
|
// The number is what the model cites, so it is printed rather than left to
|
|
// be inferred from position: three searches in one turn would otherwise
|
|
// each start again at 1.
|
|
const marker = index > 0 ? `[${index}] ` : "";
|
|
return `${marker}${result.title}\n ${result.url}\n ${snippet}`;
|
|
})
|
|
.join("\n\n");
|
|
|
|
return { content: `Web results for "${query}":\n\n${rendered}`, isError: false, sources: added };
|
|
```
|
|
|
|
- [ ] **Step 5: Number the fetched page**
|
|
|
|
In `runWebFetch`, replace the `sections` construction and return with:
|
|
|
|
```ts
|
|
const { index, source } = (context.sources ?? createSourceList()).add(page.title, url);
|
|
const heading = index > 0 ? `[${index}] ${page.title}` : page.title;
|
|
|
|
// The URL keeps its label here, unlike a search result: the body after it can
|
|
// run for thousands of characters, and the page's own address is worth being
|
|
// able to find again in that.
|
|
const sections = [heading, `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, sources: source ? [source] : [] };
|
|
```
|
|
|
|
- [ ] **Step 6: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/tools.test.ts`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
npx prettier --write src/lib/tools.ts src/__tests__/tools.test.ts
|
|
npm run typecheck && npm run lint:code
|
|
git add src/lib/tools.ts src/__tests__/tools.test.ts
|
|
git commit -m "feat: print a stable source number in the web tool results"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: One list per turn, carried on the records
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/agent.ts` (`runAgent`)
|
|
- Modify: `src/lib/schemas.ts` (`ToolCallRecordSchema`)
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to `src/__tests__/agent.test.ts`, inside the existing top-level describe that already drives `runAgent` with a stubbed stream:
|
|
|
|
```ts
|
|
it("numbers a turn's sources once and stores them on the records", async () => {
|
|
// Two searches in one turn: the second call continues the first call's
|
|
// numbering, and each record holds only what its own call found.
|
|
const result = await runAgent(twoSearchTurn());
|
|
|
|
expect(result.toolCalls[0].sources?.map((source) => source.index)).toEqual([1, 2]);
|
|
expect(result.toolCalls[1].sources?.map((source) => source.index)).toEqual([3]);
|
|
});
|
|
```
|
|
|
|
Build `twoSearchTurn()` from whatever the file's existing helpers are (a stub that streams one tool call per round, then a final answer). The assertion is the part that matters.
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `npx vitest run src/__tests__/agent.test.ts`
|
|
|
|
Expected: FAIL, `expected undefined to deeply equal [ 1, 2 ]`.
|
|
|
|
- [ ] **Step 3: Wire the list through the turn**
|
|
|
|
In `src/lib/agent.ts`, add to the imports:
|
|
|
|
```ts
|
|
import { createSourceList } from "./citations";
|
|
```
|
|
|
|
In `runAgent`, after the `toolCalls` array is declared:
|
|
|
|
```ts
|
|
/**
|
|
* The turn's sources, so one number means one page for the whole turn.
|
|
*
|
|
* Created here rather than passed in: it is scoped to a single run of the loop,
|
|
* and every call the loop makes shares it.
|
|
*/
|
|
const sources = createSourceList();
|
|
```
|
|
|
|
In the `executeTool` call, add the list to the context:
|
|
|
|
```ts
|
|
signal: options.signal,
|
|
sources,
|
|
```
|
|
|
|
And put what the call found on its record:
|
|
|
|
```ts
|
|
const record: ToolCallRecord = {
|
|
name,
|
|
arguments: args,
|
|
result: result.content,
|
|
isError: result.isError,
|
|
sources: result.sources,
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 4: Accept the field when reading storage**
|
|
|
|
In `src/lib/schemas.ts`, add to `ToolCallRecordSchema`:
|
|
|
|
```ts
|
|
// Absent on turns stored before sources were numbered, which is honest: those
|
|
// answers never cited a number, so there is nothing to render for them. zod
|
|
// strips unknown keys, so without this line the field would be lost on the
|
|
// first reload and a reopened conversation would lose every link.
|
|
sources: z.array(SourceSchema).optional(),
|
|
```
|
|
|
|
and above it:
|
|
|
|
```ts
|
|
export const SourceSchema = z.object({
|
|
index: z.number(),
|
|
title: z.string(),
|
|
url: z.string(),
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 5: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/agent.test.ts src/__tests__/sessions.test.ts`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
npm run typecheck && npm run lint:code
|
|
git add src/lib/agent.ts src/lib/schemas.ts src/__tests__/agent.test.ts
|
|
git commit -m "feat: number a turn's sources once and store them with the records"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: The render pipeline links the markers
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/render.ts` (`renderModelOutput`)
|
|
- Modify: `src/__tests__/render.test.ts`
|
|
- Modify: `src/lib/markdown.ts` (call site only, so the tree compiles)
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
In `src/__tests__/render.test.ts`, add near the top of the file:
|
|
|
|
```ts
|
|
/**
|
|
* The rendered text, for the many cases here that are about what the pane draws
|
|
* rather than about which citations were linked.
|
|
*/
|
|
function rendered(markdown: string, sources?: Source[]): string {
|
|
return renderModelOutput(markdown, sources ? { sources } : {}).text;
|
|
}
|
|
```
|
|
|
|
Then change every existing `renderModelOutput(` call in the file to `rendered(`, with one exception: keep one direct call and give it its own assertion, so the object shape is pinned:
|
|
|
|
```ts
|
|
it("reports which citations it linked", () => {
|
|
const result = renderModelOutput("Rain [1].", {
|
|
sources: [{ index: 1, title: "First", url: "https://first.example" }],
|
|
});
|
|
|
|
expect(result.text).toBe("Rain [\\[1\\]](https://first.example).");
|
|
expect([...result.cited]).toEqual([1]);
|
|
});
|
|
```
|
|
|
|
Finally, append this describe:
|
|
|
|
````ts
|
|
describe("citations in the pipeline", () => {
|
|
const sources = [{ index: 1, title: "First", url: "https://first.example" }];
|
|
|
|
it("links a marker that survives the maths and markup passes", () => {
|
|
const text = rendered("Let $x^2$ be [1], and nothing else.", sources);
|
|
|
|
expect(text).toContain("x² be [\\[1\\]](https://first.example)");
|
|
});
|
|
|
|
it("leaves a marker inside a fence for the reader to copy", () => {
|
|
const text = rendered("```bash\ncurl [1]\n```\n\nSee [1].", sources);
|
|
|
|
expect(text).toContain("curl [1]");
|
|
expect(text).toContain("[\\[1\\]](https://first.example).");
|
|
});
|
|
|
|
it("changes nothing when no sources are given", () => {
|
|
expect(rendered("Rain [1].")).toBe("Rain [1].");
|
|
});
|
|
});
|
|
````
|
|
|
|
Add `import type { Source } from "../lib/types";` to the file's imports.
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/render.test.ts`
|
|
|
|
Expected: FAIL, `renderModelOutput(...).text` is `undefined`.
|
|
|
|
- [ ] **Step 3: Change the pipeline**
|
|
|
|
In `src/lib/render.ts`, add to the imports:
|
|
|
|
```ts
|
|
import { linkCitations } from "./citations";
|
|
import type { Source } from "./types";
|
|
```
|
|
|
|
Replace `renderModelOutput` with:
|
|
|
|
```ts
|
|
/** What the pane will draw, and what the answer cited so the list can filter. */
|
|
export interface RenderedMarkdown {
|
|
text: string;
|
|
/**
|
|
* The numbers this answer cited and that became links.
|
|
*
|
|
* Reported rather than worked out again by the caller: the pass already had to
|
|
* decide which markers were real, inside code or not, and a second reader of
|
|
* the same text could only disagree with it.
|
|
*/
|
|
cited: Set<number>;
|
|
}
|
|
|
|
/** Optional input to the pass. */
|
|
export interface RenderOptions {
|
|
/**
|
|
* The turn's sources, so a marker can become a link to the page it names.
|
|
*
|
|
* Absent for a question, and absent for an answer in a turn that consulted
|
|
* nothing, in which case the citation pass is the identity function.
|
|
*/
|
|
sources?: Source[];
|
|
}
|
|
|
|
/**
|
|
* The full rendering pass, from what the model wrote to what the pane draws.
|
|
*
|
|
* Takes one message's text, not a whole transcript: the transcript adds its own
|
|
* markdown around each message (a blockquote for a question, a rule between
|
|
* exchanges) and none of that should be read as model output.
|
|
*/
|
|
export function renderModelOutput(markdown: string, options: RenderOptions = {}): RenderedMarkdown {
|
|
const sources = options.sources ?? [];
|
|
const cited = new Set<number>();
|
|
|
|
// Citations run first. The pass produces a markdown link, and the maths and
|
|
// markup passes after it would only ever be looking at a marker, which is the
|
|
// one thing they now cannot misread.
|
|
const segments = splitSegments(repairCodeBlocks(markdown)).map((segment) => {
|
|
if (segment.fence || sources.length === 0) return segment;
|
|
|
|
const result = linkCitations(segment.lines.join("\n"), sources);
|
|
for (const index of result.cited) cited.add(index);
|
|
return { fence: false, language: "", lines: result.text.split("\n") };
|
|
});
|
|
|
|
return { text: flatten(htmlPass(mathPass(segments))), cited };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Fix the one call site so the tree compiles**
|
|
|
|
In `src/lib/markdown.ts`, change:
|
|
|
|
```ts
|
|
renderModelOutput(answered);
|
|
```
|
|
|
|
to:
|
|
|
|
```ts
|
|
renderModelOutput(answered).text;
|
|
```
|
|
|
|
- [ ] **Step 5: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/render.test.ts src/__tests__/markdown.test.ts`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
npx prettier --write src/lib/render.ts src/__tests__/render.test.ts
|
|
npm run typecheck && npm run lint:code && npx vitest run
|
|
git add src/lib/render.ts src/__tests__/render.test.ts src/lib/markdown.ts
|
|
git commit -m "feat: link citation markers in the render pipeline"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: The list under the answer, and in the copied conversation
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/markdown.ts` (`renderMessage`, `sessionToPlainText`)
|
|
- Modify: `src/__tests__/markdown.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Append to `src/__tests__/markdown.test.ts`:
|
|
|
|
```ts
|
|
describe("sources", () => {
|
|
const sources = [
|
|
{ index: 1, title: "First", url: "https://first.example" },
|
|
{ index: 2, title: "Second", url: "https://second.example" },
|
|
];
|
|
|
|
function answered(text: string, options?: Partial<TranscriptOptions>): string {
|
|
return buildThreadMarkdown(
|
|
[
|
|
message({ id: "t1:assistant", role: "assistant", text, toolCalls: calls(sources) }),
|
|
message({ id: "t1:user", role: "user", text: "How is the weather?" }),
|
|
],
|
|
{ showToolCalls: false, showReasoning: false, ...options },
|
|
);
|
|
}
|
|
|
|
it("links the markers and lists the sources under the answer", () => {
|
|
const text = answered("Rain is likely [1].");
|
|
|
|
expect(text).toContain("Rain is likely [\\[1\\]](https://first.example).");
|
|
expect(text).toContain("_Sources_");
|
|
expect(text).toContain("- [\\[1\\]](https://first.example) First");
|
|
// The cited source only: the answer did not lean on the second one.
|
|
expect(text).not.toContain("Second");
|
|
});
|
|
|
|
it("lists them with tool calls hidden, because they are the answer's, not the log's", () => {
|
|
expect(answered("Rain [1].", { showToolCalls: false })).toContain("_Sources_");
|
|
});
|
|
|
|
it("adds nothing to an answer whose turn consulted nothing", () => {
|
|
const text = buildThreadMarkdown([message({ id: "t1:assistant", role: "assistant", text: "Plain." })]);
|
|
|
|
expect(text).toBe("Plain.");
|
|
});
|
|
|
|
it("carries the list into the copied conversation", () => {
|
|
const copied = sessionToPlainText([turn({ response: "Rain [1].", toolCalls: calls(sources) })]);
|
|
|
|
expect(copied).toContain("_Sources_");
|
|
expect(copied).toContain("- [\\[1\\]](https://first.example) First");
|
|
});
|
|
});
|
|
```
|
|
|
|
Reuse the file's existing `message()` and `turn()` builders, or write the two small builders inline if it has none. The assertions are the part that matters.
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `npx vitest run src/__tests__/markdown.test.ts`
|
|
|
|
Expected: FAIL, the output has no `_Sources_`.
|
|
|
|
- [ ] **Step 3: Collect, render and append**
|
|
|
|
In `src/lib/markdown.ts`, add to the imports:
|
|
|
|
```ts
|
|
import { citedIndices, collectSources, renderSources } from "./citations";
|
|
```
|
|
|
|
In `renderMessage`, replace the `answered`/`body` pair with:
|
|
|
|
```ts
|
|
/** The answer, or whatever stands in for it while there is none. */
|
|
const answered = message.text.trim();
|
|
/**
|
|
* The turn's sources, read back off its records rather than derived from a
|
|
* tool result string, so a reopened conversation shows the same list.
|
|
*/
|
|
const sources = collectSources(message.toolCalls);
|
|
const drawn = answered ? renderModelOutput(answered, { sources }) : { text: "", cited: new Set<number>() };
|
|
|
|
const body = answered
|
|
? // One pass, in one place: fences get their languages and links recovered,
|
|
// then TeX becomes something drawable, the HTML around it is filtered to
|
|
// the subset the renderer acts on, and citation markers become links. See
|
|
// `render.ts` for the order and why.
|
|
drawn.text
|
|
: !message.isStreaming
|
|
? "_No response._"
|
|
: activityLine
|
|
? // The activity line above is already saying what is being waited on, so
|
|
// printing the placeholder as well put the same sentence on screen
|
|
// twice: once as a note above the transcript and once as the stand-in.
|
|
undefined
|
|
: liveThinking
|
|
? // The reasoning below is already saying the model is working, so
|
|
// "Thinking…" on top of it would only repeat it.
|
|
undefined
|
|
: message.status
|
|
? `_${message.status}…_`
|
|
: "_Thinking…_";
|
|
```
|
|
|
|
After the error section is pushed, add the list:
|
|
|
|
```ts
|
|
/**
|
|
* Sources belong to the answer, not to the tool list, so they are rendered
|
|
* whether or not the user asked to see tool calls. Hiding the activity line is
|
|
* about chrome; a citation list is the answer's own provenance.
|
|
*/
|
|
const list = renderSources(sources, drawn.cited, message.isStreaming);
|
|
if (list) sections.push("", list);
|
|
```
|
|
|
|
- [ ] **Step 4: Carry it into the export**
|
|
|
|
In `sessionToPlainText`, replace the per-turn mapping with:
|
|
|
|
```ts
|
|
const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_");
|
|
const sources = collectSources(turn.toolCalls);
|
|
// The export scans the finished text rather than reusing the panel's own
|
|
// set: it is plain markdown on its way to somewhere else, where a `[1]`
|
|
// inside a snippet counting as a citation costs a reader nothing.
|
|
const list = renderSources(sources, citedIndices(turn.response, sources), false);
|
|
const failure = turn.error ? `\n\n**[error]** ${turn.error}` : "";
|
|
const cited = list ? `\n\n${list}` : "";
|
|
return `${question}${attached}\n\n**Lam**\n\n${answer}${failure}${cited}`;
|
|
```
|
|
|
|
- [ ] **Step 5: Run the tests to verify they pass**
|
|
|
|
Run: `npx vitest run src/__tests__/markdown.test.ts`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
npx prettier --write src/lib/markdown.ts src/__tests__/markdown.test.ts
|
|
npm run typecheck && npm run lint:code && npx vitest run
|
|
git add src/lib/markdown.ts src/__tests__/markdown.test.ts
|
|
git commit -m "feat: list an answer's sources under it, and keep them when copying"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Tell the model what to do with a number
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/lib/constants.ts` (`DEFAULT_SYSTEM_PROMPT`)
|
|
|
|
- [ ] **Step 1: Add the instruction**
|
|
|
|
In `DEFAULT_SYSTEM_PROMPT`, in the paragraph list that already covers links, add a new paragraph after the "A web address is a link" paragraph:
|
|
|
|
```
|
|
When a tool result gives you a source, cite it: put its number in square brackets right after the sentence it supports, like a claim resting on a search result [1]. Cite the specific page that carries the point rather than the search that found it, cite one number at a time (write [1][2], not [1,2]) when a sentence rests on two, and cite nothing you were not shown a number for. The window turns the number into a link and lists the sources under your answer, so write no footnote definitions and no sources or references list of your own.
|
|
```
|
|
|
|
- [ ] **Step 2: Check the prompt still compiles into the bundle**
|
|
|
|
Run: `npm run typecheck && npx vitest run src/__tests__/preferences.test.ts src/__tests__/thread.test.ts`
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/lib/constants.ts
|
|
git commit -m "feat: ask the model to cite the sources it was given"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Say so in the README
|
|
|
|
**Files:**
|
|
|
|
- Modify: `README.md`
|
|
|
|
- [ ] **Step 1: Add the feature bullet**
|
|
|
|
In the "What it does" list, after the "**Tools.**" bullet, add:
|
|
|
|
```markdown
|
|
- **Sources you can check.** A search or a fetched page is numbered, and those numbers are the ones Lam cites, so `[1]` in the answer and `[1]` under it are the same page. The list sits under the answer it belongs to, each number opens the page it names, and a number that does not match a source Lam was given stays as plain text rather than becoming a link to somewhere else.
|
|
```
|
|
|
|
- [ ] **Step 2: Note the renderer limit where the rendering is described**
|
|
|
|
In the `## Rendering` section, at the end of the first paragraph about inline content, add:
|
|
|
|
```markdown
|
|
Footnotes deserve a note of their own, because the window cannot draw them. Vicinae parses markdown with `CMARK_OPT_DEFAULT` and attaches only the table and strikethrough extensions, so `[^1]` arrives as literal text. The citation pass therefore does the whole job itself: it links the marker and builds the list from the sources the tools registered, which is also what stops the list from being something a model can quietly invent.
|
|
```
|
|
|
|
- [ ] **Step 3: Check the formatting and commit**
|
|
|
|
```bash
|
|
npx prettier --check README.md
|
|
git add README.md
|
|
git commit -m "docs: describe source citations"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Full verification and merge
|
|
|
|
- [ ] **Step 1: Run everything**
|
|
|
|
```bash
|
|
npm run typecheck && npm run lint:code && npm test
|
|
npx prettier --check $(git diff --name-only prod...HEAD | grep -E '\.(ts|tsx)$')
|
|
```
|
|
|
|
Expected: typecheck silent, lint silent, every test passing, prettier clean on every file this branch touched.
|
|
|
|
- [ ] **Step 2: Check the feature end to end by hand**
|
|
|
|
Run a scripted check through the real pipeline, in a scratch test file, then delete it:
|
|
|
|
```ts
|
|
import { renderModelOutput } from "../lib/render";
|
|
import { renderSources } from "../lib/citations";
|
|
|
|
const sources = [
|
|
{ index: 1, title: "Rain in Cebu", url: "https://weather.example/cebu" },
|
|
{ index: 2, title: "Monsoon season", url: "https://climate.example/monsoon" },
|
|
];
|
|
|
|
const { text, cited } = renderModelOutput("Rain is likely on Tuesday [1]. It is [^2] the season for it.", {
|
|
sources,
|
|
});
|
|
process.stdout.write(`${text}\n\n${renderSources(sources, cited, false)}\n`);
|
|
```
|
|
|
|
Expected: both markers become links, the second one's `^` is gone, and the list has two entries in number order.
|
|
|
|
- [ ] **Step 3: Open the pull request and squash-merge it**
|
|
|
|
```bash
|
|
git push -u origin feat/source-citations
|
|
tea pr create --title "feat: source citations with footnotes in the transcript" --head feat/source-citations --base prod --description "Numbers every source a turn consults, prints those numbers in the web tool results, asks the model to cite them, and renders the markers as links with a sources list under the answer. Vicinae cannot render footnotes (CMARK_OPT_DEFAULT, table and strikethrough extensions only), so the whole thing is done in this extension's pipeline. See docs/superpowers/specs/2026-10-07-source-citations-design.md."
|
|
tea pr merge <index> -s squash
|
|
git checkout prod && git pull origin prod && git push origin --delete feat/source-citations && git branch -D feat/source-citations
|
|
```
|
|
|
|
- [ ] **Step 4: Rebuild the installed extension**
|
|
|
|
```bash
|
|
npm run dev
|
|
```
|
|
|
|
Expected: `Extension built` and the installed bundle at `~/.local/share/vicinae/extensions/lam-agent/chat.js` carries the new code.
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
**Spec coverage.** Numbering and deduplication: Tasks 1 and 5. Storage and reload: Task 6. Marker linking with the defensive rules: Task 3. The list and its fallback: Task 4. Pipeline placement: Task 7. Transcript and export: Task 8. Prompt: Task 9. Documentation: Task 10. Verification and merge: Task 11. Every row of the spec's error table has a test in Task 3, 4 or 8.
|
|
|
|
**Placeholders.** None: every step carries the code or the exact command, and the three test-builder helpers the reader may not find in the existing files are called out as "build them from the file's existing helpers, or inline" rather than left as an instruction to invent behaviour.
|
|
|
|
**Type consistency.** `Source` comes from `types.ts` in every task. `createSourceList` returns `{ index, source? }` in Tasks 1, 5 and 6. `linkCitations` returns `{ text, cited }` in Tasks 3 and 7. `renderSources(sources, cited, streaming)` takes the same three arguments in Tasks 4, 8. `renderModelOutput` returns `RenderedMarkdown` in Tasks 7 and 8. `ToolResult.sources` is `Source[] | undefined` in Tasks 5 and 6, and the record's optional field matches.
|
|
|
|
**Known deviation from the spec text.** The spec's list example shows `[[1]](url)`. The implementation produces `[\[1\]](url)`, because `markdownLink` escapes square brackets in a label so they cannot end the link text early. Both render as a clickable `[1]`; the code is the authority.
|