@protobase/server 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ export type ModelSettings = {
9
9
  /**
10
10
  * `url`: an assistant backend elsewhere, which the dock talks to instead of the built-in one.
11
11
  * `apiKey`, `model`, `baseUrl`: the built-in assistant, which answers questions about the app with a chat model.
12
+ * `chats`: the directory the built-in assistant keeps conversations in, one file each.
12
13
  * Each falls back to its environment variable; `fetch` replaces the model endpoint, for tests.
13
14
  */
14
15
  export type AssistantOptions = {
@@ -16,6 +17,7 @@ export type AssistantOptions = {
16
17
  apiKey?: string;
17
18
  model?: string;
18
19
  baseUrl?: string;
20
+ chats?: string;
19
21
  fetch?: typeof fetch;
20
22
  };
21
23
  export type AssistantSettings = {
@@ -24,12 +26,14 @@ export type AssistantSettings = {
24
26
  } | {
25
27
  kind: 'built-in';
26
28
  model: ModelSettings;
29
+ chats: string;
27
30
  };
28
31
  export declare const assistantVariables: {
29
32
  readonly url: "PROTOBASE_ASSISTANT_URL";
30
33
  readonly apiKey: "PROTOBASE_ASSISTANT_API_KEY";
31
34
  readonly model: "PROTOBASE_ASSISTANT_MODEL";
32
35
  readonly baseUrl: "PROTOBASE_ASSISTANT_BASE_URL";
36
+ readonly chats: "PROTOBASE_ASSISTANT_CHATS";
33
37
  };
34
38
  /** The roles that see the assistant: `/meta` names it, and the built-in one answers, only for a caller with one of them. */
35
39
  export declare const assistantRoles: readonly ["admin", "ai"];
@@ -3,8 +3,11 @@ const assistantVariables = {
3
3
  url: "PROTOBASE_ASSISTANT_URL",
4
4
  apiKey: "PROTOBASE_ASSISTANT_API_KEY",
5
5
  model: "PROTOBASE_ASSISTANT_MODEL",
6
- baseUrl: "PROTOBASE_ASSISTANT_BASE_URL"
6
+ baseUrl: "PROTOBASE_ASSISTANT_BASE_URL",
7
+ chats: "PROTOBASE_ASSISTANT_CHATS"
7
8
  };
9
+ /** Where the built-in assistant keeps conversations without `chats`: relative to the working directory. */
10
+ const defaultChatsDirectory = ".protobase/chats";
8
11
  const openRouterUrl = "https://openrouter.ai/api/v1";
9
12
  const openRouterModel = "openrouter/auto";
10
13
  /** The roles that see the assistant: `/meta` names it, and the built-in one answers, only for a caller with one of them. */
@@ -44,8 +47,9 @@ const assistantSettings = (option, env) => {
44
47
  apiKey,
45
48
  model,
46
49
  ...option && option.fetch && { fetch: option.fetch }
47
- }
50
+ },
51
+ chats: value("chats") ?? ".protobase/chats"
48
52
  };
49
53
  };
50
54
  //#endregion
51
- export { assistantRoles, assistantSettings, assistantVariables, openRouterModel, openRouterUrl, seesAssistant };
55
+ export { assistantRoles, assistantSettings, assistantVariables, defaultChatsDirectory, openRouterModel, openRouterUrl, seesAssistant };
@@ -1,24 +1,28 @@
1
- import { seesAssistant } from "./assistant-settings.js";
1
+ import { openRouterUrl, seesAssistant } from "./assistant-settings.js";
2
2
  import { appContext } from "./app-context.js";
3
3
  import { conversationKey, createConversations } from "./conversations.js";
4
+ import { fileConversationStore } from "./file-conversation-store.js";
4
5
  import { assistantProtocolRoutes } from "./protocol-routes.js";
5
6
  import { readOnlyQueryTool, readWriteQueryTool } from "./query-tools.js";
6
7
  import { runTurn } from "./turn.js";
7
8
  //#region src/assistant/built-in-assistant.ts
9
+ const pageContext = (page) => page && `The user is on the page ${page} of the app (/<resource> lists records, /<resource>/<key> shows one).`;
8
10
  /**
9
11
  * Protobase's own assistant: answers questions about the app, reads its data with read-only queries and proposes
10
12
  * changes the user approves, for callers with the `admin` or `ai` role. Made only of the exported building blocks and
11
- * its prompt, which describes what the caller may see of the app.
13
+ * its prompt, which describes what the caller may see of the app. Conversations are files in the `chats` directory.
14
+ * On OpenRouter it caches the prompt and asks for little reasoning.
12
15
  */
