heyio 0.18.0 → 0.20.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.
@@ -14,11 +14,10 @@ import { requireAuth } from "./auth.js";
14
14
  import { listSchedules, getSchedule, deleteSchedule, setScheduleEnabled } from "../store/schedules.js";
15
15
  import { listIoSchedules, getIoSchedule, deleteIoSchedule, setIoScheduleEnabled } from "../store/io-schedules.js";
16
16
  import { getScheduleRuns } from "../store/schedule-runs.js";
17
- import { createInboxEntry, listInboxEntries, deleteInboxEntry, countInboxEntries } from "../store/inbox.js";
17
+ import { createFeedEntry, listFeedEntries, countUnreadFeedEntries, markFeedEntryRead, markAllFeedEntriesRead, deleteFeedEntry } from "../store/feed.js";
18
18
  import { listPages, readPage } from "../wiki/fs.js";
19
19
  import { runScheduleNow } from "../copilot/scheduler.js";
20
20
  import { runIoScheduleNow } from "../copilot/io-scheduler.js";
21
- import { listRecentNotifications, listUnreadNotifications, countUnreadNotifications, markNotificationRead, markAllNotificationsRead, } from "../store/notifications.js";
22
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
22
  const WEB_DIST = path.resolve(__dirname, "../../web-dist");
24
23
  let messageHandler;
@@ -33,7 +32,7 @@ export function broadcastToSSE(text) {
33
32
  }
34
33
  }
35
34
  export function broadcastNotificationToSSE(payload) {
36
- const data = JSON.stringify({ type: "notification", ...payload });
35
+ const data = JSON.stringify({ type: "feed", ...payload });
37
36
  for (const res of sseConnections) {
38
37
  res.write(`data: ${data}\n\n`);
39
38
  }
@@ -99,24 +98,53 @@ export async function startApiServer() {
99
98
  res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
100
99
  }
101
100
  });
