heyio 0.19.0 → 0.21.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,207 @@
1
+ /**
2
+ * Tests for src/store/feed.ts — unified feed store.
3
+ *
4
+ * DB isolation: setDbPathForTests() redirects the SQLite singleton to a
5
+ * fresh tmp file so 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 { createFeedEntry, listFeedEntries, countUnreadFeedEntries, markFeedEntryRead, markAllFeedEntriesRead, deleteFeedEntry, pruneOldFeedEntries, } from "./feed.js";
14
+ // ── DB isolation ─────────────────────────────────────────────────────────────
15
+ let tmpDir;
16
+ before(() => {
17
+ tmpDir = mkdtempSync(join(tmpdir(), "io-feed-test-"));
18
+ setDbPathForTests(join(tmpDir, "io.db"));
19
+ });
20
+ after(() => {
21
+ closeDb();
22
+ rmSync(tmpDir, { recursive: true, force: true });
23
+ });
24
+ beforeEach(() => {
25
+ getDb().prepare("DELETE FROM unified_feed").run();
26
+ });
27
+ // ── createFeedEntry ───────────────────────────────────────────────────────────
28
+ describe("createFeedEntry", () => {
29
+ it("creates a deliverable entry with correct fields", () => {
30
+ const entry = createFeedEntry({ type: "deliverable", title: "Task done", body: "Here are the results." });
31
+ assert.equal(entry.type, "deliverable");
32
+ assert.equal(entry.title, "Task done");
33
+ assert.equal(entry.body, "Here are the results.");
34
+ assert.equal(entry.read_at, null);
35
+ assert.equal(entry.source_type, null);
36
+ assert.equal(entry.source_ref, null);
37
+ assert.ok(entry.id > 0);
38
+ assert.ok(entry.created_at);
39
+ });
40
+ it("creates a notification entry with correct fields", () => {
41
+ const entry = createFeedEntry({ type: "notification", title: "Schedule ran", body: "Background task complete." });
42
+ assert.equal(entry.type, "notification");
43
+ assert.equal(entry.read_at, null);
44
+ });
45
+ it("stores source_type and source_ref when provided", () => {
46
+ const entry = createFeedEntry({
47
+ type: "notification",
48
+ title: "Sched",
49
+ body: "Done",
50
+ source_type: "io-schedule",
51
+ source_ref: JSON.stringify({ id: 42 }),
52
+ });
53
+ assert.equal(entry.source_type, "io-schedule");
54
+ assert.equal(entry.source_ref, JSON.stringify({ id: 42 }));
55
+ });
56
+ it("autoincrements ids", () => {
57
+ const a = createFeedEntry({ type: "deliverable", title: "A", body: "a" });
58
+ const b = createFeedEntry({ type: "notification", title: "B", body: "b" });
59
+ assert.ok(b.id > a.id);
60
+ });
61
+ });
62
+ // ── listFeedEntries ───────────────────────────────────────────────────────────
63
+ describe("listFeedEntries", () => {
64
+ it("returns all entries newest first", () => {
65
+ createFeedEntry({ type: "deliverable", title: "First", body: "x" });
66
+ createFeedEntry({ type: "notification", title: "Second", body: "y" });
67
+ const entries = listFeedEntries();
68
+ assert.equal(entries.length, 2);
69
+ assert.equal(entries[0].title, "Second");
70
+ assert.equal(entries[1].title, "First");
71
+ });
72
+ it("filters by type=deliverable", () => {
73
+ createFeedEntry({ type: "deliverable", title: "D", body: "d" });
74
+ createFeedEntry({ type: "notification", title: "N", body: "n" });
75
+ const entries = listFeedEntries({ type: "deliverable" });
76
+ assert.equal(entries.length, 1);
77
+ assert.equal(entries[0].type, "deliverable");
78
+ });
79
+ it("filters by type=notification", () => {
80
+ createFeedEntry({ type: "deliverable", title: "D", body: "d" });
81
+ createFeedEntry({ type: "notification", title: "N", body: "n" });
82
+ const entries = listFeedEntries({ type: "notification" });
83
+ assert.equal(entries.length, 1);
84
+ assert.equal(entries[0].type, "notification");
85
+ });
86
+ it("filters by unreadOnly=true", () => {
87
+ const e = createFeedEntry({ type: "notification", title: "Read me", body: "u" });
88
+ createFeedEntry({ type: "notification", title: "Still unread", body: "v" });
89
+ markFeedEntryRead(e.id);
90
+ const entries = listFeedEntries({ unreadOnly: true });
91
+ assert.equal(entries.length, 1);
92
+ assert.equal(entries[0].title, "Still unread");
93
+ });
94
+ it("respects limit", () => {
95
+ for (let i = 0; i < 5; i++) {
96
+ createFeedEntry({ type: "notification", title: `N${i}`, body: "x" });
97
+ }
98
+ const entries = listFeedEntries({ limit: 3 });
99
+ assert.equal(entries.length, 3);
100
+ });
101
+ it("returns empty array on clean DB", () => {
102
+ assert.deepEqual(listFeedEntries(), []);
103
+ });
104
+ });
105
+ // ── countUnreadFeedEntries ────────────────────────────────────────────────────
106
+ describe("countUnreadFeedEntries", () => {
107
+ it("returns 0 on clean DB", () => {
108
+ assert.equal(countUnreadFeedEntries(), 0);
109
+ });
110
+ it("increments on insert", () => {
111
+ createFeedEntry({ type: "deliverable", title: "T", body: "b" });
112
+ assert.equal(countUnreadFeedEntries(), 1);
113
+ createFeedEntry({ type: "notification", title: "N", body: "b" });
114
+ assert.equal(countUnreadFeedEntries(), 2);
115
+ });
116
+ it("decreases when marked read", () => {
117
+ const e = createFeedEntry({ type: "deliverable", title: "T", body: "b" });
118
+ markFeedEntryRead(e.id);
119
+ assert.equal(countUnreadFeedEntries(), 0);
120
+ });
121
+ it("filters by type", () => {
122
+ createFeedEntry({ type: "deliverable", title: "D", body: "d" });
123
+ createFeedEntry({ type: "notification", title: "N", body: "n" });
124
+ assert.equal(countUnreadFeedEntries("deliverable"), 1);
125
+ assert.equal(countUnreadFeedEntries("notification"), 1);
126
+ });
127
+ });
128
+ // ── markFeedEntryRead ─────────────────────────────────────────────────────────
129
+ describe("markFeedEntryRead", () => {
130
+ it("returns false for a non-existent id", () => {
131
+ assert.equal(markFeedEntryRead(9999), false);
132
+ });
133
+ it("returns true and sets read_at for an unread entry", () => {
134
+ const e = createFeedEntry({ type: "notification", title: "T", body: "b" });
135
+ const result = markFeedEntryRead(e.id);
136
+ assert.equal(result, true);
137
+ const all = listFeedEntries();
138
+ const updated = all.find((x) => x.id === e.id);
139
+ assert.ok(updated.read_at !== null);
140
+ });
141
+ it("is idempotent — returns true if already read", () => {
142
+ const e = createFeedEntry({ type: "notification", title: "T", body: "b" });
143
+ markFeedEntryRead(e.id);
144
+ assert.equal(markFeedEntryRead(e.id), true);
145
+ });
146
+ });
147
+ // ── markAllFeedEntriesRead ────────────────────────────────────────────────────
148
+ describe("markAllFeedEntriesRead", () => {
149
+ it("returns count of newly-marked rows", () => {
150
+ createFeedEntry({ type: "notification", title: "A", body: "a" });
151
+ createFeedEntry({ type: "notification", title: "B", body: "b" });
152
+ const count = markAllFeedEntriesRead();
153
+ assert.equal(count, 2);
154
+ });
155
+ it("subsequent call returns 0 (all already read)", () => {
156
+ createFeedEntry({ type: "notification", title: "A", body: "a" });
157
+ markAllFeedEntriesRead();
158
+ assert.equal(markAllFeedEntriesRead(), 0);
159
+ });
160
+ it("respects type filter — only marks matching type", () => {
161
+ createFeedEntry({ type: "deliverable", title: "D", body: "d" });
162
+ createFeedEntry({ type: "notification", title: "N", body: "n" });
163
+ const count = markAllFeedEntriesRead("notification");
164
+ assert.equal(count, 1);
165
+ assert.equal(countUnreadFeedEntries("deliverable"), 1);
166
+ assert.equal(countUnreadFeedEntries("notification"), 0);
167
+ });
168
+ });
169
+ // ── deleteFeedEntry ───────────────────────────────────────────────────────────
170
+ describe("deleteFeedEntry", () => {
171
+ it("returns false for a non-existent id", () => {
172
+ assert.equal(deleteFeedEntry(9999), false);
173
+ });
174
+ it("returns true and removes the entry", () => {
175
+ const e = createFeedEntry({ type: "deliverable", title: "T", body: "b" });
176
+ assert.equal(deleteFeedEntry(e.id), true);
177
+ const entries = listFeedEntries();
178
+ assert.equal(entries.find((x) => x.id === e.id), undefined);
179
+ });
180
+ it("second delete returns false (not idempotent)", () => {
181
+ const e = createFeedEntry({ type: "deliverable", title: "T", body: "b" });
182
+ deleteFeedEntry(e.id);
183
+ assert.equal(deleteFeedEntry(e.id), false);
184
+ });
185
+ });
186
+ // ── pruneOldFeedEntries ───────────────────────────────────────────────────────
187
+ describe("pruneOldFeedEntries", () => {
188
+ it("returns 0 when nothing is old enough to prune", () => {
189
+ createFeedEntry({ type: "notification", title: "Fresh", body: "b" });
190
+ assert.equal(pruneOldFeedEntries(30), 0);
191
+ });
192
+ it("deletes rows older than threshold and returns count", () => {
193
+ getDb()
194
+ .prepare("INSERT INTO unified_feed (type, title, body, created_at) VALUES ('notification', 'Old', 'x', datetime('now', '-31 days'))")
195
+ .run();
196
+ createFeedEntry({ type: "notification", title: "Fresh", body: "y" });
197
+ const pruned = pruneOldFeedEntries(30);
198
+ assert.equal(pruned, 1);
199
+ const remaining = listFeedEntries();
200
+ assert.equal(remaining.length, 1);
201
+ assert.equal(remaining[0].title, "Fresh");
202
+ });
203
+ it("is safe on an empty table", () => {
204
+ assert.equal(pruneOldFeedEntries(30), 0);
205
+ });
206
+ });
207
+ //# sourceMappingURL=feed.test.js.map
package/dist/tui/index.js CHANGED
@@ -2,7 +2,7 @@ import { createInterface } from "readline";
2
2
  import { listRecentTasks, getTask } from "../store/tasks.js";
3
3
  import { getTaskEvents } from "../copilot/agents.js";
4
4
  import { summarize } from "../copilot/event-summary.js";
5
- import { listInboxEntries, deleteInboxEntry, countInboxEntries, } from "../store/inbox.js";
5
+ import { listFeedEntries, deleteFeedEntry, countUnreadFeedEntries, } from "../store/feed.js";
6
6
  let messageHandler;
7
7
  export function setMessageHandler(handler) {
8
8
  messageHandler = handler;
@@ -15,9 +15,9 @@ Type a message to chat. Commands:
15
15
  /status — show status
16
16
  /activity [id|N] — show summarized activity for a task (default: most recent)
17
17
  /verbose — toggle verbose mode (raw event detail in /activity)
18
- /inbox — list inbox entries
19
- /inbox delete <id> — delete an inbox entry by ID
20
- /inbox clear — delete all inbox entries
18
+ /inbox — list deliverables from the unified feed
19
+ /inbox delete <id> — delete a feed entry by ID
20
+ /inbox clear — delete all deliverables from the feed
21
21
  /quit — exit
22
22
  `;
23
23
  let verbose = false;
@@ -129,7 +129,7 @@ function renderActivity(taskIdArg) {
129
129
  }
130
130
  }
131
131
  function renderInbox() {
132
- const entries = listInboxEntries();
132
+ const entries = listFeedEntries({ type: "deliverable" });
133
133
  if (entries.length === 0) {
134
134
  console.log("\u2705 Inbox is empty.");
135
135
  return;
@@ -138,7 +138,8 @@ function renderInbox() {
138
138
  console.log("\u2500".repeat(60));
139
139
  for (const entry of entries) {
140
140
  const ts = new Date(entry.created_at).toLocaleString();
141
- console.log(`[${entry.id}] ${entry.title} \u2014 ${ts}`);
141
+ const unreadMarker = entry.read_at === null ? "\u25cf " : " ";
142
+ console.log(`${unreadMarker}[${entry.id}] ${entry.title} \u2014 ${ts}`);
142
143
  const preview = entry.body.length > 200 ? entry.body.slice(0, 200) + "\u2026" : entry.body;
143
144
  for (const line of preview.split(/\r?\n/)) {
144
145
  console.log(` ${line}`);
@@ -172,10 +173,10 @@ export async function startTui() {
172
173
  process.exit(0);
173
174
  }
174
175
  if (trimmed === "/status") {
175
- const inboxCount = countInboxEntries();
176
+ const unreadCount = countUnreadFeedEntries();
176
177
  console.log(`[io] Uptime: ${Math.floor(process.uptime())}s`);
177
- if (inboxCount > 0) {
178
- console.log(`[io] \u2705 Inbox: ${inboxCount} ${inboxCount === 1 ? "entry" : "entries"}`);
178
+ if (unreadCount > 0) {
179
+ console.log(`[io] \u2705 Unread feed items: ${unreadCount} ${unreadCount === 1 ? "item" : "items"}`);
179
180
  }
180
181
  rl.prompt();
181
182
  return;
@@ -204,10 +205,10 @@ export async function startTui() {
204
205
  renderInbox();
205
206
  }
206
207
  else if (sub === "clear") {
207
- const entries = listInboxEntries();
208
+ const entries = listFeedEntries({ type: "deliverable" });
208
209
  let deleted = 0;
209
210
  for (const entry of entries) {
210
- if (deleteInboxEntry(entry.id))
211
+ if (deleteFeedEntry(entry.id))
211
212
  deleted++;
212
213
  }
213
214
  console.log(`[io] Cleared ${deleted} inbox ${deleted === 1 ? "entry" : "entries"}.`);
@@ -218,11 +219,11 @@ export async function startTui() {
218
219
  if (Number.isNaN(id)) {
219
220
  console.log(`[io] Invalid ID: "${rawId}". Usage: /inbox delete <id>`);
220
221
  }
221
- else if (deleteInboxEntry(id)) {
222
- console.log(`[io] Deleted inbox entry #${id}.`);
222
+ else if (deleteFeedEntry(id)) {
223
+ console.log(`[io] Deleted feed entry #${id}.`);
223
224
  }
224
225
  else {
225
- console.log(`[io] Inbox entry #${id} not found.`);
226
+ console.log(`[io] Feed entry #${id} not found.`);
226
227
  }
227
228
  }
228
229
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heyio",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "IO — a personal AI assistant built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "io": "dist/index.js"