13
- const builtInAssistant = ({ model, db, meta, authenticate, report }) => {
14
- const conversations = createConversations();
16
+ const builtInAssistant = ({ model, chats, db, meta, authenticate, report }) => {
17
+ const conversations = createConversations({ store: fileConversationStore(chats) });
15
18
  const tools = [readOnlyQueryTool(db), readWriteQueryTool(db)];
19
+ const onOpenRouter = model.baseUrl === openRouterUrl;
16
20
  return assistantProtocolRoutes({
17
21
  authenticate,
18
22
  allows: (session) => seesAssistant(session.user.roles),
19
23
  conversationOf: (session) => conversations(conversationKey(session)),
20
24
  report,
21
- onMessage: async (text, session, conversation) => {
25
+ onMessage: async ({ text, page }, session, conversation) => {
22
26
  const { body } = await meta.forCaller(session);
23
27
  await runTurn({
24
28
  model,
@@ -26,7 +30,12 @@ const builtInAssistant = ({ model, db, meta, authenticate, report }) => {
26
30
  session,
27
31
  system: appContext(body.resources, body.views),
28
32
  tools,
29
- report
33
+ report,
34
+ ...page && { context: pageContext(page) },
35
+ ...onOpenRouter && {
36
+ cache: true,
37
+ reasoning: "low"
38
+ }
30
39
  }, text);
31
40
  }
32
41
  });
@@ -0,0 +1,9 @@
1
+ import { ChatMessage } from "./chat-completions.js";
2
+ //#region src/assistant/cache-breakpoints.d.ts
3
+ /**
4
+ * The messages with a prompt-cache breakpoint (`cache_control`, as OpenRouter and Anthropic read it) at the end of each
5
+ * message at `indexes`: the endpoint caches everything up to it, tools included. A message without text, such as an
6
+ * assistant message of only tool calls, gets none.
7
+ */
8
+ export declare const withCacheBreakpoints: (messages: ChatMessage[], indexes: number[]) => ChatMessage[];
9
+ //#endregion
@@ -0,0 +1,25 @@
1
+ //#region src/assistant/cache-breakpoints.ts
2
+ const breakpoint = { type: "ephemeral" };
3
+ const marked = (message) => {
4
+ if (!message.content) return message;
5
+ const parts = typeof message.content === "string" ? [{
6
+ type: "text",
7
+ text: message.content
8
+ }] : message.content;
9
+ const content = parts.map((part, index) => index === parts.length - 1 ? {
10
+ ...part,
11
+ cache_control: breakpoint
12
+ } : part);
13
+ return {
14
+ ...message,
15
+ content
16
+ };
17
+ };
18
+ /**
19
+ * The messages with a prompt-cache breakpoint (`cache_control`, as OpenRouter and Anthropic read it) at the end of each
20
+ * message at `indexes`: the endpoint caches everything up to it, tools included. A message without text, such as an
21
+ * assistant message of only tool calls, gets none.
22
+ */
23
+ const withCacheBreakpoints = (messages, indexes) => messages.map((message, index) => indexes.includes(index) ? marked(message) : message);
24
+ //#endregion
25
+ export { withCacheBreakpoints };
@@ -8,19 +8,39 @@ export type ToolCall = {
8
8
  arguments: string;
9
9
  };
10
10
  };
11
- /** A message of a chat completion: what the model reads, and the tool calls it made with their results. */
11
+ /** A piece of a message's text; `cache_control` marks a prompt-cache breakpoint after it (OpenRouter, Anthropic). */
12
+ export type ChatTextPart = {
13
+ type: 'text';
14
+ text: string;
15
+ cache_control?: {
16
+ type: 'ephemeral';
17
+ };
18
+ };
19
+ /**
20
+ * A message of a chat completion: what the model reads, and the tool calls it made with their results. An assistant
21
+ * message keeps the model's `reasoning_details` (OpenRouter), which a reasoning model needs back with its tool calls.
22
+ */
12
23
  export type ChatMessage = {
13
24
  role: 'system' | 'user';
14
- content: string;
25
+ content: string | ChatTextPart[];
15
26
  } | {
16
27
  role: 'assistant';
17
- content: string | null;
28
+ content: string | ChatTextPart[] | null;
18
29
  tool_calls?: ToolCall[];
30
+ reasoning_details?: ReasoningDetail[];
19
31
  } | {
20
32
  role: 'tool';
21
33
  tool_call_id: string;
22
- content: string;
34
+ content: string | ChatTextPart[];
23
35
  };
36
+ /** One block of a model's reasoning, as OpenRouter streams it: passed back unchanged, never shown. */
37
+ export type ReasoningDetail = {
38
+ type: string;
39
+ index?: number;
40
+ [field: string]: unknown;
41
+ };
42
+ /** How much a reasoning model thinks before it answers, as OpenRouter's `reasoning.effort`. */
43
+ export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
24
44
  /** A tool as the endpoint's `tools` field describes it: a function with JSON Schema parameters. */
25
45
  export type ToolSpec = {
26
46
  type: 'function';
@@ -33,6 +53,12 @@ export type ToolSpec = {
33
53
  export type Completion = {
34
54
  content: string;
35
55
  toolCalls: ToolCall[];
56
+ reasoningDetails: ReasoningDetail[];
57
+ };
58
+ export type CompletionOptions = {
59
+ tools?: ToolSpec[];
60
+ reasoning?: ReasoningEffort;
61
+ signal?: AbortSignal;
36
62
  };
37
63
  /** A failed call to the model endpoint, with a message an admin can act on. */
38
64
  export declare class ModelError extends Error {
@@ -40,11 +66,9 @@ export declare class ModelError extends Error {
40
66
  }
41
67
  /**
42
68
  * Streams a chat completion from an OpenAI-compatible endpoint (`POST <baseUrl>/chat/completions` with `stream: true`),
43
- * offering `tools` and calling `onText` with each piece of the answer. Resolves with the whole answer and the tool
44
- * calls the model made; rejects with a `ModelError`.
69
+ * offering `tools` and calling `onText` with each piece of the answer. `reasoning` sets OpenRouter's reasoning effort,
70
+ * which other endpoints may refuse. Resolves with the whole answer, the tool calls the model made and its reasoning
71
+ * details; rejects with a `ModelError`.
45
72
  */
46
- export declare const streamChatCompletion: (settings: ModelSettings, messages: ChatMessage[], onText: (text: string) => void, options?: {
47
- tools?: ToolSpec[];
48
- signal?: AbortSignal;
49
- }) => Promise<Completion>;
73
+ export declare const streamChatCompletion: (settings: ModelSettings, messages: ChatMessage[], onText: (text: string) => void, options?: CompletionOptions) => Promise<Completion>;
50
74
  //#endregion
@@ -24,7 +24,7 @@ const refusal = async (response, settings) => {
24
24
  const hint = statusHints[response.status];
25
25
  return new ModelError(`The model endpoint ${settings.baseUrl} answered ${response.status}${detail ? `: ${detail}` : ""}${hint ? ` (${hint})` : ""}`);
26
26
  };
27
- const send = (settings, messages, tools, signal) => {
27
+ const send = (settings, messages, { tools = [], reasoning, signal }) => {
28
28
  return (settings.fetch ?? fetch)(`${settings.baseUrl}/chat/completions`, {
29
29
  method: "POST",
30
30
  headers: {
@@ -36,7 +36,8 @@ const send = (settings, messages, tools, signal) => {
36
36
  model: settings.model,
37
37
  messages,
38
38
  stream: true,
39
- ...tools.length > 0 && { tools }
39
+ ...tools.length > 0 && { tools },
40
+ ...reasoning && { reasoning: { effort: reasoning } }
40
41
  }),
41
42
  signal
42
43
  }).catch((error) => {
@@ -57,30 +58,43 @@ const addToolCall = (calls, delta) => {
57
58
  if (delta.function?.name) call.function.name += delta.function.name;
58
59
  if (delta.function?.arguments) call.function.arguments += delta.function.arguments;
59
60
  };
61
+ const addReasoning = (details, piece) => {
62
+ const at = piece.index ?? details.length;
63
+ const detail = details[at];
64
+ if (!detail) {
65
+ details[at] = { ...piece };
66
+ return;
67
+ }
68
+ for (const [field, value] of Object.entries(piece)) detail[field] = (field === "text" || field === "summary" || field === "data") && typeof value === "string" && typeof detail[field] === "string" ? `${String(detail[field])}${value}` : value;
69
+ };
60
70
  /**
61
71
  * Streams a chat completion from an OpenAI-compatible endpoint (`POST <baseUrl>/chat/completions` with `stream: true`),
62
- * offering `tools` and calling `onText` with each piece of the answer. Resolves with the whole answer and the tool
63
- * calls the model made; rejects with a `ModelError`.
72
+ * offering `tools` and calling `onText` with each piece of the answer. `reasoning` sets OpenRouter's reasoning effort,
73
+ * which other endpoints may refuse. Resolves with the whole answer, the tool calls the model made and its reasoning
74
+ * details; rejects with a `ModelError`.
64
75
  */
65
76
  const streamChatCompletion = async (settings, messages, onText, options = {}) => {
66
- const response = await send(settings, messages, options.tools ?? [], options.signal);
77
+ const response = await send(settings, messages, options);
67
78
  if (!response.ok) throw await refusal(response, settings);
68
79
  if (!response.body) throw new ModelError(`The model endpoint ${settings.baseUrl} answered without a body`);
69
80
  let content = "";
70
81
  const toolCalls = [];
82
+ const reasoningDetails = [];
71
83
  await readEventStream(response.body, (data) => {
72
84
  if (data === "[DONE]") return;
73
85
  const chunk = JSON.parse(data);
74
86
  if (chunk.error) throw new ModelError(`The model endpoint ${settings.baseUrl} stopped: ${chunk.error.message ?? "unknown error"}`);
75
87
  const delta = chunk.choices?.[0]?.delta;
76
88
  for (const call of delta?.tool_calls ?? []) addToolCall(toolCalls, call);
89
+ for (const piece of delta?.reasoning_details ?? []) addReasoning(reasoningDetails, piece);
77
90
  if (!delta?.content) return;
78
91
  content += delta.content;
79
92
  onText(delta.content);
80
93
  });
81
94
  return {
82
95
  content,
83
- toolCalls: toolCalls.filter(Boolean)
96
+ toolCalls: toolCalls.filter(Boolean),
97
+ reasoningDetails: reasoningDetails.filter(Boolean)
84
98
  };
85
99
  };
86
100
  //#endregion
@@ -0,0 +1,28 @@
1
+ import { ChatMessage } from "./chat-completions.js";
2
+ import { AssistantEvent } from "@protobase/schema";
3
+ //#region src/assistant/conversation-store.d.ts
4
+ /**
5
+ * What a conversation keeps, appended in order: the chat the user sees as `state` and `message` events, and the
6
+ * transcript the model read, one `transcript` record per message, exactly as it was sent.
7
+ */
8
+ export type ConversationRecord = Extract<AssistantEvent, {
9
+ type: 'state' | 'message';
10
+ }> | {
11
+ type: 'transcript';
12
+ message: ChatMessage;
13
+ };
14
+ /**
15
+ * Where conversations live between requests and restarts, by owner (one user, see `conversationKey`) and id. The
16
+ * built-in assistant keeps them in files (`fileConversationStore`); a hosted backend can keep them in its database.
17
+ */
18
+ export type ConversationStore = {
19
+ /** The conversation's records in the order they were appended; none for a conversation not started. */
20
+ load: (owner: string, id: string) => Promise<ConversationRecord[]>;
21
+ append: (owner: string, id: string, records: ConversationRecord[]) => Promise<void>;
22
+ /** The owner's conversations, the latest changed first. */
23
+ list: (owner: string) => Promise<Array<{
24
+ id: string;
25
+ updatedAt: Date;
26
+ }>>;
27
+ };
28
+ //#endregion
@@ -1,5 +1,6 @@
1
1
  import { Session } from "../types.js";
2
2
  import { ChatMessage } from "./chat-completions.js";
3
+ import { ConversationStore } from "./conversation-store.js";
3
4
  import { AssistantEvent, AssistantState } from "@protobase/schema";
4
5
  //#region src/assistant/conversations.d.ts
5
6
  export type Conversation = {
@@ -7,18 +8,34 @@ export type Conversation = {
7
8
  apply: (event: AssistantEvent) => void;
8
9
  /** Calls `listener` with every later event until the returned function is called. */
9
10
  subscribe: (listener: (event: AssistantEvent) => void) => () => void;
10
- /** The chat as the model reads it, tool calls and their results included; turns add to it. */
11
- transcript: ChatMessage[];
11
+ /** The chat as the model read it, tool calls and their results included, oldest first. */
12
+ transcript: () => readonly ChatMessage[];
13
+ /** Adds messages to the transcript, as they were sent to the model or came from it. */
14
+ remember: (...messages: ChatMessage[]) => void;
15
+ /** Appends what changed since the last save to the store: the chat's changed messages and the transcript's new ones. */
16
+ save: () => Promise<void>;
12
17
  /** Calls `handler` with the action's id when a button of part `partId` is clicked, until the returned function is called. */
13
18
  onAction: (partId: string, handler: (actionId: string) => void) => () => void;
14
19
  /** Hands a click to the part's handler; false when no handler waits for that part. */
15
20
  act: (partId: string, actionId: string) => boolean;
16
21
  };
17
- /** One conversation per user and tenant. */
22
+ export type ConversationsOptions = {
23
+ /** Where conversations are kept; without one they live in this process's memory only. */
24
+ store?: ConversationStore;
25
+ /** Chat messages the user sees. Default 50. */
26
+ keepMessages?: number;
27
+ /**
28
+ * Transcript messages the model reads. Past it, the transcript drops its older half at the next user message, so
29
+ * the prefix a model endpoint caches stays the same for many turns. Default 40.
30
+ */
31
+ keepTranscript?: number;
32
+ };
33
+ /** The owner of a user's conversations, per tenant. */
18
34
  export declare const conversationKey: (session: Session) => string;
19
35
  /**
20
- * Conversations in this process's memory, by key (see `conversationKey`): shared by a user's open tabs, gone on a
21
- * restart, each cut to its last `keep` messages.
36
+ * Conversations by owner (see `conversationKey`) and id, each loaded from the store on first use and then kept in this
37
+ * process's memory, shared by the owner's open tabs. A deployment of several instances needs each owner's requests on
38
+ * one instance, since a turn and its approvals run in the instance that started it.
22
39
  */
23
- export declare const createConversations: (keep?: number) => (key: string) => Conversation;
40
+ export declare const createConversations: ({ store, keepMessages, keepTranscript }?: ConversationsOptions) => (owner: string, id?: string) => Promise<Conversation>;
24
41
  //#endregion
@@ -1,24 +1,78 @@
1
1
  import { applyAssistantEvent, emptyAssistantState } from "@protobase/schema";
2
2
  //#region src/assistant/conversations.ts
3
- const createConversation = (keep) => {
3
+ const remembered = (transcript, message, keep) => {
4
+ const next = [...transcript, message];
5
+ if (message.role !== "user" || next.length <= keep) return next;
6
+ return next.slice(next.findIndex((entry, index) => index >= next.length - keep / 2 && entry.role === "user"));
7
+ };
8
+ const createConversation = (records, keepMessages, keepTranscript, write) => {
4
9
  let state = emptyAssistantState;
10
+ let transcript = [];
5
11
  const listeners = /* @__PURE__ */ new Set();
6
12
  const actions = /* @__PURE__ */ new Map();
13
+ let replaced = false;
14
+ const changed = /* @__PURE__ */ new Set();
15
+ let unsaved = [];
16
+ let saving = Promise.resolve();
17
+ const fold = (event) => {
18
+ const next = applyAssistantEvent(state, event);
19
+ state = next.messages.length > keepMessages ? {
20
+ ...next,
21
+ messages: next.messages.slice(-keepMessages)
22
+ } : next;
23
+ };
24
+ const remember = (message) => {
25
+ transcript = remembered(transcript, message, keepTranscript);
26
+ };
27
+ for (const record of records) if (record.type === "transcript") remember(record.message);
28
+ else fold(record);
29
+ state = {
30
+ ...state,
31
+ replying: false
32
+ };
33
+ const pending = () => {
34
+ const records = [...replaced ? [{
35
+ type: "state",
36
+ state: {
37
+ messages: state.messages,
38
+ replying: false
39
+ }
40
+ }] : state.messages.filter((message) => changed.has(message.id)).map((message) => ({
41
+ type: "message",
42
+ message
43
+ })), ...unsaved.map((message) => ({
44
+ type: "transcript",
45
+ message
46
+ }))];
47
+ replaced = false;
48
+ changed.clear();
49
+ unsaved = [];
50
+ return records;
51
+ };
7
52
  return {
8
53
  state: () => state,
9
54
  apply: (event) => {
10
- const next = applyAssistantEvent(state, event);
11
- state = next.messages.length > keep ? {
12
- ...next,
13
- messages: next.messages.slice(-keep)
14
- } : next;
55
+ fold(event);
56
+ if (event.type === "state") replaced = true;
57
+ if (event.type === "message") changed.add(event.message.id);
58
+ if (event.type === "part") changed.add(event.messageId);
15
59
  for (const listener of listeners) listener(event);
16
60
  },
17
61
  subscribe: (listener) => {
18
62
  listeners.add(listener);
19
63
  return () => listeners.delete(listener);
20
64
  },
21
- transcript: [],
65
+ transcript: () => transcript,
66
+ remember: (...messages) => {
67
+ for (const message of messages) remember(message);
68
+ unsaved.push(...messages);
69
+ },
70
+ save: () => {
71
+ const records = pending();
72
+ const next = () => records.length > 0 ? write(records) : Promise.resolve();
73
+ saving = saving.then(next, next);
74
+ return saving;
75
+ },
22
76
  onAction: (partId, handler) => {
23
77
  actions.set(partId, handler);
24
78
  return () => actions.delete(partId);
@@ -30,19 +84,23 @@ const createConversation = (keep) => {
30
84
  }
31
85
  };
32
86
  };
33
- /** One conversation per user and tenant. */
87
+ /** The owner of a user's conversations, per tenant. */
34
88
  const conversationKey = (session) => JSON.stringify([session.tenant ?? null, session.user.id]);
35
89
  /**
36
- * Conversations in this process's memory, by key (see `conversationKey`): shared by a user's open tabs, gone on a
37
- * restart, each cut to its last `keep` messages.
90
+ * Conversations by owner (see `conversationKey`) and id, each loaded from the store on first use and then kept in this
91
+ * process's memory, shared by the owner's open tabs. A deployment of several instances needs each owner's requests on
92
+ * one instance, since a turn and its approvals run in the instance that started it.
38
93
  */
39
- const createConversations = (keep = 50) => {
40
- const byKey = /* @__PURE__ */ new Map();
41
- return (key) => {
42
- const existing = byKey.get(key);
94
+ const createConversations = ({ store, keepMessages = 50, keepTranscript = 40 } = {}) => {
95
+ const loaded = /* @__PURE__ */ new Map();
96
+ return (owner, id = "chat") => {
97
+ const key = JSON.stringify([owner, id]);
98
+ const existing = loaded.get(key);
43
99
  if (existing) return existing;
44
- const conversation = createConversation(keep);
45
- byKey.set(key, conversation);
100
+ const write = (records) => store?.append(owner, id, records) ?? Promise.resolve();
101
+ const conversation = (store?.load(owner, id) ?? Promise.resolve([])).then((records) => createConversation(records, keepMessages, keepTranscript, write));
102
+ conversation.catch(() => loaded.delete(key));
103
+ loaded.set(key, conversation);
46
104
  return conversation;
47
105
  };
48
106
  };
@@ -0,0 +1,8 @@
1
+ import { ConversationStore } from "./conversation-store.js";
2
+ //#region src/assistant/file-conversation-store.d.ts
3
+ /**
4
+ * Conversations as files: `<directory>/<owner>/<id>.jsonl`, one record per line, appended as the chat goes on. A
5
+ * relative directory is resolved against the working directory; it is made on the first append.
6
+ */
7
+ export declare const fileConversationStore: (directory: string) => ConversationStore;
8
+ //#endregion
@@ -0,0 +1,40 @@
1
+ //#region src/assistant/file-conversation-store.ts
2
+ const fs = () => import("node:fs/promises");
3
+ const missing = (error) => error instanceof Error && error.code === "ENOENT";
4
+ const segment = (name) => encodeURIComponent(name).replaceAll(".", "%2E");
5
+ const extension = ".jsonl";
6
+ /**
7
+ * Conversations as files: `<directory>/<owner>/<id>.jsonl`, one record per line, appended as the chat goes on. A
8
+ * relative directory is resolved against the working directory; it is made on the first append.
9
+ */
10
+ const fileConversationStore = (directory) => {
11
+ const folder = (owner) => `${directory}/${segment(owner)}`;
12
+ const file = (owner, id) => `${folder(owner)}/${segment(id)}${extension}`;
13
+ return {
14
+ load: async (owner, id) => {
15
+ return (await (await fs()).readFile(file(owner, id), "utf8").catch((error) => {
16
+ if (missing(error)) return "";
17
+ throw error;
18
+ })).split("\n").slice(0, -1).map((line) => JSON.parse(line));
19
+ },
20
+ append: async (owner, id, records) => {
21
+ if (records.length === 0) return;
22
+ const { appendFile, mkdir } = await fs();
23
+ await mkdir(folder(owner), { recursive: true });
24
+ await appendFile(file(owner, id), records.map((record) => `${JSON.stringify(record)}\n`).join(""));
25
+ },
26
+ list: async (owner) => {
27
+ const { readdir, stat } = await fs();
28
+ const names = await readdir(folder(owner)).catch((error) => {
29
+ if (missing(error)) return [];
30
+ throw error;
31
+ });
32
+ return (await Promise.all(names.filter((name) => name.endsWith(extension)).map(async (name) => ({
33
+ id: decodeURIComponent(name.slice(0, -6)),
34
+ updatedAt: (await stat(`${folder(owner)}/${name}`)).mtime
35
+ })))).sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
36
+ }
37
+ };
38
+ };
39
+ //#endregion
40
+ export { fileConversationStore };
@@ -1,15 +1,16 @@
1
1
  import { Authenticator, Session } from "../types.js";
2
2
  import { Conversation } from "./conversations.js";
3
3
  import { Hono } from "hono";
4
+ import { AssistantMessageRequest } from "@protobase/schema";
4
5
  //#region src/assistant/protocol-routes.d.ts
5
6
  export type AssistantBackend = {
6
7
  /** Resolves the caller from the request's bearer token, or throws a 401 problem. */
7
8
  authenticate: Authenticator;
8
9
  /** Whether the caller may use the assistant; others get 403. */
9
10
  allows: (session: Session) => boolean;
10
- conversationOf: (session: Session) => Conversation;
11
+ conversationOf: (session: Session) => Conversation | Promise<Conversation>;
11
12
  /** Answers a message of the user with events on the conversation; called only while the backend is not replying. */
12
- onMessage: (text: string, session: Session, conversation: Conversation) => Promise<void>;
13
+ onMessage: (message: AssistantMessageRequest, session: Session, conversation: Conversation) => Promise<void>;
13
14
  /** Gets errors of `onMessage` and of requests that become a 500. */
14
15
  report?: (error: unknown) => void;
15
16
  /** The longest message accepted. Default 4000. */
@@ -4,11 +4,16 @@ import { Hono } from "hono";
4
4
  import { streamSSE } from "hono/streaming";
5
5
  //#region src/assistant/protocol-routes.ts
6
6
  const isObject = (body) => typeof body === "object" && body !== null;
7
- const textOf = (body, max) => {
8
- const text = isObject(body) ? body.text : void 0;
7
+ const maxPageLength = 2e3;
8
+ const messageOf = (body, max) => {
9
+ const { text, page } = isObject(body) ? body : {};
9
10
  if (typeof text !== "string" || !text.trim()) throw badRequest("invalid-message", "The body must be { \"text\": \"...\" }");
10
11
  if (text.length > max) throw badRequest("invalid-message", `A message is at most ${max} characters`);
11
- return text.trim();
12
+ if (page !== void 0 && (typeof page !== "string" || page.length > maxPageLength)) throw badRequest("invalid-message", `"page" must be a path of at most ${maxPageLength} characters`);
13
+ return {
14
+ text: text.trim(),
15
+ ...page && { page }
16
+ };
12
17
  };
13
18
  const actionOf = (body) => {
14
19
  const { partId, actionId } = isObject(body) ? body : {};
@@ -31,8 +36,8 @@ const assistantProtocolRoutes = (backend) => {
31
36
  c.set("session", session);
32
37
  await next();
33
38
  });
34
- app.get("/events", (c) => {
35
- const conversation = backend.conversationOf(c.get("session"));
39
+ app.get("/events", async (c) => {
40
+ const conversation = await backend.conversationOf(c.get("session"));
36
41
  return streamSSE(c, async (stream) => {
37
42
  const queue = [{
38
43
  type: "state",
@@ -56,15 +61,15 @@ const assistantProtocolRoutes = (backend) => {
56
61
  });
57
62
  app.post("/messages", async (c) => {
58
63
  const session = c.get("session");
59
- const text = textOf(await c.req.json(), backend.maxMessageLength ?? 4e3);
60
- const conversation = backend.conversationOf(session);
64
+ const message = messageOf(await c.req.json(), backend.maxMessageLength ?? 4e3);
65
+ const conversation = await backend.conversationOf(session);
61
66
  if (conversation.state().replying) throw new HttpProblem(409, "assistant-replying", "Conflict", "The assistant is still answering");
62
- backend.onMessage(text, session, conversation).catch((error) => backend.report?.(error));
67
+ backend.onMessage(message, session, conversation).catch((error) => backend.report?.(error));
63
68
  return c.body(null, 202);
64
69
  });
65
70
  app.post("/actions", async (c) => {
66
71
  const { partId, actionId } = actionOf(await c.req.json());
67
- if (!backend.conversationOf(c.get("session")).act(partId, actionId)) throw notFound("No card waits for that action");
72
+ if (!(await backend.conversationOf(c.get("session"))).act(partId, actionId)) throw notFound("No card waits for that action");
68
73
  return c.body(null, 202);
69
74
  });
70
75
  return app;
@@ -1,5 +1,6 @@
1
1
  import { ModelSettings } from "./assistant-settings.js";
2
2
  import { Session } from "../types.js";
3
+ import { ReasoningEffort } from "./chat-completions.js";
3
4
  import { Conversation } from "./conversations.js";
4
5
  import { AssistantTool } from "./tools.js";
5
6
  //#region src/assistant/turn.d.ts
@@ -14,13 +15,23 @@ export type TurnOptions = {
14
15
  maxSteps?: number;
15
16
  /** Gets errors other than the model endpoint's refusals, which the user only sees as a failure. */
16
17
  report?: (error: unknown) => void;
17
- /** Transcript messages kept for the next turns. Default 40. */
18
- keep?: number;
18
+ /** Text the model reads before the user's message, such as the page they are on; the transcript keeps it with the message. */
19
+ context?: string;
20
+ /** How much a reasoning model thinks before it answers (OpenRouter's `reasoning.effort`); the model's default without it. */
21
+ reasoning?: ReasoningEffort;
22
+ /**
23
+ * Marks prompt-cache breakpoints (`cache_control`) on the system prompt, the transcript before this turn and the
24
+ * latest message, so an endpoint that caches on them (OpenRouter, Anthropic) bills the repeated prefix as a cache read.
25
+ * Off by default: an endpoint that does not know the field may refuse it.
26
+ */
27
+ cache?: boolean;
19
28
  };
20
29
  /**
21
30
  * One turn of the chat: adds the user's message, then calls the model with the transcript and the tools, runs the
22
31
  * tools it calls and calls it again with their results, until it answers without a tool call. Its text streams into
23
- * one assistant message, beside the parts tools show; a failure ends the turn with a danger card.
32
+ * one assistant message, beside the parts tools show; a failure ends the turn with a danger card. The transcript keeps
33
+ * every message as the model read it, so the next turn's request starts with the same prefix; the turn ends by saving
34
+ * the conversation.
24
35
  */
25
36
  export declare const runTurn: (options: TurnOptions, text: string) => Promise<void>;
26
37
  //#endregion
@@ -1,4 +1,5 @@
1
1
  import { requestApproval } from "./approval.js";
2
+ import { withCacheBreakpoints } from "./cache-breakpoints.js";
2
3
  import { ModelError, streamChatCompletion } from "./chat-completions.js";
3
4
  //#region src/assistant/turn.ts
4
5
  const toolResult = async (call, tools, context) => {
@@ -8,24 +9,26 @@ const toolResult = async (call, tools, context) => {
8
9
  if (typeof args !== "object" || args === null || Array.isArray(args)) return "The arguments must be a JSON object.";
9
10
  return tool.run(args, context);
10
11
  };
11
- const trimmed = (transcript, keep) => {
12
- if (transcript.length <= keep) return transcript;
13
- const start = transcript.findIndex((message, index) => index >= transcript.length - keep && message.role === "user");
14
- return start === -1 ? transcript.slice(-1) : transcript.slice(start);
15
- };
16
12
  const failure = (error, report) => {
17
13
  if (error instanceof ModelError) return error.message;
18
14
  if (error instanceof SyntaxError) return "The model answered with JSON that could not be read.";
19
15
  report?.(error);
20
16
  return "The server failed to answer.";
21
17
  };
18
+ const marked = (messages, before, cache) => cache ? withCacheBreakpoints(messages, [
19
+ 0,
20
+ before,
21
+ messages.length - 1
22
+ ]) : messages;
22
23
  /**
23
24
  * One turn of the chat: adds the user's message, then calls the model with the transcript and the tools, runs the
24
25
  * tools it calls and calls it again with their results, until it answers without a tool call. Its text streams into
25
- * one assistant message, beside the parts tools show; a failure ends the turn with a danger card.
26
+ * one assistant message, beside the parts tools show; a failure ends the turn with a danger card. The transcript keeps
27
+ * every message as the model read it, so the next turn's request starts with the same prefix; the turn ends by saving
28
+ * the conversation.
26
29
  */
27
30
  const runTurn = async (options, text) => {
28
- const { model, conversation, session, system, tools = [], maxSteps = 8 } = options;
31
+ const { model, conversation, session, system, tools = [], maxSteps = 8, reasoning, cache = false } = options;
29
32
  const messageId = crypto.randomUUID();
30
33
  const show = (part) => conversation.apply({
31
34
  type: "part",
@@ -62,11 +65,15 @@ const runTurn = async (options, text) => {
62
65
  type: "patch",
63
66
  replying: true
64
67
  });
65
- conversation.transcript = trimmed([...conversation.transcript, {
68
+ conversation.remember({
66
69
  role: "user",
67
- content: text
68
- }], options.keep ?? 40);
69
- const before = conversation.transcript.length;
70
+ content: options.context ? `${options.context}\n\n${text}` : text
71
+ });
72
+ const earlier = [{
73
+ role: "system",
74
+ content: system
75
+ }, ...conversation.transcript()];
76
+ const added = [];
70
77
  const steps = async () => {
71
78
  for (let step = 0; step < maxSteps; step++) {
72
79
  const part = {
@@ -74,20 +81,21 @@ const runTurn = async (options, text) => {
74
81
  id: `${messageId}-${step}`,
75
82
  text: ""
76
83
  };
77
- const { content, toolCalls } = await streamChatCompletion(model, [{
78
- role: "system",
79
- content: system
80
- }, ...conversation.transcript], (piece) => {
84
+ const { content, toolCalls, reasoningDetails } = await streamChatCompletion(model, marked([...earlier, ...added], earlier.length - 2, cache), (piece) => {
81
85
  part.text += piece;
82
86
  show({ ...part });
83
- }, { tools: specs });
84
- conversation.transcript.push({
87
+ }, {
88
+ tools: specs,
89
+ reasoning
90
+ });
91
+ added.push({
85
92
  role: "assistant",
86
93
  content: content || null,
87
- ...toolCalls.length > 0 && { tool_calls: toolCalls }
94
+ ...toolCalls.length > 0 && { tool_calls: toolCalls },
95
+ ...reasoningDetails.length > 0 && { reasoning_details: reasoningDetails }
88
96
  });
89
97
  if (toolCalls.length === 0) return;
90
- for (const call of toolCalls) conversation.transcript.push({
98
+ for (const call of toolCalls) added.push({
91
99
  role: "tool",
92
100
  tool_call_id: call.id,
93
101
  content: await toolResult(call, tools, context)
@@ -99,16 +107,14 @@ const runTurn = async (options, text) => {
99
107
  text: `Stopped after ${maxSteps} steps.`
100
108
  });
101
109
  };
102
- await steps().catch((error) => {
103
- conversation.transcript.splice(before);
104
- show({
105
- type: "card",
106
- id: `${messageId}-error`,
107
- title: "The assistant could not answer",
108
- tone: "danger",
109
- body: failure(error, options.report)
110
- });
111
- }).finally(() => conversation.apply({
110
+ await steps().then(() => conversation.remember(...added), (error) => show({
111
+ type: "card",
112
+ id: `${messageId}-error`,
113
+ title: "The assistant could not answer",
114
+ tone: "danger",
115
+ body: failure(error, options.report)
116
+ }));
117
+ await conversation.save().finally(() => conversation.apply({
112
118
  type: "patch",
113
119
  replying: false
114
120
  }));
@@ -83,6 +83,7 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
83
83
  }
84
84
  if (assistant?.kind === "built-in") app.route(`${systemPath}/assistant`, builtInAssistant({
85
85
  model: assistant.model,
86
+ chats: assistant.chats,
86
87
  db,
87
88
  meta,
88
89
  authenticate,
package/dist/index.d.ts CHANGED
@@ -9,12 +9,15 @@ import { HttpProblem, badRequest, forbidden, notFound, unauthorized } from "./pr
9
9
  import { BetterAuthAuthenticatorOptions, betterAuthAuthenticator } from "./better-auth/authenticator.js";
10
10
  import { NewUser, StoredUser, createUser, deleteUser, hasUsers, listUsers, parseRoles, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
11
11
  import { IssueTokenOptions, issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
12
- import { ChatMessage, Completion, ModelError, ToolCall, ToolSpec, streamChatCompletion } from "./assistant/chat-completions.js";
13
- import { Conversation, conversationKey, createConversations } from "./assistant/conversations.js";
12
+ import { ChatMessage, ChatTextPart, Completion, CompletionOptions, ModelError, ReasoningDetail, ReasoningEffort, ToolCall, ToolSpec, streamChatCompletion } from "./assistant/chat-completions.js";
13
+ import { ConversationRecord, ConversationStore } from "./assistant/conversation-store.js";
14
+ import { Conversation, ConversationsOptions, conversationKey, createConversations } from "./assistant/conversations.js";
14
15
  import { AssistantBackend, assistantProtocolRoutes } from "./assistant/protocol-routes.js";
16
+ import { fileConversationStore } from "./assistant/file-conversation-store.js";
17
+ import { withCacheBreakpoints } from "./assistant/cache-breakpoints.js";
15
18
  import { ApprovalAnswer, ApprovalRequest, requestApproval } from "./assistant/approval.js";
16
19
  import { AssistantTool, ToolContext, defineTool } from "./assistant/tools.js";
17
20
  import { TurnOptions, runTurn } from "./assistant/turn.js";
18
21
  import { QueryOptions, QueryResult, runReadOnlyQuery, runReadWriteQuery } from "./assistant/run-query.js";
19
22
  import { readOnlyQueryTool, readWriteQueryTool } from "./assistant/query-tools.js";
20
- export { type Admin, type AdminAuth, type AdminOptions, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type ChatMessage, type Completion, type Conversation, type CreateAdminInput, type CreateAuthOptions, HttpProblem, type IssueTokenOptions, type JwtOptions, ModelError, type ModelSettings, type NewUser, type PipelineHook, type QueryOptions, type QueryResult, type ResourceSource, type ScanGuardMode, type Session, type StoredUser, type TenantValue, type ToolCall, type ToolContext, type ToolSpec, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WriteEvent, type WriteOperation, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createUser, defineTool, deleteUser, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, readOnlyQueryTool, readWriteQueryTool, requestApproval, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, seesAssistant, setUserBanned, setUserRole, streamChatCompletion, unauthorized };
23
+ export { type Admin, type AdminAuth, type AdminOptions, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type ChatMessage, type ChatTextPart, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CreateAdminInput, type CreateAuthOptions, HttpProblem, type IssueTokenOptions, type JwtOptions, ModelError, type ModelSettings, type NewUser, type PipelineHook, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type ResourceSource, type ScanGuardMode, type Session, type StoredUser, type TenantValue, type ToolCall, type ToolContext, type ToolSpec, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WriteEvent, type WriteOperation, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createUser, defineTool, deleteUser, fileConversationStore, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, readOnlyQueryTool, readWriteQueryTool, requestApproval, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, seesAssistant, setUserBanned, setUserRole, streamChatCompletion, unauthorized, withCacheBreakpoints };
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { assistantRoles, assistantSettings, assistantVariables, seesAssistant } from "./assistant/assistant-settings.js";
2
2
  import { conversationKey, createConversations } from "./assistant/conversations.js";
3
+ import { fileConversationStore } from "./assistant/file-conversation-store.js";
3
4
  import { HttpProblem, badRequest, forbidden, notFound, unauthorized } from "./problem.js";
4
5
  import { assistantProtocolRoutes } from "./assistant/protocol-routes.js";
5
6
  import { runReadOnlyQuery, runReadWriteQuery } from "./assistant/run-query.js";
6
7
  import { defineTool } from "./assistant/tools.js";
7
8
  import { readOnlyQueryTool, readWriteQueryTool } from "./assistant/query-tools.js";
8
9
  import { requestApproval } from "./assistant/approval.js";
10
+ import { withCacheBreakpoints } from "./assistant/cache-breakpoints.js";
9
11
  import { ModelError, streamChatCompletion } from "./assistant/chat-completions.js";
10
12
  import { runTurn } from "./assistant/turn.js";
11
13
  import { consoleAuditQueue } from "./audit/console-queue.js";
@@ -16,4 +18,4 @@ import { configExports } from "./resource-source.js";
16
18
  import { jwtAuthenticator } from "./auth/jwt.js";
17
19
  import { betterAuthAuthenticator } from "./better-auth/authenticator.js";
18
20
  import { issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
19
- export { HttpProblem, ModelError, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createUser, defineTool, deleteUser, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, readOnlyQueryTool, readWriteQueryTool, requestApproval, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, seesAssistant, setUserBanned, setUserRole, streamChatCompletion, unauthorized };
21
+ export { HttpProblem, ModelError, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createUser, defineTool, deleteUser, fileConversationStore, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, readOnlyQueryTool, readWriteQueryTool, requestApproval, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, seesAssistant, setUserBanned, setUserRole, streamChatCompletion, unauthorized, withCacheBreakpoints };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -32,9 +32,9 @@
32
32
  "kysely": "^0.29.6",
33
33
  "nodemailer": "^10.0.16",
34
34
  "zod": "^4.6.5",
35
- "@protobase/layout": "0.2.0",
36
- "@protobase/query": "0.2.0",
37
- "@protobase/schema": "0.2.0"
35
+ "@protobase/layout": "0.3.1",
36
+ "@protobase/schema": "0.3.1",
37
+ "@protobase/query": "0.3.1"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsdown"