@dana4/sdk 0.1.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/dist/agent.js ADDED
@@ -0,0 +1,304 @@
1
+ import { z } from "zod";
2
+ /** Typed helper: infers `input` in `run` from the schema. */
3
+ export const capability = (c) => c;
4
+ export const defineAgent = (agent) => agent;
5
+ const BASE_FIELDS = ["workspace_id", "task_id", "channel_id", "tag_usernames"];
6
+ /** The JSON schema advertised for a capability. The server only forwards the
7
+ * payload keys a schema declares, so the base fields must be declared too. */
8
+ export function capabilitySchema(key, cap) {
9
+ // Input mode: a field with a default is optional to whoever calls it.
10
+ const { $schema: _, ...schema } = z.toJSONSchema(cap.input, { io: "input" });
11
+ return {
12
+ ...schema,
13
+ title: cap.title ?? key,
14
+ ...(cap.description && { description: cap.description }),
15
+ properties: {
16
+ ...schema.properties,
17
+ workspace_id: { type: "string" },
18
+ task_id: { type: "string" },
19
+ channel_id: { type: ["string", "null"] },
20
+ tag_usernames: { type: ["array", "null"], items: { type: "string" } },
21
+ },
22
+ required: [...(schema.required ?? []), "workspace_id", "task_id"],
23
+ };
24
+ }
25
+ const errorMessage = (e) => (e instanceof Error ? e.message : String(e));
26
+ /** Usernames @-mentioned in `body`, lowercased; the server's `extract_tagged_usernames`. */
27
+ const taggedUsernames = (body) => body
28
+ .split(/\s+/)
29
+ .map((w) => w.replace(/^[^\p{L}\p{N}@._-]+|[^\p{L}\p{N}@._-]+$/gu, ""))
30
+ .filter((w) => w.length > 1 && w.startsWith("@"))
31
+ .map((w) => w.slice(1).toLowerCase());
32
+ /** Everything a handler needs, bound to its workspace, channel and task, so ids
33
+ * never have to be threaded through calls. */
34
+ export class Context {
35
+ client;
36
+ me;
37
+ workspaceId;
38
+ channelId;
39
+ taskId;
40
+ tagUsernames;
41
+ /** Posted into the channel when the task completes. */
42
+ resultMessage;
43
+ #usd = 0;
44
+ #credits = 0;
45
+ #costDetails = [];
46
+ constructor(client,
47
+ /** This agent's username. */
48
+ me, workspaceId,
49
+ /** `null` means the workspace's default channel. */
50
+ channelId, taskId,
51
+ /** Who to @-mention when posting results back (set by whoever delegated). */
52
+ tagUsernames = []) {
53
+ this.client = client;
54
+ this.me = me;
55
+ this.workspaceId = workspaceId;
56
+ this.channelId = channelId;
57
+ this.taskId = taskId;
58
+ this.tagUsernames = tagUsernames;
59
+ }
60
+ #task() {
61
+ if (!this.taskId)
62
+ throw new Error("No task in this context: document writes and uploads need one");
63
+ return this.taskId;
64
+ }
65
+ /** Post a message; to this context's channel unless `channelId` says otherwise. */
66
+ send(message, opts = {}) {
67
+ return this.client.chat.send({
68
+ workspace_id: this.workspaceId,
69
+ channel_id: opts.channelId === undefined ? this.channelId : opts.channelId,
70
+ message,
71
+ category: opts.category,
72
+ parent_message_id: opts.parentMessageId,
73
+ document_id: opts.documentId,
74
+ new_channel: opts.newChannel,
75
+ });
76
+ }
77
+ /** Workspace params (API keys, feature flags, …) set in workspace settings. */
78
+ params() {
79
+ return this.client.workspaces.params(this.workspaceId);
80
+ }
81
+ docs = {
82
+ /** Read by path; shows this agent as `activity` on the document. */
83
+ get: async (path, activity = "reading") => {
84
+ const doc = await this.client.docs.getByPath(this.workspaceId, path);
85
+ this.client.docs.presence(doc.id, activity).catch(() => { });
86
+ return doc;
87
+ },
88
+ byChannel: (channelId = this.channelId) => this.client.docs.byChannel(channelId ?? ""),
89
+ list: (path = "/", recursive = false) => this.client.docs.list(this.workspaceId, { path, recursive }),
90
+ search: (query, limit) => this.client.docs.search(this.workspaceId, query, limit),
91
+ messages: (documentId) => this.client.docs.messages(documentId),
92
+ create: (doc) => this.client.docs.create({
93
+ path: doc.path,
94
+ title: doc.title,
95
+ content: doc.content ?? "",
96
+ document_type: doc.type,
97
+ overwrite: doc.overwrite,
98
+ workspace_id: this.workspaceId,
99
+ task_id: this.taskId,
100
+ }),
101
+ replace: (path, content) => this.client.docs.replace({ path, content, task_id: this.#task() }),
102
+ append: (path, content) => this.client.docs.append({ path, content, task_id: this.#task() }),
103
+ /** Exact-string replace, applied server-side. Answers the replacement count. */
104
+ edit: async (path, oldString, newString, replaceAll = false) => (await this.client.docs.edit({ path, old_string: oldString, new_string: newString, replace_all: replaceAll, task_id: this.#task() }))
105
+ .replacements,
106
+ };
107
+ /** The channel's plan: a markdown checklist. Each call answers the updated channel. */
108
+ plan = {
109
+ set: (plan, channelId = this.channelId) => this.client.chat.setPlan(this.#channel(channelId), plan),
110
+ complete: (item, channelId = this.channelId) => this.client.chat.completePlanItem(this.#channel(channelId), item),
111
+ add: (item, channelId = this.channelId) => this.client.chat.addPlanItem(this.#channel(channelId), item),
112
+ };
113
+ #channel(channelId) {
114
+ if (!channelId)
115
+ throw new Error("No channel: pass channelId explicitly");
116
+ return channelId;
117
+ }
118
+ assets = {
119
+ /** Store a file; put the answered `url` (`dana4-asset://…`) in content. */
120
+ upload: (data, file) => this.client.assets.upload(this.#task(), data, file),
121
+ };
122
+ /** Report progress (0–1) and optionally a message people see in the channel. */
123
+ async progress(progress, message) {
124
+ await this.client.tasks.update(this.#task(), { status: "running", progress, message });
125
+ }
126
+ /** Record spend. It accumulates and is charged once, when the task ends. */
127
+ cost({ usd = 0, credits = 0, detail }) {
128
+ this.#usd += usd;
129
+ this.#credits += credits;
130
+ if (detail)
131
+ this.#costDetails.push(detail);
132
+ }
133
+ /** Queue another agent's capability. It runs asynchronously and posts its
134
+ * result back here, @-mentioning this agent. Answers the new task's id. */
135
+ delegate(agent, capability, inputs = {}) {
136
+ return this.client.agents.execute(agent, {
137
+ tool_key: capability,
138
+ task_id: this.#task(),
139
+ channel_id: this.channelId,
140
+ inputs,
141
+ metadata: { triggered_by: this.me, triggered_from: "chat", tag_usernames: [this.me] },
142
+ });
143
+ }
144
+ /** The result of an earlier step in this task's journey, or null if not ready. */
145
+ stepResult(stepId) {
146
+ return this.client.tasks.stepResult(this.#task(), stepId);
147
+ }
148
+ /** @internal The accumulated cost, in `PATCH /tasks/{id}` shape. */
149
+ settle() {
150
+ return {
151
+ cost_usd: this.#usd || undefined,
152
+ credits_cost: this.#credits || undefined,
153
+ cost_details: this.#costDetails.length ? this.#costDetails : undefined,
154
+ };
155
+ }
156
+ }
157
+ /** Runs an `AgentDefinition` against Dana4. Host-agnostic: `run()` drives it
158
+ * from a loop, `Dana4Agent` from Durable Object alarms. */
159
+ export class AgentRuntime {
160
+ agent;
161
+ client;
162
+ log;
163
+ /** This agent's username, known after `register()`. */
164
+ me = "";
165
+ constructor(agent, client, log = console) {
166
+ this.agent = agent;
167
+ this.client = client;
168
+ this.log = log;
169
+ }
170
+ /** Advertise the capabilities. Also tells us who we are. */
171
+ async register() {
172
+ const { version, bio, description, capabilities = {} } = this.agent;
173
+ const me = await this.client.agents.updateMe({
174
+ bio,
175
+ description,
176
+ schema: {
177
+ version,
178
+ capabilities: Object.fromEntries(Object.entries(capabilities).map(([key, cap]) => [
179
+ key,
180
+ { input_schema: capabilitySchema(key, cap), show_in_chat: cap.showInChat ?? true },
181
+ ])),
182
+ },
183
+ });
184
+ this.me = me.username;
185
+ return this.me;
186
+ }
187
+ /** Put tasks left assigned/running by a crashed process back in the queue. */
188
+ async requeueActive() {
189
+ for (const task of await this.client.tasks.active())
190
+ await this.requeue(task.id);
191
+ }
192
+ /** Put a task interrupted by a crash back in the queue. Never throws. */
193
+ async requeue(taskId) {
194
+ await this.client.tasks
195
+ .update(taskId, { status: "retry", progress: 0, message: "Agent restarted; re-queuing task" })
196
+ .catch((e) => this.log.error("dana4: re-queuing failed:", errorMessage(e)));
197
+ }
198
+ /** Take the next task and chat thread. A taken task is acked (moved to
199
+ * `running`) right away, or `tasks/take` would hand it out again. Never throws. */
200
+ async poll() {
201
+ const work = [];
202
+ try {
203
+ const taken = await this.client.tasks.take();
204
+ if (taken) {
205
+ await this.client.tasks.update(taken.task.id, { status: "running", progress: 0 });
206
+ work.push({ kind: "task", taken });
207
+ }
208
+ }
209
+ catch (e) {
210
+ this.log.warn("dana4: taking a task failed:", errorMessage(e));
211
+ }
212
+ try {
213
+ work.push(...this.addressed(await this.client.chat.take()));
214
+ }
215
+ catch (e) {
216
+ this.log.warn("dana4: taking chat failed:", errorMessage(e));
217
+ }
218
+ return work;
219
+ }
220
+ /** The messages in a thread meant for this agent: those that @-mention it,
221
+ * or, in a one-to-one DM, the other person's latest message (the whole DM is
222
+ * addressed to it, and one reply covers a burst of messages). System
223
+ * messages (no `from`, e.g. a failed task's error) never count in a DM, or
224
+ * the agent would answer its own errors forever. */
225
+ addressed(thread) {
226
+ const theirs = thread.messages.filter((m) => m.from_username !== this.me);
227
+ const last = theirs.filter((m) => m.from).at(-1);
228
+ if (thread.direct && last)
229
+ return [{ kind: "chat", message: last, thread }];
230
+ const me = this.me.toLowerCase();
231
+ return theirs
232
+ .filter((m) => taggedUsernames(m.message).includes(me))
233
+ .map((m) => m.category === "invited_to_workspace" ? { kind: "join", workspaceId: m.workspace_id } : { kind: "chat", message: m, thread });
234
+ }
235
+ /** Do one piece of work. Never throws: failures close the task as failed.
236
+ * `save` gets the work back whenever it gains state a crash must not lose (a
237
+ * chat's task id); a host persists it, and after a crash runs the saved work
238
+ * again so the chat closes the task it opened instead of orphaning it. */
239
+ async run(work, save) {
240
+ try {
241
+ if (work.kind === "task")
242
+ await this.#runTask(work.taken);
243
+ else if (work.kind === "chat")
244
+ await this.#runChat(work, save);
245
+ else
246
+ await this.agent.onJoin?.(new Context(this.client, this.me, work.workspaceId, null));
247
+ }
248
+ catch (e) {
249
+ this.log.error(`dana4: ${work.kind} failed:`, errorMessage(e));
250
+ }
251
+ }
252
+ async #runTask({ task, payload }) {
253
+ const fail = (message, ctx) => this.client.tasks.update(task.id, { status: "failed", message, ...ctx?.settle() });
254
+ const step = task.step_type;
255
+ const spec = typeof step === "object" && "agent" in step ? step.agent : null;
256
+ if (!spec || spec.name !== this.me)
257
+ return void (await fail(`Not a task for @${this.me}`));
258
+ const cap = this.agent.capabilities?.[spec.capability];
259
+ if (!cap)
260
+ return void (await fail(`Capability not registered: ${spec.capability}`));
261
+ const own = Object.fromEntries(Object.entries(payload).filter(([k]) => !BASE_FIELDS.includes(k)));
262
+ const parsed = cap.input.safeParse(own);
263
+ if (!parsed.success)
264
+ return void (await fail(`Invalid input for ${spec.capability}: ${z.prettifyError(parsed.error)}`));
265
+ const ctx = new Context(this.client, this.me, String(payload.workspace_id), typeof payload.channel_id === "string" ? payload.channel_id : task.channel_id, task.id, Array.isArray(payload.tag_usernames) ? payload.tag_usernames.map(String) : []);
266
+ let result;
267
+ try {
268
+ result = await cap.run(parsed.data, ctx);
269
+ }
270
+ catch (e) {
271
+ return void (await fail(errorMessage(e), ctx));
272
+ }
273
+ await this.client.tasks.update(task.id, {
274
+ status: "completed",
275
+ progress: 1,
276
+ message: ctx.resultMessage,
277
+ result_json: result === undefined ? undefined : JSON.stringify(result),
278
+ ...ctx.settle(),
279
+ });
280
+ }
281
+ async #runChat(work, save) {
282
+ if (!this.agent.onChat)
283
+ return;
284
+ const { message, thread } = work;
285
+ let taskId = work.taskId;
286
+ if (!taskId) {
287
+ taskId = await this.client.tasks.start({ step_name: "read_message", workspace_id: message.workspace_id, channel_id: message.channel_id });
288
+ save?.({ ...work, taskId });
289
+ }
290
+ const ctx = new Context(this.client, this.me, message.workspace_id, message.channel_id, taskId, [this.me]);
291
+ try {
292
+ const thinking = this.agent.thinking ?? "*Thinking...*";
293
+ if (thinking)
294
+ await ctx.send(thinking, { category: "transient" });
295
+ await this.agent.onChat({ message, thread }, ctx);
296
+ await this.client.tasks.update(taskId, { status: "completed", progress: 1, ...ctx.settle() });
297
+ }
298
+ catch (e) {
299
+ // The server posts a failed task's message into its channel, so the
300
+ // person who asked sees it.
301
+ await this.client.tasks.update(taskId, { status: "failed", message: errorMessage(e), ...ctx.settle() });
302
+ }
303
+ }
304
+ }
package/dist/ai.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /** Glue for the Vercel AI SDK (`ai`): Dana4 tools ready for `generateText`,
2
+ * and chat threads turned into model messages. */
3
+ import { type ModelMessage, type ToolSet } from "ai";
4
+ import type { Context } from "./agent.js";
5
+ import type { ChatThread, DocumentHeader, JsonObject, Message } from "./client.js";
6
+ /** `ls -R`-style listing: grouped per directory, folders first, titles aligned. */
7
+ export declare function formatDocumentList(documents: DocumentHeader[]): string;
8
+ /** Matching lines (`>`) with `context` lines around them, blocks split by `---`. */
9
+ export declare function grep(content: string, pattern: string, context?: number): {
10
+ matches: number;
11
+ lines: number;
12
+ text: string;
13
+ };
14
+ /** Insert `content` before 1-based `line` (0 = start; past the end appends). */
15
+ export declare function insertAt(document: string, line: number, content: string): string;
16
+ /** A capability's input schema as a tool schema: only primitive params (the
17
+ * platform resolves documents and the like), and never the ids it injects. */
18
+ export declare function capabilityToolSchema(schema: JsonObject | null | undefined): JsonObject;
19
+ export type Dana4ToolName = "list_documents" | "grep_document" | "search_documents" | "read_document" | "get_step_result" | "create_document" | "write_document" | "append_document" | "edit_document" | "insert_in_document" | "send_message" | "set_plan" | "complete_plan_item" | "add_plan_item";
20
+ /** Tools for reading and writing the workspace's documents, messaging, and the
21
+ * thread's plan — plus, unless `delegation: false`, one `<agent>__<capability>`
22
+ * tool per capability other agents in the workspace expose. Tool errors go back
23
+ * to the model, which can correct itself. */
24
+ export declare function dana4Tools(ctx: Context, opts?: {
25
+ exclude?: Dana4ToolName[];
26
+ delegation?: boolean;
27
+ }): Promise<ToolSet>;
28
+ /** A thread's messages as model messages, oldest first: this agent's own
29
+ * messages become assistant turns, everyone else's user turns prefixed with
30
+ * the author. Only conversational messages (and errors) are kept. */
31
+ export declare function chatHistory(messages: Message[], { me, exclude }: {
32
+ me: string;
33
+ exclude?: string;
34
+ }): ModelMessage[];
35
+ /** `base` followed by the thread's topics, intent and plan, so the model knows
36
+ * what the conversation is for. */
37
+ export declare function threadInstructions(base: string, thread: Pick<ChatThread, "topics" | "intent" | "plan">): string;