heyio 0.6.0 → 0.8.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.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Tests for src/store/notifications.ts — SQLite CRUD helpers.
3
+ *
4
+ * DB isolation: setDbPathForTests() redirects the SQLite singleton to a
5
+ * fresh tmp file, ensuring these tests never touch ~/.io/io.db.
6
+ */
7
+ import { before, after, beforeEach, describe, it } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { setDbPathForTests, closeDb, getDb } from "./db.js";
13
+ import { insertNotification, listRecentNotifications, listUnreadNotifications, countUnreadNotifications, markNotificationRead, markAllNotificationsRead, pruneOldNotifications, } from "./notifications.js";
14
+ // ── DB isolation ────────────────────────────────────────────────────────────
15
+ let tmpDir;
16
+ before(() => {
17
+ tmpDir = mkdtempSync(join(tmpdir(), "io-notifs-test-"));
18
+ setDbPathForTests(join(tmpDir, "io.db"));
19
+ });
20
+ // Wipe all notifications between tests for a clean slate.
21
+ beforeEach(() => {
22
+ getDb().prepare("DELETE FROM background_notifications").run();
23
+ });
24
+ after(() => {
25
+ closeDb();
26
+ rmSync(tmpDir, { recursive: true, force: true });
27
+ });
28
+ // ── Helpers ─────────────────────────────────────────────────────────────────
29
+ function makeNotif(overrides = {}) {
30
+ return insertNotification({
31
+ source_type: "io-schedule",
32
+ source_ref: JSON.stringify({ scheduleId: 1 }),
33
+ title: "Test Notification",
34
+ text: "This is the notification body.",
35
+ ...overrides,
36
+ });
37
+ }
38
+ // ── insertNotification ───────────────────────────────────────────────────────
39
+ describe("insertNotification", () => {
40
+ it("returns a row with autoincrement id and created_at timestamp", () => {
41
+ const row = makeNotif();
42
+ assert.ok(typeof row.id === "number" && row.id > 0, "id should be a positive integer");
43
+ assert.ok(row.created_at, "created_at should be set");
44
+ assert.equal(row.title, "Test Notification");
45
+ assert.equal(row.text, "This is the notification body.");
46
+ assert.equal(row.read_at, null);
47
+ });
48
+ it("ids are autoincremented across inserts", () => {
49
+ const a = makeNotif();
50
+ const b = makeNotif();
51
+ assert.ok(b.id > a.id, "second id should be greater than first");
52
+ });
53
+ it("accepts source_ref: null", () => {
54
+ const row = makeNotif({ source_ref: null });
55
+ assert.equal(row.source_ref, null);
56
+ });
57
+ it("stores source_type correctly", () => {
58
+ const row = makeNotif({ source_type: "squad-schedule" });
59
+ assert.equal(row.source_type, "squad-schedule");
60
+ });
61
+ });
62
+ // ── listRecentNotifications ──────────────────────────────────────────────────
63
+ describe("listRecentNotifications", () => {
64
+ it("returns newest first", () => {
65
+ const a = makeNotif({ title: "First" });
66
+ const b = makeNotif({ title: "Second" });
67
+ const c = makeNotif({ title: "Third" });
68
+ const rows = listRecentNotifications();
69
+ assert.equal(rows[0].id, c.id, "newest should be first");
70
+ assert.equal(rows[rows.length - 1].id, a.id, "oldest should be last");
71
+ });
72
+ it("default limit is 50", () => {
73
+ for (let i = 0; i < 55; i++)
74
+ makeNotif({ title: `N${i}` });
75
+ const rows = listRecentNotifications();
76
+ assert.equal(rows.length, 50);
77
+ });
78
+ it("explicit limit is honored", () => {
79
+ for (let i = 0; i < 10; i++)
80
+ makeNotif({ title: `N${i}` });
81
+ const rows = listRecentNotifications(3);
82
+ assert.equal(rows.length, 3);
83
+ });
84
+ });
85
+ // ── countUnreadNotifications ─────────────────────────────────────────────────
86
+ describe("countUnreadNotifications", () => {
87
+ it("starts at zero on a clean DB", () => {
88
+ assert.equal(countUnreadNotifications(), 0);
89
+ });
90
+ it("increases on insert", () => {
91
+ makeNotif();
92
+ assert.equal(countUnreadNotifications(), 1);
93
+ makeNotif();
94
+ assert.equal(countUnreadNotifications(), 2);
95
+ });
96
+ it("decreases when a notification is marked read", () => {
97
+ const a = makeNotif();
98
+ makeNotif();
99
+ assert.equal(countUnreadNotifications(), 2);
100
+ markNotificationRead(a.id);
101
+ assert.equal(countUnreadNotifications(), 1);
102
+ });
103
+ });
104
+ // ── markNotificationRead ─────────────────────────────────────────────────────
105
+ describe("markNotificationRead", () => {
106
+ it("returns false on a non-existent id", () => {
107
+ assert.equal(markNotificationRead(999999), false);
108
+ });
109
+ it("returns true on an existing unread notification", () => {
110
+ const row = makeNotif();
111
+ assert.equal(markNotificationRead(row.id), true);
112
+ });
113
+ it("is idempotent — returns true even if already read", () => {
114
+ const row = makeNotif();
115
+ assert.equal(markNotificationRead(row.id), true);
116
+ assert.equal(markNotificationRead(row.id), true, "second call should still return true");
117
+ });
118
+ it("sets read_at on the row", () => {
119
+ const row = makeNotif();
120
+ markNotificationRead(row.id);
121
+ const updated = getDb()
122
+ .prepare("SELECT read_at FROM background_notifications WHERE id = ?")
123
+ .get(row.id);
124
+ assert.ok(updated.read_at, "read_at should be set after marking read");
125
+ });
126
+ });
127
+ // ── markAllNotificationsRead ─────────────────────────────────────────────────
128
+ describe("markAllNotificationsRead", () => {
129
+ it("returns the count of newly-marked rows", () => {
130
+ makeNotif();
131
+ makeNotif();
132
+ makeNotif();
133
+ assert.equal(markAllNotificationsRead(), 3);
134
+ });
135
+ it("subsequent call returns 0 (all already read)", () => {
136
+ makeNotif();
137
+ makeNotif();
138
+ markAllNotificationsRead();
139
+ assert.equal(markAllNotificationsRead(), 0);
140
+ });
141
+ it("only marks unread rows — pre-read rows not re-touched", () => {
142
+ const a = makeNotif();
143
+ makeNotif();
144
+ markNotificationRead(a.id); // mark one manually first
145
+ const count = markAllNotificationsRead(); // should only mark the remaining 1
146
+ assert.equal(count, 1);
147
+ });
148
+ });
149
+ // ── listUnreadNotifications ──────────────────────────────────────────────────
150
+ describe("listUnreadNotifications", () => {
151
+ it("excludes read rows", () => {
152
+ const a = makeNotif({ title: "A" });
153
+ const b = makeNotif({ title: "B" });
154
+ markNotificationRead(a.id);
155
+ const unread = listUnreadNotifications();
156
+ assert.ok(!unread.some((r) => r.id === a.id), "read row should not appear");
157
+ assert.ok(unread.some((r) => r.id === b.id), "unread row should appear");
158
+ });
159
+ it("returns newest first", () => {
160
+ const a = makeNotif({ title: "A" });
161
+ const b = makeNotif({ title: "B" });
162
+ const rows = listUnreadNotifications();
163
+ assert.equal(rows[0].id, b.id);
164
+ assert.equal(rows[1].id, a.id);
165
+ });
166
+ it("returns empty array when all are read", () => {
167
+ makeNotif();
168
+ makeNotif();
169
+ markAllNotificationsRead();
170
+ assert.deepEqual(listUnreadNotifications(), []);
171
+ });
172
+ });
173
+ // ── pruneOldNotifications ─────────────────────────────────────────────────────
174
+ describe("pruneOldNotifications", () => {
175
+ it("deletes rows older than the threshold and returns the count", () => {
176
+ const old = makeNotif({ title: "Old" });
177
+ makeNotif({ title: "Recent" }); // stays untouched
178
+ // Back-date the 'old' row to 10 days ago
179
+ getDb()
180
+ .prepare("UPDATE background_notifications SET created_at = datetime('now', '-10 days') WHERE id = ?")
181
+ .run(old.id);
182
+ const deleted = pruneOldNotifications(7); // prune rows older than 7 days
183
+ assert.equal(deleted, 1, "should delete exactly the back-dated row");
184
+ const remaining = listRecentNotifications();
185
+ assert.ok(!remaining.some((r) => r.id === old.id), "old row should be gone");
186
+ assert.ok(remaining.some((r) => r.title === "Recent"), "recent row should remain");
187
+ });
188
+ it("returns 0 when nothing is old enough to prune", () => {
189
+ makeNotif();
190
+ makeNotif();
191
+ assert.equal(pruneOldNotifications(7), 0);
192
+ });
193
+ it("is safe on an empty table", () => {
194
+ assert.equal(pruneOldNotifications(1), 0);
195
+ });
196
+ });
197
+ //# sourceMappingURL=notifications.test.js.map
@@ -0,0 +1,46 @@
1
+ import { getDb } from "./db.js";
2
+ /** Create a new run in 'running' status. Returns the row. */
3
+ export function startScheduleRun(input) {
4
+ const db = getDb();
5
+ const info = db
6
+ .prepare(`INSERT INTO schedule_runs (schedule_type, schedule_id, schedule_name, squad_slug)
7
+ VALUES (?, ?, ?, ?)`)
8
+ .run(input.schedule_type, input.schedule_id, input.schedule_name, input.squad_slug ?? null);
9
+ return db
10
+ .prepare("SELECT * FROM schedule_runs WHERE id = ?")
11
+ .get(info.lastInsertRowid);
12
+ }
13
+ /** Mark a run complete (success). Optionally link a notification_id. */
14
+ export function completeScheduleRun(id, notificationId) {
15
+ getDb()
16
+ .prepare(`UPDATE schedule_runs
17
+ SET status = 'done', completed_at = CURRENT_TIMESTAMP, notification_id = ?
18
+ WHERE id = ?`)
19
+ .run(notificationId ?? null, id);
20
+ }
21
+ /** Mark a run failed with an error message. */
22
+ export function failScheduleRun(id, errorText) {
23
+ getDb()
24
+ .prepare(`UPDATE schedule_runs
25
+ SET status = 'failed', completed_at = CURRENT_TIMESTAMP, error_text = ?
26
+ WHERE id = ?`)
27
+ .run(errorText, id);
28
+ }
29
+ /** Get last N runs for a specific schedule. Newest first. */
30
+ export function getScheduleRuns(scheduleType, scheduleId, limit = 10) {
31
+ return getDb()
32
+ .prepare(`SELECT * FROM schedule_runs
33
+ WHERE schedule_type = ? AND schedule_id = ?
34
+ ORDER BY started_at DESC
35
+ LIMIT ?`)
36
+ .all(scheduleType, scheduleId, limit);
37
+ }
38
+ /** Prune runs older than the given number of days. Returns rows deleted. */
39
+ export function pruneOldScheduleRuns(olderThanDays) {
40
+ const info = getDb()
41
+ .prepare(`DELETE FROM schedule_runs
42
+ WHERE started_at < datetime('now', '-' || ? || ' days')`)
43
+ .run(olderThanDays);
44
+ return info.changes;
45
+ }
46
+ //# sourceMappingURL=schedule-runs.js.map
@@ -141,4 +141,18 @@ export async function sendProactiveMessage(text) {
141
141
  }
