@mulmoclaude/core 3.5.0 → 3.6.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.
Files changed (45) hide show
  1. package/assets/helps/collection-skills.md +49 -1
  2. package/assets/helps/error-recovery.md +53 -0
  3. package/dist/calendarGrid-CQ8MVSRb.js.map +1 -1
  4. package/dist/calendarGrid-DGILaVxI.cjs.map +1 -1
  5. package/dist/collection/core/schema.d.ts +8 -1
  6. package/dist/collection/core/schemaZ.d.ts +36 -26
  7. package/dist/collection/firestore.cjs +51 -0
  8. package/dist/collection/firestore.cjs.map +1 -0
  9. package/dist/collection/firestore.d.ts +1 -0
  10. package/dist/collection/firestore.js +50 -0
  11. package/dist/collection/firestore.js.map +1 -0
  12. package/dist/collection/registry/server/index.cjs +2 -2
  13. package/dist/collection/registry/server/index.js +2 -2
  14. package/dist/collection/server/appManifest.d.ts +53 -0
  15. package/dist/collection/server/delete.d.ts +10 -0
  16. package/dist/collection/server/discoveredCollection.d.ts +10 -0
  17. package/dist/collection/server/discovery.d.ts +1 -0
  18. package/dist/collection/server/firestoreDocs.d.ts +39 -0
  19. package/dist/collection/server/firestoreStore.d.ts +14 -0
  20. package/dist/collection/server/host.d.ts +41 -0
  21. package/dist/collection/server/index.cjs +9 -2
  22. package/dist/collection/server/index.d.ts +4 -1
  23. package/dist/collection/server/index.js +3 -3
  24. package/dist/collection-watchers/index.cjs +132 -41
  25. package/dist/collection-watchers/index.cjs.map +1 -1
  26. package/dist/collection-watchers/index.js +132 -41
  27. package/dist/collection-watchers/index.js.map +1 -1
  28. package/dist/collection-watchers/reconciler.d.ts +1 -1
  29. package/dist/{discovery-DH9wweuj.js → discovery-CdkaURVY.js} +368 -57
  30. package/dist/discovery-CdkaURVY.js.map +1 -0
  31. package/dist/{discovery-Ck4AqikY.cjs → discovery-Cw5xWzZD.cjs} +407 -54
  32. package/dist/discovery-Cw5xWzZD.cjs.map +1 -0
  33. package/dist/feeds/server/index.cjs +2 -2
  34. package/dist/feeds/server/index.js +2 -2
  35. package/dist/google/index.cjs +1 -1
  36. package/dist/google/index.js +1 -1
  37. package/dist/{server-5EMj3naj.cjs → server-9mFaGLdq.cjs} +23 -4
  38. package/dist/server-9mFaGLdq.cjs.map +1 -0
  39. package/dist/{server-BiRLLMpW.js → server-DdHaX_Uu.js} +23 -4
  40. package/dist/server-DdHaX_Uu.js.map +1 -0
  41. package/package.json +7 -1
  42. package/dist/discovery-Ck4AqikY.cjs.map +0 -1
  43. package/dist/discovery-DH9wweuj.js.map +0 -1
  44. package/dist/server-5EMj3naj.cjs.map +0 -1
  45. package/dist/server-BiRLLMpW.js.map +0 -1
@@ -3,8 +3,8 @@ const require_root = require("../root-rPH6FGDT.cjs");
3
3
  const require_calendarGrid = require("../calendarGrid-DGILaVxI.cjs");
4
4
  const require_promptSafety = require("../promptSafety-NNGViiCr.cjs");
5
5
  require("../collection/index.cjs");
6
- const require_discovery = require("../discovery-Ck4AqikY.cjs");
7
- const require_server = require("../server-5EMj3naj.cjs");
6
+ const require_discovery = require("../discovery-Cw5xWzZD.cjs");
7
+ const require_server = require("../server-9mFaGLdq.cjs");
8
8
  const require_notifier = require("../notifier-DZJj0sOh.cjs");
9
9
  let node_fs_promises = require("node:fs/promises");
10
10
  //#region src/collection-watchers/config.ts
