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