8000 feat(chat): export a conversation as Markdown, PDF or JSON by adeelahmad · Pull Request #474 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
7FFF
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ee327cf
fix(assistant): let the assistant's tools be imported first
claude Oct 16, 2026
defb487
feat(chat): each answer records what produced it
claude Oct 17, 2026
983d046
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 17, 2026
acf97d7
test(notes): keep the daily notes test from failing on 2026-10-17
claude Oct 17, 2026
db85b14
Merge origin/main into claude/project-thread-r960xc-c1-record
claude Oct 17, 2026
275692b
Merge remote-tracking branch 'origin/main' into port-c1
claude Oct 17, 2026
8a8946f
feat(chat): export a conversation as Markdown, PDF or JSON
claude Oct 17, 2026
c400aef
Merge remote-tracking branch 'origin/claude/project-thread-r960xc-c1-…
claude Oct 17, 2026
2284d67
docs(chat): changelog, threat model and client for conversation exports
claude Oct 17, 2026
fbf65fa
fix(chat): footnote grouped citations like [2, 3] in Markdown exports
claude Oct 17, 2026
abef0b0
Merge origin/main into claude/project-thread-r960xc-c3-export
claude Oct 17, 2026
6746387
Merge origin/main into claude/project-thread-r960xc-c3-export
claude Oct 17, 2026
d8e800d
test(entities): wait for the typed lookup before checking merge options
claude Oct 17, 2026
2e8ddb3
Merge origin/main into claude/project-thread-r960xc-c3-export
claude Oct 17, 2026
59b7198
Merge origin/main into claude/project-thread-r960xc-c3-export
claude Oct 17, 2026
d5b8f9d
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 17, 2026
c3f4854
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 17, 2026
ddec371
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ The backend (`fastapi_backend`) and the frontend (`nextjs-frontend`) are version

## Unreleased

