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.
- package/dist/api/server.js +139 -82
- package/dist/copilot/scheduler.js +2 -2
- package/dist/copilot/tools.js +2 -2
- package/dist/daemon.js +4 -4
- package/dist/notify.js +5 -4
- package/dist/store/db.js +32 -0
- package/dist/store/feed.js +94 -0
- package/dist/store/feed.test.js +207 -0
- package/dist/tui/index.js +15 -14
- package/package.json +1 -1
- package/web-dist/assets/index-BYB6CJZn.js +86 -0
- package/web-dist/assets/index-CJCS-9Kg.css +10 -0
- package/web-dist/index.html +2 -2
- package/web-dist/assets/InboxView-CMlpsjfr.js +0 -1
- package/web-dist/assets/index-Bon1pjbf.css +0 -10
- package/web-dist/assets/index-DjUKdHcw.js +0 -86
|
@@ -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 {
|
|
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
|
|
19
|
-
/inbox delete <id> — delete
|
|
20
|
-
/inbox clear — delete all
|
|
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 =
|
|
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
|
-
|
|
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
|
|
176
|
+
const unreadCount = countUnreadFeedEntries();
|
|
176
177
|
console.log(`[io] Uptime: ${Math.floor(process.uptime())}s`);
|
|
177
|
-
if (
|
|
178
|
-
console.log(`[io] \u2705
|
|
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 =
|
|
208
|
+
const entries = listFeedEntries({ type: "deliverable" });
|
|
208
209
|
let deleted = 0;
|
|
209
210
|
for (const entry of entries) {
|
|
210
|
-
if (
|
|
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 (
|
|
222
|
-
console.log(`[io] Deleted
|
|
222
|
+
else if (deleteFeedEntry(id)) {
|
|
223
|
+
console.log(`[io] Deleted feed entry #${id}.`);
|
|
223
224
|
}
|
|
224
225
|
else {
|
|
225
|
-
console.log(`[io]
|
|
226
|
+
console.log(`[io] Feed entry #${id} not found.`);
|
|
226
227
|
}
|
|
227
228
|
}
|
|
228
229
|
else {
|