102
- // Inbox read endpoints
103
- api.get("/inbox/count", (_req, res) => {
101
+ // Feed endpoints — unified deliverables + notifications feed
102
+ api.get("/feed/count", (req, res) => {
104
103
  try {
105
- const count = countInboxEntries();
104
+ const rawType = req.query.type;
105
+ const type = rawType === "deliverable" || rawType === "notification"
106
+ ? rawType
107
+ : undefined;
108
+ const count = countUnreadFeedEntries(type);
106
109
  res.json({ count });
107
110
  }
108
111
  catch (e) {
109
- console.error("Error counting inbox entries:", e);
112
+ console.error("Error counting feed entries:", e);
110
113
  res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
111
114
  }
112
115
  });
113
- api.get("/inbox", (_req, res) => {
116
+ api.get("/feed", (req, res) => {
114
117
  try {
115
- const entries = listInboxEntries();
116
- res.json({ entries });
118
+ const rawType = req.query.type;
119
+ const type = rawType === "deliverable" || rawType === "notification"
120
+ ? rawType
121
+ : undefined;
122
+ const unreadOnly = req.query.unread === "true";
123
+ const rawLimit = req.query.limit;
124
+ const parsed = typeof rawLimit === "string" ? Number.parseInt(rawLimit, 10) : NaN;
125
+ const limit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 200) : 50;
126
+ const rows = listFeedEntries({ type, unreadOnly, limit });
127
+ const unreadCount = countUnreadFeedEntries(type);
128
+ const entries = rows.map(({ id, type: entryType, title, body, created_at, read_at, source_type, source_ref }) => {
129
+ let source = null;
130
+ if (source_type) {
131
+ source = { type: source_type };
132
+ if (source_ref) {
133
+ try {
134
+ const parsedRef = JSON.parse(source_ref);
135
+ source = { type: source_type, ...parsedRef };
136
+ }
137
+ catch {
138
+ // source_ref is not valid JSON — fall back to type-only
139
+ }
140
+ }
141
+ }
142
+ return { id, type: entryType, title, body, created_at, read_at, source };
143
+ });
144
+ res.json({ entries, unreadCount });
117
145
  }
118
146
  catch (e) {
119
- console.error("Error listing inbox entries:", e);
147
+ console.error("Error listing feed entries:", e);
120
148
  res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
121
149
  }
122
150
  });
@@ -124,39 +152,6 @@ export async function startApiServer() {
124
152
  api.get("/status", (_req, res) => {
125
153
  res.json({ version: IO_VERSION, uptime: process.uptime() });
126
154
  });
127
- // Notifications endpoint
128
- api.get("/notifications", (_req, res) => {
129
- try {
130
- const unreadOnly = _req.query.unread === "true";
131
- const rows = unreadOnly
132
- ? listUnreadNotifications()
133
- : (() => {
134
- const rawLimit = _req.query.limit;
135
- const parsed = typeof rawLimit === "string" ? Number.parseInt(rawLimit, 10) : NaN;
136
- const limit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 200) : 50;
137
- return listRecentNotifications(limit);
138
- })();
139
- const unreadCount = countUnreadNotifications();
140
- const notifications = rows.map(({ id, title, text, created_at, read_at, source_type, source_ref }) => {
141
- let source = { type: source_type };
142
- if (source_ref) {
143
- try {
144
- const parsed = JSON.parse(source_ref);
145
- source = { type: source_type, ...parsed };
146
- }
147
- catch {
148
- // source_ref is not valid JSON — fall back to type-only
149
- }
150
- }
151
- return { id, title, text, created_at, read_at, source };
152
- });
153
- res.json({ notifications, unreadCount });
154
- }
155
- catch (e) {
156
- console.error("Error listing notifications:", e);
157
- res.status(500).json({ error: "Failed to list notifications" });
158
- }
159
- });
160
155
  // SSE events endpoint
161
156
  api.get("/events", (req, res) => {
162
157
  res.setHeader("Content-Type", "text/event-stream");
@@ -238,9 +233,48 @@ export async function startApiServer() {
238
233
  res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
239
234
  }
240
235
  });
241
- // Inbox write endpoints — auth required
242
- api.post("/inbox", (req, res) => {
243
- const { title, body } = req.body;
236
+ // Feed write endpoints
237
+ // Note: POST /feed/read-all must be before POST /feed/:id/read to avoid route shadowing
238
+ api.post("/feed/read-all", (req, res) => {
239
+ try {
240
+ const rawType = req.query.type;
241
+ const type = rawType === "deliverable" || rawType === "notification"
242
+ ? rawType
243
+ : undefined;
244
+ const marked = markAllFeedEntriesRead(type);
245
+ res.json({ marked });
246
+ }
247
+ catch (e) {
248
+ console.error("Error marking feed entries read:", e);
249
+ res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
250
+ }
251
+ });
252
+ api.post("/feed/:id/read", (req, res) => {
253
+ const raw = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
254
+ const id = Number.parseInt(raw, 10);
255
+ if (Number.isNaN(id)) {
256
+ res.status(400).json({ error: "Invalid id" });
257
+ return;
258
+ }
259
+ try {
260
+ const found = markFeedEntryRead(id);
261
+ if (!found) {
262
+ res.status(404).json({ error: "Feed entry not found" });
263
+ return;
264
+ }
265
+ res.json({ ok: true });
266
+ }
267
+ catch (e) {
268
+ console.error("Error marking feed entry read:", e);
269
+ res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
270
+ }
271
+ });
272
+ api.post("/feed", (req, res) => {
273
+ const { type, title, body, source_type, source_ref } = req.body;
274
+ if (type !== "deliverable" && type !== "notification") {
275
+ res.status(400).json({ error: "type must be 'deliverable' or 'notification'" });
276
+ return;
277
+ }
244
278
  if (!title || typeof title !== "string" || title.trim() === "") {
245
279
  res.status(400).json({ error: "Missing or empty required field: title" });
246
280
  return;
@@ -250,15 +284,21 @@ export async function startApiServer() {
250
284
  return;
251
285
  }
252
286
  try {
253
- const entry = createInboxEntry(title.trim(), body.trim());
287
+ const entry = createFeedEntry({
288
+ type: type,
289
+ title: title.trim(),
290
+ body: body.trim(),
291
+ source_type: typeof source_type === "string" ? source_type : undefined,
292
+ source_ref: typeof source_ref === "string" ? source_ref : undefined,
293
+ });
254
294
  res.status(201).json({ entry });
255
295
  }
256
296
  catch (e) {
257
- console.error("Error creating inbox entry:", e);
297
+ console.error("Error creating feed entry:", e);
258
298
  res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
259
299
  }
260
300
  });
261
- api.delete("/inbox/:id", (req, res) => {
301
+ api.delete("/feed/:id", (req, res) => {
262
302
  const raw = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
263
303
  const id = Number.parseInt(raw, 10);
264
304
  if (Number.isNaN(id)) {
@@ -266,15 +306,15 @@ export async function startApiServer() {
266
306
  return;
267
307
  }
268
308
  try {
269
- const deleted = deleteInboxEntry(id);
309
+ const deleted = deleteFeedEntry(id);
270
310
  if (!deleted) {
271
- res.status(404).json({ error: "Inbox entry not found" });
311
+ res.status(404).json({ error: "Feed entry not found" });
272
312
  return;
273
313
  }
274
314
  res.json({ deleted: true });
275
315
  }
276
316
  catch (e) {
277
- console.error("Error deleting inbox entry:", e);
317
+ console.error("Error deleting feed entry:", e);
278
318
  res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
279
319
  }
280
320
  });
@@ -651,36 +691,6 @@ export async function startApiServer() {
651
691
  res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
652
692
  }
653
693
  });
654
- api.post("/notifications/read-all", (_req, res) => {
655
- try {
656
- const marked = markAllNotificationsRead();
657
- res.json({ marked });
658
- }
659
- catch (e) {
660
- console.error("Error marking all notifications read:", e);
661
- res.status(500).json({ error: "Failed to mark notifications read" });
662
- }
663
- });
664
- api.post("/notifications/:id/read", (req, res) => {
665
- try {
666
- const rawId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
667
- const id = Number.parseInt(rawId, 10);
668
- if (Number.isNaN(id)) {
669
- res.status(400).json({ error: "invalid id" });
670
- return;
671
- }
672
- const found = markNotificationRead(id);
673
- if (!found) {
674
- res.status(404).json({ error: "notification not found" });
675
- return;
676
- }
677
- res.json({ ok: true });
678
- }
679
- catch (e) {
680
- console.error("Error marking notification read:", e);
681
- res.status(500).json({ error: "Failed to mark notification read" });
682
- }
683
- });
684
694
  // Chat endpoints
685
695
  api.post("/message", async (req, res) => {
686
696
  const { text } = req.body;
@@ -16,7 +16,7 @@ import { delegateToAgent } from "./agents.js";
16
16
  import { nextRun } from "./cron.js";
17
17
  import { notifyBackground } from "../notify.js";
18
18
  import { startScheduleRun, completeScheduleRun, failScheduleRun } from "../store/schedule-runs.js";
19
- import { createInboxEntry } from "../store/inbox.js";
19
+ import { createFeedEntry } from "../store/feed.js";
20
20
  import { shouldRouteToInbox } from "./tools.js";
21
21
  const TICK_MS = 30_000;
22
22
  const AGENDA_BLOCKS = {
@@ -83,7 +83,7 @@ async function fireSchedule(schedule) {
83
83
  try {
84
84
  await delegateToAgent(squad.slug, prompt, (_taskId, result) => {
85
85
  if (shouldRouteToInbox(prompt)) {
86
- createInboxEntry(`[${squad.slug}] ${schedule.name}`, result);
86
+ createFeedEntry({ type: "deliverable", title: `[${squad.slug}] ${schedule.name}`, body: result });
87
87
  console.error(`[io] Schedule ${schedule.id} result routed to inbox`);
88
88
  completeScheduleRun(run.id, 0);
89
89
  }
@@ -5,7 +5,7 @@ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSy
5
5
  import { join, dirname, resolve } from "path";
6
6
  import { homedir } from "os";
7
7
  import { UNIVERSES } from "./universes.js";
8
- import { createInboxEntry } from "../store/inbox.js";
8
+ import { createFeedEntry } from "../store/feed.js";
9
9
  import { validateCron, nextRun } from "./cron.js";
10
10
  import { createIoSchedule, deleteIoSchedule, getIoSchedule, listIoSchedules, setIoScheduleEnabled, updateIoScheduleNextRun, } from "../store/io-schedules.js";
11
11
  import { runIoScheduleNow } from "./io-scheduler.js";
@@ -399,7 +399,7 @@ export function createTools(deps) {
399
399
  const taskId = await deps.delegateToAgent(slug, task, (id, result) => {
400
400
  console.error(`[io] Agent task ${id} completed for squad ${slug}`);
401
401
  if (shouldRouteToInbox(task)) {
402
- createInboxEntry(`[${slug}] Task result`, result);
402
+ createFeedEntry({ type: "deliverable", title: `[${slug}] Task result`, body: result });
403
403
  console.error(`[io] Task ${id} result routed to inbox`);
404
404
  }
405
405
  }, agent);
package/dist/daemon.js CHANGED
@@ -4,7 +4,7 @@ import { startApiServer, setMessageHandler as setApiHandler, broadcastNotificati
4
4
  import { createBot, startBot, stopBot, sendProactiveMessage, sendBackgroundNotification, setMessageHandler as setTelegramHandler } from "./telegram/bot.js";
5
5
  import { setTelegramSender, setTuiSender, setSseBroadcaster } from "./notify.js";
6
6
  import { pruneOldScheduleRuns } from "./store/schedule-runs.js";
7
- import { pruneOldNotifications } from "./store/notifications.js";
7
+ import { pruneOldFeedEntries } from "./store/feed.js";
8
8
  import { printBackgroundNotification } from "./tui/index.js";
9
9
  import { getDb, closeDb } from "./store/db.js";
10
10
  import { clearStaleTasks } from "./store/tasks.js";
@@ -132,9 +132,9 @@ export async function startDaemon() {
132
132
  const pruneTimer = setInterval(() => {
133
133
  try {
134
134
  const runsDeleted = pruneOldScheduleRuns(PRUNE_RETENTION_DAYS);
135
- const notificationsDeleted = pruneOldNotifications(PRUNE_RETENTION_DAYS);
135
+ const notificationsDeleted = pruneOldFeedEntries(PRUNE_RETENTION_DAYS);
136
136
  if (runsDeleted > 0 || notificationsDeleted > 0) {
137
- console.log(`[prune] Cleaned up ${runsDeleted} schedule runs and ${notificationsDeleted} notifications older than ${PRUNE_RETENTION_DAYS} days`);
137
+ console.log(`[prune] Cleaned up ${runsDeleted} schedule runs and ${notificationsDeleted} feed entries older than ${PRUNE_RETENTION_DAYS} days`);
138
138
  }
139
139
  }
140
140
  catch (err) {
@@ -146,7 +146,7 @@ export async function startDaemon() {
146
146
  const pruneStartup = setTimeout(() => {
147
147
  try {
148
148
  pruneOldScheduleRuns(PRUNE_RETENTION_DAYS);
149
- pruneOldNotifications(PRUNE_RETENTION_DAYS);
149
+ pruneOldFeedEntries(PRUNE_RETENTION_DAYS);
150
150
  }
151
151
  catch { /* best effort */ }
152
152
  }, 5000);
package/dist/notify.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { config } from "./config.js";
2
- import { insertNotification, } from "./store/notifications.js";
2
+ import { createFeedEntry } from "./store/feed.js";
3
3
  const HEARTBEAT_PATTERNS = [
4
4
  /^no active tasks?\.?$/i,
5
5
  /^nothing to report\.?$/i,
@@ -51,11 +51,12 @@ export async function notifyBackground(input) {
51
51
  const sourceRefJson = JSON.stringify(stripType(source));
52
52
  let row;
53
53
  try {
54
- row = insertNotification({
54
+ row = createFeedEntry({
55
+ type: "notification",
56
+ title,
57
+ body: text,
55
58
  source_type: source.type,
56
59
  source_ref: sourceRefJson === "{}" ? null : sourceRefJson,
57
- title,
58
- text,
59
60
  });
60
61
  }
61
62
  catch (err) {
package/dist/store/db.js CHANGED
@@ -160,6 +160,18 @@ GROUP BY agent_slug`,
160
160
  body TEXT NOT NULL,
161
161
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
162
162
  )`,
163
+ `CREATE TABLE IF NOT EXISTS unified_feed (
164
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
165
+ type TEXT NOT NULL CHECK(type IN ('deliverable', 'notification')),
166
+ title TEXT NOT NULL,
167
+ body TEXT NOT NULL,
168
+ source_type TEXT,
169
+ source_ref TEXT,
170
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
171
+ read_at DATETIME
172
+ )`,
173
+ `CREATE INDEX IF NOT EXISTS idx_unified_feed_type ON unified_feed(type, created_at)`,
174
+ `CREATE INDEX IF NOT EXISTS idx_unified_feed_unread ON unified_feed(read_at, created_at)`,
163
175
  ];
164
176
  for (const migration of migrations) {
165
177
  try {
@@ -169,6 +181,26 @@ GROUP BY agent_slug`,
169
181
  // Already applied — ignore
170
182
  }
171
183
  }
184
+ // One-time data migration: copy inbox_entries + background_notifications → unified_feed
185
+ try {
186
+ const migrated = db.prepare("SELECT value FROM io_state WHERE key = 'unified_feed_migrated'").get();
187
+ if (!migrated) {
188
+ db.exec(`
189
+ INSERT OR IGNORE INTO unified_feed (type, title, body, source_type, source_ref, created_at, read_at)
190
+ SELECT 'deliverable', title, body, NULL, NULL, created_at, NULL
191
+ FROM inbox_entries
192
+ `);
193
+ db.exec(`
194
+ INSERT OR IGNORE INTO unified_feed (type, title, body, source_type, source_ref, created_at, read_at)
195
+ SELECT 'notification', title, text, source_type, source_ref, created_at, read_at
196
+ FROM background_notifications
197
+ `);
198
+ db.prepare("INSERT OR REPLACE INTO io_state (key, value) VALUES ('unified_feed_migrated', '1')").run();
199
+ }
200
+ }
201
+ catch {
202
+ // Migration failed (e.g. old tables don't exist yet on a fresh install) — safe to ignore
203
+ }
172
204
  return db;
173
205
  }
174
206
  export function closeDb() {
@@ -0,0 +1,76 @@
1
+ import { getDb } from "./db.js";
2
+ export function createFeedEntry(input) {
3
+ const db = getDb();
4
+ const info = db
5
+ .prepare(`INSERT INTO unified_feed (type, title, body, source_type, source_ref)
6
+ VALUES (?, ?, ?, ?, ?)`)
7
+ .run(input.type, input.title, input.body, input.source_type ?? null, input.source_ref ?? null);
8
+ return db
9
+ .prepare("SELECT * FROM unified_feed WHERE id = ?")
10
+ .get(info.lastInsertRowid);
11
+ }
12
+ export function listFeedEntries(opts) {
13
+ const conditions = [];
14
+ const params = [];
15
+ if (opts?.type) {
16
+ conditions.push("type = ?");
17
+ params.push(opts.type);
18
+ }
19
+ if (opts?.unreadOnly) {
20
+ conditions.push("read_at IS NULL");
21
+ }
22
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
23
+ const limit = opts?.limit ?? 50;
24
+ params.push(limit);
25
+ return getDb()
26
+ .prepare(`SELECT * FROM unified_feed ${where} ORDER BY created_at DESC, id DESC LIMIT ?`)
27
+ .all(...params);
28
+ }
29
+ export function countUnreadFeedEntries(type) {
30
+ if (type) {
31
+ const row = getDb()
32
+ .prepare("SELECT COUNT(*) AS n FROM unified_feed WHERE read_at IS NULL AND type = ?")
33
+ .get(type);
34
+ return row.n;
35
+ }
36
+ const row = getDb()
37
+ .prepare("SELECT COUNT(*) AS n FROM unified_feed WHERE read_at IS NULL")
38
+ .get();
39
+ return row.n;
40
+ }
41
+ export function markFeedEntryRead(id) {
42
+ const db = getDb();
43
+ const info = db
44
+ .prepare("UPDATE unified_feed SET read_at = CURRENT_TIMESTAMP WHERE id = ? AND read_at IS NULL")
45
+ .run(id);
46
+ if (info.changes > 0)
47
+ return true;
48
+ // Idempotent: return true if row exists (already read), false if missing
49
+ const exists = db.prepare("SELECT id FROM unified_feed WHERE id = ?").get(id);
50
+ return exists !== undefined;
51
+ }
52
+ export function markAllFeedEntriesRead(type) {
53
+ if (type) {
54
+ const info = getDb()
55
+ .prepare("UPDATE unified_feed SET read_at = CURRENT_TIMESTAMP WHERE read_at IS NULL AND type = ?")
56
+ .run(type);
57
+ return info.changes;
58
+ }
59
+ const info = getDb()
60
+ .prepare("UPDATE unified_feed SET read_at = CURRENT_TIMESTAMP WHERE read_at IS NULL")
61
+ .run();
62
+ return info.changes;
63
+ }
64
+ export function deleteFeedEntry(id) {
65
+ const info = getDb()
66
+ .prepare("DELETE FROM unified_feed WHERE id = ?")
67
+ .run(id);
68
+ return info.changes > 0;
69
+ }
70
+ export function pruneOldFeedEntries(olderThanDays) {
71
+ const info = getDb()
72
+ .prepare(`DELETE FROM unified_feed WHERE created_at < datetime('now', '-' || ? || ' days')`)
73
+ .run(olderThanDays);
74
+ return info.changes;
75
+ }
76
+ //# sourceMappingURL=feed.js.map