@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/ai.js ADDED
@@ -0,0 +1,337 @@
1
+ /** Glue for the Vercel AI SDK (`ai`): Dana4 tools ready for `generateText`,
2
+ * and chat threads turned into model messages. */
3
+ import { jsonSchema, tool } from "ai";
4
+ import { z } from "zod";
5
+ const MAX_LINES_PER_READ = 500;
6
+ const MAX_LINES_PER_WRITE = 400;
7
+ /** `ls -R`-style listing: grouped per directory, folders first, titles aligned. */
8
+ export function formatDocumentList(documents) {
9
+ const groups = new Map();
10
+ for (const d of documents) {
11
+ const dir = d.path.slice(0, d.path.lastIndexOf("/")) || "/";
12
+ groups.set(dir, [...(groups.get(dir) ?? []), d]);
13
+ }
14
+ const leaf = (d) => d.path.slice(d.path.lastIndexOf("/") + 1);
15
+ const isFolder = (d) => d.document_type === "folder";
16
+ return [...groups.keys()]
17
+ .sort()
18
+ .map((dir) => {
19
+ const entries = groups
20
+ .get(dir)
21
+ .sort((a, b) => Number(isFolder(b)) - Number(isFolder(a)) || leaf(a).localeCompare(leaf(b)));
22
+ const names = entries.map((d) => leaf(d) + (isFolder(d) ? "/" : ""));
23
+ const width = Math.max(...names.map((n) => n.length));
24
+ return [`${dir}:`, ...entries.map((d, i) => ` ${names[i].padEnd(width)} ${d.title}`)].join("\n");
25
+ })
26
+ .join("\n\n");
27
+ }
28
+ /** Matching lines (`>`) with `context` lines around them, blocks split by `---`. */
29
+ export function grep(content, pattern, context = 3) {
30
+ const lines = content.split(/\r?\n/);
31
+ let regex;
32
+ try {
33
+ regex = new RegExp(pattern, "i");
34
+ }
35
+ catch {
36
+ regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
37
+ }
38
+ const hits = lines.flatMap((line, i) => (regex.test(line) ? [i] : []));
39
+ const shown = new Set(hits.flatMap((i) => Array.from({ length: 2 * context + 1 }, (_, k) => i - context + k)));
40
+ const out = [];
41
+ let prev = -2;
42
+ for (const i of [...shown].filter((i) => i >= 0 && i < lines.length).sort((a, b) => a - b)) {
43
+ if (i !== prev + 1 && out.length)
44
+ out.push("---");
45
+ out.push(`${regex.test(lines[i]) ? ">" : " "} ${i + 1}: ${lines[i]}`);
46
+ prev = i;
47
+ }
48
+ return { matches: hits.length, lines: lines.length, text: out.join("\n") };
49
+ }
50
+ /** Insert `content` before 1-based `line` (0 = start; past the end appends). */
51
+ export function insertAt(document, line, content) {
52
+ const lines = document.match(/[^\n]*\n|[^\n]+$/g) ?? [];
53
+ const at = Math.max(0, Math.min(line - 1, lines.length));
54
+ // Appending after an unterminated last line: end it first, or the two join.
55
+ if (at === lines.length && at > 0 && !lines[at - 1].endsWith("\n"))
56
+ lines[at - 1] += "\n";
57
+ lines.splice(at, 0, content.endsWith("\n") ? content : `${content}\n`);
58
+ return lines.join("");
59
+ }
60
+ const planStatus = (channelId, done) => `Plan updated for channel ${channelId}. ${done ? "All items are checked; the plan is complete." : "Some items are still unchecked."}`;
61
+ const PRIMITIVE = new Set(["string", "number", "integer", "boolean", "null"]);
62
+ /** A capability's input schema as a tool schema: only primitive params (the
63
+ * platform resolves documents and the like), and never the ids it injects. */
64
+ export function capabilityToolSchema(schema) {
65
+ const props = (schema?.properties ?? {});
66
+ const keep = Object.entries(props).filter(([name, p]) => {
67
+ if (["workspace_id", "task_id"].includes(name))
68
+ return false;
69
+ const t = p?.type;
70
+ return typeof t === "string" ? PRIMITIVE.has(t) : Array.isArray(t) && t.every((x) => typeof x === "string" && PRIMITIVE.has(x));
71
+ });
72
+ const kept = new Set(keep.map(([n]) => n));
73
+ const required = Array.isArray(schema?.required) ? schema.required.filter((r) => typeof r === "string" && kept.has(r)) : [];
74
+ return { type: "object", additionalProperties: false, properties: Object.fromEntries(keep), required };
75
+ }
76
+ /** Tools for reading and writing the workspace's documents, messaging, and the
77
+ * thread's plan — plus, unless `delegation: false`, one `<agent>__<capability>`
78
+ * tool per capability other agents in the workspace expose. Tool errors go back
79
+ * to the model, which can correct itself. */
80
+ export async function dana4Tools(ctx, opts = {}) {
81
+ const tools = {
82
+ list_documents: tool({
83
+ description: "List documents in a folder with their paths and titles. Optionally recurse into subfolders.",
84
+ inputSchema: z.object({
85
+ path: z.string().default("/").describe("Folder to list, as a path (e.g. '/foo/bar'). Defaults to the root."),
86
+ recursive: z
87
+ .boolean()
88
+ .default(false)
89
+ .describe("List every descendant recursively (ls -R) instead of just the folder's immediate children."),
90
+ }),
91
+ execute: async ({ path, recursive }) => formatDocumentList(await ctx.docs.list(path, recursive)),
92
+ }),
93
+ grep_document: tool({
94
+ description: "Search for a text pattern within a document. Returns matching lines with line numbers and surrounding context. Use this BEFORE read_document to find relevant sections without reading the entire file.",
95
+ inputSchema: z.object({
96
+ path: z.string().describe("Path of the document to search in."),
97
+ pattern: z.string().describe("Text or regex pattern to search for. Case-insensitive."),
98
+ context_lines: z.number().int().default(3).describe("Number of lines to show before and after each match."),
99
+ }),
100
+ execute: async ({ path, pattern, context_lines }) => {
101
+ const r = grep((await ctx.docs.get(path)).content, pattern, context_lines);
102
+ return r.matches ? `Found ${r.matches} match(es) in ${path} (${r.lines} lines total):\n${r.text}` : `No matches for '${pattern}' in ${path}`;
103
+ },
104
+ }),
105
+ search_documents: tool({
106
+ description: "Search across ALL documents in the workspace by meaning or keyword, returning the most relevant documents with a snippet and relevance score. Use this when you don't know which document holds the content you need. Use grep_document instead to search within a single known document.",
107
+ inputSchema: z.object({
108
+ query: z.string().describe("Natural-language or keyword query to search across all documents in the workspace."),
109
+ limit: z.number().int().optional().describe("Max number of results to return. Defaults to the server default."),
110
+ }),
111
+ execute: async ({ query, limit }) => {
112
+ const results = await ctx.docs.search(query, limit);
113
+ if (!results.length)
114
+ return `No documents matched '${query}'.`;
115
+ return [
116
+ `Found ${results.length} result(s) for '${query}':\n`,
117
+ ...results.flatMap((r) => [`- ${r.path} — ${r.title} (score: ${r.score.toFixed(3)})`, ...(r.snippet ? [` ${r.snippet}`] : [])]),
118
+ ].join("\n");
119
+ },
120
+ }),
121
+ read_document: tool({
122
+ description: `Read a document's content with line numbers. Returns up to ${MAX_LINES_PER_READ} lines.\n\nUsage:\n- Use grep_document first to find relevant line numbers, then read targeted ranges.\n- Use offset and limit to read specific sections. Avoid reading entire large documents.\n- Lines are prefixed with line numbers: \`42: content here\`.\n- Call this tool in parallel when reading multiple documents.`,
123
+ inputSchema: z.object({
124
+ path: z.string().describe("The path to the document to read."),
125
+ offset: z.number().int().optional().describe("Line number to start reading from (1-indexed). Defaults to 1."),
126
+ limit: z
127
+ .number()
128
+ .int()
129
+ .optional()
130
+ .describe(`Max lines to read. Defaults to ${MAX_LINES_PER_READ}. Use grep_document first to find relevant sections, then read targeted ranges.`),
131
+ }),
132
+ execute: async ({ path, offset = 1, limit = MAX_LINES_PER_READ }) => {
133
+ const doc = await ctx.docs.get(path);
134
+ const lines = doc.content.split(/\r?\n/);
135
+ const start = offset - 1;
136
+ const end = Math.min(start + limit, lines.length);
137
+ const body = lines
138
+ .slice(start, end)
139
+ .map((l, i) => `${start + i + 1}: ${l}`)
140
+ .join("\n");
141
+ const more = end < lines.length ? `\n\n[Showing lines ${start + 1}-${end} of ${lines.length}. Use offset=${end + 1} to read more.]` : "";
142
+ return `Title: ${doc.title}\nPath: ${path}\nTotal lines: ${lines.length}\nContent:\n\n${body}${more}\n`;
143
+ },
144
+ }),
145
+ get_step_result: tool({
146
+ description: "Fetch the result produced by an earlier step in the current journey, by its step id. Returns the result as JSON, or a note if the step has not produced a result yet.",
147
+ inputSchema: z.object({
148
+ step_id: z.string().describe("The id of the step whose result to fetch (e.g. 'step_brand_questions')."),
149
+ }),
150
+ execute: async ({ step_id }) => {
151
+ const result = await ctx.stepResult(step_id);
152
+ return result == null ? `No result available for step '${step_id}' yet (it may not have run or completed).` : JSON.stringify(result, null, 2);
153
+ },
154
+ }),
155
+ create_document: tool({
156
+ description: "Create a new document at the given path.\n\nFor large documents (HTML, reports): include only the opening structure in content (e.g. head/CSS/boilerplate), then call append_document for each section.",
157
+ inputSchema: z.object({
158
+ path: z
159
+ .string()
160
+ .describe("Path for the new document, including filename. Extension is optional, defaults to markdown. Example: `/projects/pitch-deck`."),
161
+ title: z.string().describe("The title of the document."),
162
+ content: z
163
+ .string()
164
+ .default("")
165
+ .describe("Initial content. For large documents, put only the opening structure here (e.g. HTML head + CSS), then use append_document for each section."),
166
+ }),
167
+ execute: async ({ path, title, content }) => {
168
+ await ctx.docs.create({ path, title, content });
169
+ return `Document ${path} created in workspace ${ctx.workspaceId}`;
170
+ },
171
+ }),
172
+ write_document: tool({
173
+ description: `Overwrite a document's entire content. Max ~${MAX_LINES_PER_WRITE} lines.\n\nONLY use for small documents. For anything larger, use create_document + append_document.\nFor targeted changes, prefer edit_document or insert_in_document instead.`,
174
+ inputSchema: z.object({
175
+ path: z.string().describe("Path to the document to overwrite."),
176
+ content: z.string().default("").describe("Full replacement content. Must be under ~200 lines."),
177
+ }),
178
+ execute: async ({ path, content }) => {
179
+ if (!content)
180
+ return "FAILED: content was empty (likely truncated — document too large for one call). Do NOT retry. Use create_document for the skeleton, then append_document for each section.";
181
+ const n = content.split("\n").length;
182
+ if (n > MAX_LINES_PER_WRITE)
183
+ return `FAILED: content is ${n} lines (max ${MAX_LINES_PER_WRITE}). Split into create_document + multiple append_document calls.`;
184
+ await ctx.docs.replace(path, content);
185
+ return `Document ${path} updated (${n} lines)`;
186
+ },
187
+ }),
188
+ append_document: tool({
189
+ description: "Append content to the end of an existing document.\n\nUse this for building large documents incrementally:\n1. create_document with opening structure\n2. append_document for each section\nKeep each append under ~300 lines.",
190
+ inputSchema: z.object({
191
+ path: z.string().describe("Path of the document to append to."),
192
+ content: z.string().describe("Content to append to the end of the document."),
193
+ }),
194
+ execute: async ({ path, content }) => {
195
+ await ctx.docs.append(path, content);
196
+ return `Content appended to ${path}`;
197
+ },
198
+ }),
199
+ edit_document: tool({
200
+ description: "Find and replace exact text in a document.\n\nUsage:\n- You MUST read_document or grep_document first to see the exact text.\n- old_str must match exactly (whitespace, indentation, line breaks).\n- old_str must be unique in the document. Include surrounding lines if needed, or set replace_all to change every occurrence.\n- Do NOT include line number prefixes (e.g. `42: `) in old_str/new_str.",
201
+ inputSchema: z.object({
202
+ path: z.string().describe("Path to the document to modify."),
203
+ old_str: z.string().describe("Exact text to find and replace. Must match uniquely. Include surrounding lines for context if needed."),
204
+ new_str: z.string().describe("Replacement text. Must differ from old_str."),
205
+ replace_all: z.boolean().default(false).describe("Replace every occurrence instead of requiring a unique match."),
206
+ }),
207
+ execute: async ({ path, old_str, new_str, replace_all }) => {
208
+ try {
209
+ const n = await ctx.docs.edit(path, old_str, new_str, replace_all);
210
+ return `Document ${path} updated successfully (${n} replacement${n !== 1 ? "s" : ""})`;
211
+ }
212
+ catch (e) {
213
+ throw new Error(`Failed to edit document ${path}: ${e instanceof Error ? e.message : e}. If old_str was not found, re-read the document with read_document to get its current content; if the match was ambiguous, include more surrounding context or set replace_all.`);
214
+ }
215
+ },
216
+ }),
217
+ insert_in_document: tool({
218
+ description: "Insert content at a specific line number without removing existing content.\n\nUsage:\n- Use read_document or grep_document first to find the right line number.\n- Content is inserted BEFORE the specified line.\n- Use line=0 to insert at the very beginning.\n- Prefer this over edit_document when adding new sections between existing ones.",
219
+ inputSchema: z.object({
220
+ path: z.string().describe("Path to the document to insert into."),
221
+ line: z
222
+ .number()
223
+ .int()
224
+ .describe("Line number to insert BEFORE (1-indexed). Use 0 to insert at the very beginning. Use a line number beyond the end to append."),
225
+ content: z.string().describe("Content to insert at the specified line."),
226
+ }),
227
+ execute: async ({ path, line, content }) => {
228
+ const doc = await ctx.docs.get(path, "editing");
229
+ await ctx.docs.replace(path, insertAt(doc.content, line, content));
230
+ const n = (content.endsWith("\n") ? content : `${content}\n`).split("\n").length - 1;
231
+ return `Inserted ${n} line(s) at line ${line} in ${path}`;
232
+ },
233
+ }),
234
+ send_message: tool({
235
+ description: "Send a message to a channel. Uses the current channel if channel_id is omitted.",
236
+ inputSchema: z.object({
237
+ message: z.string().describe("The message to send."),
238
+ channel_id: z.string().optional().describe("The id of the channel."),
239
+ }),
240
+ execute: async ({ message, channel_id }) => {
241
+ const sent = await ctx.send(message, channel_id ? { channelId: channel_id } : {});
242
+ return `Message delivered to channel ${sent.channel_id} in workspace ${ctx.workspaceId}`;
243
+ },
244
+ }),
245
+ set_plan: tool({
246
+ description: "Author or overwrite this message thread's ENTIRE plan (a markdown checklist) in one call.\n\nUse this only to write the initial plan or restructure it wholesale. For everyday edits — checking items off or adding one — use complete_plan_item / add_plan_item instead, which are cheaper and won't clobber concurrent changes.",
247
+ inputSchema: z.object({
248
+ plan: z
249
+ .string()
250
+ .describe("The full plan as a markdown checklist, e.g. `- [x] research\n- [ ] draft`. This REPLACES the existing plan, so include every item, not just the changed ones. Use this to author the initial plan; prefer complete_plan_item / add_plan_item for incremental edits."),
251
+ channel_id: z.string().optional().describe("The id of the channel. Defaults to the current channel."),
252
+ }),
253
+ execute: async ({ plan, channel_id }) => {
254
+ const ch = await ctx.plan.set(plan, channel_id ?? ctx.channelId);
255
+ return planStatus(ch.id, ch.plan_completed);
256
+ },
257
+ }),
258
+ complete_plan_item: tool({
259
+ description: "Check off one item in this message thread's plan as you finish it.\n\nUsage:\n- The current plan (a markdown checklist) is in your instructions.\n- Pass the item's exact label text — just that one item, not the whole plan.\n- If the text matches no item or several, you'll get an error; retry with more of the item's wording.\n- The thread is complete once every item is checked.",
260
+ inputSchema: z.object({
261
+ item: z
262
+ .string()
263
+ .describe("The exact label text of the checklist item to check off (the words after the `[ ]`), as shown in the current plan in your instructions. Matched ignoring case and extra spaces; it must be unambiguous."),
264
+ channel_id: z.string().optional().describe("The id of the channel. Defaults to the current channel."),
265
+ }),
266
+ execute: async ({ item, channel_id }) => {
267
+ try {
268
+ const ch = await ctx.plan.complete(item, channel_id ?? ctx.channelId);
269
+ return planStatus(ch.id, ch.plan_completed);
270
+ }
271
+ catch (e) {
272
+ throw new Error(`Could not complete '${item}': ${e instanceof Error ? e.message : e}. Check the current plan and pass the item's exact wording.`);
273
+ }
274
+ },
275
+ }),
276
+ add_plan_item: tool({
277
+ description: "Append a new unchecked item to this message thread's plan.\n\nUse this to record a step you've discovered; pass just the new item's text, not the whole plan.",
278
+ inputSchema: z.object({
279
+ item: z.string().describe("The text of the new unchecked checklist item to append."),
280
+ channel_id: z.string().optional().describe("The id of the channel. Defaults to the current channel."),
281
+ }),
282
+ execute: async ({ item, channel_id }) => {
283
+ const ch = await ctx.plan.add(item, channel_id ?? ctx.channelId);
284
+ return planStatus(ch.id, ch.plan_completed);
285
+ },
286
+ }),
287
+ };
288
+ for (const name of opts.exclude ?? [])
289
+ delete tools[name];
290
+ if (opts.delegation === false)
291
+ return tools;
292
+ // Without the lookup the chat still has every other tool, so don't fail it.
293
+ const agents = await ctx.client.workspaces.agents(ctx.workspaceId).catch((e) => {
294
+ console.warn("dana4: listing agents failed; no delegation tools:", e instanceof Error ? e.message : e);
295
+ return [];
296
+ });
297
+ for (const agent of agents) {
298
+ for (const cap of agent.capabilities) {
299
+ // Providers cap tool names at 64 chars; sanitizing can make two equal.
300
+ const base = `${agent.username}__${cap.key}`.replace(/[^A-Za-z0-9_-]/g, "_");
301
+ let name = base.slice(0, 64);
302
+ for (let i = 2; name in tools; i++)
303
+ name = `${base.slice(0, 63 - String(i).length)}_${i}`;
304
+ tools[name] = tool({
305
+ description: cap.description || `Trigger \`${cap.key}\` from agent \`${agent.username}\`.`,
306
+ inputSchema: jsonSchema(capabilityToolSchema(cap.input_schema)),
307
+ execute: async (inputs) => {
308
+ const taskId = await ctx.delegate(agent.username, cap.key, inputs);
309
+ return `Queued \`${cap.key}\` (agent \`${agent.username}\`) for workspace \`${ctx.workspaceId}\` as task \`${taskId}\`. It will run asynchronously—tell the user the results will be posted when the task finishes.`;
310
+ },
311
+ });
312
+ }
313
+ }
314
+ return tools;
315
+ }
316
+ /** A thread's messages as model messages, oldest first: this agent's own
317
+ * messages become assistant turns, everyone else's user turns prefixed with
318
+ * the author. Only conversational messages (and errors) are kept. */
319
+ export function chatHistory(messages, { me, exclude }) {
320
+ return messages
321
+ .filter((m) => (m.category === "default" || m.category === "error") && m.id !== exclude)
322
+ .sort((a, b) => a.time.created_at.localeCompare(b.time.created_at))
323
+ .map((m) => {
324
+ const content = `${m.from_username}: ${m.category === "error" ? "ERROR: " : ""}${m.message}`;
325
+ return m.from_username === me ? { role: "assistant", content } : { role: "user", content };
326
+ });
327
+ }
328
+ /** `base` followed by the thread's topics, intent and plan, so the model knows
329
+ * what the conversation is for. */
330
+ export function threadInstructions(base, thread) {
331
+ const context = [
332
+ ...thread.topics.map((t) => `Thread topic: ${t.title} — ${t.summary}`),
333
+ ...(thread.intent ? [`Thread intent: ${thread.intent}`] : []),
334
+ ...(thread.plan ? [`Thread plan:\n${thread.plan}`] : []),
335
+ ].join("\n");
336
+ return [base, context].filter(Boolean).join("\n\n");
337
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { enroll } from "./enroll.js";
4
+ const { values, positionals } = parseArgs({
5
+ allowPositionals: true,
6
+ options: { username: { type: "string" }, bio: { type: "string" }, description: { type: "string" }, url: { type: "string" } },
7
+ });
8
+ const username = values.username;
9
+ if (positionals[0] !== "enroll" || !username) {
10
+ console.error("usage: dana4 enroll --username <name> [--url https://app.dana4.io] [--bio …] [--description …]");
11
+ process.exitCode = 2;
12
+ }
13
+ else {
14
+ const key = await enroll({ ...values, username, url: values.url ?? process.env.DANA4_URL });
15
+ console.log(`\nApproved. Your API key (shown once), e.g. for DANA4_API_KEY:\n\n${key}`);
16
+ }