@@ -133,16 +133,28 @@ function resolveDisplayLabel(schema, item, itemId) {
133
133
  const label = require_calendarGrid.fieldText(item[displayField]).trim();
134
134
  return label.length > 0 ? label : itemId;
135
135
  }
136
+ /** The scope a collection's bells belong to.
137
+ *
138
+ * A shared collection's `appId` wins over the call's `workspaceRoot`: the
139
+ * root is where this host happens to have the repository checked out, which
140
+ * is exactly the fact that must NOT reach the identity. A local collection is
141
+ * unchanged — its root is present only when the call carried an explicit one,
142
+ * so a single-workspace host's ids stay byte-identical to what it has already
143
+ * written to `active.json`. */
144
+ function bellScopeOf(collection, ioOpts) {
145
+ return collection.appId === void 0 ? { root: ioOpts.workspaceRoot } : { aid: collection.appId };
146
+ }
147
+ var scopedLegacyId = (slug, itemId, scope) => completionLegacyId(slug, itemId, scope.root, scope.aid);
136
148
  /** Every active bell entry whose key matches this (slug, itemId).
137
149
  * Returns multiple when defensive cleanup is needed. Scans `listAll()`
138
150
  * — cheap because the active set is bounded. */
139
- async function findActiveEntries(slug, itemId, root) {
151
+ async function findActiveEntries(slug, itemId, scope) {
140
152
  const adapter = requireAdapter();
141
- const legacyId = completionLegacyId(slug, itemId, root);
153
+ const legacyId = scopedLegacyId(slug, itemId, scope);
142
154
  return (await require_notifier.listAll()).filter((entry) => adapter.readEntry(entry.pluginData)?.legacyId === legacyId);
143
155
  }
144
- async function findActiveEntryIds(slug, itemId, root) {
145
- return (await findActiveEntries(slug, itemId, root)).map((entry) => entry.id);
156
+ async function findActiveEntryIds(slug, itemId, scope) {
157
+ return (await findActiveEntries(slug, itemId, scope)).map((entry) => entry.id);
146
158
  }
147
159
  var ensureLocks = /* @__PURE__ */ new Map();
148
160
  /** Bell priority for a record: the FIRST flagged value in `notifyWhen.in`
@@ -155,8 +167,8 @@ function notifyPriorityForItem(schema, item) {
155
167
  const value = item[spec.field] === void 0 || item[spec.field] === null ? "" : String(item[spec.field]);
156
168
  return spec.in.indexOf(value) === 0 ? "high" : "normal";
157
169
  }
158
- async function ensureItemNotification(slug, schema, itemId, displayLabel, priority, root) {
159
- const legacyId = completionLegacyId(slug, itemId, root);
170
+ async function ensureItemNotification(slug, schema, itemId, displayLabel, priority, scope) {
171
+ const legacyId = scopedLegacyId(slug, itemId, scope);
160
172
  while (true) {
161
173
  const inflight = ensureLocks.get(legacyId);
162
174
  if (!inflight) break;
@@ -169,7 +181,7 @@ async function ensureItemNotification(slug, schema, itemId, displayLabel, priori
169
181
  legacyId,
170
182
  displayLabel,
171
183
  priority,
172
- root
184
+ scope
173
185
  }) };
174
186
  ensureLocks.set(legacyId, lock);
175
187
  try {
@@ -182,8 +194,9 @@ async function ensureItemNotification(slug, schema, itemId, displayLabel, priori
182
194
  * place (preserving id / position / createdAt) so a record whose flagged
183
195
  * value changed while it stayed pending re-colours the bell without a
184
196
  * clear+republish flicker. No-op when the stored priority already matches. */
185
- async function reconcileEntrySeverity(slug, itemId, entries, priority, root) {
197
+ async function reconcileEntrySeverity(slug, itemId, entries, priority, scope) {
186
198
  const adapter = requireAdapter();
199
+ const { root } = scope;
187
200
  for (const entry of entries) {
188
201
  const parsed = adapter.readEntry(entry.pluginData);
189
202
  if (!parsed || parsed.priority === priority) continue;
@@ -200,12 +213,13 @@ async function reconcileEntrySeverity(slug, itemId, entries, priority, root) {
200
213
  });
201
214
  }
202
215
  }
203
- async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displayLabel, priority, root }) {
216
+ async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displayLabel, priority, scope }) {
204
217
  const adapter = requireAdapter();
218
+ const { root } = scope;
205
219
  try {
206
- const existing = await findActiveEntries(slug, itemId, root);
220
+ const existing = await findActiveEntries(slug, itemId, scope);
207
221
  if (existing.length > 0) {
208
- await reconcileEntrySeverity(slug, itemId, existing, priority, root);
222
+ await reconcileEntrySeverity(slug, itemId, existing, priority, scope);
209
223
  return;
210
224
  }
211
225
  const navigateTarget = adapter.buildNavigateTarget(slug, itemId, root);
@@ -235,9 +249,18 @@ async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displa
235
249
  /** Idempotently clear EVERY bell entry that matches this (slug, itemId).
236
250
  * Silent no-op when nothing matches. The "every" is defensive: if a
237
251
  * duplicate ever slips through, this drains the lot. */
238
- async function clearItemNotification(slug, itemId, root) {
252
+ async function clearItemNotification(slug, itemId, root, aid) {
253
+ await clearScoped(slug, itemId, {
254
+ root,
255
+ aid
256
+ });
257
+ }
258
+ /** The scoped form every internal caller uses. `clearItemNotification` keeps
259
+ * its positional `(slug, itemId, root?)` shape because it is public API
260
+ * (MulmoTerminal calls it); `aid` is additive. */
261
+ async function clearScoped(slug, itemId, scope) {
239
262
  try {
240
- const ids = await findActiveEntryIds(slug, itemId, root);
263
+ const ids = await findActiveEntryIds(slug, itemId, scope);
241
264
  for (const entryId of ids) await require_notifier.clear(entryId);
242
265
  } catch (err) {
243
266
  log().warn("notify clear failed", {
@@ -257,19 +280,19 @@ async function clearItemNotification(slug, itemId, root) {
257
280
  * `{ workspaceRoot: <tmpdir> }` so the check accepts a fixture dataDir. */
258
281
  async function reconcileItem(collection, itemId, ioOpts = {}, now = evalNow()) {
259
282
  const { slug, schema } = collection;
260
- const root = ioOpts.workspaceRoot;
283
+ const scope = bellScopeOf(collection, ioOpts);
261
284
  if (!schema.completionField) {
262
- await clearItemNotification(slug, itemId, root);
285
+ await clearScoped(slug, itemId, scope);
263
286
  return;
264
287
  }
265
288
  const item = await require_discovery.storeFor(collection, ioOpts).read(itemId);
266
289
  if (item === null) {
267
- await clearItemNotification(slug, itemId, root);
290
+ await clearScoped(slug, itemId, scope);
268
291
  return;
269
292
  }
270
293
  await require_server.maybeSpawnSuccessor(collection, item, itemId, ioOpts);
271
294
  if (require_promptSafety.itemIsDone(schema, item)) {
272
- await clearItemNotification(slug, itemId, root);
295
+ await clearScoped(slug, itemId, scope);
273
296
  return;
274
297
  }
275
298
  if (schema.triggerField) {
@@ -281,15 +304,15 @@ async function reconcileItem(collection, itemId, ioOpts = {}, now = evalNow()) {
281
304
  triggerField: schema.triggerField
282
305
  });
283
306
  if (due !== true) {
284
- await clearItemNotification(slug, itemId, root);
307
+ await clearScoped(slug, itemId, scope);
285
308
  return;
286
309
  }
287
310
  }
288
311
  if (!require_promptSafety.whenMatches(schema.notifyWhen, item)) {
289
- await clearItemNotification(slug, itemId, root);
312
+ await clearScoped(slug, itemId, scope);
290
313
  return;
291
314
  }
292
- await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item), root);
315
+ await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item), scope);
293
316
  }
294
317
  /** Boot-time reconcile: walk every record of the collection once (through
295
318
  * its store) and reconcile it. Catches up changes that happened while the
@@ -335,7 +358,7 @@ async function reconcileAllItems(collection, ioOpts = {}, now = evalNow()) {
335
358
  * ever clear, while the reconcile publishes a second rooted one beside it.
336
359
  * Clearing converges — the record republishes rooted if still pending. */
337
360
  function sweepVerdict(entry, ownRoot) {
338
- if (entry.aid !== void 0) return "skip";
361
+ if (entry.aid !== void 0) return "shared";
339
362
  if (entry.root === ownRoot) return "mine";
340
363
  if (entry.root === void 0) return "drop-legacy";
341
364
  return "skip";
@@ -345,12 +368,40 @@ function sweepVerdict(entry, ownRoot) {
345
368
  * `notifyWhen`. The reverse of the reconcile invariant, applied to an entry
346
369
  * whose record the forward pass can never walk to (a delete leaves a bell that
347
370
  * a walk over the SURVIVING records cannot clear). */
348
- async function isStaleEntry(slug, itemId, opts) {
371
+ async function isStaleEntry(slug, itemId, opts, aid) {
349
372
  const collection = await require_discovery.loadCollection(slug, opts);
373
+ if (aid !== void 0 && collection?.appId !== aid) return false;
350
374
  if (!collection || !collection.schema.completionField) return true;
375
+ return recordNoLongerBells(collection, itemId, opts);
376
+ }
377
+ /** The record half of the staleness question: gone, done, or outside
378
+ * `notifyWhen`. Split out so the caller stays one decision — WHOSE entry this
379
+ * is — and this one stays the other. */
380
+ async function recordNoLongerBells(collection, itemId, opts) {
351
381
  const item = await require_discovery.storeFor(collection, opts).read(itemId);
352
382
  return item === null || require_promptSafety.itemIsDone(collection.schema, item) || !require_promptSafety.whenMatches(collection.schema.notifyWhen, item);
353
383
  }
384
+ /** One entry's fate: whose it is (`sweepVerdict`), then — when it is this
385
+ * sweep's to judge — whether its record still justifies the bell. Extracted so
386
+ * the loop above stays a walk and this stays the decision. */
387
+ async function sweepOneEntry(entryId, parsed, ownRoot, opts) {
388
+ const verdict = sweepVerdict(parsed, ownRoot);
389
+ if (verdict === "skip") return;
390
+ if (verdict === "drop-legacy") {
391
+ await require_notifier.clear(entryId);
392
+ return;
393
+ }
394
+ const { slug, itemId } = parsed;
395
+ try {
396
+ if (await isStaleEntry(slug, itemId, opts, verdict === "shared" ? parsed.aid : void 0)) await require_notifier.clear(entryId);
397
+ } catch (err) {
398
+ log().warn("sweep entry failed", {
399
+ slug,
400
+ itemId,
401
+ error: errMsg(err)
402
+ });
403
+ }
404
+ }
354
405
  async function sweepStaleActiveEntries(opts = {}) {
355
406
  const adapter = requireAdapter();
356
407
  let entries;
@@ -366,22 +417,7 @@ async function sweepStaleActiveEntries(opts = {}) {
366
417
  if (!own) continue;
367
418
  const parsed = parseCompletionLegacyId(own.legacyId);
368
419
  if (!parsed) continue;
369
- const verdict = sweepVerdict(parsed, ownRoot);
370
- if (verdict === "skip") continue;
371
- if (verdict === "drop-legacy") {
372
- await require_notifier.clear(entry.id);
373
- continue;
374
- }
375
- const { slug, itemId } = parsed;
376
- try {
377
- if (await isStaleEntry(slug, itemId, opts)) await require_notifier.clear(entry.id);
378
- } catch (err) {
379
- log().warn("sweep entry failed", {
380
- slug,
381
- itemId,
382
- error: errMsg(err)
383
- });
384
- }
420
+ await sweepOneEntry(entry.id, parsed, ownRoot, opts);
385
421
  }
386
422
  }
387
423
  /** Test-only: clear the per-key in-flight locks. */
@@ -591,9 +627,58 @@ async function _tickTimeTriggersForTesting(now, workspaceRoot) {
591
627
  async function tickTimeTriggers(gen, now = evalNow()) {
592
628
  for (const entry of gen.watchers.values()) {
593
629
  const { schema } = entry.collection;
630
+ if (cannotReportChanges(gen, entry.collection)) continue;
594
631
  if (!schema.triggerField && !schema.spawn) continue;
595
632
  await reconcileAllItems(entry.collection, gen.discoveryOpts, now);
596
633
  }
634
+ await tickUnwatchedCollections(gen, now);
635
+ }
636
+ /** True when the collection's store implements no `watch` — nothing will ever
637
+ * tell this module its records moved, so the clock tick is its only change
638
+ * detection. A capability question, deliberately not a backend one: the day a
639
+ * shared collection's store grows an `onSnapshot` watch, it stops being
640
+ * special here with no edit to this file. */
641
+ function cannotReportChanges(gen, collection) {
642
+ return require_discovery.storeFor(collection, gen.discoveryOpts).watch === void 0;
643
+ }
644
+ /** True when a schema declares behaviour that only a reconcile pass can
645
+ * produce: bells (`completionField`), date-triggered bells (`triggerField`),
646
+ * or recurrence successors (`spawn`). */
647
+ function needsReconcilePass(schema) {
648
+ return Boolean(schema.completionField ?? schema.triggerField ?? schema.spawn);
649
+ }
650
+ /** One unwatched collection's reconcile pass. Extracted so the single-flight
651
+ * callback doesn't close over loop state.
652
+ *
653
+ * `reconcileAllItems` already swallows a failing store read (it logs and
654
+ * returns), so a closed session or a denied rule surfaces there, not as a
655
+ * rejection here. This catch is only for the unexpected — it must not let one
656
+ * collection's fault abort the rest of the tick. */
657
+ async function reconcileUnwatched(gen, collection, now) {
658
+ try {
659
+ await runSingleFlight(gen.collectionSlots, collection.slug, () => reconcileAllItems(collection, gen.discoveryOpts, now));
660
+ } catch (err) {
661
+ log().warn("unwatched collection reconcile failed", {
662
+ slug: collection.slug,
663
+ error: errMsg(err)
664
+ });
665
+ }
666
+ }
667
+ /** Stand in for the store-change path (2) on backends that don't have one.
668
+ *
669
+ * Gated on `needsReconcilePass` so a collection declaring none of it costs
670
+ * nothing: unlike the local backends, every pass here is a network round trip.
671
+ *
672
+ * A collection that DROPS OUT of eligibility (its `completionField` edited
673
+ * away, or the collection removed) is not this pass's problem — `syncWatchers`
674
+ * already sweeps on a changed schema and on a vanished slug, and the sweep is
675
+ * what clears bells the declaration no longer justifies. */
676
+ async function tickUnwatchedCollections(gen, now) {
677
+ if (require_discovery.firestoreHandle() === null) return;
678
+ const pending = [...gen.watchers.values()].map((entry) => entry.collection).filter((collection) => cannotReportChanges(gen, collection) && needsReconcilePass(collection.schema));
679
+ if (pending.length === 0) return;
680
+ for (const collection of pending) await reconcileUnwatched(gen, collection, now);
681
+ await sweepStaleActiveEntries(gen.discoveryOpts);
597
682
  }
598
683
  /** Reconcile the watcher set against the currently-discovered
599
684
  * collections. Adds watchers for new slugs (with a boot reconcile of
@@ -634,7 +719,13 @@ function stopVanishedWatchers(gen, liveSlugs) {
634
719
  * modes. The mounted fs.watch is bound to the OLD location, so it must
635
720
  * be remounted, not just re-reconciled. */
636
721
  function storagePathChanged(previous, next) {
637
- return previous.dataSource?.path !== next.dataSource?.path || previous.dataPath !== next.dataPath || previous.storage?.path !== next.storage?.path;
722
+ return previous.dataSource?.path !== next.dataSource?.path || previous.dataPath !== next.dataPath || previous.storage?.type !== next.storage?.type || storageFilePath(previous.storage) !== storageFilePath(next.storage);
723
+ }
724
+ /** The on-disk path of a storage backend, or undefined when it has none (a
725
+ * shared collection keeps its records off this filesystem, so there is no
726
+ * mount to move — only a `type` change matters for it). */
727
+ function storageFilePath(storage) {
728
+ return storage?.type === "sqlite" ? storage.path : void 0;
638
729
  }
639
730
  /** Re-reconcile already-watched collections whose schema changed since
640
731
  * the last tick. New collections fall through to `startNewWatchers`. */