@mulmoclaude/core 0.1.0 → 0.2.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,464 +1,2 @@
1
- import { promises } from "node:fs";
2
- import { randomUUID } from "node:crypto";
3
- //#region src/notifier/types.ts
4
- /** Two notification shapes, distinguished by who fires the close call:
5
- *
6
- * `fyi` — informational. The host (bell panel) clears it when the
7
- * user dismisses the row. No deep-link target.
8
- * `action` — pending obligation. The plugin clears it when the
9
- * underlying domain state changes (the user paid the tax,
10
- * viewed the digest, etc.). The bell row navigates to
11
- * `navigateTarget` on click.
12
- *
13
- * The engine reads `lifecycle` only to enforce two publish-time rules
14
- * (everything downstream — pubsub fan-out, persistence, history — is
15
- * lifecycle-blind):
16
- *
17
- * 1. `action` requires a non-empty `navigateTarget`. Without one,
18
- * clicking the row does nothing and the entry is a degraded fyi.
19
- * 2. `action` cannot use `info` severity. A low-priority obligation
20
- * is incoherent — fyi if it's a ping, `nudge`/`urgent` if it's a
21
- * real obligation worth a landing page.
22
- *
23
- * Both rules are mirrored in the HTTP layer so plugin-runtime callers
24
- * and HTTP callers hit the same wall. */
25
- var NOTIFIER_LIFECYCLES = ["fyi", "action"];
26
- /** Severity drives badge color (gray / amber / red, worst-wins) and
27
- * in a future iteration channel routing. Mostly stored verbatim by
28
- * the engine; the one engine-visible interaction is the rule that
29
- * `action` lifecycle cannot pair with `info` severity (see
30
- * `NotifierLifecycle` above). */
31
- var NOTIFIER_SEVERITIES = [
32
- "info",
33
- "nudge",
34
- "urgent"
35
- ];
36
- /** History size cap. The bell popup's History section renders this
37
- * many entries; older ones fall off when new terminations land. */
38
- var HISTORY_CAP = 50;
39
- //#endregion
40
- //#region src/notifier/store.ts
41
- function isNotFoundError(err) {
42
- return typeof err === "object" && err !== null && err.code === "ENOENT";
43
- }
44
- /** Read the active-entries file. Returns an empty store when the file
45
- * doesn't exist yet (first ever call on a fresh workspace). Any other
46
- * read or parse failure throws — the caller has to decide whether to
47
- * surface or recover, since silently treating "malformed file" as
48
- * "no entries" would lose data. */
49
- async function loadActive(filePath) {
50
- let text;
51
- try {
52
- text = await promises.readFile(filePath, "utf-8");
53
- } catch (err) {
54
- if (isNotFoundError(err)) return { entries: {} };
55
- throw err;
56
- }
57
- const parsed = JSON.parse(text);
58
- if (typeof parsed !== "object" || parsed === null || !("entries" in parsed)) throw new Error(`notifier: malformed active.json at ${filePath}`);
59
- const { entries } = parsed;
60
- if (typeof entries !== "object" || entries === null || Array.isArray(entries)) throw new Error(`notifier: malformed active.json at ${filePath}`);
61
- return parsed;
62
- }
63
- /** Write the active-entries file via the injected atomic writer so a
64
- * half-written file is never visible to readers. The caller serialises
65
- * writes (engine.ts queues mutations) — this function makes no
66
- * concurrency guarantees of its own. */
67
- async function saveActive(writeJson, filePath, state) {
68
- await writeJson(filePath, state);
69
- }
70
- /** Read the history file. Empty array on first run. Same parse-error
71
- * policy as `loadActive`. */
72
- async function loadHistory(filePath) {
73
- let text;
74
- try {
75
- text = await promises.readFile(filePath, "utf-8");
76
- } catch (err) {
77
- if (isNotFoundError(err)) return { entries: [] };
78
- throw err;
79
- }
80
- const parsed = JSON.parse(text);
81
- if (typeof parsed !== "object" || parsed === null || !("entries" in parsed) || !Array.isArray(parsed.entries)) throw new Error(`notifier: malformed history.json at ${filePath}`);
82
- return parsed;
83
- }
84
- async function saveHistory(writeJson, filePath, state) {
85
- await writeJson(filePath, state);
86
- }
87
- //#endregion
88
- //#region src/notifier/validate.ts
89
- /** Hard caps on publish-input fields. The engine reads each entry on
90
- * every list/get call (no in-memory cache), so unbounded fields hurt
91
- * every reader. Caps chosen to be generous for legitimate UX copy
92
- * while bounding active.json growth: a notification fundamentally is
93
- * a short blurb, not a document. */
94
- var NOTIFIER_LIMITS = {
95
- titleMax: 200,
96
- bodyMax: 4e3,
97
- navigateTargetMax: 1e3,
98
- pluginDataMaxBytes: 16 * 1024
99
- };
100
- function validateTitle(title) {
101
- if (typeof title !== "string" || title.length === 0) return "title must be a non-empty string";
102
- if (title.length > NOTIFIER_LIMITS.titleMax) return `title exceeds max length of ${NOTIFIER_LIMITS.titleMax} chars`;
103
- return null;
104
- }
105
- function validateBody(body) {
106
- if (body === void 0) return null;
107
- if (body.length > NOTIFIER_LIMITS.bodyMax) return `body exceeds max length of ${NOTIFIER_LIMITS.bodyMax} chars`;
108
- return null;
109
- }
110
- function validateNavigateTarget(target) {
111
- if (target === void 0) return null;
112
- if (target.length === 0) return "navigateTarget must be a non-empty relative path when set";
113
- if (target.length > NOTIFIER_LIMITS.navigateTargetMax) return `navigateTarget exceeds max length of ${NOTIFIER_LIMITS.navigateTargetMax} chars`;
114
- if (!target.startsWith("/") || target.startsWith("//")) return "navigateTarget must be a relative path beginning with a single '/' (no scheme, no '//')";
115
- return null;
116
- }
117
- function validatePluginData(pluginData) {
118
- if (pluginData === void 0) return null;
119
- let serialized;
120
- try {
121
- serialized = JSON.stringify(pluginData);
122
- } catch (err) {
123
- return `pluginData is not JSON-serialisable: ${String(err)}`;
124
- }
125
- if (typeof serialized !== "string") return "pluginData is not JSON-serialisable";
126
- if (serialized.length > NOTIFIER_LIMITS.pluginDataMaxBytes) return `pluginData JSON exceeds ${NOTIFIER_LIMITS.pluginDataMaxBytes} bytes`;
127
- return null;
128
- }
129
- function validateActionCoherence(input) {
130
- if (input.lifecycle !== "action") return null;
131
- if (input.severity === "info") return "action lifecycle is incompatible with info severity (use fyi for low-priority pings)";
132
- if (typeof input.navigateTarget !== "string" || input.navigateTarget.length === 0) return "action lifecycle requires a non-empty navigateTarget";
133
- return null;
134
- }
135
- /** Validate a `PublishInput`. Returns `null` if OK, or a
136
- * human-readable error string. Order matters — shape/size errors are
137
- * reported before lifecycle/severity coherence errors so the message
138
- * the caller sees points at the most fundamental problem first. */
139
- function validatePublishInput(input) {
140
- return validateTitle(input.title) ?? validateBody(input.body) ?? validateNavigateTarget(input.navigateTarget) ?? validatePluginData(input.pluginData) ?? validateActionCoherence(input);
141
- }
142
- //#endregion
143
- //#region src/notifier/engine.ts
144
- var NOOP_LOG = {
145
- warn: () => {},
146
- error: () => {}
147
- };
148
- var config = null;
149
- var activeFilePath = "";
150
- var historyFilePath = "";
151
- function logger() {
152
- return config?.log ?? NOOP_LOG;
153
- }
154
- /** Wire the engine's I/O deps. Call once at startup, before the first
155
- * mutation. Does NOT set file paths — those are set independently via
156
- * `setNotifierFilePaths` so a host can bind production paths at module
157
- * load and a test can override them without re-supplying the deps. */
158
- function configureNotifier(injected) {
159
- config = injected;
160
- }
161
- var listeners = [];
162
- /** Register an in-process listener for engine events. Returns an
163
- * unsubscribe function the caller can use during teardown. */
164
- function onEvent(listener) {
165
- listeners.push(listener);
166
- return () => {
167
- const idx = listeners.indexOf(listener);
168
- if (idx >= 0) listeners.splice(idx, 1);
169
- };
170
- }
171
- function emit(event) {
172
- for (const listener of listeners) try {
173
- listener(event);
174
- } catch (err) {
175
- logger().error("in-process listener failed", {
176
- type: event.type,
177
- error: String(err)
178
- });
179
- }
180
- if (!config) {
181
- logger().warn("emit before init", { type: event.type });
182
- return;
183
- }
184
- try {
185
- config.publishEvent(event);
186
- } catch (err) {
187
- logger().error("emit failed", {
188
- type: event.type,
189
- error: String(err)
190
- });
191
- }
192
- }
193
- var writing = false;
194
- var waiters = [];
195
- /** Point the engine at its active/history files. Resets the write
196
- * queue, so callers must not have in-flight mutations. The host calls
197
- * this once with the workspace paths; tests call it per-case with temp
198
- * files. */
199
- function setNotifierFilePaths(paths) {
200
- activeFilePath = paths.active;
201
- historyFilePath = paths.history;
202
- writing = false;
203
- waiters = [];
204
- }
205
- /** Test-only: clear config + queue so each suite starts clean. */
206
- function resetNotifier() {
207
- config = null;
208
- activeFilePath = "";
209
- historyFilePath = "";
210
- writing = false;
211
- waiters = [];
212
- listeners.length = 0;
213
- }
214
- function requireWriteJson() {
215
- if (!config) throw new Error("notifier: configureNotifier() not called");
216
- return config.writeJson;
217
- }
218
- function applyBatchMutations(batch, state) {
219
- return batch.map((waiter) => {
220
- try {
221
- return {
222
- ok: true,
223
- outcome: waiter.mutate(state)
224
- };
225
- } catch (err) {
226
- return {
227
- ok: false,
228
- error: err
229
- };
230
- }
231
- });
232
- }
233
- function collectEvents(results) {
234
- const events = [];
235
- for (const result of results) if (result.ok && result.outcome !== null) events.push(result.outcome.event);
236
- return events;
237
- }
238
- function collectHistoryEntries(results) {
239
- const entries = [];
240
- for (const result of results) if (result.ok && result.outcome !== null && result.outcome.historyEntry) entries.push(result.outcome.historyEntry);
241
- return entries;
242
- }
243
- function settleBatch(batch, results) {
244
- for (let index = 0; index < batch.length; index += 1) {
245
- const result = results[index];
246
- if (result.ok) batch[index].resolve();
247
- else batch[index].reject(result.error);
248
- }
249
- }
250
- function rejectBatch(batch, err) {
251
- for (const waiter of batch) waiter.reject(err);
252
- }
253
- async function persistHistory(newEntries) {
254
- const existing = await loadHistory(historyFilePath);
255
- const merged = [...newEntries.slice().reverse(), ...existing.entries].slice(0, 50);
256
- await saveHistory(requireWriteJson(), historyFilePath, { entries: merged });
257
- }
258
- async function processBatch(batch) {
259
- let state;
260
- try {
261
- state = await loadActive(activeFilePath);
262
- } catch (err) {
263
- logger().error("load failed", { error: String(err) });
264
- rejectBatch(batch, err);
265
- return;
266
- }
267
- const results = applyBatchMutations(batch, state);
268
- const events = collectEvents(results);
269
- const historyEntries = collectHistoryEntries(results);
270
- if (events.length > 0) {
271
- try {
272
- await saveActive(requireWriteJson(), activeFilePath, state);
273
- } catch (err) {
274
- logger().error("active write failed", { error: String(err) });
275
- rejectBatch(batch, err);
276
- return;
277
- }
278
- if (historyEntries.length > 0) try {
279
- await persistHistory(historyEntries);
280
- } catch (err) {
281
- logger().error("history write failed", { error: String(err) });
282
- }
283
- for (const event of events) emit(event);
284
- }
285
- settleBatch(batch, results);
286
- }
287
- async function drain() {
288
- writing = true;
289
- try {
290
- while (waiters.length > 0) {
291
- const batch = waiters;
292
- waiters = [];
293
- await processBatch(batch);
294
- }
295
- } finally {
296
- writing = false;
297
- }
298
- }
299
- function enqueue(mutate) {
300
- return new Promise((resolve, reject) => {
301
- waiters.push({
302
- mutate,
303
- resolve,
304
- reject
305
- });
306
- if (!writing) drain();
307
- });
308
- }
309
- function removeEntry(state, entryId) {
310
- const { [entryId]: __removed, ...remaining } = state.entries;
311
- return remaining;
312
- }
313
- function buildHistoryEntry(entry, terminalType) {
314
- return {
315
- ...entry,
316
- terminalType,
317
- terminalAt: (/* @__PURE__ */ new Date()).toISOString()
318
- };
319
- }
320
- async function publish(input) {
321
- const validationError = validatePublishInput(input);
322
- if (validationError) throw new Error(`notifier.publish: ${validationError}`);
323
- const entryId = randomUUID();
324
- const entry = {
325
- id: entryId,
326
- pluginPkg: input.pluginPkg,
327
- severity: input.severity,
328
- lifecycle: input.lifecycle,
329
- title: input.title,
330
- body: input.body,
331
- navigateTarget: input.navigateTarget,
332
- pluginData: input.pluginData,
333
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
334
- };
335
- await enqueue((state) => {
336
- state.entries[entryId] = entry;
337
- return { event: {
338
- type: "published",
339
- entry
340
- } };
341
- });
342
- return { id: entryId };
343
- }
344
- async function clear(entryId) {
345
- await enqueue((state) => {
346
- const entry = state.entries[entryId];
347
- if (!entry) return null;
348
- state.entries = removeEntry(state, entryId);
349
- return {
350
- event: {
351
- type: "cleared",
352
- id: entryId
353
- },
354
- historyEntry: buildHistoryEntry(entry, "cleared")
355
- };
356
- });
357
- }
358
- async function cancel(entryId) {
359
- await enqueue((state) => {
360
- const entry = state.entries[entryId];
361
- if (!entry) return null;
362
- state.entries = removeEntry(state, entryId);
363
- return {
364
- event: {
365
- type: "cancelled",
366
- id: entryId
367
- },
368
- historyEntry: buildHistoryEntry(entry, "cancelled")
369
- };
370
- });
371
- }
372
- /** In-place update for an active entry. Only the fields present on
373
- * `patch` are rewritten; `id`, `pluginPkg`, `lifecycle`, and
374
- * `createdAt` stay fixed. Emits a single `"updated"` event with the
375
- * post-mutation entry — no history record is written because the
376
- * entry is still active, just with refreshed content.
377
- *
378
- * No-ops (no throw) when the id is unknown, the entry belongs to a
379
- * different plugin, or the merged shape would violate
380
- * `validatePublishInput`. The silent skip matches `clearForPlugin`'s
381
- * isolation semantics; validation failures are logged for diagnosis. */
382
- async function updateForPlugin(pluginPkg, entryId, patch) {
383
- await enqueue((state) => {
384
- const entry = state.entries[entryId];
385
- if (!entry) return null;
386
- if (entry.pluginPkg !== pluginPkg) return null;
387
- const next = {
388
- ...entry,
389
- ...patch.severity !== void 0 ? { severity: patch.severity } : {},
390
- ...patch.title !== void 0 ? { title: patch.title } : {},
391
- ...patch.body !== void 0 ? { body: patch.body } : {},
392
- ...patch.navigateTarget !== void 0 ? { navigateTarget: patch.navigateTarget } : {},
393
- ...patch.pluginData !== void 0 ? { pluginData: patch.pluginData } : {}
394
- };
395
- const validationError = validatePublishInput({
396
- pluginPkg: next.pluginPkg,
397
- severity: next.severity,
398
- title: next.title,
399
- body: next.body,
400
- lifecycle: next.lifecycle,
401
- navigateTarget: next.navigateTarget,
402
- pluginData: next.pluginData
403
- });
404
- if (validationError) {
405
- logger().warn("update rejected by validation", {
406
- entryId,
407
- pluginPkg,
408
- error: validationError
409
- });
410
- return null;
411
- }
412
- state.entries[entryId] = next;
413
- return { event: {
414
- type: "updated",
415
- entry: next
416
- } };
417
- });
418
- }
419
- /** Plugin-scoped point lookup. Returns the entry by id, but only if it
420
- * belongs to the caller's plugin; otherwise undefined. Cross-plugin
421
- * reads return undefined for isolation — same property as
422
- * `clearForPlugin` / `updateForPlugin`. */
423
- async function getForPlugin(pluginPkg, entryId) {
424
- const entry = (await loadActive(activeFilePath)).entries[entryId];
425
- if (!entry) return void 0;
426
- if (entry.pluginPkg !== pluginPkg) return void 0;
427
- return entry;
428
- }
429
- /** Plugin-scoped clear. Same as `clear` but no-ops if the entry's
430
- * `pluginPkg` doesn't match the caller's, so a plugin can't dismiss
431
- * another plugin's notification by guessing or scraping its id. */
432
- async function clearForPlugin(pluginPkg, entryId) {
433
- await enqueue((state) => {
434
- const entry = state.entries[entryId];
435
- if (!entry) return null;
436
- if (entry.pluginPkg !== pluginPkg) return null;
437
- state.entries = removeEntry(state, entryId);
438
- return {
439
- event: {
440
- type: "cleared",
441
- id: entryId
442
- },
443
- historyEntry: buildHistoryEntry(entry, "cleared")
444
- };
445
- });
446
- }
447
- async function get(entryId) {
448
- return (await loadActive(activeFilePath)).entries[entryId];
449
- }
450
- async function listFor(pluginPkg) {
451
- const state = await loadActive(activeFilePath);
452
- return Object.values(state.entries).filter((entry) => entry.pluginPkg === pluginPkg);
453
- }
454
- async function listAll() {
455
- const state = await loadActive(activeFilePath);
456
- return Object.values(state.entries);
457
- }
458
- async function listHistory() {
459
- return (await loadHistory(historyFilePath)).entries;
460
- }
461
- //#endregion
1
+ import { _ as HISTORY_CAP, a as get, c as listFor, d as publish, f as resetNotifier, g as validatePublishInput, h as NOTIFIER_LIMITS, i as configureNotifier, l as listHistory, m as updateForPlugin, n as clear, o as getForPlugin, p as setNotifierFilePaths, r as clearForPlugin, s as listAll, t as cancel, u as onEvent, v as NOTIFIER_LIFECYCLES, y as NOTIFIER_SEVERITIES } from "../notifier-6PjsLxLm.js";
462
2
  export { HISTORY_CAP, NOTIFIER_LIFECYCLES, NOTIFIER_LIMITS, NOTIFIER_SEVERITIES, cancel, clear, clearForPlugin, configureNotifier, get, getForPlugin, listAll, listFor, listHistory, onEvent, publish, resetNotifier, setNotifierFilePaths, updateForPlugin, validatePublishInput };
463
-
464
- //# sourceMappingURL=index.js.map