@pingvinen/donna-assistant 0.9.1 → 0.11.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
@@ -92,7 +92,7 @@ For CLI tools, Donna verifies installation and authentication, learns capabiliti
92
92
 
93
93
  When multiple tools are registered, Donna runs them in parallel — one agent per tool — so your morning brief stays fast regardless of how many tools you have.
94
94
 
95
- Edit a tool's configuration (scope, capabilities, auth, command, type) at any time:
95
+ Edit a tool's configuration (scope, capabilities, auth, command) at any time:
96
96
 
97
97
  ```
98
98
  /donna:adjust-tool
@@ -111,6 +111,7 @@ If a CLI tool version is newer than what the AI model knows, Donna will re-learn
111
111
  donna/
112
112
  role.md # your job role definition
113
113
  recurring.md # recurring task schedule
114
+ follow-ups.md # scheduled follow-up tasks with due dates
114
115
  tools.md # registered tool configurations
115
116
  daily/
116
117
  2026-03-16.md # today's tasks, tool data, notes
@@ -126,22 +127,51 @@ The storage repo is yours. Donna only writes to the `donna/` and `daily/` direct
126
127
 
127
128
  Most commands are safe to run again. Want to update your role? Run `/donna:set-role` again. Added a new tool mid-day? Run `/donna:begin-the-day` again to pull its data in. Re-running a command updates state rather than duplicating it.
128
129
 
130
+ ## Why not automate tool pulls?
131
+
132
+ Donna intentionally does not support automated periodic invocations of `/donna:run-tools` (e.g., via cron or background timers). Each tool pull runs inside an AI coding assistant session, which means:
133
+
134
+ - **Cost** -- every invocation consumes API tokens. A cron job running hourly would burn tokens even when you are not working.
135
+ - **Context** -- tool data is most useful when you are actively looking at it. Pulling data while you are away produces stale results by the time you return.
136
+ - **Conflicts** -- background runs could write to your daily file while you are editing it, causing git merge conflicts in your storage repo.
137
+
138
+ Instead, run `/donna:run-tools` on demand when you want fresh data, or let `/donna:begin-the-day` handle it as part of your morning routine.
139
+
129
140
  ## All commands
130
141
 
142
+ ### Setup and configuration
143
+
131
144
  | Command | What it does |
132
145
  |---------|-------------|
133
146
  | `/donna:setup` | First-time configuration (storage repo, directories) |
134
147
  | `/donna:set-role` | Define your job role, get recurring task suggestions |
148
+
149
+ ### Daily workflow
150
+
151
+ | Command | What it does |
152
+ |---------|-------------|
135
153
  | `/donna:begin-the-day` | Morning brief with carry-forward, recurring tasks, tool data |
136
154
  | `/donna:add-task` | Capture a task instantly |
137
155
  | `/donna:done` | Mark a task complete |
156
+ | `/donna:add-follow-up-task` | Schedule a task for a future date -- Donna reminds you when it is due |
157
+ | `/donna:focus` | Distill today's tasks into a short prioritized focus list |
158
+
159
+ ### Tool management
160
+
161
+ | Command | What it does |
162
+ |---------|-------------|
138
163
  | `/donna:add-tool` | Register an external tool (CLI, REST API, GraphQL API, MCP server) |
139
- | `/donna:adjust-tool` | Edit a tool's configuration (scope, capabilities, auth, command, type) |
164
+ | `/donna:adjust-tool` | Edit a tool's configuration (scope, capabilities, auth, command) |
165
+ | `/donna:remove-tool` | Remove a registered tool from tools.md |
140
166
  | `/donna:run-tools` | Refresh tool data mid-day |
141
167
  | `/donna:relearn-tools` | Update CLI tool knowledge after upgrades |
168
+
169
+ ### Help and feedback
170
+
171
+ | Command | What it does |
172
+ |---------|-------------|
142
173
  | `/donna:help` | Conversational troubleshooting for config, storage, or skill issues |
143
174
  | `/donna:contribute-idea` | Submit a feature idea or bug report via GitHub Issues |
144
- | `/donna:focus` | Distill today's tasks into a short prioritized focus list |
145
175
 
146
176
  ## License
147
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pingvinen/donna-assistant",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Donna - your AI powered personal assistant",
5
5
  "bin": {
6
6
  "donna-assistant": "./bin/donna-assistant"
@@ -0,0 +1,429 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const os = require("node:os");
6
+ const https = require("node:https");
7
+ const { execSync } = require("node:child_process");
8
+
9
+ const { readVersion } = require("./version.cjs");
10
+ const { runMigrations } = require("./migrator.cjs");
11
+ const { semverGt } = require("./changelog.cjs");
12
+
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ // Config reader
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Read and parse ~/.config/donna/config.md YAML frontmatter.
19
+ *
20
+ * @param {string} homeDir - Home directory override (for testing)
21
+ * @returns {{ storage_repo: string|null, daily_folder: string, auto_push: boolean }|null}
22
+ */
23
+ function readConfig(homeDir) {
24
+ const configPath = path.join(homeDir, ".config", "donna", "config.md");
25
+ if (!fs.existsSync(configPath)) return null;
26
+
27
+ const content = fs.readFileSync(configPath, "utf8");
28
+
29
+ const storage_repo = content.match(/^storage_repo:\s*(.+)$/m)?.[1]?.trim() || null;
30
+ const daily_folder = content.match(/^daily_folder:\s*(.+)$/m)?.[1]?.trim() || "daily";
31
+ const auto_push_str = content.match(/^auto_push:\s*(.+)$/m)?.[1]?.trim() || "false";
32
+ const auto_push = auto_push_str === "true";
33
+
34
+ return { storage_repo, daily_folder, auto_push };
35
+ }
36
+
37
+ // ─────────────────────────────────────────────────────────────────────────────
38
+ // Version check
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Fetch the latest version from npm registry.
43
+ * Returns the latest version string or null on any failure.
44
+ *
45
+ * @param {number} [timeout=3000] - Socket timeout in ms
46
+ * @returns {Promise<string|null>}
47
+ */
48
+ function fetchLatestVersionFromRegistry(timeout = 3000) {
49
+ return new Promise((resolve) => {
50
+ const req = https.get(
51
+ "https://registry.npmjs.org/@pingvinen%2Fdonna-assistant",
52
+ { headers: { Accept: "application/json" } },
53
+ (res) => {
54
+ if (res.statusCode !== 200) {
55
+ resolve(null);
56
+ return;
57
+ }
58
+ let data = "";
59
+ res.on("data", (chunk) => {
60
+ data += chunk;
61
+ });
62
+ res.on("end", () => {
63
+ try {
64
+ const latest = JSON.parse(data)["dist-tags"]?.latest || null;
65
+ resolve(latest);
66
+ } catch {
67
+ resolve(null);
68
+ }
69
+ });
70
+ },
71
+ );
72
+ req.setTimeout(timeout, () => {
73
+ req.destroy();
74
+ resolve(null);
75
+ });
76
+ req.on("error", () => resolve(null));
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Read version-check.md cache from donnaDir.
82
+ * Returns { date, latest } or null if file doesn't exist or can't be parsed.
83
+ *
84
+ * @param {string} donnaDir
85
+ * @returns {{ date: string, latest: string }|null}
86
+ */
87
+ function readVersionCache(donnaDir) {
88
+ const cachePath = path.join(donnaDir, "version-check.md");
89
+ if (!fs.existsSync(cachePath)) return null;
90
+
91
+ const content = fs.readFileSync(cachePath, "utf8");
92
+ const date = content.match(/^checked_on:\s*(.+)$/m)?.[1]?.trim();
93
+ const latest = content.match(/^latest_version:\s*(.+)$/m)?.[1]?.trim();
94
+ if (!date || !latest) return null;
95
+ return { date, latest };
96
+ }
97
+
98
+ /**
99
+ * Write version-check.md cache to donnaDir.
100
+ *
101
+ * @param {string} donnaDir
102
+ * @param {string} date - YYYY-MM-DD
103
+ * @param {string} latestVersion
104
+ */
105
+ function writeVersionCache(donnaDir, date, latestVersion) {
106
+ const cachePath = path.join(donnaDir, "version-check.md");
107
+ fs.writeFileSync(
108
+ cachePath,
109
+ `---\nchecked_on: ${date}\nlatest_version: ${latestVersion}\n---\n`,
110
+ );
111
+ }
112
+
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+ // Obsidian daily-notes sync
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Sync Obsidian daily-notes.json with the configured daily_folder.
119
+ * Mutates the configMd file if the folder differs.
120
+ *
121
+ * @param {string} storageRepo
122
+ * @param {string} configPath
123
+ * @param {string} dailyFolder
124
+ * @returns {string} - Possibly updated daily_folder
125
+ */
126
+ function syncObsidianDailyNotes(storageRepo, configPath, dailyFolder) {
127
+ const obsidianDir = path.join(storageRepo, ".obsidian");
128
+ const dailyNotesJson = path.join(obsidianDir, "daily-notes.json");
129
+
130
+ if (fs.existsSync(dailyNotesJson)) {
131
+ try {
132
+ const parsed = JSON.parse(fs.readFileSync(dailyNotesJson, "utf8"));
133
+ if (parsed.folder && parsed.folder !== dailyFolder) {
134
+ // Sync config to Obsidian's value
135
+ const configContent = fs.readFileSync(configPath, "utf8");
136
+ const updated = configContent.replace(
137
+ /^daily_folder:\s*.+$/m,
138
+ `daily_folder: ${parsed.folder}`,
139
+ );
140
+ fs.writeFileSync(configPath, updated);
141
+ return parsed.folder;
142
+ }
143
+ } catch {
144
+ // Parse error — ignore
145
+ }
146
+ } else if (fs.existsSync(obsidianDir)) {
147
+ // .obsidian/ exists but no daily-notes.json — write it
148
+ try {
149
+ fs.writeFileSync(dailyNotesJson, JSON.stringify({ folder: dailyFolder }, null, 2));
150
+ } catch {
151
+ // Ignore errors writing Obsidian config
152
+ }
153
+ }
154
+
155
+ return dailyFolder;
156
+ }
157
+
158
+ // ─────────────────────────────────────────────────────────────────────────────
159
+ // Subcommand handlers
160
+ // ─────────────────────────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Run the `init` subcommand.
164
+ *
165
+ * Reads config, runs migrations, syncs Obsidian, checks for updates.
166
+ *
167
+ * @param {string[]} _args
168
+ * @param {object} [options]
169
+ * @param {string} [options.homeDir] - Override home directory (for testing)
170
+ * @param {Function} [options.fetchLatestVersion] - Override for network call (for testing)
171
+ * @returns {Promise<object>}
172
+ */
173
+ async function runInit(_args, options = {}) {
174
+ const homeDir = options.homeDir || os.homedir();
175
+ const donnaDir = path.join(homeDir, ".donna");
176
+ const migrationsDir = path.join(__dirname, "..", "migrations");
177
+ const fetchLatestVersion = options.fetchLatestVersion || fetchLatestVersionFromRegistry;
178
+
179
+ // Read config
180
+ const config = readConfig(homeDir);
181
+ if (!config || !config.storage_repo) {
182
+ return { error: "not_configured", storage_repo: null };
183
+ }
184
+
185
+ let { storage_repo, daily_folder, auto_push } = config;
186
+
187
+ // Sync Obsidian daily-notes
188
+ const configPath = path.join(homeDir, ".config", "donna", "config.md");
189
+ daily_folder = syncObsidianDailyNotes(storage_repo, configPath, daily_folder);
190
+
191
+ // Run migrations
192
+ const versionInfo = readVersion(donnaDir);
193
+ const lastMigration = versionInfo?.lastMigration || 0;
194
+ const migrationResults = runMigrations(migrationsDir, donnaDir, lastMigration);
195
+ const migrations_applied = migrationResults.filter((r) => r.ok).map((r) => r.description);
196
+
197
+ // Version check (once per day, non-blocking)
198
+ let update_available = null;
199
+ try {
200
+ const today = new Date().toISOString().slice(0, 10);
201
+ const cache = readVersionCache(donnaDir);
202
+
203
+ let latestVersion;
204
+ if (cache && cache.date === today) {
205
+ // Cache hit — use cached value
206
+ latestVersion = cache.latest;
207
+ } else {
208
+ // Cache miss — fetch from registry
209
+ latestVersion = await fetchLatestVersion();
210
+ if (latestVersion) {
211
+ writeVersionCache(donnaDir, today, latestVersion);
212
+ }
213
+ }
214
+
215
+ if (latestVersion) {
216
+ const pkg = require("../package.json");
217
+ const currentVersion = versionInfo?.version || pkg.version;
218
+ if (semverGt(latestVersion, currentVersion)) {
219
+ update_available = latestVersion;
220
+ }
221
+ }
222
+ } catch {
223
+ // Never throw from version check — treat as no update available
224
+ update_available = null;
225
+ }
226
+
227
+ return {
228
+ storage_repo,
229
+ daily_folder,
230
+ auto_push,
231
+ update_available,
232
+ migrations_applied,
233
+ error: null,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Run the `commit` subcommand.
239
+ *
240
+ * Args: commit <msg> --files f1 f2 ...
241
+ *
242
+ * @param {string[]} args
243
+ * @param {object} [options]
244
+ * @param {string} [options.homeDir] - Override home directory (for testing)
245
+ * @returns {Promise<object>}
246
+ */
247
+ async function runCommit(args, options = {}) {
248
+ const homeDir = options.homeDir || os.homedir();
249
+ const config = readConfig(homeDir);
250
+ if (!config || !config.storage_repo) {
251
+ return { error: "not_configured" };
252
+ }
253
+
254
+ const { storage_repo, auto_push } = config;
255
+
256
+ // Parse message and files
257
+ const filesIdx = args.indexOf("--files");
258
+ const message = args[0] || "";
259
+ const files = filesIdx !== -1 ? args.slice(filesIdx + 1) : [];
260
+
261
+ try {
262
+ // Stage files
263
+ if (files.length > 0) {
264
+ for (const file of files) {
265
+ execSync(`git -C ${JSON.stringify(storage_repo)} add ${JSON.stringify(file)}`, {
266
+ stdio: "pipe",
267
+ });
268
+ }
269
+ } else {
270
+ execSync(`git -C ${JSON.stringify(storage_repo)} add -A`, { stdio: "pipe" });
271
+ }
272
+
273
+ // Check if there's anything to commit
274
+ const status = execSync(`git -C ${JSON.stringify(storage_repo)} status --porcelain`, {
275
+ stdio: "pipe",
276
+ encoding: "utf8",
277
+ });
278
+
279
+ if (!status || status.trim() === "") {
280
+ return { committed: false, reason: "nothing_to_commit" };
281
+ }
282
+
283
+ // Commit
284
+ execSync(`git -C ${JSON.stringify(storage_repo)} commit -m ${JSON.stringify(message)}`, {
285
+ stdio: "pipe",
286
+ });
287
+
288
+ // Push if auto_push
289
+ if (auto_push) {
290
+ execSync(`git -C ${JSON.stringify(storage_repo)} push`, { stdio: "pipe" });
291
+ }
292
+
293
+ return { committed: true, message };
294
+ } catch (err) {
295
+ return { error: err.message, committed: false };
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Run the `daily-path` subcommand.
301
+ *
302
+ * @param {string[]} _args
303
+ * @param {object} [options]
304
+ * @param {string} [options.homeDir] - Override home directory (for testing)
305
+ * @returns {Promise<object>}
306
+ */
307
+ async function runDailyPath(_args, options = {}) {
308
+ const homeDir = options.homeDir || os.homedir();
309
+ const config = readConfig(homeDir);
310
+ if (!config || !config.storage_repo) {
311
+ return { error: "not_configured" };
312
+ }
313
+
314
+ const { storage_repo, daily_folder } = config;
315
+ const today = new Date().toISOString().slice(0, 10);
316
+ const dailyDir = path.join(storage_repo, daily_folder);
317
+ const filePath = path.join(dailyDir, `${today}.md`);
318
+
319
+ // Create directory if it doesn't exist
320
+ fs.mkdirSync(dailyDir, { recursive: true });
321
+
322
+ return { path: filePath };
323
+ }
324
+
325
+ /**
326
+ * Run the `resolve-secret` subcommand.
327
+ *
328
+ * Args: resolve-secret <key>
329
+ *
330
+ * @param {string[]} args
331
+ * @param {object} [options]
332
+ * @param {string} [options.homeDir] - Override home directory (for testing)
333
+ * @returns {Promise<object>}
334
+ */
335
+ async function runResolveSecret(args, options = {}) {
336
+ const homeDir = options.homeDir || os.homedir();
337
+ const config = readConfig(homeDir);
338
+ if (!config || !config.storage_repo) {
339
+ return { error: "not_configured" };
340
+ }
341
+
342
+ const { storage_repo } = config;
343
+ const key = args[0];
344
+
345
+ if (!key) {
346
+ return { error: "missing_key" };
347
+ }
348
+
349
+ const secretsPath = path.join(storage_repo, "donna", "secrets.md");
350
+ if (!fs.existsSync(secretsPath)) {
351
+ return { error: "secrets_not_found", key };
352
+ }
353
+
354
+ const content = fs.readFileSync(secretsPath, "utf8");
355
+
356
+ // Parse "- KEY: value" pattern (allowing optional spaces)
357
+ const pattern = new RegExp(`^-\\s+${key}:\\s*(.+)$`, "m");
358
+ const match = content.match(pattern);
359
+
360
+ if (!match) {
361
+ return { error: "key_not_found", key };
362
+ }
363
+
364
+ const value = match[1].trim();
365
+
366
+ // Check for placeholder patterns
367
+ if (
368
+ /^your-.+-here$/i.test(value) ||
369
+ value === "TODO" ||
370
+ value === "PLACEHOLDER" ||
371
+ value === "xxx" ||
372
+ /^<.+>$/.test(value)
373
+ ) {
374
+ return { error: "placeholder_value", key, value };
375
+ }
376
+
377
+ return { key, value };
378
+ }
379
+
380
+ // ─────────────────────────────────────────────────────────────────────────────
381
+ // CLI entry point
382
+ // ─────────────────────────────────────────────────────────────────────────────
383
+
384
+ /**
385
+ * Main CLI entry point. Routes subcommands and prints JSON to stdout.
386
+ */
387
+ async function main() {
388
+ const args = process.argv.slice(2);
389
+ const cmd = args[0];
390
+
391
+ if (cmd === "init") {
392
+ const result = await runInit(args.slice(1));
393
+ console.log(JSON.stringify(result));
394
+ return;
395
+ }
396
+
397
+ if (cmd === "commit") {
398
+ const result = await runCommit(args.slice(1));
399
+ console.log(JSON.stringify(result));
400
+ return;
401
+ }
402
+
403
+ if (cmd === "daily-path") {
404
+ const result = await runDailyPath(args.slice(1));
405
+ console.log(JSON.stringify(result));
406
+ return;
407
+ }
408
+
409
+ if (cmd === "resolve-secret") {
410
+ const result = await runResolveSecret(args.slice(1));
411
+ console.log(JSON.stringify(result));
412
+ return;
413
+ }
414
+
415
+ process.stderr.write(`Unknown command: ${cmd}\n`);
416
+ process.exit(1);
417
+ }
418
+
419
+ main.catch = undefined; // prevent accidental chaining — the module handles it below
420
+
421
+ module.exports = { main, runInit, runCommit, runDailyPath, runResolveSecret };
422
+
423
+ // Only run main when invoked directly (not when require()'d in tests)
424
+ if (require.main === module) {
425
+ main().catch((err) => {
426
+ process.stderr.write(`${err.message}\n`);
427
+ process.exit(1);
428
+ });
429
+ }
package/src/installer.cjs CHANGED
@@ -79,7 +79,7 @@ async function run(options = {}) {
79
79
  for (const provider of detected) {
80
80
  fs.cpSync(provider.stubSource, provider.stubTarget, { recursive: true });
81
81
  output.success(
82
- `Copied donna skills (setup, add-task, done, set-role, begin-the-day, add-tool, relearn-tools, run-tools, help, contribute-idea, adjust-tool, focus) to ${provider.stubTarget}`,
82
+ `Copied donna skills (setup, add-task, done, set-role, begin-the-day, add-tool, remove-tool, relearn-tools, run-tools, help, contribute-idea, adjust-tool, focus, add-follow-up-task) to ${provider.stubTarget}`,
83
83
  );
84
84
  }
85
85
  } else {
@@ -93,13 +93,35 @@ async function run(options = {}) {
93
93
  fs.cpSync(workflowsSource, workflowsTarget, { recursive: true });
94
94
  output.success("Installed workflows to ~/.donna/workflows/");
95
95
 
96
+ // Copy donna-tools.cjs and its dependencies to donnaDir for workflow access
97
+ const toolFiles = [
98
+ "donna-tools.cjs",
99
+ "version.cjs",
100
+ "migrator.cjs",
101
+ "changelog.cjs",
102
+ "output.cjs",
103
+ ];
104
+ for (const file of toolFiles) {
105
+ const src = path.join(__dirname, file);
106
+ if (fs.existsSync(src)) {
107
+ fs.copyFileSync(src, path.join(donnaDir, file));
108
+ }
109
+ }
110
+ output.success("Installed donna-tools.cjs + dependencies to ~/.donna/");
111
+
96
112
  // Write version.md
97
113
  version.writeVersion(donnaDir, packageVersion, lastSuccessful);
98
114
  output.success(`Version ${packageVersion} installed`);
99
115
 
100
- // Final message
101
- console.log("");
102
- output.info("Run /donna:setup in Claude Code to get started.");
116
+ // Final message — suppress setup prompt if already configured (D-04)
117
+ const configPath = path.join(homeDir, ".config", "donna", "config.md");
118
+ const isConfigured =
119
+ fs.existsSync(configPath) && fs.readFileSync(configPath, "utf8").includes("storage_repo:");
120
+
121
+ if (!isConfigured) {
122
+ console.log("");
123
+ output.info("Run /donna:setup in Claude Code to get started.");
124
+ }
103
125
  }
104
126
 
105
127
  module.exports = { run };
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: donna:add-follow-up-task
3
+ description: Schedule a follow-up task for a future date
4
+ allowed-tools:
5
+ - Read
6
+ - Write
7
+ - Bash
8
+ - AskUserQuestion
9
+ ---
10
+
11
+ <objective>
12
+ Run the Donna follow-up workflow.
13
+ </objective>
14
+
15
+ <execution_context>
16
+ @~/.donna/workflows/follow-up.md
17
+ </execution_context>
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: donna:remove-tool
3
+ description: Remove a registered tool from tools.md
4
+ allowed-tools:
5
+ - Read
6
+ - Write
7
+ - Bash
8
+ - AskUserQuestion
9
+ ---
10
+
11
+ <objective>
12
+ Run the Donna remove-tool workflow. Remove a registered tool cleanly.
13
+ </objective>
14
+
15
+ <execution_context>
16
+ @~/.donna/workflows/remove-tool.md
17
+ </execution_context>