sideagent 0.4.0 → 0.5.0

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.
package/README.md CHANGED
@@ -55,6 +55,18 @@ With an agent hosted on sideagent Cloud, pass its publishable key instead of `en
55
55
 
56
56
  Pass `endpoint` as well to send the requests somewhere else, such as a proxy in front of sideagent Cloud. A malformed key, or neither prop, throws during render. `parsePublishableKey` and `publishableEndpoint` are exported from `sideagent/keys`.
57
57
 
58
+ Signed-in users: pass `getHeaders`, which the chat calls before every request, so a login token that expires is fetched fresh each time. Pass `storageKey` (the user's ID) so the last conversation the chat remembers in `localStorage` belongs to that user; on a shared browser another user starts from their own.
59
+
60
+ ```tsx
61
+ <AgentChat
62
+ publishableKey="pk_live_…"
63
+ getHeaders={async () => ({ Authorization: `Bearer ${await getToken()}` })}
64
+ storageKey={user.id}
65
+ />
66
+ ```
67
+
68
+ `getHeaders` works with `endpoint` too. Its headers are merged under `headers`, which wins on a clash, so it cannot replace the publishable key. If it throws, the request is not sent and the chat shows an error.
69
+
58
70
  ### Docked sidebar
59
71
 
60
72
  Wrap your app in `AgentProvider` to dock the chat on the right, side by side with the app instead of over it. It takes the same props as `AgentChat`. The user drags the panel's left edge (or uses the arrow keys on it) to resize; the width is remembered. Outside `AgentProvider`, sidebar mode overlays the page.
package/dist/index.d.ts CHANGED
@@ -49,6 +49,17 @@ interface AgentChatProps {
49
49
  /** Use "include" when the endpoint is on another origin and relies on cookies. */
50
50
  credentials?: RequestCredentials;
51
51
  headers?: Record<string, string>;
52
+ /**
53
+ * Called before every request; its headers are added to `headers` (which wins on a clash). Use it for a
54
+ * login token that expires, e.g. `Authorization: Bearer <token>` from your login SDK. If it throws, the
55
+ * request is not sent and the chat shows an error.
56
+ */
57
+ getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
58
+ /**
59
+ * Scopes what the chat remembers in localStorage (the last conversation) to one user, e.g. their user ID.
60
+ * Changing it starts over with that user's last conversation.
61
+ */
62
+ storageKey?: string;
52
63
  defaultOpen?: boolean;
53
64
  /** Custom result views per operationId. Anything not listed gets the built-in table / fields view. */
54
65
  resultRenderers?: Record<string, (e: {
@@ -79,10 +90,13 @@ interface AgentControls {
79
90
  /** Open or close the assistant from anywhere under `AgentProvider`. */
80
91
  declare function useAgent(): AgentControls;
81
92
 
93
+ type GetHeaders = () => Record<string, string> | Promise<Record<string, string>>;
82
94
  interface TransportOptions {
83
95
  endpoint: string;
84
96
  credentials?: RequestCredentials;
85
97
  headers?: Record<string, string>;
98
+ /** Called before every request, e.g. for a fresh login token. `headers` wins on a clash. */
99
+ getHeaders?: GetHeaders;
86
100
  signal?: AbortSignal;
87
101
  }
88
102
  /** POSTs a chat request and yields the server's SSE events. */
package/dist/index.js CHANGED
@@ -343,11 +343,22 @@ function toInitialMessages(messages) {
343
343
  }
344
344
 
345
345
  // src/client/transport.ts
346
+ async function requestHeaders(opts) {
347
+ const dynamic = opts.getHeaders ? await opts.getHeaders() : {};
348
+ return { "content-type": "application/json", ...dynamic, ...opts.headers };
349
+ }
346
350
  async function* streamChat(body, opts) {
351
+ let headers;
352
+ try {
353
+ headers = await requestHeaders(opts);
354
+ } catch {
355
+ yield { type: "error", code: "headers_failed", message: "Could not get credentials. Sign in again." };
356
+ return;
357
+ }
347
358
  const res = await fetch(opts.endpoint, {
348
359
  method: "POST",
349
360
  credentials: opts.credentials ?? "same-origin",
350
- headers: { "content-type": "application/json", ...opts.headers },
361
+ headers,
351
362
  body: JSON.stringify(body),
352
363
  signal: opts.signal
353
364
  });
@@ -377,10 +388,16 @@ async function* streamChat(body, opts) {
377
388
  }
378
389
  }
379
390
  async function callJson(body, opts) {
391
+ let headers;
392
+ try {
393
+ headers = await requestHeaders(opts);
394
+ } catch {
395
+ return { status: 0, data: null };
396
+ }
380
397
  const res = await fetch(opts.endpoint, {
381
398
  method: "POST",
382
399
  credentials: opts.credentials ?? "same-origin",
383
- headers: { "content-type": "application/json", ...opts.headers },
400
+ headers,
384
401
  body: JSON.stringify(body)
385
402
  });
386
403
  let data = null;
@@ -394,7 +411,7 @@ async function callJson(body, opts) {
394
411
  // src/client/Chat.tsx
395
412
  import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
396
413
  function Chat(props) {
397
- const { endpoint, hooks, credentials, headers } = props;
414
+ const { endpoint, hooks, credentials, headers, getHeaders } = props;
398
415
  const latest = useRef(props);
399
416
  latest.current = props;
400
417
  const threadId = useRef(props.session.threadId);
@@ -417,6 +434,7 @@ function Chat(props) {
417
434
  endpoint: p.endpoint,
418
435
  credentials: p.credentials,
419
436
  headers: p.headers,
437
+ getHeaders: p.getHeaders,
420
438
  signal: abortSignal
421
439
  })) {
422
440
  parts = applyEvent(parts, ev);
@@ -459,7 +477,7 @@ function Chat(props) {
459
477
  setLoadedPending(null);
460
478
  for await (const ev of streamChat(
461
479
  { threadId: threadId.current, approval: { token: req.token, decision } },
462
- { endpoint, credentials, headers }
480
+ { endpoint, credentials, headers, getHeaders }
463
481
  )) {
464
482
  if (ev.type === "tool.complete") fireComplete(hooks, ev);
465
483
  if (ev.type === "error") safely(() => hooks?.onError?.({ code: ev.code, message: ev.message }));
@@ -499,7 +517,12 @@ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
499
517
  function HistoryList(props) {
500
518
  const [threads, setThreads] = useState2(null);
501
519
  const [error, setError] = useState2(false);
502
- const opts = { endpoint: props.endpoint, credentials: props.credentials, headers: props.headers };
520
+ const opts = {
521
+ endpoint: props.endpoint,
522
+ credentials: props.credentials,
523
+ headers: props.headers,
524
+ getHeaders: props.getHeaders
525
+ };
503
526
  useEffect(() => {
504
527
  callJson({ action: "threads.list" }, opts).then((r) => r.data?.threads ? setThreads(r.data.threads) : setError(true)).catch(() => setError(true));
505
528
  }, []);
@@ -538,7 +561,7 @@ function HistoryList(props) {
538
561
  // src/client/storage.ts
539
562
  var MODE_KEY = "sideagent:mode";
540
563
  var WIDTH_KEY = "sideagent:width";
541
- var threadKey = (endpoint) => `sideagent:thread:${endpoint}`;
564
+ var threadKey = (endpoint, storageKey) => `sideagent:thread:${endpoint}${storageKey ? `:${storageKey}` : ""}`;
542
565
  var MODES = ["floating", "sidebar"];
543
566
  function storeGet(k) {
544
567
  try {
@@ -739,8 +762,13 @@ function AgentChat(props) {
739
762
  const [width, setWidth] = useState3(() => clampWidth(Number(storeGet(WIDTH_KEY)) || DEFAULT_WIDTH));
740
763
  const [resizing, setResizing] = useState3(false);
741
764
  const booted = useRef2(false);
742
- const key = threadKey(target.endpoint);
743
- const opts = { endpoint: target.endpoint, credentials: props.credentials, headers: target.headers };
765
+ const key = threadKey(target.endpoint, props.storageKey);
766
+ const opts = {
767
+ endpoint: target.endpoint,
768
+ credentials: props.credentials,
769
+ headers: target.headers,
770
+ getHeaders: props.getHeaders
771
+ };
744
772
  const setMode = useCallback((m) => {
745
773
  setModeState(m);
746
774
  storeSet(MODE_KEY, m);
@@ -765,7 +793,7 @@ function AgentChat(props) {
765
793
  }
766
794
  },
767
795
  // eslint-disable-next-line react-hooks/exhaustive-deps
768
- [target, props.credentials]
796
+ [target, props.credentials, props.getHeaders]
769
797
  );
770
798
  const resizeTo = useCallback((w) => {
771
799
  const next = clampWidth(w);
@@ -796,6 +824,14 @@ function AgentChat(props) {
796
824
  setView("chat");
797
825
  };
798
826
  const visible = open;
827
+ const shownKey = useRef2(key);
828
+ useEffect2(() => {
829
+ if (shownKey.current === key) return;
830
+ shownKey.current = key;
831
+ booted.current = false;
832
+ setView("chat");
833
+ setSession((s) => ({ key: s.key + 1 }));
834
+ }, [key]);
799
835
  useEffect2(() => {
800
836
  if (!visible || booted.current) return;
801
837
  booted.current = true;
@@ -2,7 +2,11 @@ import { ThreadMeta } from '../protocol.js';
2
2
  export { AgentEvent, ChatRequest } from '../protocol.js';
3
3
  import { z } from 'zod';
4
4
  import { BaseChatModel } from '@langchain/core/language_models/chat_models';
5
- import { BaseCheckpointSaver } from '@langchain/langgraph';
5
+ import { BaseStore, BaseCheckpointSaver } from '@langchain/langgraph';
6
+ import { Document } from '@langchain/core/documents';
7
+ import { EmbeddingsInterface } from '@langchain/core/embeddings';
8
+ import { VectorStore, VectorStoreInterface } from '@langchain/core/vectorstores';
9
+ export { VectorStoreInterface as VectorStore } from '@langchain/core/vectorstores';
6
10
 
7
11
  declare const configSchema: z.ZodObject<{
8
12
  spec: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>]>;
@@ -71,31 +75,35 @@ interface ChunkMeta {
71
75
  /** Only set for sources that came from a URL listed in config. */
72
76
  url?: string;
73
77
  }
74
- interface StoredChunk {
75
- text: string;
76
- vector: number[];
77
- meta: ChunkMeta;
78
- }
79
78
  interface Hit {
80
79
  text: string;
81
80
  score: number;
82
81
  meta: ChunkMeta;
83
82
  }
84
- /** Plug in your own (pgvector, Vectorize, …). The default is an in-memory cosine store. */
85
- interface VectorStore {
86
- replace(sourceId: string, chunks: StoredChunk[]): Promise<void>;
87
- search(vector: number[], k: number): Promise<Hit[]>;
88
- }
89
- declare class MemoryVectorStore implements VectorStore {
90
- private bySource;
91
- replace(sourceId: string, chunks: StoredChunk[]): Promise<void>;
92
- search(vector: number[], k: number): Promise<Hit[]>;
83
+
84
+ /** Default store: cosine similarity over everything in process memory. */
85
+ declare class MemoryVectorStore extends VectorStore {
86
+ FilterType: object;
87
+ lc_namespace: string[];
88
+ private rows;
89
+ constructor(embeddings: EmbeddingsInterface);
90
+ _vectorstoreType(): string;
91
+ addVectors(vectors: number[][], documents: Document[], options?: {
92
+ ids?: string[];
93
+ }): Promise<string[]>;
94
+ addDocuments(documents: Document[], options?: {
95
+ ids?: string[];
96
+ }): Promise<string[]>;
97
+ delete(params?: {
98
+ ids?: string[];
99
+ }): Promise<void>;
100
+ similaritySearchVectorWithScore(query: number[], k: number): Promise<[Document, number][]>;
93
101
  }
94
102
 
95
- /** Splits markdown/plain text into chunks of roughly `size` chars, keeping the nearest heading as context. */
96
- declare function chunkText(content: string, size: number): string[];
103
+ /** Splits markdown/plain text into chunks of at most about `size` chars, each prefixed with its nearest heading. */
104
+ declare function chunkText(content: string, size: number): Promise<string[]>;
97
105
 
98
- /** Dependency-free HTML to text/markdown-ish conversion. Plain text out; never HTML. */
106
+ /** HTML to text/markdown-ish conversion on a real parser. Plain text out; never HTML. */
99
107
  declare function htmlToText(html: string): {
100
108
  title?: string;
101
109
  text: string;
@@ -130,7 +138,7 @@ interface SourceStatus {
130
138
  interface KnowledgeDeps {
131
139
  embedDocuments(texts: string[]): Promise<number[][]>;
132
140
  embedQuery(text: string): Promise<number[]>;
133
- store?: VectorStore;
141
+ store?: VectorStoreInterface;
134
142
  allowPrivateNetworks?: boolean;
135
143
  fetch?: typeof fetch;
136
144
  now?: () => number;
@@ -138,8 +146,10 @@ interface KnowledgeDeps {
138
146
  declare class KnowledgeBase {
139
147
  private opts;
140
148
  private deps;
141
- readonly store: VectorStore;
149
+ readonly store: VectorStoreInterface;
142
150
  private statuses;
151
+ /** Chunk ids currently stored per source, so a refresh can drop the ones that disappeared. */
152
+ private chunkIds;
143
153
  private refreshing;
144
154
  private lastRefresh;
145
155
  private totalChunks;
@@ -208,6 +218,20 @@ declare class MemoryThreadStore implements ThreadStore {
208
218
  touch(userId: string, threadId: string, title: string): Promise<void>;
209
219
  delete(userId: string, threadId: string): Promise<void>;
210
220
  }
221
+ /**
222
+ * Thread index on any LangGraph `BaseStore` (Postgres, Redis, …), so one store can serve the checkpointer
223
+ * ecosystem and the thread list. Keeps at most 200 threads per user, like `MemoryThreadStore`.
224
+ */
225
+ declare class LangGraphThreadStore implements ThreadStore {
226
+ private store;
227
+ constructor(store: BaseStore);
228
+ private ns;
229
+ private all;
230
+ list(userId: string): Promise<ThreadMeta[]>;
231
+ has(userId: string, threadId: string): Promise<boolean>;
232
+ touch(userId: string, threadId: string, title: string): Promise<void>;
233
+ delete(userId: string, threadId: string): Promise<void>;
234
+ }
211
235
 
212
236
  interface AuthResult {
213
237
  /** Stable identity of the signed-in user. Threads are bound to it. */
@@ -238,7 +262,7 @@ interface CreateAgentOptions extends AgentConfigInput {
238
262
  /** Index of each user's conversations (history list). Defaults to in-memory. */
239
263
  threadStore?: ThreadStore;
240
264
  /** Where knowledge embeddings live. Defaults to in-memory (re-embedded on every start). */
241
- vectorStore?: VectorStore;
265
+ vectorStore?: VectorStoreInterface;
242
266
  }
243
267
  declare function createAgent(options: CreateAgentOptions): Promise<{
244
268
  handler: (request: Request) => Promise<Response>;
@@ -262,4 +286,4 @@ declare function honoHandler(agent: Agent): (c: {
262
286
  /** Express (use express.json() before it): `app.post("/agent", expressHandler(agent))` and `app.options("/agent", expressHandler(agent))` */
263
287
  declare function expressHandler(agent: Agent): (req: any, res: any) => Promise<any>;
264
288
 
265
- export { type Agent, type AgentConfig, type AgentConfigInput, type AuditEvent, type AuthResult, type CreateAgentOptions, type DoctorReport, type EmbeddingsLike, KnowledgeBase, type KnowledgeSourceInput, MemoryThreadStore, MemoryVectorStore, type ThreadStore, type VectorStore, assertAllowedUrl, chunkText, configSchema, createAgent, expressHandler, honoHandler, htmlToText, nextRoute, parseConfig };
289
+ export { type Agent, type AgentConfig, type AgentConfigInput, type AuditEvent, type AuthResult, type CreateAgentOptions, type DoctorReport, type EmbeddingsLike, KnowledgeBase, type KnowledgeSourceInput, LangGraphThreadStore, MemoryThreadStore, MemoryVectorStore, type ThreadStore, assertAllowedUrl, chunkText, configSchema, createAgent, expressHandler, honoHandler, htmlToText, nextRoute, parseConfig };
@@ -232,34 +232,39 @@ import {
232
232
  } from "@langchain/langgraph";
233
233
 
234
234
  // src/server/knowledge/chunk.ts
235
- function chunkText(content, size) {
235
+ import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
236
+ var HEADING = /^#{1,6}\s.*$/;
237
+ async function chunkText(content, size) {
236
238
  const out = [];
237
239
  let heading = "";
238
- let cur = "";
239
- const flush = () => {
240
- if (cur.trim()) out.push(cur.trim());
241
- cur = "";
242
- };
243
- const withHeading = (t) => heading && !t.startsWith("#") ? `${heading}
240
+ let body = [];
241
+ const flush = async () => {
242
+ const text = body.join("\n").trim();
243
+ body = [];
244
+ if (!text) return;
245
+ const room = Math.max(size - heading.length - 2, Math.floor(size / 2));
246
+ const splitter = RecursiveCharacterTextSplitter.fromLanguage("markdown", {
247
+ chunkSize: room,
248
+ chunkOverlap: Math.floor(room / 10)
249
+ });
250
+ for (const piece of await splitter.splitText(text)) {
251
+ const t = piece.trim();
252
+ if (t) out.push(heading ? `${heading}
244
253
 
245
- ${t}` : t;
246
- for (const raw of content.replace(/\r\n/g, "\n").split(/\n{2,}/)) {
247
- const block = raw.trim();
248
- if (!block) continue;
249
- if (/^#{1,6}\s/.test(block)) {
250
- flush();
251
- heading = block.split("\n")[0];
254
+ ${t}` : t);
252
255
  }
253
- const pieces = block.length > size ? block.match(new RegExp(`[\\s\\S]{1,${size}}(?:\\s|$)`, "g")) ?? [block] : [block];
254
- for (const piece of pieces) {
255
- if (cur && cur.length + piece.length + 2 > size) flush();
256
- if (!cur) cur = withHeading(piece.trim());
257
- else cur += `
258
-
259
- ${piece.trim()}`;
256
+ };
257
+ let fence = false;
258
+ for (const line of content.replace(/\r\n/g, "\n").split("\n")) {
259
+ if (/^\s*(```|~~~)/.test(line)) fence = !fence;
260
+ if (!fence && HEADING.test(line)) {
261
+ await flush();
262
+ heading = line.trim();
263
+ } else {
264
+ body.push(line);
260
265
  }
261
266
  }
262
- flush();
267
+ await flush();
263
268
  return out;
264
269
  }
265
270
 
@@ -357,25 +362,44 @@ async function executeOperation(op, args, ctx, signal) {
357
362
  }
358
363
 
359
364
  // src/server/knowledge/html.ts
360
- var ENTITIES = {
361
- "&amp;": "&",
362
- "&lt;": "<",
363
- "&gt;": ">",
364
- "&quot;": '"',
365
- "&#39;": "'",
366
- "&nbsp;": " "
367
- };
365
+ import { Parser } from "htmlparser2";
366
+ var SKIP = /* @__PURE__ */ new Set(["script", "style", "noscript", "svg", "nav", "footer", "header", "form", "iframe", "template"]);
367
+ var BLOCK_END = /* @__PURE__ */ new Set(["p", "div", "section", "article", "tr", "ul", "ol", "table", "pre", "blockquote"]);
368
368
  function htmlToText(html) {
369
- const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1]?.trim();
370
- const text = html.replace(/<!--[\s\S]*?-->/g, "").replace(/<(script|style|noscript|svg|nav|footer|header|form|iframe)\b[\s\S]*?<\/\1>/gi, "").replace(
371
- /<h([1-6])[^>]*>([\s\S]*?)<\/h\1>/gi,
372
- (_m, n, t) => `
373
-
374
- ${"#".repeat(Number(n))} ${t}
369
+ let out = "";
370
+ let title = "";
371
+ let skip = 0;
372
+ let inTitle = false;
373
+ const parser = new Parser(
374
+ {
375
+ onopentag(name) {
376
+ if (SKIP.has(name)) skip++;
377
+ else if (name === "title") inTitle = true;
378
+ else if (skip) return;
379
+ else if (/^h[1-6]$/.test(name)) out += `
375
380
 
376
- `
377
- ).replace(/<li[^>]*>/gi, "\n- ").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|div|section|article|tr|ul|ol|table|pre|blockquote)>/gi, "\n\n").replace(/<[^>]+>/g, "").replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (m) => ENTITIES[m] ?? m).replace(/[ \t]+/g, " ").replace(/ ?\n ?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
378
- return { title: title ? htmlToText(title).text.replace(/\s+/g, " ") : void 0, text };
381
+ ${"#".repeat(Number(name[1]))} `;
382
+ else if (name === "li") out += "\n- ";
383
+ else if (name === "br") out += "\n";
384
+ },
385
+ onclosetag(name) {
386
+ if (SKIP.has(name)) skip = Math.max(0, skip - 1);
387
+ else if (name === "title") inTitle = false;
388
+ else if (skip) return;
389
+ else if (/^h[1-6]$/.test(name) || BLOCK_END.has(name)) out += "\n\n";
390
+ },
391
+ ontext(text2) {
392
+ if (inTitle) title += text2;
393
+ else if (!skip) out += text2;
394
+ }
395
+ },
396
+ { decodeEntities: true }
397
+ );
398
+ parser.write(html);
399
+ parser.end();
400
+ const text = out.replace(/[ \t ]+/g, " ").replace(/ ?\n ?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
401
+ const clean = title.replace(/\s+/g, " ").trim();
402
+ return { title: clean || void 0, text };
379
403
  }