142
142
  }
143
143
  }
144
+ /**
145
+ * Send a background schedule result as a Telegram notification.
146
+ * Delegates to sendProactiveMessage for chunking, bot-not-configured guards,
147
+ * and authorized-user checks — no extra logic needed here.
148
+ *
149
+ * Format (plain text, no parse_mode):
150
+ * 🔔 Background update — <title>
151
+ *
152
+ * <text>
153
+ */
154
+ export async function sendBackgroundNotification(opts) {
155
+ const formatted = `\ud83d\udd14 Background update \u2014 ${opts.title}\n\n${opts.text}`;
156
+ await sendProactiveMessage(formatted);
157
+ }
144
158
  //# sourceMappingURL=bot.js.map
package/dist/tui/index.js CHANGED
@@ -17,6 +17,76 @@ Type a message to chat. Commands:
17
17
  /quit — exit
18
18
  `;
19
19
  let verbose = false;
20
+ // Held so printBackgroundNotification can redraw the prompt around notifications.
21
+ // Assigned inside startTui(); undefined when running headless (no TUI).
22
+ let activeInterface;
23
+ const NOTIFICATION_MAX_LINES = 6;
24
+ const NOTIFICATION_WRAP_WIDTH = 78;
25
+ /** Hard-wrap a single line to at most `width` visible chars, returning segments. */
26
+ function wrapLine(line, width) {
27
+ const segments = [];
28
+ while (line.length > width) {
29
+ segments.push(line.slice(0, width));
30
+ line = line.slice(width);
31
+ }
32
+ segments.push(line);
33
+ return segments;
34
+ }
35
+ /**
36
+ * Print a background notification in a bordered block above the prompt,
37
+ * preserving any in-progress readline input the user has typed.
38
+ *
39
+ * Format:
40
+ * ╭─🔔 Background update: <title>
41
+ * │ <line1>
42
+ * │ […N more lines — see /notifications] (if truncated)
43
+ * ╰─
44
+ *
45
+ * Safe to call at any time, even before startTui(). Never throws.
46
+ */
47
+ export function printBackgroundNotification(opts) {
48
+ try {
49
+ // Build display lines from text, hard-wrapping long lines.
50
+ const rawLines = opts.text.split(/\r?\n/);
51
+ const displayLines = [];
52
+ for (const raw of rawLines) {
53
+ for (const seg of wrapLine(raw, NOTIFICATION_WRAP_WIDTH)) {
54
+ displayLines.push(seg);
55
+ }
56
+ }
57
+ const truncated = displayLines.length > NOTIFICATION_MAX_LINES;
58
+ const visible = truncated ? displayLines.slice(0, NOTIFICATION_MAX_LINES) : displayLines;
59
+ const extra = displayLines.length - NOTIFICATION_MAX_LINES;
60
+ const top = "\u256d\u2500\ud83d\udd14 Background update: " + opts.title + "\n";
61
+ const body = visible.map((l) => "\u2502 " + l).join("\n");
62
+ const overflow = truncated
63
+ ? "\n\u2502 [\u2026" + extra + " more line" + (extra === 1 ? "" : "s") + " \u2014 see /notifications]"
64
+ : "";
65
+ const bottom = "\n\u2570\u2500\n";
66
+ const block = top + body + overflow + bottom;
67
+ if (!activeInterface) {
68
+ // Headless daemon — no readline interface live; plain log is fine.
69
+ process.stdout.write(block);
70
+ return;
71
+ }
72
+ // Capture whatever the user has typed so far.
73
+ // readline stores the pending input in `rl.line` (stable internal property).
74
+ const currentLine = activeInterface.line ?? "";
75
+ // Clear the current prompt+input line, print the notification, then
76
+ // redraw the prompt with the user's buffer.
77
+ process.stdout.write("\r\x1b[K");
78
+ process.stdout.write(block);
79
+ if (currentLine === "") {
80
+ activeInterface.prompt(true);
81
+ }
82
+ else {
83
+ process.stdout.write("io> " + currentLine);
84
+ }
85
+ }
86
+ catch (err) {
87
+ console.error("[io] printBackgroundNotification failed:", err instanceof Error ? err.message : String(err));
88
+ }
89
+ }
20
90
  function renderActivity(taskIdArg) {
21
91
  const recent = listRecentTasks(20);
22
92
  let task = undefined;
@@ -62,6 +132,9 @@ export async function startTui() {
62
132
  input: process.stdin,
63
133
  output: process.stdout,
64
134
  });
135
+ // Keep a module-level reference so printBackgroundNotification can redraw
136
+ // the prompt without disturbing the user's in-progress input.
137
+ activeInterface = rl;
65
138
  console.log(WELCOME_BANNER);
66
139
  rl.setPrompt("io> ");
67
140
  rl.prompt();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyio",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "IO — a personal AI assistant built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "io": "dist/index.js"
@@ -18,7 +18,8 @@
18
18
  "daemon": "tsx src/daemon.ts",
19
19
  "tui": "tsx src/tui/index.ts",
20
20
  "dev": "tsx --watch src/daemon.ts",
21
- "prepublishOnly": "npm run build:all"
21
+ "prepublishOnly": "npm run build:all",
22
+ "test": "node --import tsx --test 'src/**/*.test.ts'"
22
23
  },
23
24
  "engines": {
24
25
  "node": ">=22"