dopus 0.2.8 → 0.2.9

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
@@ -224,13 +224,68 @@ Anthropic gets an explicit cache breakpoint after layer 3. OpenAI and Gemini cac
224
224
  - `<AgentChat />`: the panel;
225
225
  - `<AgentWidget />`: a floating launcher that opens the panel.
226
226
 
227
- Both accept `api`, `suggestions`, `labels` (every UI string, for translation), `className`, and `channel` /
228
- `maxJobFollowUps` for [background jobs](#background-jobs).
227
+ Both accept `api`, `suggestions`, `labels` (every UI string, for translation), `className`, `channel` /
228
+ `maxJobFollowUps` for [background jobs](#background-jobs), and `history` to [keep the chat](#chat-history).
229
229
 
230
230
  The UI shows each tool call as it runs. When a reply finishes while the widget is closed (for example the agent
231
231
  continuing after a [background job](#background-jobs)), the launcher shows a dot until it is opened (`labels.unread`
232
232
  for screen readers; `onReply` if you want your own signal). It renders an approve/decline card for confirmed tools and continues automatically once the user answers. When a tool has run, it calls `router.refresh()` so server components show the changed data. Closing the widget keeps the conversation, and a "New chat" button clears it. Assistant replies render as Markdown (tables, lists, code) through `react-markdown` with `remark-gfm`, which `init` installs. Raw HTML is never rendered, images show only their alt text, and links keep only http(s), mailto and relative URLs. If a request is over the size limit (413), the UI says so and offers a new chat instead of a useless retry. It also has stop and retry buttons, and it does not send on Enter while an IME (Korean, Japanese, Chinese) is still composing.
233
233
 
234
+ ### Chat history
235
+
236
+ By default the conversation lives in memory: a reload starts a new chat. To keep it, pass `history` with `load`
237
+ (called once on mount) and `save` (called when the chat settles, and with `[]` on "New chat"). Where it goes is up to
238
+ you. Both hold functions, so set them up in a client component.
239
+
240
+ In this tab only (sessionStorage), keyed by user so that after a sign-out the next user in the tab does not see it:
241
+
242
+ ```tsx
243
+ // components/agent/agent.tsx
244
+ 'use client'
245
+ import { useMemo } from 'react'
246
+ import { type AgentChatHistory, type AgentChatProps, AgentWidget } from './agent-chat'
247
+
248
+ const sessionHistory = (key: string): AgentChatHistory => ({
249
+ load: () => JSON.parse(sessionStorage.getItem(key) ?? '[]'),
250
+ save: (messages) => sessionStorage.setItem(key, JSON.stringify(messages)),
251
+ })
252
+
253
+ export function Agent({ userId, ...props }: AgentChatProps & { userId: string }) {
254
+ const history = useMemo(() => sessionHistory(`dopus-chat:${userId}`), [userId])
255
+ return <AgentWidget {...props} history={history} />
256
+ }
257
+ ```
258
+
259
+ In your database: save on the server, where you know the user, and load from the client.
260
+
261
+ ```ts
262
+ // agent.config.ts
263
+ onFinish: async ({ user, messages }) => {
264
+ if (user) await db.chat.upsert({ userId: user.id, messages }) // needs `identify`
265
+ },
266
+ ```
267
+
268
+ ```tsx
269
+ // components/agent/agent.tsx: loadChat is your server action returning the saved messages
270
+ ;<AgentWidget
271
+ history={{
272
+ load: () => loadChat(),
273
+ save: (m) => {
274
+ if (m.length === 0) clearChat()
275
+ },
276
+ }}
277
+ />
278
+ ```
279
+
280
+ Things to know:
281
+
282
+ - **Tool results are stored too.** Whatever your tools returned (orders, names) is in the saved conversation. Keep it
283
+ per user, and clear it on sign-out if the device may be shared.
284
+ - **It is still one conversation.** The browser sends the whole history with each message, so a long chat reaches the
285
+ size limit (413) and the UI offers a new chat. There is no list of past chats.
286
+ - **Background jobs across a reload.** A job that finishes while the page is reloading is not announced again, so its
287
+ "working in the background" line stays. The user can ask, and the agent reads the result with your status tool.
288
+
234
289
  Styling is one plain CSS file (`agent-chat.css`) whose classes all start with `dopus-`, themed through `--dopus-*` variables, with dark mode included. It is deliberately not a CSS Module: create-next-app's Tailwind setup routes every `*.css` file, modules included, through its own Turbopack loader, which left module classes unstyled. It needs no Tailwind and no component library. The file is yours, so restyle it or swap in your own components.
235
290
 
236
291
  ## Configuration
@@ -285,6 +340,7 @@ defineAgent({
285
340
  },
286
341
 
287
342
  // Once per request, when the reply is done: tokens summed over all steps.
343
+ // also gets `messages`: the whole conversation after this reply (see "Chat history")
288
344
  onFinish: async ({ user, model, usage, steps, finishReason, durationMs }) => {
289
345
  await db.usage.insert({ userId: user?.id, model: model.modelId, ...usage })
290
346
  },
@@ -521,6 +577,7 @@ Planned next, within the lightweight scope above:
521
577
  pnpm install
522
578
  pnpm build && pnpm lint && pnpm typecheck && pnpm test
523
579
  pnpm example:build && pnpm example:e2e # next start + scripted model
580
+ pnpm example:ui # the chat UI in Chromium (Playwright): approvals, jobs, limits
524
581
  pnpm example:live # real model from examples/next-app/.env.local (costs cents)
525
582
  pnpm smoke # packed tarball → fresh app → init (older release) → update → next build
526
583
  pnpm templates:history # after changing a template: record its hash for `dopus update`
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as PROVIDERS, t as VERSION } from "./version-Bs8LB7Ph.js";
2
+ import { n as PROVIDERS, t as VERSION } from "./version-NYbd20Zw.js";
3
3
  import { execFileSync, spawn } from "node:child_process";
4
4
  import { parseArgs } from "node:util";
5
5
  import * as p from "@clack/prompts";
package/dist/index.d.ts CHANGED
@@ -111,7 +111,10 @@ interface AgentConfig<TOOLS extends DopusToolSet = DopusToolSet> {
111
111
  }) => Response | undefined | Promise<Response | undefined>;
112
112
  /** Called once per tool call: ran (`success` / `error`) or declined by the user (`denied`). For audit logs. */
113
113
  onToolCall?: (event: ToolCallEvent) => void | Promise<void>;
114
- /** Called once per request when the reply is finished: tokens, steps, model. For usage tracking. */
114
+ /**
115
+ * Called once per request when the reply is finished: tokens, steps, model (usage tracking), and the whole
116
+ * conversation including the reply (to save chat history in your database).
117
+ */
115
118
  onFinish?: (event: FinishEvent) => void | Promise<void>;
116
119
  /** Maximum model steps (tool round trips) per request. Default 10. */
117
120
  maxSteps?: number;
@@ -169,6 +172,11 @@ interface FinishEvent {
169
172
  steps: number;
170
173
  finishReason: string;
171
174
  durationMs: number;
175
+ /**
176
+ * The conversation after this reply, as the chat UI holds it (`UIMessage[]`). Save it to restore the chat later
177
+ * (the chat UI's `history.load`). It came from the browser, so treat it as the user's own data, not as trusted.
178
+ */
179
+ messages: UIMessage[];
172
180
  }
173
181
  /** Identity function that gives `agent.config.ts` type checking. */
174
182
  export declare function defineAgent<TOOLS extends DopusToolSet>(config: AgentConfig<TOOLS>): AgentConfig<TOOLS>;
@@ -216,5 +224,5 @@ interface ProviderInfo {
216
224
  }
217
225
  /** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
218
226
  export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
219
- export declare const VERSION = "0.2.8";
227
+ export declare const VERSION = "0.2.9";
220
228
  export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, FinishEvent, HandlerOptions, PromptLayers, ProviderId, ProviderInfo, RuntimeContext, ToolCallEvent, ToolContext, ToolOptions };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as PROVIDERS, t as VERSION } from "./version-Bs8LB7Ph.js";
1
+ import { n as PROVIDERS, t as VERSION } from "./version-NYbd20Zw.js";
2
2
  import { convertToModelMessages, createUIMessageStreamResponse, getToolName, isStepCount, isToolUIPart, safeValidateUIMessages, streamText, toUIMessageStream, tool } from "ai";
3
3
  // src/job.ts
4
4
  /**
@@ -378,6 +378,14 @@ function createAgentHandler(config, options = {}) {
378
378
  })
379
379
  };
380
380
  const approvalSecret = config.approvalSecret ?? process.env.DOPUS_APPROVAL_SECRET;
381
+ const finish = { async emit() {
382
+ if (this.done || !this.usage || !this.messages) return;
383
+ this.done = true;
384
+ await runHook(config.onFinish, {
385
+ ...this.usage,
386
+ messages: this.messages
387
+ }, report);
388
+ } };
381
389
  const result = streamText({
382
390
  ...config.callOptions,
383
391
  model: config.model,
@@ -410,7 +418,7 @@ function createAgentHandler(config, options = {}) {
410
418
  onError: ({ error }) => report(error),
411
419
  onEnd: async (event) => {
412
420
  await config.callOptions?.onEnd?.(event);
413
- await runHook(config.onFinish, {
421
+ finish.usage = {
414
422
  user,
415
423
  model: {
416
424
  provider: event.model.provider,
@@ -425,13 +433,19 @@ function createAgentHandler(config, options = {}) {
425
433
  steps: event.stepNumber + 1,
426
434
  finishReason: event.finishReason,
427
435
  durationMs: Date.now() - started
428
- }, report);
436
+ };
437
+ await finish.emit();
429
438
  }
430
439
  });
431
440
  const response = createUIMessageStreamResponse({ stream: toUIMessageStream({
432
441
  stream: result.stream,
433
442
  tools,
434
443
  originalMessages: messages,
444
+ onEnd: async ({ messages: final, isAborted }) => {
445
+ if (isAborted) return;
446
+ finish.messages = final;
447
+ await finish.emit();
448
+ },
435
449
  onError: (error) => {
436
450
  if (error instanceof ToolError) return error.message;
437
451
  report(error);
@@ -27,5 +27,5 @@ const PROVIDERS = {
27
27
  }
28
28
  };
29
29
  // src/version.ts
30
- const VERSION = "0.2.8";
30
+ const VERSION = "0.2.9";
31
31
  export { PROVIDERS as n, VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dopus",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "A lightweight agent for Next.js: your existing server functions become its tools, run with the signed-in user's permissions.",
5
5
  "license": "MIT",
6
6
  "author": "akodm",
@@ -5,7 +5,8 @@
5
5
  "af6a34c9f15d4bef": "0.2.5",
6
6
  "e0ff5f54b6823dba": "0.2.6",
7
7
  "a3542945427a3dfa": "0.2.7",
8
- "46c4c3eca89693fe": "0.2.8"
8
+ "46c4c3eca89693fe": "0.2.8",
9
+ "291cb0b17e9d8613": "0.2.9"
9
10
  },
10
11
  "ui/agent-chat.module.css": {
11
12
  "8ecfa2904dee75e9": "0.1.0"
@@ -5,6 +5,7 @@
5
5
  * Talks to POST /api/agent with the AI SDK `useChat` protocol.
6
6
  * Styles: ./agent-chat.css — plain global CSS with `dopus-` class names (works with or without Tailwind).
7
7
  * Background jobs: pass a `channel` and the agent continues by itself when a job it started finishes.
8
+ * Chat history: pass `history` to keep the conversation across reloads (sessionStorage, your API...).
8
9
  */
9
10
  import { useChat } from '@ai-sdk/react'
10
11
  import {
@@ -76,6 +77,16 @@ export interface AgentChatProps {
76
77
  maxJobFollowUps?: number
77
78
  /** Called when the agent finishes a reply. `AgentWidget` uses it to mark the closed launcher as unread. */
78
79
  onReply?: () => void
80
+ /** Keep the conversation across reloads. Off by default (the chat lives in memory). */
81
+ history?: AgentChatHistory
82
+ }
83
+
84
+ /** Where the conversation is kept. Both are optional; neither may throw away the user's data silently. */
85
+ export interface AgentChatHistory {
86
+ /** Called once when the chat mounts; return the conversation to restore. */
87
+ load?: () => UIMessage[] | null | undefined | Promise<UIMessage[] | null | undefined>
88
+ /** Called when the chat settles after a change, and with `[]` on "New chat". */
89
+ save?: (messages: UIMessage[]) => void | Promise<void>
79
90
  }
80
91
 
81
92
  export interface AgentChannel {
@@ -298,6 +309,7 @@ export function AgentChat({
298
309
  channel,
299
310
  maxJobFollowUps = 3,
300
311
  onReply,
312
+ history,
301
313
  }: AgentChatProps) {
302
314
  const labels = { ...DEFAULT_LABELS, ...labelOverrides }
303
315
  const router = useRouter()
@@ -340,6 +352,39 @@ export function AgentChat({
340
352
  const listRef = useRef<HTMLDivElement>(null)
341
353
  const busy = status === 'submitted' || status === 'streaming'
342
354
 
355
+ // Chat history: restore once on mount (after hydration), then save whenever the chat settles.
356
+ const historyRef = useRef(history)
357
+ useEffect(() => {
358
+ historyRef.current = history
359
+ })
360
+ const [restored, setRestored] = useState(!history?.load)
361
+ useEffect(() => {
362
+ const load = historyRef.current?.load
363
+ if (!load) return
364
+ let cancelled = false
365
+ Promise.resolve()
366
+ .then(load)
367
+ .then((saved) => {
368
+ // Keep anything the user already typed while it was loading.
369
+ if (!cancelled && saved?.length)
370
+ setMessages((current) => (current.length ? current : saved))
371
+ })
372
+ .catch((error) => console.error('[dopus] history.load failed', error))
373
+ .finally(() => {
374
+ if (!cancelled) setRestored(true)
375
+ })
376
+ return () => {
377
+ cancelled = true
378
+ }
379
+ }, [setMessages])
380
+ useEffect(() => {
381
+ // Empty is saved only by "New chat", so a slow or failed load never overwrites what was stored.
382
+ if (!restored || status !== 'ready' || messages.length === 0) return
383
+ Promise.resolve(historyRef.current?.save?.(messages)).catch((error) =>
384
+ console.error('[dopus] history.save failed', error),
385
+ )
386
+ }, [restored, status, messages])
387
+
343
388
  // Jobs the channel reported finished. Kept even before this chat shows the job (a job can finish before
344
389
  // its tool output has streamed in); only IDs this chat started are acted on.
345
390
  const [finishedJobs, setFinishedJobs] = useState<Set<string>>(() => new Set())
@@ -428,6 +473,9 @@ export function AgentChat({
428
473
  clearError()
429
474
  setMessages([])
430
475
  setInput('')
476
+ Promise.resolve(historyRef.current?.save?.([])).catch((error) =>
477
+ console.error('[dopus] history.save failed', error),
478
+ )
431
479
  }
432
480
 
433
481
  // The route answers 413 when the history is over its size limit; retrying cannot help then.