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
package/dist/api/server.js
CHANGED
|
@@ -14,13 +14,25 @@ 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 {
|
|
17
|
+
import { createFeedEntry, listFeedEntries, countUnreadFeedEntries, markFeedEntryRead, markAllFeedEntriesRead, deleteFeedEntry, markFeedEntriesRead, deleteFeedEntries } 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");
|
|
23
|
+
let releasesCache = null;
|
|
24
|
+
function loadReleases() {
|
|
25
|
+
if (releasesCache)
|
|
26
|
+
return releasesCache;
|
|
27
|
+
try {
|
|
28
|
+
const raw = readFileSync(path.join(__dirname, "../releases.json"), "utf-8");
|
|
29
|
+
releasesCache = JSON.parse(raw);
|
|
30
|
+
return releasesCache;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
24
36
|
let messageHandler;
|
|
25
37
|
const sseConnections = new Set();
|
|
26
38
|
export function setMessageHandler(handler) {
|
|
@@ -33,7 +45,7 @@ export function broadcastToSSE(text) {
|
|
|
33
45
|
}
|
|
34
46
|
}
|
|
35
47
|
export function broadcastNotificationToSSE(payload) {
|
|
36
|
-
const data = JSON.stringify({ type: "
|
|
48
|
+
const data = JSON.stringify({ type: "feed", ...payload });
|
|
37
49
|
for (const res of sseConnections) {
|
|
38
50
|
res.write(`data: ${data}\n\n`);
|
|
39
51
|
}
|
|
@@ -99,24 +111,53 @@ export async function startApiServer() {
|
|
|
99
111
|
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
100
112
|
}
|
|
101
113
|
});
|
|
102
|
-
//
|
|
103
|
-
api.get("/
|
|
114
|
+
// Feed endpoints — unified deliverables + notifications feed
|
|
115
|
+
api.get("/feed/count", (req, res) => {
|
|
104
116
|
try {
|
|
105
|
-
const
|
|
117
|
+
const rawType = req.query.type;
|
|
118
|
+
const type = rawType === "deliverable" || rawType === "notification"
|
|
119
|
+
? rawType
|
|
120
|
+
: undefined;
|
|
121
|
+
const count = countUnreadFeedEntries(type);
|
|
106
122
|
res.json({ count });
|
|
107
123
|
}
|
|
108
124
|
catch (e) {
|
|
109
|
-
console.error("Error counting
|
|
125
|
+
console.error("Error counting feed entries:", e);
|
|
110
126
|
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
111
127
|
}
|
|
112
128
|
});
|
|
113
|
-
api.get("/
|
|
129
|
+
api.get("/feed", (req, res) => {
|
|
114
130
|
try {
|
|
115
|
-
const
|
|
116
|
-
|
|
131
|
+
const rawType = req.query.type;
|
|
132
|
+
const type = rawType === "deliverable" || rawType === "notification"
|
|
133
|
+
? rawType
|
|
134
|
+
: undefined;
|
|
135
|
+
const unreadOnly = req.query.unread === "true";
|
|
136
|
+
const rawLimit = req.query.limit;
|
|
137
|
+
const parsed = typeof rawLimit === "string" ? Number.parseInt(rawLimit, 10) : NaN;
|
|
138
|
+
const limit = Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 200) : 50;
|
|
139
|
+
const rows = listFeedEntries({ type, unreadOnly, limit });
|
|
140
|
+
const unreadCount = countUnreadFeedEntries(type);
|
|
141
|
+
const entries = rows.map(({ id, type: entryType, title, body, created_at, read_at, source_type, source_ref }) => {
|
|
142
|
+
let source = null;
|
|
143
|
+
if (source_type) {
|
|
144
|
+
source = { type: source_type };
|
|
145
|
+
if (source_ref) {
|
|
146
|
+
try {
|
|
147
|
+
const parsedRef = JSON.parse(source_ref);
|
|
148
|
+
source = { type: source_type, ...parsedRef };
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// source_ref is not valid JSON — fall back to type-only
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { id, type: entryType, title, body, created_at, read_at, source };
|
|
156
|
+
});
|
|
157
|
+
res.json({ entries, unreadCount });
|
|
117
158
|
}
|
|
118
159
|
catch (e) {
|
|
119
|
-
console.error("Error listing
|
|
160
|
+
console.error("Error listing feed entries:", e);
|
|
120
161
|
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
121
162
|
}
|
|
122
163
|
});
|
|
@@ -124,38 +165,9 @@ export async function startApiServer() {
|
|
|
124
165
|
api.get("/status", (_req, res) => {
|
|
125
166
|
res.json({ version: IO_VERSION, uptime: process.uptime() });
|
|
126
167
|
});
|
|
127
|
-
//
|
|
128
|
-
api.get("/
|
|
129
|
-
|
|
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
|
-
}
|
|
168
|
+
// Releases endpoint — serves build-time bundled GitHub release notes
|
|
169
|
+
api.get("/releases", (_req, res) => {
|
|
170
|
+
res.json({ releases: loadReleases() });
|
|
159
171
|
});
|
|
160
172
|
// SSE events endpoint
|
|
161
173
|
api.get("/events", (req, res) => {
|
|
@@ -238,9 +250,78 @@ export async function startApiServer() {
|
|
|
238
250
|
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
239
251
|
}
|
|
240
252
|
});
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
253
|
+
// Feed write endpoints
|
|
254
|
+
// Note: POST /feed/read-all must be before POST /feed/:id/read to avoid route shadowing
|
|
255
|
+
api.post("/feed/read-all", (req, res) => {
|
|
256
|
+
try {
|
|
257
|
+
const rawType = req.query.type;
|
|
258
|
+
const type = rawType === "deliverable" || rawType === "notification"
|
|
259
|
+
? rawType
|
|
260
|
+
: undefined;
|
|
261
|
+
const marked = markAllFeedEntriesRead(type);
|
|
262
|
+
res.json({ marked });
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
console.error("Error marking feed entries read:", e);
|
|
266
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
api.post("/feed/batch-read", (req, res) => {
|
|
270
|
+
const { ids } = req.body;
|
|
271
|
+
if (!Array.isArray(ids) || ids.length === 0 || !ids.every((x) => typeof x === "number")) {
|
|
272
|
+
res.status(400).json({ error: "ids must be a non-empty array of numbers" });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const marked = markFeedEntriesRead(ids);
|
|
277
|
+
res.json({ marked });
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
console.error("Error batch-marking feed entries read:", e);
|
|
281
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
api.post("/feed/batch-delete", (req, res) => {
|
|
285
|
+
const { ids } = req.body;
|
|
286
|
+
if (!Array.isArray(ids) || ids.length === 0 || !ids.every((x) => typeof x === "number")) {
|
|
287
|
+
res.status(400).json({ error: "ids must be a non-empty array of numbers" });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const deleted = deleteFeedEntries(ids);
|
|
292
|
+
res.json({ deleted });
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
console.error("Error batch-deleting feed entries:", e);
|
|
296
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
api.post("/feed/:id/read", (req, res) => {
|
|
300
|
+
const raw = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
301
|
+
const id = Number.parseInt(raw, 10);
|
|
302
|
+
if (Number.isNaN(id)) {
|
|
303
|
+
res.status(400).json({ error: "Invalid id" });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const found = markFeedEntryRead(id);
|
|
308
|
+
if (!found) {
|
|
309
|
+
res.status(404).json({ error: "Feed entry not found" });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
res.json({ ok: true });
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
console.error("Error marking feed entry read:", e);
|
|
316
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
api.post("/feed", (req, res) => {
|
|
320
|
+
const { type, title, body, source_type, source_ref } = req.body;
|
|
321
|
+
if (type !== "deliverable" && type !== "notification") {
|
|
322
|
+
res.status(400).json({ error: "type must be 'deliverable' or 'notification'" });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
244
325
|
if (!title || typeof title !== "string" || title.trim() === "") {
|
|
245
326
|
res.status(400).json({ error: "Missing or empty required field: title" });
|
|
246
327
|
return;
|
|
@@ -250,15 +331,21 @@ export async function startApiServer() {
|
|
|
250
331
|
return;
|
|
251
332
|
}
|
|
252
333
|
try {
|
|
253
|
-
const entry =
|
|
334
|
+
const entry = createFeedEntry({
|
|
335
|
+
type: type,
|
|
336
|
+
title: title.trim(),
|
|
337
|
+
body: body.trim(),
|
|
338
|
+
source_type: typeof source_type === "string" ? source_type : undefined,
|
|
339
|
+
source_ref: typeof source_ref === "string" ? source_ref : undefined,
|
|
340
|
+
});
|
|
254
341
|
res.status(201).json({ entry });
|
|
255
342
|
}
|
|
256
343
|
catch (e) {
|
|
257
|
-
console.error("Error creating
|
|
344
|
+
console.error("Error creating feed entry:", e);
|
|
258
345
|
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
259
346
|
}
|
|
260
347
|
});
|
|
261
|
-
api.delete("/
|
|
348
|
+
api.delete("/feed/:id", (req, res) => {
|
|
262
349
|
const raw = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
263
350
|
const id = Number.parseInt(raw, 10);
|
|
264
351
|
if (Number.isNaN(id)) {
|
|
@@ -266,15 +353,15 @@ export async function startApiServer() {
|
|
|
266
353
|
return;
|
|
267
354
|
}
|
|
268
355
|
try {
|
|
269
|
-
const deleted =
|
|
356
|
+
const deleted = deleteFeedEntry(id);
|
|
270
357
|
if (!deleted) {
|
|
271
|
-
res.status(404).json({ error: "
|
|
358
|
+
res.status(404).json({ error: "Feed entry not found" });
|
|
272
359
|
return;
|
|
273
360
|
}
|
|
274
361
|
res.json({ deleted: true });
|
|
275
362
|
}
|
|
276
363
|
catch (e) {
|
|
277
|
-
console.error("Error deleting
|
|
364
|
+
console.error("Error deleting feed entry:", e);
|
|
278
365
|
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
279
366
|
}
|
|
280
367
|
});
|
|
@@ -651,36 +738,6 @@ export async function startApiServer() {
|
|
|
651
738
|
res.status(500).json({ error: (e instanceof Error ? e.message : String(e)) });
|
|
652
739
|
}
|
|
653
740
|
});
|
|
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
741
|
// Chat endpoints
|
|
685
742
|
api.post("/message", async (req, res) => {
|
|
686
743
|
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 {
|
|
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
|
-
|
|
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
|
}
|
package/dist/copilot/tools.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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}
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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,94 @@
|
|
|
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
|
+
export function markFeedEntriesRead(ids) {
|
|
77
|
+
if (ids.length === 0)
|
|
78
|
+
return 0;
|
|
79
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
80
|
+
const info = getDb()
|
|
81
|
+
.prepare(`UPDATE unified_feed SET read_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders}) AND read_at IS NULL`)
|
|
82
|
+
.run(...ids);
|
|
83
|
+
return info.changes;
|
|
84
|
+
}
|
|
85
|
+
export function deleteFeedEntries(ids) {
|
|
86
|
+
if (ids.length === 0)
|
|
87
|
+
return 0;
|
|
88
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
89
|
+
const info = getDb()
|
|
90
|
+
.prepare(`DELETE FROM unified_feed WHERE id IN (${placeholders})`)
|
|
91
|
+
.run(...ids);
|
|
92
|
+
return info.changes;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=feed.js.map
|