- **Export a conversation.** `GET /api/v1/chats/<id>/export.md`, `.pdf` or `.json` gives the version showing, one
message (`?message=`) or, as JSON, every version (`?all=true`). The Markdown has each question and answer, its
citations as footnotes linking back to the moment in Lens and the tools it used; the PDF is that Markdown printed by
Chromium, else anytopdf, else LibreOffice (409 when none can run); the JSON is OpenAI's chat message format in a tree
with Dublin Core terms, or `?format=messages` for one `{"messages": [...]}` line, checked against a published schema.
Agents use `list_chats`, `read_chat` and `export_chat` over MCP. The web app's copy and export buttons are in
progress ([Copy and export](docs/assistant.md#copy-and-export)).

## 0.14.0 <small>October 17, 2026</small> {id="0.14.0"}

- **Dates read as days, for agents.** With Settings → Analysis → Read dates on (off by default), analysis reads the
Expand Down
58 changes: 58 additions & 0 deletions docs/assistant.md
< 10000 tr data-hunk="1f8c7c05547826fdf6f20c3358d53fd0ea91a83d5a82773116f19a76148d020c" class="show-top-border">
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,64 @@ An app's chat completions keep no messages, so their record comes back in the re
cost ([Chat completions](api.md#chat-completions-for-any-app)). Answers from before Lens kept records have none; Details
shows what they have and says the rest wasn't recorded.

### Copy and export

A conversation, or one message of it, comes out as a file to keep or hand on: `GET
/api/v1/chats/<id>/export.md`, `export.pdf` or `export.json`. They hold the version showing; `?message=<mid>` holds
one message (an answer comes with the question it answers), and the JSON export's `?all=true` holds every version.
Citations follow the reader's access now, as `GET /api/v1/chats/<id>` does, and no model is called.

- **Markdown:** the title, a short header (the conversation's address in Lens, when it started, what it draws on in
words, and the Lens version that exported it), then each question and answer with its time, the model that wrote
it and its version (version 2 of 3). An answer's citations become numbered footnotes naming the asset, its time or
page and speaker, a short quote and a link back to that moment in Lens; a web page's citation links the page. The
tools an answer used are a "Looked up" list with each one's summary.
- **PDF:** that Markdown on a page, printed by Chromium where it's installed, else by anytopdf (installed, on a
conversion node, or fetched on first use, [anytopdf](configuration.md#anytopdf)), else by LibreOffice. When none
can run here (Settings › Documents says never to use anytopdf, and nothing else is installed), or printing fails,
it answers 409 with the reason; the web app can still print the conversation.
- **JSON:** the conversation in OpenAI's chat message format, as other tools and trainers read it, with Dublin Core
terms (`dc:title`, `dc:created`, `dc:modified`, `dc:creator`, `dc:identifier`, `dc:publisher`). `mapping` holds each
message by id with its `parent` and `children` (more than one child where there are versions), and `current_node`
is the last message of the version showing. Each node's `message` is `{role, content}`; an answer that used tools
has them before it in `tool_messages`: an assistant message with `tool_calls`, then a `tool` message per call
carrying the step's summary. What only Lens knows is under `lens`: the citations (`[n]` in the text is `n` there,
with a `url` back to Lens or the web page), the answer's record (`meta`, [above](#what-each-answer-records)),
`versions`, attached files, and the page a question was asked from. `?format=messages` gives the version showing
as one `{"messages": [...]}` line instead, the shape fine-tuning reads. The schema is
[chat-export.schema.json](schemas/chat-export.schema.json).

```json
{
"@context": {"dc": "http://purl.org/dc/terms/"},
"format": "lens.chat-export/1",
"dc:title": "Are ships slow?",
"dc:identifier": "https://lens.example/chat/4",
"current_node": "2",
"mapping": {
"1": {"id": "1", "parent": null, "children": ["2"], "message": {"role": "user", "content": "Are ships slow?"}, "lens": {}},
"2": {
"id": "2", "parent": "1", "children": [],
"tool_messages": [
{"role": "assistant", "content": null, "tool_calls": [{"id": "call_2_1", "type": "function",
"function": {"name": "search_transcripts", "arguments": "{\"query\": \"ships\"}"}}]},
{"role": "tool", "tool_call_id": "call_2_1", "content": "2 moments"}
],
"message": {"role": "assistant", "content": "Yes, said Ana [1]."},
"lens": {"citations": [{"n": 1, "source": "said", "recording_id": 5, "title": "Ep 1", "time": "1:15",
"speaker": "Ana", "text": "They are slow.", "url": "https://lens.example/resources/5?t=75"}]}
}
}
}
```

Exports carry each tool call's arguments and summary, never its raw result, which Lens doesn't keep. Agents read and
export the same way over MCP: `list_chats`, `read_chat` and `export_chat` ([MCP](mcp.md)) work on the person's own
conversations.

The web app's copy buttons and Export menus are in progress. Planned: importing a conversation from its JSON,
exporting one to a note, and a styled PDF without Chromium.

### Picking the namespace for a conversation over everything

When a conversation has no scope (the assistant home starts it over everything), its first question goes to the
Expand Down
7 changes: 5 additions & 2 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ that should see that namespace and nothing else: a family assistant, a project's
has the same tools, but sees only that namespace, whatever else the person who signs the agent in can read. Tools
that take a namespace use this one when none is named, `list_namespaces` and `list_files` show only it, and the tools
about the server itself (`file_storage`, `monitoring`, `sandbox_gateway`, `install_settings` and the ones that change
them) aren't there,
even for admins. An admin counts as an owner of the namespace there.
them) aren't there, even for admins. Nor are the person's own conversations (`list_chats`, `read_chat`,
`export_chat`), which belong to no namespace. An admin counts as an owner of the namespace there.

It is off until an owner of the namespace turns it on: **Admin → Namespaces →** the namespace **→ Connect apps**, which
also copies its address, or `PATCH /api/v1/namespaces/<namespace>/integrations` with `{"mcp": true}`, or an agent's
Expand Down Expand Up @@ -94,6 +94,9 @@ For an app that speaks OpenAI's API rather than MCP, the namespace can also answ
| `find_notes` | [notes](notes.md) by words in their title, summary or text, or by where they're filed (PARA), with the words around the match and links |
| `read_note` | a note's Markdown with its links and backlinks, by id or as the page of a thing (`recording:12`) |
| `write_note` | write a note or a thing's page, marked as written by an assistant. Needs a write-scope token and editor access |
| `list_chats` | the person's conversations with Lens's [assistant](assistant.md#chat), newest first, by words in their title, each with its link |
| `read_chat` | one of them: what it draws on, and the version showing (or the one ending at a `message`), each message with its versions, the tools an answer used, its citations with links and its record; `tree` adds every message's parent and role |
| `export_chat` | one of them as Markdown or JSON ([Copy and export](assistant.md#copy-and-export)): the version showing, one `message`, every version (`all_versions`) or OpenAI's `{"messages": [...]}` shape (`as_messages`) |
| `create_podcast` | make a two-host learning [episode](podcasts.md) about assets or lines in them, with an angle, a length and a style; it's made in the background. Needs a write-scope token and editor access where it goes |
| `get_podcast` | an episode: how far it has got, its script with each line's speaker and the sources it cites (links), and what the fact-check changed |
| `nsfw_settings` | how [NSFW classification](nsfw.md) works in a namespace: on or off, the threshold, how flagged resources show, and how many are classified, flagged and not classified yet |
Expand Down
145 changes: 145 additions & 0 deletions docs/schemas/chat-export.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/adeelahmad/lense/blob/main/docs/schemas/chat-export.schema.json",
"title": "Lens conversation export",
"description": "GET /api/v1/chats/{id}/export.json (docs/assistant.md, Copy and export): a conversation as a tree of OpenAI chat messages with Dublin Core terms, or with format=messages one version as {\"messages\": [...]}.",
"oneOf": [{ "$ref": "#/$defs/document" }, { "$ref": "#/$defs/messages" }],
"$defs": {
"document": {
"type": "object",
"required": ["@context", "format", "dc:title", "dc:identifier", "dc:format", "id", "title", "current_node", "mapping"],
"properties": {
"@context": {
"type": "object",
"required": ["dc"],
"properties": { "dc": { "const": "http://purl.org/dc/terms/" } }
},
"format": { "const": "lens.chat-export/1" },
"dc:title": { "type": "string" },
"dc:created": { "type": ["string", "null"], "description": "when the conversation started (ISO 8601, UTC)" },
"dc:modified": { "type": ["string", "null"], "description": "when it was last added to" },
"dc:creator": { "type": "string", "description": "the person whose conversation it is" },
"dc:identifier": { "type": "string", "description": "its address in Lens" },
"dc:format": { "const": "application/json" },
"dc:publisher": { "type": "string", "description": "the Lens and version that exported it" },
"id": { "type": "string" },
"title": { "type": "string" },
"scope": { "type": "object", "description": "what the conversation draws on (namespaces, recordings, collections, speakers, from, to)" },
"model": { "type": ["string", "null"], "description": "the model picked for it; null: the configured one" },
"exported_at": { "type": "string" },
"current_node": { "type": ["string", "null"], "description": "the last message of the version showing" },
"mapping": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/node" }
}
}
},
"node": {
"type": "object",
"required": ["id", "parent", "children", "message", "lens"],
"properties": {
"id": { "type": "string" },
"parent": { "type": ["string", "null"], "description": "the message it follows, among those exported" },
"children": { "type": "array", "items": { "type": "string" }, "description": "the messages that follow it: more than one where there are versions" },
"created_at": { "type": ["string", "null"] },
"message": { "$ref": "#/$defs/chatMessage" },
"tool_messages": {
"type": "array",
"description": "an answer's tool calls, before it: an assistant message calling them, then a tool message for each carrying its summary (raw results aren't kept)",
"items": { "$ref": "#/$defs/openaiMessage" }
},
"lens": { "$ref": "#/$defs/lens" }
}
},
"chatMessage": {
"type": "object",
"required": ["role", "content"],
"properties": {
"role": { "enum": ["user", "assistant"] },
"content": { "type": "string", "description": "the text; an answer cites with [n], the n of lens.citations" }
}
},
"openaiMessage": {
"type": "object",
"required": ["role"],
"properties": {
"role": { "enum": ["system", "user", "assistant", "tool"] },
"content": { "type": ["string", "null"] },
"tool_call_id": { "type": "string" },
"tool_calls": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "type", "function"],
"properties": {
"id": { "type": "string" },
"type": { "const": "function" },
"function": {
"type": "object",
"required": ["name", "arguments"],
"properties": {
"name": { "type": "string" },
"arguments": { "type": "string", "description": "the arguments, as JSON text" }
}
}
}
}
}
},
"allOf": [
{ "if": { "properties": { "role": { "const": "tool" } } }, "then": { "required": ["tool_call_id", "content"] } }
]
},
"citation": {
"type": "object",
"required": ["n", "source", "text"],
"properties": {
"n": { "type": "integer" },
"source": { "enum": ["said", "page", "screen", "memory", "web"], "description": "where it is: said in a recording, on a document's page, shown on screen, from memory, or a web page" },
"url": { "type": ["string", "null"], "description": "the web page, or the asset's page in Lens at that moment" },
"recording_id": { "type": "integer" },
"title": { "type": ["string", "null"] },
"namespace": { "type": ["string", "null"] },
"recorded_at": { "type": ["string", "null"] },
"time": { "type": ["string", "null"] },
"page": { "type": ["integer", "null"] },
"speaker": { "type": ["string", "null"] },
"site": { "type": ["string", "null"] },
"fetched_at": { "type": ["string", "null"] },
"text": { "type": "string" },
"used": { "type": ["boolean", "null"], "description": "cited in the answer" }
}
},
"lens": {
"type": "object",
"description": "what only Lens knows of a message",
"properties" 92CB : {
"model": { "type": "string" },
"stopped": { "type": "boolean" },
"notice": { "type": "string" },
"error": { "type": "string" },
"check": { "type": "object" },
"context": { "type": "object", "description": "a question asked from a page: the page and any highlighted text" },
"versions": {
"type": "object",
"required": ["index", "count", "ids"],
"properties": { "index": { "type": "integer" }, "count": { "type": "integer" }, "ids": { "type": "array", "items": { "type": "integer" } } }
},
"attachments": {
"type": "array",
"items": { "type": "object", "properties": { "filename": { "type": "string" }, "size": { "type": "integer" }, "recording": { "type": "integer" } } }
},
"citations": { "type": "array", "items": { "$ref": "#/$defs/citation" } },
"meta": { "type": "object", "description": "what produced it (GET /chats/{cid}/messages/{mid} meta): model, provider, tools, scope, limits, use, outcome, tokens, cost, time" }
}
},
"messages": {
"type": "object",
"required": ["messages"],
"additionalProperties": false,
"properties": {
"messages": { "type": "array", "items": { "$ref": "#/$defs/openaiMessage" } }
}
}
}
}
Loading
Loading
0