380
404
 
381
405
  // src/server/knowledge/fetch-page.ts
@@ -415,32 +439,43 @@ async function fetchPage(url2, opts) {
415
439
  throw new Error("too many redirects");
416
440
  }
417
441
 
442
+ // src/server/knowledge/index.ts
443
+ import { Document } from "@langchain/core/documents";
444
+
418
445
  // src/server/knowledge/vector-store.ts
419
- var MemoryVectorStore = class {
420
- bySource = /* @__PURE__ */ new Map();
421
- async replace(sourceId, chunks) {
422
- this.bySource.set(sourceId, chunks);
423
- }
424
- async search(vector, k) {
425
- const hits = [];
426
- for (const chunks of this.bySource.values()) {
427
- for (const c of chunks) hits.push({ text: c.text, meta: c.meta, score: cosine(vector, c.vector) });
428
- }
429
- return hits.sort((a, b) => b.score - a.score).slice(0, k);
446
+ import { VectorStore } from "@langchain/core/vectorstores";
447
+ import { cosineSimilarity } from "@langchain/core/utils/math";
448
+ var MemoryVectorStore = class extends VectorStore {
449
+ lc_namespace = ["sideagent", "vectorstores", "memory"];
450
+ rows = /* @__PURE__ */ new Map();
451
+ constructor(embeddings) {
452
+ super(embeddings, {});
453
+ }
454
+ _vectorstoreType() {
455
+ return "sideagent_memory";
456
+ }
457
+ async addVectors(vectors, documents, options) {
458
+ const ids = documents.map((_, i) => options?.ids?.[i] ?? crypto.randomUUID());
459
+ documents.forEach((doc, i) => this.rows.set(ids[i], { vector: vectors[i], doc }));
460
+ return ids;
461
+ }
462
+ async addDocuments(documents, options) {
463
+ const vectors = await this.embeddings.embedDocuments(documents.map((d) => d.pageContent));
464
+ return this.addVectors(vectors, documents, options);
465
+ }
466
+ async delete(params) {
467
+ for (const id of params?.ids ?? []) this.rows.delete(id);
468
+ }
469
+ async similaritySearchVectorWithScore(query, k) {
470
+ const rows = [...this.rows.values()].filter((r) => r.vector.length === query.length);
471
+ if (!rows.length) return [];
472
+ const scores = cosineSimilarity(
473
+ [query],
474
+ rows.map((r) => r.vector)
475
+ )[0];
476
+ return rows.map((r, i) => [r.doc, scores[i]]).sort((a, b) => b[1] - a[1]).slice(0, k);
430
477
  }
431
478
  };
432
- function cosine(a, b) {
433
- let dot = 0;
434
- let na = 0;
435
- let nb = 0;
436
- const n = Math.min(a.length, b.length);
437
- for (let i = 0; i < n; i++) {
438
- dot += a[i] * b[i];
439
- na += a[i] * a[i];
440
- nb += b[i] * b[i];
441
- }
442
- return na && nb ? dot / Math.sqrt(na * nb) : 0;
443
- }
444
479
 
445
480
  // src/server/knowledge/format.ts
446
481
  function wrapKnowledge(hits, maxBytes) {
@@ -478,12 +513,14 @@ var KnowledgeBase = class {
478
513
  constructor(opts, deps) {
479
514
  this.opts = opts;
480
515
  this.deps = deps;
481
- this.store = deps.store ?? new MemoryVectorStore();
516
+ this.store = deps.store ?? new MemoryVectorStore({ embedDocuments: (t) => deps.embedDocuments(t), embedQuery: (t) => deps.embedQuery(t) });
482
517
  }
483
518
  opts;
484
519
  deps;
485
520
  store;
486
521
  statuses = /* @__PURE__ */ new Map();
522
+ /** Chunk ids currently stored per source, so a refresh can drop the ones that disappeared. */
523
+ chunkIds = /* @__PURE__ */ new Map();
487
524
  refreshing = null;
488
525
  lastRefresh = 0;
489
526
  totalChunks = 0;
@@ -525,16 +562,24 @@ var KnowledgeBase = class {
525
562
  if (new TextEncoder().encode(content).byteLength > this.opts.maxSourceBytes) {
526
563
  throw new Error(`source larger than maxSourceBytes (${this.opts.maxSourceBytes})`);
527
564
  }
528
- const texts = chunkText(content, this.opts.chunkSize);
565
+ const texts = await chunkText(content, this.opts.chunkSize);
529
566
  const others = this.totalChunks - (this.statuses.get(id)?.chunks ?? 0);
530
567
  if (others + texts.length > this.opts.maxChunks)
531
568
  throw new Error(`would exceed maxChunks (${this.opts.maxChunks})`);
532
569
  const vectors = texts.length ? await this.deps.embedDocuments(texts) : [];
533
570
  const meta = { sourceId: id, title, ...url2 ? { url: url2 } : {} };
534
- await this.store.replace(
535
- id,
536
- texts.map((text, i) => ({ text, vector: vectors[i], meta }))
537
- );
571
+ const ids = texts.map((_, i) => `${id}#${i}`);
572
+ if (texts.length) {
573
+ await this.store.addVectors(
574
+ vectors,
575
+ texts.map((text) => new Document({ pageContent: text, metadata: meta })),
576
+ { ids }
577
+ );
578
+ }
579
+ const keep = new Set(ids);
580
+ const stale = (this.chunkIds.get(id) ?? []).filter((old) => !keep.has(old));
581
+ if (stale.length) await this.store.delete({ ids: stale });
582
+ this.chunkIds.set(id, ids);
538
583
  this.totalChunks = others + texts.length;
539
584
  this.statuses.set(id, { id, title, ...url2 ? { url: url2 } : {}, chunks: texts.length, loadedAt: this.now() });
540
585
  } catch (e) {
@@ -557,7 +602,8 @@ var KnowledgeBase = class {
557
602
  if (this.opts.urls.length && this.now() - this.lastRefresh > this.opts.ttlMs) void this.refresh().catch(() => {
558
603
  });
559
604
  const vector = await this.deps.embedQuery(query);
560
- return this.store.search(vector, this.opts.topK);
605
+ const found = await this.store.similaritySearchVectorWithScore(vector, this.opts.topK);
606
+ return found.map(([doc, score]) => ({ text: doc.pageContent, score, meta: doc.metadata }));
561
607
  }
562
608
  };
563
609
 
@@ -647,6 +693,7 @@ async function verifyApproval(secret, token, claims) {
647
693
  }
648
694
 
649
695
  // src/server/openapi.ts
696
+ import { Validator } from "@langchain/core/utils/json_schema";
650
697
  import { dereference } from "@scalar/openapi-parser";
651
698
  var METHODS = ["get", "post", "put", "patch", "delete"];
652
699
  async function loadSpec(spec) {
@@ -743,6 +790,7 @@ async function loadOperations(spec, allow, maxTools) {
743
790
  }
744
791
  return ops;
745
792
  }
793
+ var validators = /* @__PURE__ */ new WeakMap();
746
794
  function validateArgs(op, args) {
747
795
  if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error("arguments must be an object");
748
796
  const a = args;
@@ -751,6 +799,13 @@ function validateArgs(op, args) {
751
799
  for (const r of op.inputSchema.required ?? []) {
752
800
  if (a[r] === void 0) throw new Error(`missing required argument "${r}"`);
753
801
  }
802
+ let validator = validators.get(op);
803
+ if (!validator) validators.set(op, validator = new Validator(op.inputSchema, "2019-09", false));
804
+ const result = validator.validate(a);
805
+ if (!result.valid) {
806
+ const detail = result.errors.slice(0, 3).map((e) => `${e.instanceLocation.replace(/^#\/?/, "") || "arguments"}: ${e.error}`).join("; ");
807
+ throw new Error(detail);
808
+ }
754
809
  return a;
755
810
  }
756
811
 
@@ -982,6 +1037,34 @@ var MemoryThreadStore = class {
982
1037
  this.of(userId).delete(threadId);
983
1038
  }
984
1039
  };
1040
+ var LangGraphThreadStore = class {
1041
+ constructor(store) {
1042
+ this.store = store;
1043
+ }
1044
+ store;
1045
+ // Namespace labels cannot contain "." and user ids are often emails.
1046
+ ns = (userId) => ["sideagent", "threads", encodeURIComponent(userId).replace(/\./g, "%2E")];
1047
+ async all(userId) {
1048
+ const items = await this.store.search(this.ns(userId), { limit: MAX_THREADS_PER_USER + 1 });
1049
+ return items.map((i) => i.value).sort((a, b) => b.updatedAt - a.updatedAt);
1050
+ }
1051
+ async list(userId) {
1052
+ return (await this.all(userId)).slice(0, MAX_THREADS_PER_USER);
1053
+ }
1054
+ async has(userId, threadId) {
1055
+ return await this.store.get(this.ns(userId), threadId) != null;
1056
+ }
1057
+ async touch(userId, threadId, title) {
1058
+ const existing = (await this.store.get(this.ns(userId), threadId))?.value;
1059
+ const meta = { id: threadId, title: existing?.title ?? title, updatedAt: Date.now() };
1060
+ await this.store.put(this.ns(userId), threadId, meta);
1061
+ const threads = await this.all(userId);
1062
+ if (threads.length > MAX_THREADS_PER_USER) await this.delete(userId, threads[threads.length - 1].id);
1063
+ }
1064
+ async delete(userId, threadId) {
1065
+ await this.store.delete(this.ns(userId), threadId);
1066
+ }
1067
+ };
985
1068
  var titleFrom = (message) => message.replace(/\s+/g, " ").trim().slice(0, 60) || "New chat";
986
1069
  var threadPrefix = (userId) => `${encodeURIComponent(userId)}:`;
987
1070
  async function ownsThread(store, userId, threadId) {
@@ -1048,7 +1131,7 @@ function toHistory(messages) {
1048
1131
  return last;
1049
1132
  };
1050
1133
  for (const m of messages) {
1051
- const type = m._getType();
1134
+ const type = m.getType();
1052
1135
  if (type === "human") {
1053
1136
  out.push({ role: "user", parts: [{ type: "text", text: messageText(m.content) }] });
1054
1137
  } else if (type === "ai") {
@@ -1092,7 +1175,7 @@ async function handleThreadAction(body, { userId, threads, graph }, cors) {
1092
1175
 
1093
1176
  // src/server/http/turn.ts
1094
1177
  import { HumanMessage } from "@langchain/core/messages";
1095
- import { Command } from "@langchain/langgraph";
1178
+ import { Command, GraphRecursionError } from "@langchain/langgraph";
1096
1179
  async function runTurn(request, body, auth, deps, cors) {
1097
1180
  const { config, graph, threads, secret, active } = deps;
1098
1181
  const { userId } = auth;
@@ -1122,7 +1205,11 @@ async function runTurn(request, body, auth, deps, cors) {
1122
1205
  await threads.touch(userId, threadId, titleFrom("message" in body ? body.message : ""));
1123
1206
  active.set(userId, (active.get(userId) ?? 0) + 1);
1124
1207
  const timeout = new AbortController();
1125
- const timer = setTimeout(() => timeout.abort(new Error("turn timed out")), config.limits.turnTimeoutMs);
1208
+ let timedOut = false;
1209
+ const timer = setTimeout(() => {
1210
+ timedOut = true;
1211
+ timeout.abort(new Error("turn timed out"));
1212
+ }, config.limits.turnTimeoutMs);
1126
1213
  request.signal.addEventListener("abort", () => timeout.abort(), { once: true });
1127
1214
  const encoder = new TextEncoder();
1128
1215
  const stream = new ReadableStream({
@@ -1155,9 +1242,9 @@ async function runTurn(request, body, auth, deps, cors) {
1155
1242
  const waiting = pendingApprovalOf(await graph.getState(runCfg));
1156
1243
  if (waiting) send({ type: "approval.request", ...waiting });
1157
1244
  } catch (e) {
1158
- const msg = e.message ?? "error";
1159
- const code = /recursion/i.test(msg) ? "step_limit" : /timed out/i.test(msg) ? "timeout" : "error";
1160
- send({ type: "error", code, message: code === "error" ? "The agent failed. Try again." : msg });
1245
+ const code = e instanceof GraphRecursionError ? "step_limit" : timedOut ? "timeout" : "error";
1246
+ const message = code === "step_limit" ? e.message : timedOut ? "turn timed out" : void 0;
1247
+ send({ type: "error", code, message: message ?? "The agent failed. Try again." });
1161
1248
  } finally {
1162
1249
  clearTimeout(timer);
1163
1250
  active.set(userId, Math.max(0, (active.get(userId) ?? 1) - 1));
@@ -1341,6 +1428,7 @@ function expressHandler(agent) {
1341
1428
  }
1342
1429
  export {
1343
1430
  KnowledgeBase,
1431
+ LangGraphThreadStore,
1344
1432
  MemoryThreadStore,
1345
1433
  MemoryVectorStore,
1346
1434
  assertAllowedUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sideagent",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -54,8 +54,10 @@
54
54
  "@langchain/core": "^1.0.0",
55
55
  "@langchain/langgraph": "^1.5.2",
56
56
  "@langchain/openai": "^1.7.0",
57
+ "@langchain/textsplitters": "^1.0.2",
57
58
  "@radix-ui/react-icons": "^1.3.2",
58
59
  "@scalar/openapi-parser": "^0.29.16",
60
+ "htmlparser2": "^12.0.0",
59
61
  "react-markdown": "^10.1.0",
60
62
  "remark-gfm": "^4.0.1",
61
63
  "zod": "^4.0.0"