@fyresmith/hive-server 2.4.0 → 3.1.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.
@@ -1,448 +0,0 @@
1
- import { randomUUID } from 'crypto';
2
- import { dirname, join, resolve } from 'path';
3
- import { appendFile, mkdir, readFile, rename, writeFile } from 'fs/promises';
4
-
5
- const DEFAULT_ACTIVITY_LIMIT = 100;
6
- const MAX_ACTIVITY_LIMIT = 500;
7
- const MAX_ACTIVITY_RETENTION = 5000;
8
-
9
- function nowTs() {
10
- return Date.now();
11
- }
12
-
13
- function isoDay(ts) {
14
- return new Date(ts).toISOString().slice(0, 10);
15
- }
16
-
17
- function workspaceKeyForPath(filePath) {
18
- if (typeof filePath !== 'string' || filePath.length === 0) return 'root';
19
- const normalized = filePath.replace(/\\/g, '/');
20
- const [head] = normalized.split('/');
21
- return head || 'root';
22
- }
23
-
24
- function clampLimit(value) {
25
- const num = Number(value);
26
- if (!Number.isFinite(num)) return DEFAULT_ACTIVITY_LIMIT;
27
- const int = Math.trunc(num);
28
- if (int < 1) return 1;
29
- if (int > MAX_ACTIVITY_LIMIT) return MAX_ACTIVITY_LIMIT;
30
- return int;
31
- }
32
-
33
- async function readJson(path, fallback) {
34
- try {
35
- const raw = await readFile(path, 'utf-8');
36
- return JSON.parse(raw);
37
- } catch (err) {
38
- if (err?.code === 'ENOENT') return fallback;
39
- throw err;
40
- }
41
- }
42
-
43
- async function writeJsonAtomic(path, value) {
44
- const tmp = `${path}.tmp`;
45
- await mkdir(dirname(path), { recursive: true });
46
- await writeFile(tmp, JSON.stringify(value, null, 2), 'utf-8');
47
- await rename(tmp, path);
48
- }
49
-
50
- function normalizeMode(mode) {
51
- if (mode === 'mute' || mode === 'focus' || mode === 'digest' || mode === 'all') {
52
- return mode;
53
- }
54
- return 'all';
55
- }
56
-
57
- export class CollabStore {
58
- constructor(options = {}) {
59
- const vaultRoot = process.env.VAULT_PATH ? resolve(process.env.VAULT_PATH) : process.cwd();
60
- this.root = options.root ?? join(vaultRoot, '.hive-collab');
61
- this.eventsDir = join(this.root, 'events');
62
- this.indexDir = join(this.root, 'index');
63
- this.threadsFile = join(this.indexDir, 'threads.json');
64
- this.activityFile = join(this.indexDir, 'activity.json');
65
- this.preferencesFile = join(this.indexDir, 'notify-preferences.json');
66
- this.initPromise = null;
67
- }
68
-
69
- async ensureReady() {
70
- if (!this.initPromise) {
71
- this.initPromise = (async () => {
72
- await mkdir(this.eventsDir, { recursive: true });
73
- await mkdir(this.indexDir, { recursive: true });
74
- const [threads, activity, preferences] = await Promise.all([
75
- readJson(this.threadsFile, null),
76
- readJson(this.activityFile, null),
77
- readJson(this.preferencesFile, null),
78
- ]);
79
-
80
- if (!threads) {
81
- await writeJsonAtomic(this.threadsFile, { threads: {} });
82
- }
83
- if (!activity) {
84
- await writeJsonAtomic(this.activityFile, { events: [] });
85
- }
86
- if (!preferences) {
87
- await writeJsonAtomic(this.preferencesFile, { users: {} });
88
- }
89
- })();
90
- }
91
-
92
- await this.initPromise;
93
- }
94
-
95
- async appendEvent(type, payload) {
96
- await this.ensureReady();
97
- const ts = nowTs();
98
- const line = JSON.stringify({
99
- eventId: randomUUID(),
100
- type,
101
- ts,
102
- payload,
103
- });
104
- await appendFile(join(this.eventsDir, `${isoDay(ts)}.jsonl`), `${line}\n`, 'utf-8');
105
- }
106
-
107
- async listThreads(filter = {}) {
108
- await this.ensureReady();
109
- const store = await readJson(this.threadsFile, { threads: {} });
110
- const threads = Object.values(store.threads ?? {})
111
- .filter((thread) => {
112
- if (filter.filePath && thread.filePath !== filter.filePath) return false;
113
- if (filter.status && thread.status !== filter.status) return false;
114
- return true;
115
- })
116
- .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
117
-
118
- return threads;
119
- }
120
-
121
- async getThread(threadId) {
122
- await this.ensureReady();
123
- const store = await readJson(this.threadsFile, { threads: {} });
124
- return store.threads?.[threadId] ?? null;
125
- }
126
-
127
- async createThread({ filePath, anchor, author, body, mentions = [] }) {
128
- await this.ensureReady();
129
-
130
- const store = await readJson(this.threadsFile, { threads: {} });
131
- const ts = nowTs();
132
- const threadId = randomUUID();
133
-
134
- const baseThread = {
135
- threadId,
136
- filePath,
137
- anchor: anchor ?? null,
138
- status: 'thread_open',
139
- participants: author?.username ? [author.username] : [],
140
- createdAt: ts,
141
- updatedAt: ts,
142
- comments: [],
143
- task: null,
144
- };
145
-
146
- if (typeof body === 'string' && body.trim().length > 0) {
147
- baseThread.comments.push({
148
- commentId: randomUUID(),
149
- threadId,
150
- author,
151
- body,
152
- mentions,
153
- createdAt: ts,
154
- updatedAt: ts,
155
- });
156
- }
157
-
158
- store.threads[threadId] = baseThread;
159
- await writeJsonAtomic(this.threadsFile, store);
160
- await this.appendEvent('thread.create', { thread: baseThread });
161
-
162
- return baseThread;
163
- }
164
-
165
- async updateThread({ threadId, patch }) {
166
- await this.ensureReady();
167
-
168
- const store = await readJson(this.threadsFile, { threads: {} });
169
- const thread = store.threads?.[threadId];
170
- if (!thread) return null;
171
-
172
- const ts = nowTs();
173
- if (typeof patch?.status === 'string') {
174
- thread.status = patch.status;
175
- }
176
- if (patch?.anchor !== undefined) {
177
- thread.anchor = patch.anchor;
178
- }
179
- thread.updatedAt = ts;
180
-
181
- store.threads[threadId] = thread;
182
- await writeJsonAtomic(this.threadsFile, store);
183
- await this.appendEvent('thread.update', { threadId, patch });
184
-
185
- return thread;
186
- }
187
-
188
- async deleteThread(threadId) {
189
- await this.ensureReady();
190
-
191
- const store = await readJson(this.threadsFile, { threads: {} });
192
- const thread = store.threads?.[threadId];
193
- if (!thread) return null;
194
-
195
- const ts = nowTs();
196
- thread.status = 'thread_archived';
197
- thread.archivedAt = ts;
198
- thread.updatedAt = ts;
199
- store.threads[threadId] = thread;
200
-
201
- await writeJsonAtomic(this.threadsFile, store);
202
- await this.appendEvent('thread.delete', { threadId });
203
-
204
- return thread;
205
- }
206
-
207
- async createComment({ threadId, author, body, mentions = [] }) {
208
- await this.ensureReady();
209
-
210
- const store = await readJson(this.threadsFile, { threads: {} });
211
- const thread = store.threads?.[threadId];
212
- if (!thread) return null;
213
-
214
- const ts = nowTs();
215
- const comment = {
216
- commentId: randomUUID(),
217
- threadId,
218
- author,
219
- body,
220
- mentions,
221
- createdAt: ts,
222
- updatedAt: ts,
223
- };
224
-
225
- thread.comments = Array.isArray(thread.comments) ? thread.comments : [];
226
- thread.comments.push(comment);
227
- if (author?.username) {
228
- const next = new Set(thread.participants ?? []);
229
- next.add(author.username);
230
- thread.participants = [...next];
231
- }
232
- thread.updatedAt = ts;
233
- store.threads[threadId] = thread;
234
-
235
- await writeJsonAtomic(this.threadsFile, store);
236
- await this.appendEvent('comment.create', { threadId, comment });
237
-
238
- return { thread, comment };
239
- }
240
-
241
- async updateComment({ threadId, commentId, body }) {
242
- await this.ensureReady();
243
-
244
- const store = await readJson(this.threadsFile, { threads: {} });
245
- const thread = store.threads?.[threadId];
246
- if (!thread || !Array.isArray(thread.comments)) return null;
247
-
248
- const comment = thread.comments.find((entry) => entry.commentId === commentId);
249
- if (!comment) return null;
250
-
251
- const ts = nowTs();
252
- comment.body = body;
253
- comment.updatedAt = ts;
254
- thread.updatedAt = ts;
255
- store.threads[threadId] = thread;
256
-
257
- await writeJsonAtomic(this.threadsFile, store);
258
- await this.appendEvent('comment.update', { threadId, commentId });
259
-
260
- return { thread, comment };
261
- }
262
-
263
- async deleteComment({ threadId, commentId }) {
264
- await this.ensureReady();
265
-
266
- const store = await readJson(this.threadsFile, { threads: {} });
267
- const thread = store.threads?.[threadId];
268
- if (!thread || !Array.isArray(thread.comments)) return null;
269
-
270
- const next = thread.comments.filter((entry) => entry.commentId !== commentId);
271
- if (next.length === thread.comments.length) return null;
272
-
273
- const ts = nowTs();
274
- thread.comments = next;
275
- thread.updatedAt = ts;
276
- store.threads[threadId] = thread;
277
-
278
- await writeJsonAtomic(this.threadsFile, store);
279
- await this.appendEvent('comment.delete', { threadId, commentId });
280
-
281
- return { thread, commentId };
282
- }
283
-
284
- async setTaskState({ threadId, status, assignee = null, dueAt = null }) {
285
- await this.ensureReady();
286
-
287
- const store = await readJson(this.threadsFile, { threads: {} });
288
- const thread = store.threads?.[threadId];
289
- if (!thread) return null;
290
-
291
- const ts = nowTs();
292
- const previous = thread.task;
293
- thread.task = {
294
- taskId: previous?.taskId ?? randomUUID(),
295
- threadId,
296
- status,
297
- assignee,
298
- dueAt,
299
- updatedAt: ts,
300
- createdAt: previous?.createdAt ?? ts,
301
- };
302
- thread.updatedAt = ts;
303
- store.threads[threadId] = thread;
304
-
305
- await writeJsonAtomic(this.threadsFile, store);
306
- await this.appendEvent('task.set-state', { threadId, task: thread.task });
307
-
308
- return { thread, task: thread.task };
309
- }
310
-
311
- async recordActivity({ type, filePath = null, actor = null, payload = null, groupKey = null }) {
312
- await this.ensureReady();
313
-
314
- const store = await readJson(this.activityFile, { events: [] });
315
- const activity = {
316
- eventId: randomUUID(),
317
- type,
318
- filePath,
319
- actor,
320
- payload,
321
- groupKey,
322
- ts: nowTs(),
323
- };
324
-
325
- store.events = Array.isArray(store.events) ? store.events : [];
326
- store.events.unshift(activity);
327
- if (store.events.length > MAX_ACTIVITY_RETENTION) {
328
- store.events = store.events.slice(0, MAX_ACTIVITY_RETENTION);
329
- }
330
-
331
- await writeJsonAtomic(this.activityFile, store);
332
- await this.appendEvent('activity.record', activity);
333
-
334
- return activity;
335
- }
336
-
337
- async listActivity({ filePath = null, types = null, limit = DEFAULT_ACTIVITY_LIMIT, cursor = null } = {}) {
338
- await this.ensureReady();
339
-
340
- const store = await readJson(this.activityFile, { events: [] });
341
- let events = Array.isArray(store.events) ? [...store.events] : [];
342
-
343
- if (filePath) {
344
- events = events.filter((entry) => entry.filePath === filePath);
345
- }
346
- if (Array.isArray(types) && types.length > 0) {
347
- const set = new Set(types.map((entry) => String(entry)));
348
- events = events.filter((entry) => set.has(entry.type));
349
- }
350
-
351
- if (cursor) {
352
- const idx = events.findIndex((entry) => entry.eventId === cursor);
353
- if (idx >= 0) {
354
- events = events.slice(idx + 1);
355
- }
356
- }
357
-
358
- const clamped = clampLimit(limit);
359
- const page = events.slice(0, clamped);
360
- const nextCursor = page.length > 0 ? page[page.length - 1].eventId : null;
361
-
362
- return {
363
- events: page,
364
- nextCursor,
365
- };
366
- }
367
-
368
- async getNotifyPreferences(userId) {
369
- await this.ensureReady();
370
- const store = await readJson(this.preferencesFile, { users: {} });
371
-
372
- const current = store.users?.[userId] ?? {
373
- global: 'all',
374
- workspace: {},
375
- file: {},
376
- };
377
-
378
- return {
379
- global: normalizeMode(current.global),
380
- workspace: current.workspace ?? {},
381
- file: current.file ?? {},
382
- };
383
- }
384
-
385
- async setNotifyPreference({ userId, scope, key = null, mode }) {
386
- await this.ensureReady();
387
-
388
- const store = await readJson(this.preferencesFile, { users: {} });
389
- const current = store.users?.[userId] ?? {
390
- global: 'all',
391
- workspace: {},
392
- file: {},
393
- };
394
-
395
- const normalizedMode = normalizeMode(mode);
396
-
397
- if (scope === 'global') {
398
- current.global = normalizedMode;
399
- } else if (scope === 'workspace' && typeof key === 'string' && key.length > 0) {
400
- current.workspace[key] = normalizedMode;
401
- } else if (scope === 'file' && typeof key === 'string' && key.length > 0) {
402
- current.file[key] = normalizedMode;
403
- } else {
404
- throw new Error('Invalid notification scope or key');
405
- }
406
-
407
- store.users[userId] = current;
408
- await writeJsonAtomic(this.preferencesFile, store);
409
- await this.appendEvent('notify.preference', { userId, scope, key, mode: normalizedMode });
410
-
411
- return {
412
- global: normalizeMode(current.global),
413
- workspace: current.workspace,
414
- file: current.file,
415
- };
416
- }
417
-
418
- async resolveNotifyMode(userId, filePath) {
419
- const preferences = await this.getNotifyPreferences(userId);
420
- const workspace = workspaceKeyForPath(filePath);
421
-
422
- if (filePath && preferences.file?.[filePath]) {
423
- return normalizeMode(preferences.file[filePath]);
424
- }
425
- if (preferences.workspace?.[workspace]) {
426
- return normalizeMode(preferences.workspace[workspace]);
427
- }
428
- return normalizeMode(preferences.global);
429
- }
430
-
431
- async shouldDeliverNotification(userId, filePath, kind) {
432
- const mode = await this.resolveNotifyMode(userId, filePath);
433
- if (mode === 'mute') {
434
- return { deliver: false, mode };
435
- }
436
- if (mode === 'focus') {
437
- return { deliver: kind === 'mention' || kind === 'task', mode };
438
- }
439
- if (mode === 'digest') {
440
- return { deliver: false, mode };
441
- }
442
- return { deliver: true, mode };
443
- }
444
- }
445
-
446
- export function createCollabStore(options) {
447
- return new CollabStore(options);
448
- }
@@ -1,81 +0,0 @@
1
- function parseWebhookMap(raw) {
2
- if (!raw) return [];
3
- try {
4
- const parsed = JSON.parse(raw);
5
- if (!parsed || typeof parsed !== 'object') return [];
6
- return Object.entries(parsed)
7
- .filter(([prefix, url]) => typeof prefix === 'string' && typeof url === 'string')
8
- .map(([prefix, url]) => ({
9
- prefix: prefix.replace(/\\/g, '/').replace(/^\//, ''),
10
- url,
11
- }))
12
- .sort((a, b) => b.prefix.length - a.prefix.length);
13
- } catch {
14
- return [];
15
- }
16
- }
17
-
18
- const mappedWebhooks = parseWebhookMap(process.env.HIVE_DISCORD_WEBHOOKS_JSON);
19
-
20
- function resolveWebhook(filePath) {
21
- const normalized = typeof filePath === 'string' ? filePath.replace(/\\/g, '/') : '';
22
-
23
- for (const entry of mappedWebhooks) {
24
- if (!entry.prefix) continue;
25
- if (normalized === entry.prefix || normalized.startsWith(`${entry.prefix}/`)) {
26
- return entry.url;
27
- }
28
- }
29
-
30
- const fallback = process.env.HIVE_DISCORD_WEBHOOK_URL;
31
- return typeof fallback === 'string' && fallback.length > 0 ? fallback : null;
32
- }
33
-
34
- function buildMessage(payload) {
35
- const kind = payload?.kind === 'task' ? 'Task' : 'Mention';
36
- const actor = payload?.actor?.username ? `@${payload.actor.username}` : 'Someone';
37
- const filePath = payload?.filePath ?? 'unknown file';
38
- const threadId = payload?.threadId ?? 'n/a';
39
- const target = payload?.targetUser?.username ? `@${payload.targetUser.username}` : 'a collaborator';
40
-
41
- return {
42
- username: 'Hive',
43
- content: `**${kind}** for ${target} in \`${filePath}\` by ${actor}`,
44
- embeds: [
45
- {
46
- title: `${kind} Notification`,
47
- fields: [
48
- { name: 'File', value: `\`${filePath}\``, inline: false },
49
- { name: 'Thread', value: threadId, inline: true },
50
- { name: 'Recipient', value: target, inline: true },
51
- ],
52
- description: typeof payload?.body === 'string' ? payload.body.slice(0, 500) : '',
53
- timestamp: new Date(payload?.ts ?? Date.now()).toISOString(),
54
- },
55
- ],
56
- };
57
- }
58
-
59
- export async function sendDiscordWebhook(payload) {
60
- const webhook = resolveWebhook(payload?.filePath);
61
- if (!webhook) return false;
62
-
63
- try {
64
- const res = await fetch(webhook, {
65
- method: 'POST',
66
- headers: { 'content-type': 'application/json' },
67
- body: JSON.stringify(buildMessage(payload)),
68
- });
69
-
70
- if (!res.ok) {
71
- const body = await res.text();
72
- console.warn(`[notify] Discord webhook failed (${res.status}): ${body}`);
73
- return false;
74
- }
75
-
76
- return true;
77
- } catch (err) {
78
- console.warn('[notify] Discord webhook error:', err?.message ?? err);
79
- return false;
80
- }
81
- }
@@ -1,13 +0,0 @@
1
- const MENTION_RE = /(^|\s)@([a-zA-Z0-9](?:[a-zA-Z0-9_.-]{0,62}[a-zA-Z0-9])?)(?=$|\s|[.,!?;:])/g;
2
-
3
- export function parseMentions(text) {
4
- if (typeof text !== 'string' || text.length === 0) return [];
5
-
6
- const mentions = new Set();
7
- let match;
8
- while ((match = MENTION_RE.exec(text)) !== null) {
9
- mentions.add(match[2].toLowerCase());
10
- }
11
-
12
- return [...mentions];
13
- }