@mulmoclaude/core 1.0.0 → 1.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.
- package/assets/helps/collection-skills.md +28 -0
- package/assets/helps/custom-view.md +5 -0
- package/dist/artifacts/paths.cjs +68 -0
- package/dist/artifacts/paths.cjs.map +1 -0
- package/dist/artifacts/paths.d.ts +45 -0
- package/dist/artifacts/paths.js +62 -0
- package/dist/artifacts/paths.js.map +1 -0
- package/dist/atomic-C_7YpMiM.cjs +102 -0
- package/dist/atomic-C_7YpMiM.cjs.map +1 -0
- package/dist/atomic-DPpdrJzO.js +89 -0
- package/dist/atomic-DPpdrJzO.js.map +1 -0
- package/dist/collection/core/completion.d.ts +21 -0
- package/dist/collection/core/ids.d.ts +5 -0
- package/dist/collection/core/linkTargets.d.ts +14 -0
- package/dist/collection/core/ownProp.d.ts +9 -0
- package/dist/collection/core/recordKeys.d.ts +14 -0
- package/dist/collection/core/schemaRules.d.ts +167 -0
- package/dist/collection/core/schemaZ.d.ts +490 -10
- package/dist/collection/core/sortValueOf.d.ts +21 -0
- package/dist/collection/core/textSearch.d.ts +12 -0
- package/dist/collection/index.cjs +109 -29
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.d.ts +4 -0
- package/dist/collection/index.js +98 -29
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/server/index.cjs +14 -42
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +6 -34
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/discovery.d.ts +7 -0
- package/dist/collection/server/host.d.ts +3 -8
- package/dist/collection/server/index.cjs +10 -9
- package/dist/collection/server/index.d.ts +1 -0
- package/dist/collection/server/index.js +4 -4
- package/dist/collection/server/io.d.ts +0 -88
- package/dist/collection/server/skillAssets.d.ts +90 -0
- package/dist/collection-watchers/config.d.ts +2 -4
- package/dist/collection-watchers/index.cjs +5 -5
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +5 -5
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/{discovery-BbsJwVEq.js → discovery-DYlEOa9a.js} +427 -393
- package/dist/discovery-DYlEOa9a.js.map +1 -0
- package/dist/{discovery-Bklck7Ck.cjs → discovery-UxkvBxV-.cjs} +445 -435
- package/dist/discovery-UxkvBxV-.cjs.map +1 -0
- package/dist/dist-Cwk0e12G.js +50 -0
- package/dist/dist-Cwk0e12G.js.map +1 -0
- package/dist/dist-pWpC-b04.cjs +61 -0
- package/dist/dist-pWpC-b04.cjs.map +1 -0
- package/dist/feeds/index.cjs +2 -2
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/host.d.ts +5 -8
- package/dist/feeds/server/index.cjs +143 -61
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +141 -59
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/files/atomic.d.ts +34 -0
- package/dist/files/index.cjs +47 -0
- package/dist/files/index.cjs.map +1 -0
- package/dist/files/index.d.ts +3 -0
- package/dist/files/index.js +40 -0
- package/dist/files/index.js.map +1 -0
- package/dist/files/json.d.ts +4 -0
- package/dist/files/safe.d.ts +9 -0
- package/dist/google/auth.d.ts +1 -1
- package/dist/google/collectionDateTime.d.ts +5 -0
- package/dist/google/collectionSync.d.ts +2 -2
- package/dist/google/fsJson.d.ts +3 -2
- package/dist/google/host.d.ts +3 -6
- package/dist/google/index.cjs +64 -91
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +1 -0
- package/dist/google/index.js +57 -85
- package/dist/google/index.js.map +1 -1
- package/dist/host/hostSlot.d.ts +27 -0
- package/dist/{ids-DWmHjm17.cjs → ids-BJ7rdrDN.cjs} +17 -1
- package/dist/{ids-DWmHjm17.cjs.map → ids-BJ7rdrDN.cjs.map} +1 -1
- package/dist/{ids-D1M1T6KJ.js → ids-Bv6AOW9Z.js} +12 -2
- package/dist/{ids-D1M1T6KJ.js.map → ids-Bv6AOW9Z.js.map} +1 -1
- package/dist/{ingestTypes-DbuQNuK6.cjs → ingestTypes-CkbM-zrO.cjs} +2 -2
- package/dist/{ingestTypes-DbuQNuK6.cjs.map → ingestTypes-CkbM-zrO.cjs.map} +1 -1
- package/dist/{ingestTypes-Ci4eOgv-.js → ingestTypes-DuCiZqye.js} +2 -2
- package/dist/{ingestTypes-Ci4eOgv-.js.map → ingestTypes-DuCiZqye.js.map} +1 -1
- package/dist/notifier/engine.d.ts +2 -4
- package/dist/notifier/index.cjs +1 -1
- package/dist/notifier/index.js +1 -1
- package/dist/{notifier-ChpY0XrY.js → notifier-BdA5qzhe.js} +14 -16
- package/dist/notifier-BdA5qzhe.js.map +1 -0
- package/dist/{notifier-bS8IEeLA.cjs → notifier-tMsAXyXp.cjs} +14 -16
- package/dist/notifier-tMsAXyXp.cjs.map +1 -0
- package/dist/plugin-vue/fileWatch.d.ts +5 -0
- package/dist/plugin-vue/i18n.cjs +45 -0
- package/dist/plugin-vue/i18n.cjs.map +1 -0
- package/dist/plugin-vue/i18n.js +44 -0
- package/dist/plugin-vue/i18n.js.map +1 -0
- package/dist/plugin-vue/index.cjs +98 -0
- package/dist/plugin-vue/index.cjs.map +1 -0
- package/dist/plugin-vue/index.d.ts +5 -0
- package/dist/plugin-vue/index.js +91 -0
- package/dist/plugin-vue/index.js.map +1 -0
- package/dist/plugin-vue/markdownDoc.d.ts +10 -0
- package/dist/plugin-vue/pluginI18n.d.ts +22 -0
- package/dist/plugin-vue/useClipboardCopy.d.ts +6 -0
- package/dist/plugin-vue/useFileWatch.d.ts +8 -0
- package/dist/plugin-vue/useMarkdownDoc.d.ts +3 -0
- package/dist/{promptSafety-Bugq2kqL.js → promptSafety-BNelBKhh.js} +79 -7
- package/dist/promptSafety-BNelBKhh.js.map +1 -0
- package/dist/{promptSafety-DbE6eZmP.cjs → promptSafety-QAtADXUF.cjs} +108 -6
- package/dist/promptSafety-QAtADXUF.cjs.map +1 -0
- package/dist/remote-host/index.cjs.map +1 -1
- package/dist/remote-host/index.js.map +1 -1
- package/dist/remote-host/server/hostRunner.d.ts +2 -1
- package/dist/remote-host/server/index.cjs +6 -5
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +3 -2
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/remote-view/index.cjs +1 -1
- package/dist/remote-view/index.cjs.map +1 -1
- package/dist/remote-view/index.d.ts +1 -1
- package/dist/remote-view/index.js +1 -1
- package/dist/remote-view/index.js.map +1 -1
- package/dist/scheduler/index.cjs +3 -3
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.js +2 -2
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/task-manager.d.ts +2 -5
- package/dist/{server-8EZggEg7.js → server-CnwgECW-.js} +209 -41
- package/dist/server-CnwgECW-.js.map +1 -0
- package/dist/{server-BOiz_HDi.cjs → server-DFo3zFYB.cjs} +250 -46
- package/dist/server-DFo3zFYB.cjs.map +1 -0
- package/dist/skill-bridge/index.cjs +1 -1
- package/dist/skill-bridge/index.js +1 -1
- package/dist/translation/client.cjs +16 -0
- package/dist/translation/client.cjs.map +1 -1
- package/dist/translation/client.d.ts +6 -0
- package/dist/translation/client.js +16 -1
- package/dist/translation/client.js.map +1 -1
- package/dist/utils/errors.d.ts +2 -1
- package/dist/utils/fetch.cjs +36 -0
- package/dist/utils/fetch.cjs.map +1 -0
- package/dist/{collection/registry/server → utils}/fetch.d.ts +5 -3
- package/dist/utils/fetch.js +34 -0
- package/dist/utils/fetch.js.map +1 -0
- package/dist/utils/index.cjs +11 -3
- package/dist/utils/index.cjs.map +1 -0
- package/dist/utils/index.js +9 -1
- package/dist/utils/index.js.map +1 -0
- package/dist/whisper/index.cjs +9 -9
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/whisper/index.js +3 -3
- package/dist/wiki/index.cjs +3 -16
- package/dist/wiki/index.cjs.map +1 -1
- package/dist/wiki/index.js +1 -14
- package/dist/wiki/index.js.map +1 -1
- package/dist/wiki/render.d.ts +2 -4
- package/dist/wiki/server/frontmatter.d.ts +2 -6
- package/dist/wiki/server/index.cjs +4 -40
- package/dist/wiki/server/index.cjs.map +1 -1
- package/dist/wiki/server/index.js +2 -38
- package/dist/wiki/server/index.js.map +1 -1
- package/dist/workspace-setup/index.js +22 -6
- package/dist/workspace-setup/index.js.map +1 -1
- package/package.json +50 -8
- package/dist/collection/server/atomic.d.ts +0 -1
- package/dist/discovery-BbsJwVEq.js.map +0 -1
- package/dist/discovery-Bklck7Ck.cjs.map +0 -1
- package/dist/errors-7P5eMOSX.cjs +0 -30
- package/dist/errors-7P5eMOSX.cjs.map +0 -1
- package/dist/errors-eid6Mes3.js +0 -19
- package/dist/errors-eid6Mes3.js.map +0 -1
- package/dist/google/fetch.d.ts +0 -8
- package/dist/notifier-ChpY0XrY.js.map +0 -1
- package/dist/notifier-bS8IEeLA.cjs.map +0 -1
- package/dist/promptSafety-Bugq2kqL.js.map +0 -1
- package/dist/promptSafety-DbE6eZmP.cjs.map +0 -1
- package/dist/server-8EZggEg7.js.map +0 -1
- package/dist/server-BOiz_HDi.cjs.map +0 -1
|
@@ -109,6 +109,16 @@ function isSafeRecordId(value) {
|
|
|
109
109
|
if (typeof value !== "string" || !SAFE_RECORD_ID_PATTERN.test(value)) return false;
|
|
110
110
|
return !value.includes("..");
|
|
111
111
|
}
|
|
112
|
+
var DEFAULT_UNIQUE_ID_ATTEMPTS = 8;
|
|
113
|
+
/** Pick an id not already in `existing`, re-rolling `generate()` up to
|
|
114
|
+
* `maxAttempts` times before giving up and returning the last candidate.
|
|
115
|
+
* Collisions on a wide id space are astronomically unlikely, so a caller's
|
|
116
|
+
* own overwrite guard is the final backstop rather than an unbounded loop. */
|
|
117
|
+
function generateUniqueId(existing, generate, maxAttempts = DEFAULT_UNIQUE_ID_ATTEMPTS) {
|
|
118
|
+
let candidate = generate();
|
|
119
|
+
for (let attempt = 0; attempt < maxAttempts && existing.has(candidate); attempt++) candidate = generate();
|
|
120
|
+
return candidate;
|
|
121
|
+
}
|
|
112
122
|
//#endregion
|
|
113
123
|
Object.defineProperty(exports, "AGENT_INGEST_KIND", {
|
|
114
124
|
enumerable: true,
|
|
@@ -164,6 +174,12 @@ Object.defineProperty(exports, "fieldTextOrNull", {
|
|
|
164
174
|
return fieldTextOrNull;
|
|
165
175
|
}
|
|
166
176
|
});
|
|
177
|
+
Object.defineProperty(exports, "generateUniqueId", {
|
|
178
|
+
enumerable: true,
|
|
179
|
+
get: function() {
|
|
180
|
+
return generateUniqueId;
|
|
181
|
+
}
|
|
182
|
+
});
|
|
167
183
|
Object.defineProperty(exports, "isFieldDrivenEvery", {
|
|
168
184
|
enumerable: true,
|
|
169
185
|
get: function() {
|
|
@@ -195,4 +211,4 @@ Object.defineProperty(exports, "storageKindFor", {
|
|
|
195
211
|
}
|
|
196
212
|
});
|
|
197
213
|
|
|
198
|
-
//# sourceMappingURL=ids-
|
|
214
|
+
//# sourceMappingURL=ids-BJ7rdrDN.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ids-DWmHjm17.cjs","names":[],"sources":["../src/collection/core/fieldText.ts","../src/collection/core/schema.ts","../src/collection/core/ids.ts"],"sourcesContent":["// Turning a record field into text.\n//\n// `CollectionItem` is `Record<string, unknown>`, so a field holds whatever the\n// record's JSON had — including arrays and objects (real workspace data has\n// plenty: weather `hourly`, GeoJSON `geometry`, `sites` lists). Bare\n// `String(value)` on one of those yields `\"[object Object]\"`, which then gets\n// compared, matched or displayed as if it were a value. Nothing throws; a\n// predicate just silently stops matching, or the UI shows `[object Object]`.\n//\n// These two helpers are the only sanctioned way to read a field as text. The\n// rule they encode was already in `itemLabelOf`: accept primitives, let\n// everything else fall through to the caller's fallback.\n\n/** A field value that has a meaningful text form. Dates are included because\n * a JSON record can carry one once it has been revived. */\nconst isTextable = (value: unknown): value is string | number | boolean | Date =>\n typeof value === \"string\" || typeof value === \"number\" || typeof value === \"boolean\" || value instanceof Date;\n\n/** The field's text, or `null` when it has no meaningful one (absent, or an\n * array/object that would stringify to `\"[object Object]\"`).\n *\n * Returning `null` rather than `\"\"` keeps \"the field is empty\" distinct from\n * \"the field can't be text\" — a matcher must not treat an object-valued field\n * as an empty string and match `\"\"`. */\nexport function fieldTextOrNull(value: unknown): string | null {\n if (value === undefined || value === null) return null;\n if (!isTextable(value)) return null;\n if (value instanceof Date) {\n // `new Date(\"nonsense\")` is still `instanceof Date`, and `toISOString()`\n // throws `RangeError` on it. This helper sits on the match, sort and\n // display paths, so one unparseable date in one record would take the whole\n // render down — the loud version of the bug this module exists to prevent.\n return Number.isNaN(value.getTime()) ? null : value.toISOString();\n }\n return String(value);\n}\n\n/** The field's text, or `fallback` (default `\"\"`) when it has none. Use where\n * a string is required and an empty one is a safe stand-in — display, sort\n * keys, CSV cells. Where the distinction matters, use {@link fieldTextOrNull}. */\nexport function fieldText(value: unknown, fallback = \"\"): string {\n return fieldTextOrNull(value) ?? fallback;\n}\n","// Schema-driven collection types. A \"collection\" is a skill (under\n// .claude/skills/<slug>/) that also ships a sibling `schema.json`.\n// The host's <CollectionView> reads the schema + records and renders\n// a table/form; Claude reads SKILL.md and CRUDs the records as JSON\n// files.\n//\n// SINGLE SOURCE OF TRUTH: every type describing the schema.json contract is\n// derived (`z.infer`) from the zod definitions in `./schemaZ` — the shapes,\n// their doc comments, and the validation rules live THERE; this module only\n// re-derives the TypeScript names consumers import. The imports from\n// `./schemaZ` are type-only, so zod never reaches the browser bundle through\n// the isomorphic barrel; at runtime the dependency points the other way\n// (schemaZ imports this module's consts).\n//\n// Field specs are a DISCRIMINATED UNION on `type`: narrow with\n// `field.type === \"enum\"` (etc.) before reading a variant key like `values`,\n// `to`, `formula`, or `of`.\n\nimport { fieldText } from \"./fieldText\";\nimport type { z } from \"zod\";\nimport type {\n ActionSpecZ,\n CollectionSchemaZ,\n CustomViewZ,\n DataSourceZ,\n DynamicIconRuleZ,\n DynamicIconSourceZ,\n DynamicIconSpecZ,\n EveryFieldDrivenZ,\n EveryLiteralZ,\n EveryZ,\n FieldSpecZ,\n IngestZ,\n SpawnZ,\n StorageZ,\n SubFieldSpecZ,\n WhenZ,\n} from \"./schemaZ\";\n\n/** Minimal \"this collection is a feed\" descriptor carried on the schema.\n * Deliberately narrow — the canonical collection contract stays\n * independent of the host's feeds subsystem. The host's richer retrieval\n * spec (`IngestSpec` in `feeds/ingestTypes.ts`) is a subtype, so feed code\n * reads the extra fields by typing feed schemas with that subtype;\n * collection rendering only needs these three + the presence check. */\nexport interface CollectionIngest {\n kind: string;\n schedule: string;\n /** Optional time-of-day anchor for `schedule: \"daily\"` — the hour (0–23) to\n * refresh around (the host ticks hourly, so the run lands within that hour).\n * Ignored for non-daily schedules. Absent ⇒ elapsed-based daily (\"≥24 h since\n * the last run\"). NOTE: **UTC**, not local — compared via `getUTCHours()` for\n * an unambiguous, DST-free check (matching the rest of the scheduler), so\n * convert local times before writing (e.g. 07:00 JST → `atHour: 22`). */\n atHour?: number;\n /** Declarative retrievers (`rss`/`atom`/`http-json`) only — the host fetches\n * this URL on the schedule. Absent for `kind: \"agent\"`, where the agent owns\n * retrieval. */\n url?: string;\n /** `kind: \"agent\"` only: role id the scheduled hidden worker runs in. */\n role?: string;\n /** `kind: \"agent\"` only: skill-relative template path (under `templates/`)\n * whose prose tells the worker how to refresh the records. */\n template?: string;\n}\n\n/** Declarative retriever kinds a Feed's `ingest.kind` may declare. The host's\n * feeds engine dispatches on these; they live here (with the schema contract)\n * so the schema validator can enforce them. The host re-exports these from\n * `server/workspace/feeds/ingestTypes.ts`. */\nexport const INGEST_KINDS = [\"rss\", \"atom\", \"http-json\"] as const;\nexport type IngestKind = (typeof INGEST_KINDS)[number];\n\n/** The agent-performed ingest kind. Instead of a declarative fetch, the host\n * dispatches a hidden background chat (origin `system`) in `ingest.role`,\n * seeded with `ingest.template` + a summary of every record, on the\n * `ingest.schedule` cadence; the worker edits records via the collections io\n * layer. Kept separate from {@link INGEST_KINDS} (which the declarative\n * retriever registry keys on) so the schema validator can model `ingest` as a\n * discriminated union without the feeds engine gaining an \"agent\" retriever. */\nexport const AGENT_INGEST_KIND = \"agent\" as const;\nexport type AgentIngestKind = typeof AGENT_INGEST_KIND;\n\n/** Refresh cadences a Feed's `ingest.schedule` may declare. */\nexport const FEED_SCHEDULES = [\"hourly\", \"daily\", \"weekly\", \"on-demand\"] as const;\nexport type FeedSchedule = (typeof FEED_SCHEDULES)[number];\n\n// \"feed\" collections live in the non-skill `<workspace>/feeds/` registry\n// and carry an `ingest` block; they reuse the same storage + rendering\n// as skill-backed collections but are never loaded into the agent prompt.\nexport type CollectionSource = \"user\" | \"project\" | \"feed\";\n\n/** One field of a record — a discriminated union on `type`; see the variant\n * docs in `./schemaZ` (`FieldSpecZ`). */\nexport type CollectionFieldSpec = z.infer<typeof FieldSpecZ>;\n\n/** A `table` field's row sub-schema entry — the field union minus `table` /\n * `derived` / display-only types (see `SubFieldSpecZ`). */\nexport type CollectionSubFieldSpec = z.infer<typeof SubFieldSpecZ>;\n\nexport type CollectionFieldType = CollectionFieldSpec[\"type\"];\n\n/** The computed-boolean variant — a `where` predicate bound to a field\n * name; see `FlagFieldZ`. */\nexport type CollectionFlagField = Extract<CollectionFieldSpec, { type: \"flag\" }>;\n\n/** derived/embed/backlinks/rollup/toggle/flag are host-computed or\n * projected — never written to the record JSON, so required / value\n * checks and edit-draft slots must not apply to them. THE single source\n * for \"computed\" — lives here (zod-free at runtime) so browser code\n * (`./draft`) and the zod record compiler (`./recordZ`, which re-exports\n * it) share one set instead of drifting copies. */\nexport const COMPUTED_TYPES: ReadonlySet<CollectionFieldType> = new Set<CollectionFieldType>([\"derived\", \"embed\", \"backlinks\", \"rollup\", \"toggle\", \"flag\"]);\n\n/** Optional visibility predicate: the target (an action button or a\n * field) renders only when the open record's `field` (stringified) is\n * one of `in`. Generic and domain-free — the host evaluates it against\n * the record with no knowledge of what the field means. Absent ⇒\n * always shown. */\nexport type CollectionWhen = z.infer<typeof WhenZ>;\n\n/** @deprecated Name retained for back-compat; use {@link CollectionWhen}.\n * Both actions and fields share the same predicate shape. No in-repo\n * consumers, but the package is public API (MulmoTerminal). */\n// eslint-disable-next-line sonarjs/redundant-type-aliases -- deliberate deprecated back-compat export\nexport type CollectionActionWhen = CollectionWhen;\n\n/** A schema-declared, per-record action rendered as a button in the\n * read-only detail view. Pure UI/behaviour directive — never stored,\n * never validated against record data. All domain specifics (label,\n * role, template — or the declarative `set`) live in the schema / skill\n * folder, so the host stays generic. A discriminated union on `kind`;\n * see `ActionSpecZ`. */\nexport type CollectionAction = z.infer<typeof ActionSpecZ>;\n\n/** The kind of work an action kicks off: `\"chat\"` (visible LLM chat),\n * `\"agent\"` (hidden LLM worker), or `\"mutate\"` (declarative host write,\n * no LLM). */\nexport type CollectionActionKind = CollectionAction[\"kind\"];\n\n/** The LLM-seeded action variants (`role` + `template`). */\nexport type CollectionSeededAction = Extract<CollectionAction, { kind: \"chat\" | \"agent\" }>;\n\n/** The declarative host-write variant (`set` + optional `require`/`params`). */\nexport type CollectionMutateAction = Extract<CollectionAction, { kind: \"mutate\" }>;\n\n/** A custom (LLM-authored) HTML view for a collection. The host renders\n * `file` in a sandboxed iframe over the collection's records; the view\n * reaches its data only through a slug- and capability-scoped token (see\n * `server/api/auth/viewToken.ts`). Pure data — the host holds no\n * view-specific code; meaning lives in the HTML file + this registration.\n * See `CustomViewZ` for the per-key contracts. */\nexport type CollectionCustomView = z.infer<typeof CustomViewZ>;\n\n/** What a custom view's capability token is allowed to do against the\n * collection's data endpoint. `read` returns enriched records (getItems\n * semantics); `write` validates-and-stores rows (putItems semantics).\n * There is deliberately no `delete` — a view can never do more than the\n * agent's own `manageCollection` tool. */\nexport type CollectionViewCapability = NonNullable<CollectionCustomView[\"capabilities\"]>[number];\n\n/** How a `spawn` advances the source item's `triggerField` date to\n * produce the successor's. All arithmetic is done on the civil\n * (year, month, day) triple — never by adding milliseconds — so month\n * lengths and leap years are handled correctly. */\nexport type CollectionEvery = z.infer<typeof EveryLiteralZ>;\n\n/** Recurrence unit for a `spawn.every` advance. */\nexport type CollectionRecurUnit = CollectionEvery[\"unit\"];\n\n/** Field-driven recurrence: the advance interval is selected PER RECORD by\n * the value of an `enum` field (`fromField`), looked up in `map`. See\n * `EveryFieldDrivenZ`. */\nexport type CollectionEveryFieldDriven = z.infer<typeof EveryFieldDrivenZ>;\n\n/** The `every` of a `spawn`: either a single literal interval applied to\n * every record, or a per-record interval selected by an `enum` field. The\n * literal arm is what `advanceTriggerDate` consumes — the field-driven arm\n * is resolved down to one of its `map` values before the date math runs. */\nexport type CollectionSpawnEvery = z.infer<typeof EveryZ>;\n\n/** Narrowing guard: true when `every` is the field-driven arm. */\nexport function isFieldDrivenEvery(every: CollectionSpawnEvery): every is CollectionEveryFieldDriven {\n return \"fromField\" in every;\n}\n\n/** Host-driven recurrence. See `SpawnZ`. */\nexport type CollectionSpawn = z.infer<typeof SpawnZ>;\n\n/** One rule in a `dynamicIcon.rules` list: when the resolved source\n * record matches `where` (an AND of typed conditions, see `./where`),\n * the collection's effective launcher icon becomes `icon`. Evaluated top\n * to bottom — the first match wins. */\nexport type DynamicIconRule = z.infer<typeof DynamicIconRuleZ>;\n\n/** Where a {@link DynamicIconSpec}'s source record comes from: a (possibly\n * cross-collection) pool of records, optionally narrowed by `where` and\n * reduced to a single record by `from`. */\nexport type DynamicIconSource = z.infer<typeof DynamicIconSourceZ>;\n\n/** Declarative \"data state → icon\" mapping for a collection's launcher\n * shortcut icon (see `CollectionSchema.dynamicIcon`). When absent, the\n * launcher icon is the static `schema.icon`. */\nexport type DynamicIconSpec = z.infer<typeof DynamicIconSpecZ>;\n\n/** The `ingest` block as the schema validator accepts it — a discriminated\n * union on `kind` (declarative retrievers | agent worker). The feeds\n * subsystem's `IngestSpec` is the same union under its historical name. */\nexport type CollectionIngestSpec = z.infer<typeof IngestZ>;\n\n/** The `dataSource` block: this collection's records are the rows of an\n * external read-only data file (v1: CSV). See `DataSourceZ`. */\nexport type CollectionDataSource = z.infer<typeof DataSourceZ>;\n\n/** The `storage` block: an alternative WRITABLE record backend (v1:\n * sqlite). See `StorageZ`. */\nexport type CollectionStorage = z.infer<typeof StorageZ>;\n\n/** Every storage backend a schema can select. `file` is the implicit\n * default (`dataPath`); `csv` is implied by `dataSource`; other kinds are\n * named explicitly via `storage.type`. The server's store factory registry\n * (`server/store.ts`) is keyed by this. */\nexport type CollectionStorageKind = \"file\" | \"csv\" | \"sqlite\";\n\n/** Which storage backend serves this schema's records. Derived, not stored:\n * existing schemas carry no `storage` key and must keep resolving exactly\n * as before (`dataSource` ⇒ csv, else file). */\nexport function storageKindFor(schema: Pick<CollectionSchema, \"dataSource\" | \"storage\">): CollectionStorageKind {\n if (schema.dataSource !== undefined) return \"csv\";\n return schema.storage?.type ?? \"file\";\n}\n\n/** The whole `schema.json` contract. Key-level docs live on\n * `CollectionSchemaZ` in `./schemaZ`. */\nexport type CollectionSchema = z.infer<typeof CollectionSchemaZ>;\n\n/** True when `schema` declares an external `dataSource` — i.e. the\n * collection is READ-ONLY through every UI/tool write path (updates\n * happen by editing/replacing the data file itself). Isomorphic: both\n * the server write guards and the client's control hiding key off this\n * one predicate. */\nexport function isReadOnlySchema(schema: Pick<CollectionSchema, \"dataSource\">): boolean {\n return schema.dataSource !== undefined;\n}\n\nexport interface CollectionSummary {\n slug: string;\n title: string;\n icon: string;\n source: CollectionSource;\n /** Present (true) when the collection is backed by an external\n * `dataSource` and therefore read-only in every UI/tool write path.\n * Absent-when-writable, matching the other optional summary flags. */\n readonly?: true;\n /** Slugs of the source collection(s) a `dynamicIcon` icon was computed\n * from — present only when `schema.dynamicIcon` is set. Lets a client\n * know which collection change-channel(s) to watch for a live icon\n * update (see `useDynamicShortcutIcons`). */\n iconSources?: string[];\n}\n\nexport interface CollectionDetail extends CollectionSummary {\n schema: CollectionSchema;\n}\n\nexport type CollectionItem = Record<string, unknown>;\n\n/** Resolve an `embed` field's target record id: the fixed `id`, or the value\n * of the sibling `idField` on this record (empty string when neither applies\n * — the caller renders that as \"no record\"). Pure + isomorphic so the server\n * projection (`derive.ts`) and the client preview (`useCollectionRendering`)\n * resolve embeds identically. Non-`embed` fields resolve to \"no record\". */\nexport function embedTargetId(field: CollectionFieldSpec, record: CollectionItem | null): string {\n if (field.type !== \"embed\") return \"\";\n if (field.id) return field.id;\n if (field.idField && record) return fieldText(record[field.idField]);\n return \"\";\n}\n","// Pure slug / record-id character rules. Shared by the isomorphic schema\n// validator (`./schemaZ`) — which must stay node-free — and the server-side\n// path sanitisers (`../server/paths`), which wrap these patterns with the\n// `path.basename` round-trip CodeQL recognises as a `js/path-injection`\n// sanitiser. Both layers MUST gate on the same patterns; importing them from\n// here is what keeps them in sync.\n\n// The ONE slug pattern — `server/workspace/skills/catalog.ts` imports it\n// for its own sanitiser, so there is no second copy to keep in sync.\n// Bounded character classes, no nested quantifiers; ReDoS-safe.\n// eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping character classes, no catastrophic backtracking\nexport const SAFE_SLUG_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;\n\n// Record ids are a superset of slugs: they're only ever filename stems\n// (`<id>.json`), never directory names or URL segments, so they may carry\n// dots — natural keys like a Slack ts (`1718900000.123456`), a SemVer\n// (`1.2.3`), or a decimal timestamp. The interior class adds `.` to the slug\n// set; the explicit `..` reject in `isSafeRecordId` keeps a\n// parent-dir-looking segment out while still allowing repeated `-`/`_`\n// (`a--b`, `a__b`). Start/end stay alphanumeric so leading/trailing dots\n// (hidden files, the special `.`/`..` names) and `..`-only ids are all\n// excluded.\n// eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping character classes, no catastrophic backtracking\nexport const SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;\n\n/** True when `value` is a well-formed collection slug (alphanumeric /\n * hyphen / underscore, no path separators). The pattern admits no `/`,\n * `\\`, or `.`, so a passing value is trivially also a safe basename —\n * validation callers need no `path.basename` round-trip (path-building\n * callers use `../server/paths#safeSlugName`, which adds it). */\nexport function isSafeSlug(value: string): boolean {\n return typeof value === \"string\" && SAFE_SLUG_PATTERN.test(value);\n}\n\n/** True when `value` is a well-formed record id (slug charset plus interior\n * dots), with any `..` substring rejected explicitly. Validation-only\n * counterpart of `../server/paths#safeRecordId`. */\nexport function isSafeRecordId(value: string): boolean {\n if (typeof value !== \"string\" || !SAFE_RECORD_ID_PATTERN.test(value)) return false;\n return !value.includes(\"..\");\n}\n"],"mappings":";;;AAeA,IAAM,cAAc,UAClB,OAAO,UAAU,YAAY,OAAO,UAAU,YAAY,OAAO,UAAU,aAAa,iBAAiB;;;;;;;AAQ3G,SAAgB,gBAAgB,OAA+B;CAC7D,IAAI,UAAU,KAAA,KAAa,UAAU,MAAM,OAAO;CAClD,IAAI,CAAC,WAAW,KAAK,GAAG,OAAO;CAC/B,IAAI,iBAAiB,MAKnB,OAAO,OAAO,MAAM,MAAM,QAAQ,CAAC,IAAI,OAAO,MAAM,YAAY;CAElE,OAAO,OAAO,KAAK;AACrB;;;;AAKA,SAAgB,UAAU,OAAgB,WAAW,IAAY;CAC/D,OAAO,gBAAgB,KAAK,KAAK;AACnC;;;;;;;AC4BA,IAAa,eAAe;CAAC;CAAO;CAAQ;AAAW;;;;;;;;AAUvD,IAAa,oBAAoB;;AAIjC,IAAa,iBAAiB;CAAC;CAAU;CAAS;CAAU;AAAW;;;;;;;AA4BvE,IAAa,iCAAmD,IAAI,IAAyB;CAAC;CAAW;CAAS;CAAa;CAAU;CAAU;AAAM,CAAC;;AAsE1J,SAAgB,mBAAmB,OAAkE;CACnG,OAAO,eAAe;AACxB;;;;AA2CA,SAAgB,eAAe,QAAiF;CAC9G,IAAI,OAAO,eAAe,KAAA,GAAW,OAAO;CAC5C,OAAO,OAAO,SAAS,QAAQ;AACjC;;;;;;AAWA,SAAgB,iBAAiB,QAAuD;CACtF,OAAO,OAAO,eAAe,KAAA;AAC/B;;;;;;AA6BA,SAAgB,cAAc,OAA4B,QAAuC;CAC/F,IAAI,MAAM,SAAS,SAAS,OAAO;CACnC,IAAI,MAAM,IAAI,OAAO,MAAM;CAC3B,IAAI,MAAM,WAAW,QAAQ,OAAO,UAAU,OAAO,MAAM,QAAQ;CACnE,OAAO;AACT;;;AC1QA,IAAa,oBAAoB;AAYjC,IAAa,yBAAyB;;;;;;AAOtC,SAAgB,WAAW,OAAwB;CACjD,OAAO,OAAO,UAAU,YAAY,kBAAkB,KAAK,KAAK;AAClE;;;;AAKA,SAAgB,eAAe,OAAwB;CACrD,IAAI,OAAO,UAAU,YAAY,CAAC,uBAAuB,KAAK,KAAK,GAAG,OAAO;CAC7E,OAAO,CAAC,MAAM,SAAS,IAAI;AAC7B"}
|
|
1
|
+
{"version":3,"file":"ids-BJ7rdrDN.cjs","names":[],"sources":["../src/collection/core/fieldText.ts","../src/collection/core/schema.ts","../src/collection/core/ids.ts"],"sourcesContent":["// Turning a record field into text.\n//\n// `CollectionItem` is `Record<string, unknown>`, so a field holds whatever the\n// record's JSON had — including arrays and objects (real workspace data has\n// plenty: weather `hourly`, GeoJSON `geometry`, `sites` lists). Bare\n// `String(value)` on one of those yields `\"[object Object]\"`, which then gets\n// compared, matched or displayed as if it were a value. Nothing throws; a\n// predicate just silently stops matching, or the UI shows `[object Object]`.\n//\n// These two helpers are the only sanctioned way to read a field as text. The\n// rule they encode was already in `itemLabelOf`: accept primitives, let\n// everything else fall through to the caller's fallback.\n\n/** A field value that has a meaningful text form. Dates are included because\n * a JSON record can carry one once it has been revived. */\nconst isTextable = (value: unknown): value is string | number | boolean | Date =>\n typeof value === \"string\" || typeof value === \"number\" || typeof value === \"boolean\" || value instanceof Date;\n\n/** The field's text, or `null` when it has no meaningful one (absent, or an\n * array/object that would stringify to `\"[object Object]\"`).\n *\n * Returning `null` rather than `\"\"` keeps \"the field is empty\" distinct from\n * \"the field can't be text\" — a matcher must not treat an object-valued field\n * as an empty string and match `\"\"`. */\nexport function fieldTextOrNull(value: unknown): string | null {\n if (value === undefined || value === null) return null;\n if (!isTextable(value)) return null;\n if (value instanceof Date) {\n // `new Date(\"nonsense\")` is still `instanceof Date`, and `toISOString()`\n // throws `RangeError` on it. This helper sits on the match, sort and\n // display paths, so one unparseable date in one record would take the whole\n // render down — the loud version of the bug this module exists to prevent.\n return Number.isNaN(value.getTime()) ? null : value.toISOString();\n }\n return String(value);\n}\n\n/** The field's text, or `fallback` (default `\"\"`) when it has none. Use where\n * a string is required and an empty one is a safe stand-in — display, sort\n * keys, CSV cells. Where the distinction matters, use {@link fieldTextOrNull}. */\nexport function fieldText(value: unknown, fallback = \"\"): string {\n return fieldTextOrNull(value) ?? fallback;\n}\n","// Schema-driven collection types. A \"collection\" is a skill (under\n// .claude/skills/<slug>/) that also ships a sibling `schema.json`.\n// The host's <CollectionView> reads the schema + records and renders\n// a table/form; Claude reads SKILL.md and CRUDs the records as JSON\n// files.\n//\n// SINGLE SOURCE OF TRUTH: every type describing the schema.json contract is\n// derived (`z.infer`) from the zod definitions in `./schemaZ` — the shapes,\n// their doc comments, and the validation rules live THERE; this module only\n// re-derives the TypeScript names consumers import. The imports from\n// `./schemaZ` are type-only, so zod never reaches the browser bundle through\n// the isomorphic barrel; at runtime the dependency points the other way\n// (schemaZ imports this module's consts).\n//\n// Field specs are a DISCRIMINATED UNION on `type`: narrow with\n// `field.type === \"enum\"` (etc.) before reading a variant key like `values`,\n// `to`, `formula`, or `of`.\n\nimport { fieldText } from \"./fieldText\";\nimport type { z } from \"zod\";\nimport type {\n ActionSpecZ,\n CollectionSchemaZ,\n CustomViewZ,\n DataSourceZ,\n DynamicIconRuleZ,\n DynamicIconSourceZ,\n DynamicIconSpecZ,\n EveryFieldDrivenZ,\n EveryLiteralZ,\n EveryZ,\n FieldSpecZ,\n IngestZ,\n SpawnZ,\n StorageZ,\n SubFieldSpecZ,\n WhenZ,\n} from \"./schemaZ\";\n\n/** Minimal \"this collection is a feed\" descriptor carried on the schema.\n * Deliberately narrow — the canonical collection contract stays\n * independent of the host's feeds subsystem. The host's richer retrieval\n * spec (`IngestSpec` in `feeds/ingestTypes.ts`) is a subtype, so feed code\n * reads the extra fields by typing feed schemas with that subtype;\n * collection rendering only needs these three + the presence check. */\nexport interface CollectionIngest {\n kind: string;\n schedule: string;\n /** Optional time-of-day anchor for `schedule: \"daily\"` — the hour (0–23) to\n * refresh around (the host ticks hourly, so the run lands within that hour).\n * Ignored for non-daily schedules. Absent ⇒ elapsed-based daily (\"≥24 h since\n * the last run\"). NOTE: **UTC**, not local — compared via `getUTCHours()` for\n * an unambiguous, DST-free check (matching the rest of the scheduler), so\n * convert local times before writing (e.g. 07:00 JST → `atHour: 22`). */\n atHour?: number;\n /** Declarative retrievers (`rss`/`atom`/`http-json`) only — the host fetches\n * this URL on the schedule. Absent for `kind: \"agent\"`, where the agent owns\n * retrieval. */\n url?: string;\n /** `kind: \"agent\"` only: role id the scheduled hidden worker runs in. */\n role?: string;\n /** `kind: \"agent\"` only: skill-relative template path (under `templates/`)\n * whose prose tells the worker how to refresh the records. */\n template?: string;\n}\n\n/** Declarative retriever kinds a Feed's `ingest.kind` may declare. The host's\n * feeds engine dispatches on these; they live here (with the schema contract)\n * so the schema validator can enforce them. The host re-exports these from\n * `server/workspace/feeds/ingestTypes.ts`. */\nexport const INGEST_KINDS = [\"rss\", \"atom\", \"http-json\"] as const;\nexport type IngestKind = (typeof INGEST_KINDS)[number];\n\n/** The agent-performed ingest kind. Instead of a declarative fetch, the host\n * dispatches a hidden background chat (origin `system`) in `ingest.role`,\n * seeded with `ingest.template` + a summary of every record, on the\n * `ingest.schedule` cadence; the worker edits records via the collections io\n * layer. Kept separate from {@link INGEST_KINDS} (which the declarative\n * retriever registry keys on) so the schema validator can model `ingest` as a\n * discriminated union without the feeds engine gaining an \"agent\" retriever. */\nexport const AGENT_INGEST_KIND = \"agent\" as const;\nexport type AgentIngestKind = typeof AGENT_INGEST_KIND;\n\n/** Refresh cadences a Feed's `ingest.schedule` may declare. */\nexport const FEED_SCHEDULES = [\"hourly\", \"daily\", \"weekly\", \"on-demand\"] as const;\nexport type FeedSchedule = (typeof FEED_SCHEDULES)[number];\n\n// \"feed\" collections live in the non-skill `<workspace>/feeds/` registry\n// and carry an `ingest` block; they reuse the same storage + rendering\n// as skill-backed collections but are never loaded into the agent prompt.\nexport type CollectionSource = \"user\" | \"project\" | \"feed\";\n\n/** One field of a record — a discriminated union on `type`; see the variant\n * docs in `./schemaZ` (`FieldSpecZ`). */\nexport type CollectionFieldSpec = z.infer<typeof FieldSpecZ>;\n\n/** A `table` field's row sub-schema entry — the field union minus `table` /\n * `derived` / display-only types (see `SubFieldSpecZ`). */\nexport type CollectionSubFieldSpec = z.infer<typeof SubFieldSpecZ>;\n\nexport type CollectionFieldType = CollectionFieldSpec[\"type\"];\n\n/** The computed-boolean variant — a `where` predicate bound to a field\n * name; see `FlagFieldZ`. */\nexport type CollectionFlagField = Extract<CollectionFieldSpec, { type: \"flag\" }>;\n\n/** derived/embed/backlinks/rollup/toggle/flag are host-computed or\n * projected — never written to the record JSON, so required / value\n * checks and edit-draft slots must not apply to them. THE single source\n * for \"computed\" — lives here (zod-free at runtime) so browser code\n * (`./draft`) and the zod record compiler (`./recordZ`, which re-exports\n * it) share one set instead of drifting copies. */\nexport const COMPUTED_TYPES: ReadonlySet<CollectionFieldType> = new Set<CollectionFieldType>([\"derived\", \"embed\", \"backlinks\", \"rollup\", \"toggle\", \"flag\"]);\n\n/** Optional visibility predicate: the target (an action button or a\n * field) renders only when the open record's `field` (stringified) is\n * one of `in`. Generic and domain-free — the host evaluates it against\n * the record with no knowledge of what the field means. Absent ⇒\n * always shown. */\nexport type CollectionWhen = z.infer<typeof WhenZ>;\n\n/** @deprecated Name retained for back-compat; use {@link CollectionWhen}.\n * Both actions and fields share the same predicate shape. No in-repo\n * consumers, but the package is public API (MulmoTerminal). */\n// eslint-disable-next-line sonarjs/redundant-type-aliases -- deliberate deprecated back-compat export\nexport type CollectionActionWhen = CollectionWhen;\n\n/** A schema-declared, per-record action rendered as a button in the\n * read-only detail view. Pure UI/behaviour directive — never stored,\n * never validated against record data. All domain specifics (label,\n * role, template — or the declarative `set`) live in the schema / skill\n * folder, so the host stays generic. A discriminated union on `kind`;\n * see `ActionSpecZ`. */\nexport type CollectionAction = z.infer<typeof ActionSpecZ>;\n\n/** The kind of work an action kicks off: `\"chat\"` (visible LLM chat),\n * `\"agent\"` (hidden LLM worker), or `\"mutate\"` (declarative host write,\n * no LLM). */\nexport type CollectionActionKind = CollectionAction[\"kind\"];\n\n/** The LLM-seeded action variants (`role` + `template`). */\nexport type CollectionSeededAction = Extract<CollectionAction, { kind: \"chat\" | \"agent\" }>;\n\n/** The declarative host-write variant (`set` + optional `require`/`params`). */\nexport type CollectionMutateAction = Extract<CollectionAction, { kind: \"mutate\" }>;\n\n/** A custom (LLM-authored) HTML view for a collection. The host renders\n * `file` in a sandboxed iframe over the collection's records; the view\n * reaches its data only through a slug- and capability-scoped token (see\n * `server/api/auth/viewToken.ts`). Pure data — the host holds no\n * view-specific code; meaning lives in the HTML file + this registration.\n * See `CustomViewZ` for the per-key contracts. */\nexport type CollectionCustomView = z.infer<typeof CustomViewZ>;\n\n/** What a custom view's capability token is allowed to do against the\n * collection's data endpoint. `read` returns enriched records (getItems\n * semantics); `write` validates-and-stores rows (putItems semantics).\n * There is deliberately no `delete` — a view can never do more than the\n * agent's own `manageCollection` tool. */\nexport type CollectionViewCapability = NonNullable<CollectionCustomView[\"capabilities\"]>[number];\n\n/** How a `spawn` advances the source item's `triggerField` date to\n * produce the successor's. All arithmetic is done on the civil\n * (year, month, day) triple — never by adding milliseconds — so month\n * lengths and leap years are handled correctly. */\nexport type CollectionEvery = z.infer<typeof EveryLiteralZ>;\n\n/** Recurrence unit for a `spawn.every` advance. */\nexport type CollectionRecurUnit = CollectionEvery[\"unit\"];\n\n/** Field-driven recurrence: the advance interval is selected PER RECORD by\n * the value of an `enum` field (`fromField`), looked up in `map`. See\n * `EveryFieldDrivenZ`. */\nexport type CollectionEveryFieldDriven = z.infer<typeof EveryFieldDrivenZ>;\n\n/** The `every` of a `spawn`: either a single literal interval applied to\n * every record, or a per-record interval selected by an `enum` field. The\n * literal arm is what `advanceTriggerDate` consumes — the field-driven arm\n * is resolved down to one of its `map` values before the date math runs. */\nexport type CollectionSpawnEvery = z.infer<typeof EveryZ>;\n\n/** Narrowing guard: true when `every` is the field-driven arm. */\nexport function isFieldDrivenEvery(every: CollectionSpawnEvery): every is CollectionEveryFieldDriven {\n return \"fromField\" in every;\n}\n\n/** Host-driven recurrence. See `SpawnZ`. */\nexport type CollectionSpawn = z.infer<typeof SpawnZ>;\n\n/** One rule in a `dynamicIcon.rules` list: when the resolved source\n * record matches `where` (an AND of typed conditions, see `./where`),\n * the collection's effective launcher icon becomes `icon`. Evaluated top\n * to bottom — the first match wins. */\nexport type DynamicIconRule = z.infer<typeof DynamicIconRuleZ>;\n\n/** Where a {@link DynamicIconSpec}'s source record comes from: a (possibly\n * cross-collection) pool of records, optionally narrowed by `where` and\n * reduced to a single record by `from`. */\nexport type DynamicIconSource = z.infer<typeof DynamicIconSourceZ>;\n\n/** Declarative \"data state → icon\" mapping for a collection's launcher\n * shortcut icon (see `CollectionSchema.dynamicIcon`). When absent, the\n * launcher icon is the static `schema.icon`. */\nexport type DynamicIconSpec = z.infer<typeof DynamicIconSpecZ>;\n\n/** The `ingest` block as the schema validator accepts it — a discriminated\n * union on `kind` (declarative retrievers | agent worker). The feeds\n * subsystem's `IngestSpec` is the same union under its historical name. */\nexport type CollectionIngestSpec = z.infer<typeof IngestZ>;\n\n/** The `dataSource` block: this collection's records are the rows of an\n * external read-only data file (v1: CSV). See `DataSourceZ`. */\nexport type CollectionDataSource = z.infer<typeof DataSourceZ>;\n\n/** The `storage` block: an alternative WRITABLE record backend (v1:\n * sqlite). See `StorageZ`. */\nexport type CollectionStorage = z.infer<typeof StorageZ>;\n\n/** Every storage backend a schema can select. `file` is the implicit\n * default (`dataPath`); `csv` is implied by `dataSource`; other kinds are\n * named explicitly via `storage.type`. The server's store factory registry\n * (`server/store.ts`) is keyed by this. */\nexport type CollectionStorageKind = \"file\" | \"csv\" | \"sqlite\";\n\n/** Which storage backend serves this schema's records. Derived, not stored:\n * existing schemas carry no `storage` key and must keep resolving exactly\n * as before (`dataSource` ⇒ csv, else file). */\nexport function storageKindFor(schema: Pick<CollectionSchema, \"dataSource\" | \"storage\">): CollectionStorageKind {\n if (schema.dataSource !== undefined) return \"csv\";\n return schema.storage?.type ?? \"file\";\n}\n\n/** The whole `schema.json` contract. Key-level docs live on\n * `CollectionSchemaZ` in `./schemaZ`. */\nexport type CollectionSchema = z.infer<typeof CollectionSchemaZ>;\n\n/** True when `schema` declares an external `dataSource` — i.e. the\n * collection is READ-ONLY through every UI/tool write path (updates\n * happen by editing/replacing the data file itself). Isomorphic: both\n * the server write guards and the client's control hiding key off this\n * one predicate. */\nexport function isReadOnlySchema(schema: Pick<CollectionSchema, \"dataSource\">): boolean {\n return schema.dataSource !== undefined;\n}\n\nexport interface CollectionSummary {\n slug: string;\n title: string;\n icon: string;\n source: CollectionSource;\n /** Present (true) when the collection is backed by an external\n * `dataSource` and therefore read-only in every UI/tool write path.\n * Absent-when-writable, matching the other optional summary flags. */\n readonly?: true;\n /** Slugs of the source collection(s) a `dynamicIcon` icon was computed\n * from — present only when `schema.dynamicIcon` is set. Lets a client\n * know which collection change-channel(s) to watch for a live icon\n * update (see `useDynamicShortcutIcons`). */\n iconSources?: string[];\n}\n\nexport interface CollectionDetail extends CollectionSummary {\n schema: CollectionSchema;\n}\n\nexport type CollectionItem = Record<string, unknown>;\n\n/** Resolve an `embed` field's target record id: the fixed `id`, or the value\n * of the sibling `idField` on this record (empty string when neither applies\n * — the caller renders that as \"no record\"). Pure + isomorphic so the server\n * projection (`derive.ts`) and the client preview (`useCollectionRendering`)\n * resolve embeds identically. Non-`embed` fields resolve to \"no record\". */\nexport function embedTargetId(field: CollectionFieldSpec, record: CollectionItem | null): string {\n if (field.type !== \"embed\") return \"\";\n if (field.id) return field.id;\n if (field.idField && record) return fieldText(record[field.idField]);\n return \"\";\n}\n","// Pure slug / record-id character rules. Shared by the isomorphic schema\n// validator (`./schemaZ`) — which must stay node-free — and the server-side\n// path sanitisers (`../server/paths`), which wrap these patterns with the\n// `path.basename` round-trip CodeQL recognises as a `js/path-injection`\n// sanitiser. Both layers MUST gate on the same patterns; importing them from\n// here is what keeps them in sync.\n\n// The ONE slug pattern — `server/workspace/skills/catalog.ts` imports it\n// for its own sanitiser, so there is no second copy to keep in sync.\n// Bounded character classes, no nested quantifiers; ReDoS-safe.\n// eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping character classes, no catastrophic backtracking\nexport const SAFE_SLUG_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;\n\n// Record ids are a superset of slugs: they're only ever filename stems\n// (`<id>.json`), never directory names or URL segments, so they may carry\n// dots — natural keys like a Slack ts (`1718900000.123456`), a SemVer\n// (`1.2.3`), or a decimal timestamp. The interior class adds `.` to the slug\n// set; the explicit `..` reject in `isSafeRecordId` keeps a\n// parent-dir-looking segment out while still allowing repeated `-`/`_`\n// (`a--b`, `a__b`). Start/end stay alphanumeric so leading/trailing dots\n// (hidden files, the special `.`/`..` names) and `..`-only ids are all\n// excluded.\n// eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping character classes, no catastrophic backtracking\nexport const SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;\n\n/** True when `value` is a well-formed collection slug (alphanumeric /\n * hyphen / underscore, no path separators). The pattern admits no `/`,\n * `\\`, or `.`, so a passing value is trivially also a safe basename —\n * validation callers need no `path.basename` round-trip (path-building\n * callers use `../server/paths#safeSlugName`, which adds it). */\nexport function isSafeSlug(value: string): boolean {\n return typeof value === \"string\" && SAFE_SLUG_PATTERN.test(value);\n}\n\n/** True when `value` is a well-formed record id (slug charset plus interior\n * dots), with any `..` substring rejected explicitly. Validation-only\n * counterpart of `../server/paths#safeRecordId`. */\nexport function isSafeRecordId(value: string): boolean {\n if (typeof value !== \"string\" || !SAFE_RECORD_ID_PATTERN.test(value)) return false;\n return !value.includes(\"..\");\n}\n\nconst DEFAULT_UNIQUE_ID_ATTEMPTS = 8;\n\n/** Pick an id not already in `existing`, re-rolling `generate()` up to\n * `maxAttempts` times before giving up and returning the last candidate.\n * Collisions on a wide id space are astronomically unlikely, so a caller's\n * own overwrite guard is the final backstop rather than an unbounded loop. */\nexport function generateUniqueId(existing: ReadonlySet<string>, generate: () => string, maxAttempts: number = DEFAULT_UNIQUE_ID_ATTEMPTS): string {\n let candidate = generate();\n for (let attempt = 0; attempt < maxAttempts && existing.has(candidate); attempt++) {\n candidate = generate();\n }\n return candidate;\n}\n"],"mappings":";;;AAeA,IAAM,cAAc,UAClB,OAAO,UAAU,YAAY,OAAO,UAAU,YAAY,OAAO,UAAU,aAAa,iBAAiB;;;;;;;AAQ3G,SAAgB,gBAAgB,OAA+B;CAC7D,IAAI,UAAU,KAAA,KAAa,UAAU,MAAM,OAAO;CAClD,IAAI,CAAC,WAAW,KAAK,GAAG,OAAO;CAC/B,IAAI,iBAAiB,MAKnB,OAAO,OAAO,MAAM,MAAM,QAAQ,CAAC,IAAI,OAAO,MAAM,YAAY;CAElE,OAAO,OAAO,KAAK;AACrB;;;;AAKA,SAAgB,UAAU,OAAgB,WAAW,IAAY;CAC/D,OAAO,gBAAgB,KAAK,KAAK;AACnC;;;;;;;AC4BA,IAAa,eAAe;CAAC;CAAO;CAAQ;AAAW;;;;;;;;AAUvD,IAAa,oBAAoB;;AAIjC,IAAa,iBAAiB;CAAC;CAAU;CAAS;CAAU;AAAW;;;;;;;AA4BvE,IAAa,iCAAmD,IAAI,IAAyB;CAAC;CAAW;CAAS;CAAa;CAAU;CAAU;AAAM,CAAC;;AAsE1J,SAAgB,mBAAmB,OAAkE;CACnG,OAAO,eAAe;AACxB;;;;AA2CA,SAAgB,eAAe,QAAiF;CAC9G,IAAI,OAAO,eAAe,KAAA,GAAW,OAAO;CAC5C,OAAO,OAAO,SAAS,QAAQ;AACjC;;;;;;AAWA,SAAgB,iBAAiB,QAAuD;CACtF,OAAO,OAAO,eAAe,KAAA;AAC/B;;;;;;AA6BA,SAAgB,cAAc,OAA4B,QAAuC;CAC/F,IAAI,MAAM,SAAS,SAAS,OAAO;CACnC,IAAI,MAAM,IAAI,OAAO,MAAM;CAC3B,IAAI,MAAM,WAAW,QAAQ,OAAO,UAAU,OAAO,MAAM,QAAQ;CACnE,OAAO;AACT;;;AC1QA,IAAa,oBAAoB;AAYjC,IAAa,yBAAyB;;;;;;AAOtC,SAAgB,WAAW,OAAwB;CACjD,OAAO,OAAO,UAAU,YAAY,kBAAkB,KAAK,KAAK;AAClE;;;;AAKA,SAAgB,eAAe,OAAwB;CACrD,IAAI,OAAO,UAAU,YAAY,CAAC,uBAAuB,KAAK,KAAK,GAAG,OAAO;CAC7E,OAAO,CAAC,MAAM,SAAS,IAAI;AAC7B;AAEA,IAAM,6BAA6B;;;;;AAMnC,SAAgB,iBAAiB,UAA+B,UAAwB,cAAsB,4BAAoC;CAChJ,IAAI,YAAY,SAAS;CACzB,KAAK,IAAI,UAAU,GAAG,UAAU,eAAe,SAAS,IAAI,SAAS,GAAG,WACtE,YAAY,SAAS;CAEvB,OAAO;AACT"}
|
|
@@ -109,7 +109,17 @@ function isSafeRecordId(value) {
|
|
|
109
109
|
if (typeof value !== "string" || !SAFE_RECORD_ID_PATTERN.test(value)) return false;
|
|
110
110
|
return !value.includes("..");
|
|
111
111
|
}
|
|
112
|
+
var DEFAULT_UNIQUE_ID_ATTEMPTS = 8;
|
|
113
|
+
/** Pick an id not already in `existing`, re-rolling `generate()` up to
|
|
114
|
+
* `maxAttempts` times before giving up and returning the last candidate.
|
|
115
|
+
* Collisions on a wide id space are astronomically unlikely, so a caller's
|
|
116
|
+
* own overwrite guard is the final backstop rather than an unbounded loop. */
|
|
117
|
+
function generateUniqueId(existing, generate, maxAttempts = DEFAULT_UNIQUE_ID_ATTEMPTS) {
|
|
118
|
+
let candidate = generate();
|
|
119
|
+
for (let attempt = 0; attempt < maxAttempts && existing.has(candidate); attempt++) candidate = generate();
|
|
120
|
+
return candidate;
|
|
121
|
+
}
|
|
112
122
|
//#endregion
|
|
113
|
-
export {
|
|
123
|
+
export { isSafeSlug as a, FEED_SCHEDULES as c, isFieldDrivenEvery as d, isReadOnlySchema as f, fieldTextOrNull as h, isSafeRecordId as i, INGEST_KINDS as l, fieldText as m, SAFE_SLUG_PATTERN as n, AGENT_INGEST_KIND as o, storageKindFor as p, generateUniqueId as r, COMPUTED_TYPES as s, SAFE_RECORD_ID_PATTERN as t, embedTargetId as u };
|
|
114
124
|
|
|
115
|
-
//# sourceMappingURL=ids-
|
|
125
|
+
//# sourceMappingURL=ids-Bv6AOW9Z.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ids-D1M1T6KJ.js","names":[],"sources":["../src/collection/core/fieldText.ts","../src/collection/core/schema.ts","../src/collection/core/ids.ts"],"sourcesContent":["// Turning a record field into text.\n//\n// `CollectionItem` is `Record<string, unknown>`, so a field holds whatever the\n// record's JSON had — including arrays and objects (real workspace data has\n// plenty: weather `hourly`, GeoJSON `geometry`, `sites` lists). Bare\n// `String(value)` on one of those yields `\"[object Object]\"`, which then gets\n// compared, matched or displayed as if it were a value. Nothing throws; a\n// predicate just silently stops matching, or the UI shows `[object Object]`.\n//\n// These two helpers are the only sanctioned way to read a field as text. The\n// rule they encode was already in `itemLabelOf`: accept primitives, let\n// everything else fall through to the caller's fallback.\n\n/** A field value that has a meaningful text form. Dates are included because\n * a JSON record can carry one once it has been revived. */\nconst isTextable = (value: unknown): value is string | number | boolean | Date =>\n typeof value === \"string\" || typeof value === \"number\" || typeof value === \"boolean\" || value instanceof Date;\n\n/** The field's text, or `null` when it has no meaningful one (absent, or an\n * array/object that would stringify to `\"[object Object]\"`).\n *\n * Returning `null` rather than `\"\"` keeps \"the field is empty\" distinct from\n * \"the field can't be text\" — a matcher must not treat an object-valued field\n * as an empty string and match `\"\"`. */\nexport function fieldTextOrNull(value: unknown): string | null {\n if (value === undefined || value === null) return null;\n if (!isTextable(value)) return null;\n if (value instanceof Date) {\n // `new Date(\"nonsense\")` is still `instanceof Date`, and `toISOString()`\n // throws `RangeError` on it. This helper sits on the match, sort and\n // display paths, so one unparseable date in one record would take the whole\n // render down — the loud version of the bug this module exists to prevent.\n return Number.isNaN(value.getTime()) ? null : value.toISOString();\n }\n return String(value);\n}\n\n/** The field's text, or `fallback` (default `\"\"`) when it has none. Use where\n * a string is required and an empty one is a safe stand-in — display, sort\n * keys, CSV cells. Where the distinction matters, use {@link fieldTextOrNull}. */\nexport function fieldText(value: unknown, fallback = \"\"): string {\n return fieldTextOrNull(value) ?? fallback;\n}\n","// Schema-driven collection types. A \"collection\" is a skill (under\n// .claude/skills/<slug>/) that also ships a sibling `schema.json`.\n// The host's <CollectionView> reads the schema + records and renders\n// a table/form; Claude reads SKILL.md and CRUDs the records as JSON\n// files.\n//\n// SINGLE SOURCE OF TRUTH: every type describing the schema.json contract is\n// derived (`z.infer`) from the zod definitions in `./schemaZ` — the shapes,\n// their doc comments, and the validation rules live THERE; this module only\n// re-derives the TypeScript names consumers import. The imports from\n// `./schemaZ` are type-only, so zod never reaches the browser bundle through\n// the isomorphic barrel; at runtime the dependency points the other way\n// (schemaZ imports this module's consts).\n//\n// Field specs are a DISCRIMINATED UNION on `type`: narrow with\n// `field.type === \"enum\"` (etc.) before reading a variant key like `values`,\n// `to`, `formula`, or `of`.\n\nimport { fieldText } from \"./fieldText\";\nimport type { z } from \"zod\";\nimport type {\n ActionSpecZ,\n CollectionSchemaZ,\n CustomViewZ,\n DataSourceZ,\n DynamicIconRuleZ,\n DynamicIconSourceZ,\n DynamicIconSpecZ,\n EveryFieldDrivenZ,\n EveryLiteralZ,\n EveryZ,\n FieldSpecZ,\n IngestZ,\n SpawnZ,\n StorageZ,\n SubFieldSpecZ,\n WhenZ,\n} from \"./schemaZ\";\n\n/** Minimal \"this collection is a feed\" descriptor carried on the schema.\n * Deliberately narrow — the canonical collection contract stays\n * independent of the host's feeds subsystem. The host's richer retrieval\n * spec (`IngestSpec` in `feeds/ingestTypes.ts`) is a subtype, so feed code\n * reads the extra fields by typing feed schemas with that subtype;\n * collection rendering only needs these three + the presence check. */\nexport interface CollectionIngest {\n kind: string;\n schedule: string;\n /** Optional time-of-day anchor for `schedule: \"daily\"` — the hour (0–23) to\n * refresh around (the host ticks hourly, so the run lands within that hour).\n * Ignored for non-daily schedules. Absent ⇒ elapsed-based daily (\"≥24 h since\n * the last run\"). NOTE: **UTC**, not local — compared via `getUTCHours()` for\n * an unambiguous, DST-free check (matching the rest of the scheduler), so\n * convert local times before writing (e.g. 07:00 JST → `atHour: 22`). */\n atHour?: number;\n /** Declarative retrievers (`rss`/`atom`/`http-json`) only — the host fetches\n * this URL on the schedule. Absent for `kind: \"agent\"`, where the agent owns\n * retrieval. */\n url?: string;\n /** `kind: \"agent\"` only: role id the scheduled hidden worker runs in. */\n role?: string;\n /** `kind: \"agent\"` only: skill-relative template path (under `templates/`)\n * whose prose tells the worker how to refresh the records. */\n template?: string;\n}\n\n/** Declarative retriever kinds a Feed's `ingest.kind` may declare. The host's\n * feeds engine dispatches on these; they live here (with the schema contract)\n * so the schema validator can enforce them. The host re-exports these from\n * `server/workspace/feeds/ingestTypes.ts`. */\nexport const INGEST_KINDS = [\"rss\", \"atom\", \"http-json\"] as const;\nexport type IngestKind = (typeof INGEST_KINDS)[number];\n\n/** The agent-performed ingest kind. Instead of a declarative fetch, the host\n * dispatches a hidden background chat (origin `system`) in `ingest.role`,\n * seeded with `ingest.template` + a summary of every record, on the\n * `ingest.schedule` cadence; the worker edits records via the collections io\n * layer. Kept separate from {@link INGEST_KINDS} (which the declarative\n * retriever registry keys on) so the schema validator can model `ingest` as a\n * discriminated union without the feeds engine gaining an \"agent\" retriever. */\nexport const AGENT_INGEST_KIND = \"agent\" as const;\nexport type AgentIngestKind = typeof AGENT_INGEST_KIND;\n\n/** Refresh cadences a Feed's `ingest.schedule` may declare. */\nexport const FEED_SCHEDULES = [\"hourly\", \"daily\", \"weekly\", \"on-demand\"] as const;\nexport type FeedSchedule = (typeof FEED_SCHEDULES)[number];\n\n// \"feed\" collections live in the non-skill `<workspace>/feeds/` registry\n// and carry an `ingest` block; they reuse the same storage + rendering\n// as skill-backed collections but are never loaded into the agent prompt.\nexport type CollectionSource = \"user\" | \"project\" | \"feed\";\n\n/** One field of a record — a discriminated union on `type`; see the variant\n * docs in `./schemaZ` (`FieldSpecZ`). */\nexport type CollectionFieldSpec = z.infer<typeof FieldSpecZ>;\n\n/** A `table` field's row sub-schema entry — the field union minus `table` /\n * `derived` / display-only types (see `SubFieldSpecZ`). */\nexport type CollectionSubFieldSpec = z.infer<typeof SubFieldSpecZ>;\n\nexport type CollectionFieldType = CollectionFieldSpec[\"type\"];\n\n/** The computed-boolean variant — a `where` predicate bound to a field\n * name; see `FlagFieldZ`. */\nexport type CollectionFlagField = Extract<CollectionFieldSpec, { type: \"flag\" }>;\n\n/** derived/embed/backlinks/rollup/toggle/flag are host-computed or\n * projected — never written to the record JSON, so required / value\n * checks and edit-draft slots must not apply to them. THE single source\n * for \"computed\" — lives here (zod-free at runtime) so browser code\n * (`./draft`) and the zod record compiler (`./recordZ`, which re-exports\n * it) share one set instead of drifting copies. */\nexport const COMPUTED_TYPES: ReadonlySet<CollectionFieldType> = new Set<CollectionFieldType>([\"derived\", \"embed\", \"backlinks\", \"rollup\", \"toggle\", \"flag\"]);\n\n/** Optional visibility predicate: the target (an action button or a\n * field) renders only when the open record's `field` (stringified) is\n * one of `in`. Generic and domain-free — the host evaluates it against\n * the record with no knowledge of what the field means. Absent ⇒\n * always shown. */\nexport type CollectionWhen = z.infer<typeof WhenZ>;\n\n/** @deprecated Name retained for back-compat; use {@link CollectionWhen}.\n * Both actions and fields share the same predicate shape. No in-repo\n * consumers, but the package is public API (MulmoTerminal). */\n// eslint-disable-next-line sonarjs/redundant-type-aliases -- deliberate deprecated back-compat export\nexport type CollectionActionWhen = CollectionWhen;\n\n/** A schema-declared, per-record action rendered as a button in the\n * read-only detail view. Pure UI/behaviour directive — never stored,\n * never validated against record data. All domain specifics (label,\n * role, template — or the declarative `set`) live in the schema / skill\n * folder, so the host stays generic. A discriminated union on `kind`;\n * see `ActionSpecZ`. */\nexport type CollectionAction = z.infer<typeof ActionSpecZ>;\n\n/** The kind of work an action kicks off: `\"chat\"` (visible LLM chat),\n * `\"agent\"` (hidden LLM worker), or `\"mutate\"` (declarative host write,\n * no LLM). */\nexport type CollectionActionKind = CollectionAction[\"kind\"];\n\n/** The LLM-seeded action variants (`role` + `template`). */\nexport type CollectionSeededAction = Extract<CollectionAction, { kind: \"chat\" | \"agent\" }>;\n\n/** The declarative host-write variant (`set` + optional `require`/`params`). */\nexport type CollectionMutateAction = Extract<CollectionAction, { kind: \"mutate\" }>;\n\n/** A custom (LLM-authored) HTML view for a collection. The host renders\n * `file` in a sandboxed iframe over the collection's records; the view\n * reaches its data only through a slug- and capability-scoped token (see\n * `server/api/auth/viewToken.ts`). Pure data — the host holds no\n * view-specific code; meaning lives in the HTML file + this registration.\n * See `CustomViewZ` for the per-key contracts. */\nexport type CollectionCustomView = z.infer<typeof CustomViewZ>;\n\n/** What a custom view's capability token is allowed to do against the\n * collection's data endpoint. `read` returns enriched records (getItems\n * semantics); `write` validates-and-stores rows (putItems semantics).\n * There is deliberately no `delete` — a view can never do more than the\n * agent's own `manageCollection` tool. */\nexport type CollectionViewCapability = NonNullable<CollectionCustomView[\"capabilities\"]>[number];\n\n/** How a `spawn` advances the source item's `triggerField` date to\n * produce the successor's. All arithmetic is done on the civil\n * (year, month, day) triple — never by adding milliseconds — so month\n * lengths and leap years are handled correctly. */\nexport type CollectionEvery = z.infer<typeof EveryLiteralZ>;\n\n/** Recurrence unit for a `spawn.every` advance. */\nexport type CollectionRecurUnit = CollectionEvery[\"unit\"];\n\n/** Field-driven recurrence: the advance interval is selected PER RECORD by\n * the value of an `enum` field (`fromField`), looked up in `map`. See\n * `EveryFieldDrivenZ`. */\nexport type CollectionEveryFieldDriven = z.infer<typeof EveryFieldDrivenZ>;\n\n/** The `every` of a `spawn`: either a single literal interval applied to\n * every record, or a per-record interval selected by an `enum` field. The\n * literal arm is what `advanceTriggerDate` consumes — the field-driven arm\n * is resolved down to one of its `map` values before the date math runs. */\nexport type CollectionSpawnEvery = z.infer<typeof EveryZ>;\n\n/** Narrowing guard: true when `every` is the field-driven arm. */\nexport function isFieldDrivenEvery(every: CollectionSpawnEvery): every is CollectionEveryFieldDriven {\n return \"fromField\" in every;\n}\n\n/** Host-driven recurrence. See `SpawnZ`. */\nexport type CollectionSpawn = z.infer<typeof SpawnZ>;\n\n/** One rule in a `dynamicIcon.rules` list: when the resolved source\n * record matches `where` (an AND of typed conditions, see `./where`),\n * the collection's effective launcher icon becomes `icon`. Evaluated top\n * to bottom — the first match wins. */\nexport type DynamicIconRule = z.infer<typeof DynamicIconRuleZ>;\n\n/** Where a {@link DynamicIconSpec}'s source record comes from: a (possibly\n * cross-collection) pool of records, optionally narrowed by `where` and\n * reduced to a single record by `from`. */\nexport type DynamicIconSource = z.infer<typeof DynamicIconSourceZ>;\n\n/** Declarative \"data state → icon\" mapping for a collection's launcher\n * shortcut icon (see `CollectionSchema.dynamicIcon`). When absent, the\n * launcher icon is the static `schema.icon`. */\nexport type DynamicIconSpec = z.infer<typeof DynamicIconSpecZ>;\n\n/** The `ingest` block as the schema validator accepts it — a discriminated\n * union on `kind` (declarative retrievers | agent worker). The feeds\n * subsystem's `IngestSpec` is the same union under its historical name. */\nexport type CollectionIngestSpec = z.infer<typeof IngestZ>;\n\n/** The `dataSource` block: this collection's records are the rows of an\n * external read-only data file (v1: CSV). See `DataSourceZ`. */\nexport type CollectionDataSource = z.infer<typeof DataSourceZ>;\n\n/** The `storage` block: an alternative WRITABLE record backend (v1:\n * sqlite). See `StorageZ`. */\nexport type CollectionStorage = z.infer<typeof StorageZ>;\n\n/** Every storage backend a schema can select. `file` is the implicit\n * default (`dataPath`); `csv` is implied by `dataSource`; other kinds are\n * named explicitly via `storage.type`. The server's store factory registry\n * (`server/store.ts`) is keyed by this. */\nexport type CollectionStorageKind = \"file\" | \"csv\" | \"sqlite\";\n\n/** Which storage backend serves this schema's records. Derived, not stored:\n * existing schemas carry no `storage` key and must keep resolving exactly\n * as before (`dataSource` ⇒ csv, else file). */\nexport function storageKindFor(schema: Pick<CollectionSchema, \"dataSource\" | \"storage\">): CollectionStorageKind {\n if (schema.dataSource !== undefined) return \"csv\";\n return schema.storage?.type ?? \"file\";\n}\n\n/** The whole `schema.json` contract. Key-level docs live on\n * `CollectionSchemaZ` in `./schemaZ`. */\nexport type CollectionSchema = z.infer<typeof CollectionSchemaZ>;\n\n/** True when `schema` declares an external `dataSource` — i.e. the\n * collection is READ-ONLY through every UI/tool write path (updates\n * happen by editing/replacing the data file itself). Isomorphic: both\n * the server write guards and the client's control hiding key off this\n * one predicate. */\nexport function isReadOnlySchema(schema: Pick<CollectionSchema, \"dataSource\">): boolean {\n return schema.dataSource !== undefined;\n}\n\nexport interface CollectionSummary {\n slug: string;\n title: string;\n icon: string;\n source: CollectionSource;\n /** Present (true) when the collection is backed by an external\n * `dataSource` and therefore read-only in every UI/tool write path.\n * Absent-when-writable, matching the other optional summary flags. */\n readonly?: true;\n /** Slugs of the source collection(s) a `dynamicIcon` icon was computed\n * from — present only when `schema.dynamicIcon` is set. Lets a client\n * know which collection change-channel(s) to watch for a live icon\n * update (see `useDynamicShortcutIcons`). */\n iconSources?: string[];\n}\n\nexport interface CollectionDetail extends CollectionSummary {\n schema: CollectionSchema;\n}\n\nexport type CollectionItem = Record<string, unknown>;\n\n/** Resolve an `embed` field's target record id: the fixed `id`, or the value\n * of the sibling `idField` on this record (empty string when neither applies\n * — the caller renders that as \"no record\"). Pure + isomorphic so the server\n * projection (`derive.ts`) and the client preview (`useCollectionRendering`)\n * resolve embeds identically. Non-`embed` fields resolve to \"no record\". */\nexport function embedTargetId(field: CollectionFieldSpec, record: CollectionItem | null): string {\n if (field.type !== \"embed\") return \"\";\n if (field.id) return field.id;\n if (field.idField && record) return fieldText(record[field.idField]);\n return \"\";\n}\n","// Pure slug / record-id character rules. Shared by the isomorphic schema\n// validator (`./schemaZ`) — which must stay node-free — and the server-side\n// path sanitisers (`../server/paths`), which wrap these patterns with the\n// `path.basename` round-trip CodeQL recognises as a `js/path-injection`\n// sanitiser. Both layers MUST gate on the same patterns; importing them from\n// here is what keeps them in sync.\n\n// The ONE slug pattern — `server/workspace/skills/catalog.ts` imports it\n// for its own sanitiser, so there is no second copy to keep in sync.\n// Bounded character classes, no nested quantifiers; ReDoS-safe.\n// eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping character classes, no catastrophic backtracking\nexport const SAFE_SLUG_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;\n\n// Record ids are a superset of slugs: they're only ever filename stems\n// (`<id>.json`), never directory names or URL segments, so they may carry\n// dots — natural keys like a Slack ts (`1718900000.123456`), a SemVer\n// (`1.2.3`), or a decimal timestamp. The interior class adds `.` to the slug\n// set; the explicit `..` reject in `isSafeRecordId` keeps a\n// parent-dir-looking segment out while still allowing repeated `-`/`_`\n// (`a--b`, `a__b`). Start/end stay alphanumeric so leading/trailing dots\n// (hidden files, the special `.`/`..` names) and `..`-only ids are all\n// excluded.\n// eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping character classes, no catastrophic backtracking\nexport const SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;\n\n/** True when `value` is a well-formed collection slug (alphanumeric /\n * hyphen / underscore, no path separators). The pattern admits no `/`,\n * `\\`, or `.`, so a passing value is trivially also a safe basename —\n * validation callers need no `path.basename` round-trip (path-building\n * callers use `../server/paths#safeSlugName`, which adds it). */\nexport function isSafeSlug(value: string): boolean {\n return typeof value === \"string\" && SAFE_SLUG_PATTERN.test(value);\n}\n\n/** True when `value` is a well-formed record id (slug charset plus interior\n * dots), with any `..` substring rejected explicitly. Validation-only\n * counterpart of `../server/paths#safeRecordId`. */\nexport function isSafeRecordId(value: string): boolean {\n if (typeof value !== \"string\" || !SAFE_RECORD_ID_PATTERN.test(value)) return false;\n return !value.includes(\"..\");\n}\n"],"mappings":";;;AAeA,IAAM,cAAc,UAClB,OAAO,UAAU,YAAY,OAAO,UAAU,YAAY,OAAO,UAAU,aAAa,iBAAiB;;;;;;;AAQ3G,SAAgB,gBAAgB,OAA+B;CAC7D,IAAI,UAAU,KAAA,KAAa,UAAU,MAAM,OAAO;CAClD,IAAI,CAAC,WAAW,KAAK,GAAG,OAAO;CAC/B,IAAI,iBAAiB,MAKnB,OAAO,OAAO,MAAM,MAAM,QAAQ,CAAC,IAAI,OAAO,MAAM,YAAY;CAElE,OAAO,OAAO,KAAK;AACrB;;;;AAKA,SAAgB,UAAU,OAAgB,WAAW,IAAY;CAC/D,OAAO,gBAAgB,KAAK,KAAK;AACnC;;;;;;;AC4BA,IAAa,eAAe;CAAC;CAAO;CAAQ;AAAW;;;;;;;;AAUvD,IAAa,oBAAoB;;AAIjC,IAAa,iBAAiB;CAAC;CAAU;CAAS;CAAU;AAAW;;;;;;;AA4BvE,IAAa,iCAAmD,IAAI,IAAyB;CAAC;CAAW;CAAS;CAAa;CAAU;CAAU;AAAM,CAAC;;AAsE1J,SAAgB,mBAAmB,OAAkE;CACnG,OAAO,eAAe;AACxB;;;;AA2CA,SAAgB,eAAe,QAAiF;CAC9G,IAAI,OAAO,eAAe,KAAA,GAAW,OAAO;CAC5C,OAAO,OAAO,SAAS,QAAQ;AACjC;;;;;;AAWA,SAAgB,iBAAiB,QAAuD;CACtF,OAAO,OAAO,eAAe,KAAA;AAC/B;;;;;;AA6BA,SAAgB,cAAc,OAA4B,QAAuC;CAC/F,IAAI,MAAM,SAAS,SAAS,OAAO;CACnC,IAAI,MAAM,IAAI,OAAO,MAAM;CAC3B,IAAI,MAAM,WAAW,QAAQ,OAAO,UAAU,OAAO,MAAM,QAAQ;CACnE,OAAO;AACT;;;AC1QA,IAAa,oBAAoB;AAYjC,IAAa,yBAAyB;;;;;;AAOtC,SAAgB,WAAW,OAAwB;CACjD,OAAO,OAAO,UAAU,YAAY,kBAAkB,KAAK,KAAK;AAClE;;;;AAKA,SAAgB,eAAe,OAAwB;CACrD,IAAI,OAAO,UAAU,YAAY,CAAC,uBAAuB,KAAK,KAAK,GAAG,OAAO;CAC7E,OAAO,CAAC,MAAM,SAAS,IAAI;AAC7B"}
|
|
1
|
+
{"version":3,"file":"ids-Bv6AOW9Z.js","names":[],"sources":["../src/collection/core/fieldText.ts","../src/collection/core/schema.ts","../src/collection/core/ids.ts"],"sourcesContent":["// Turning a record field into text.\n//\n// `CollectionItem` is `Record<string, unknown>`, so a field holds whatever the\n// record's JSON had — including arrays and objects (real workspace data has\n// plenty: weather `hourly`, GeoJSON `geometry`, `sites` lists). Bare\n// `String(value)` on one of those yields `\"[object Object]\"`, which then gets\n// compared, matched or displayed as if it were a value. Nothing throws; a\n// predicate just silently stops matching, or the UI shows `[object Object]`.\n//\n// These two helpers are the only sanctioned way to read a field as text. The\n// rule they encode was already in `itemLabelOf`: accept primitives, let\n// everything else fall through to the caller's fallback.\n\n/** A field value that has a meaningful text form. Dates are included because\n * a JSON record can carry one once it has been revived. */\nconst isTextable = (value: unknown): value is string | number | boolean | Date =>\n typeof value === \"string\" || typeof value === \"number\" || typeof value === \"boolean\" || value instanceof Date;\n\n/** The field's text, or `null` when it has no meaningful one (absent, or an\n * array/object that would stringify to `\"[object Object]\"`).\n *\n * Returning `null` rather than `\"\"` keeps \"the field is empty\" distinct from\n * \"the field can't be text\" — a matcher must not treat an object-valued field\n * as an empty string and match `\"\"`. */\nexport function fieldTextOrNull(value: unknown): string | null {\n if (value === undefined || value === null) return null;\n if (!isTextable(value)) return null;\n if (value instanceof Date) {\n // `new Date(\"nonsense\")` is still `instanceof Date`, and `toISOString()`\n // throws `RangeError` on it. This helper sits on the match, sort and\n // display paths, so one unparseable date in one record would take the whole\n // render down — the loud version of the bug this module exists to prevent.\n return Number.isNaN(value.getTime()) ? null : value.toISOString();\n }\n return String(value);\n}\n\n/** The field's text, or `fallback` (default `\"\"`) when it has none. Use where\n * a string is required and an empty one is a safe stand-in — display, sort\n * keys, CSV cells. Where the distinction matters, use {@link fieldTextOrNull}. */\nexport function fieldText(value: unknown, fallback = \"\"): string {\n return fieldTextOrNull(value) ?? fallback;\n}\n","// Schema-driven collection types. A \"collection\" is a skill (under\n// .claude/skills/<slug>/) that also ships a sibling `schema.json`.\n// The host's <CollectionView> reads the schema + records and renders\n// a table/form; Claude reads SKILL.md and CRUDs the records as JSON\n// files.\n//\n// SINGLE SOURCE OF TRUTH: every type describing the schema.json contract is\n// derived (`z.infer`) from the zod definitions in `./schemaZ` — the shapes,\n// their doc comments, and the validation rules live THERE; this module only\n// re-derives the TypeScript names consumers import. The imports from\n// `./schemaZ` are type-only, so zod never reaches the browser bundle through\n// the isomorphic barrel; at runtime the dependency points the other way\n// (schemaZ imports this module's consts).\n//\n// Field specs are a DISCRIMINATED UNION on `type`: narrow with\n// `field.type === \"enum\"` (etc.) before reading a variant key like `values`,\n// `to`, `formula`, or `of`.\n\nimport { fieldText } from \"./fieldText\";\nimport type { z } from \"zod\";\nimport type {\n ActionSpecZ,\n CollectionSchemaZ,\n CustomViewZ,\n DataSourceZ,\n DynamicIconRuleZ,\n DynamicIconSourceZ,\n DynamicIconSpecZ,\n EveryFieldDrivenZ,\n EveryLiteralZ,\n EveryZ,\n FieldSpecZ,\n IngestZ,\n SpawnZ,\n StorageZ,\n SubFieldSpecZ,\n WhenZ,\n} from \"./schemaZ\";\n\n/** Minimal \"this collection is a feed\" descriptor carried on the schema.\n * Deliberately narrow — the canonical collection contract stays\n * independent of the host's feeds subsystem. The host's richer retrieval\n * spec (`IngestSpec` in `feeds/ingestTypes.ts`) is a subtype, so feed code\n * reads the extra fields by typing feed schemas with that subtype;\n * collection rendering only needs these three + the presence check. */\nexport interface CollectionIngest {\n kind: string;\n schedule: string;\n /** Optional time-of-day anchor for `schedule: \"daily\"` — the hour (0–23) to\n * refresh around (the host ticks hourly, so the run lands within that hour).\n * Ignored for non-daily schedules. Absent ⇒ elapsed-based daily (\"≥24 h since\n * the last run\"). NOTE: **UTC**, not local — compared via `getUTCHours()` for\n * an unambiguous, DST-free check (matching the rest of the scheduler), so\n * convert local times before writing (e.g. 07:00 JST → `atHour: 22`). */\n atHour?: number;\n /** Declarative retrievers (`rss`/`atom`/`http-json`) only — the host fetches\n * this URL on the schedule. Absent for `kind: \"agent\"`, where the agent owns\n * retrieval. */\n url?: string;\n /** `kind: \"agent\"` only: role id the scheduled hidden worker runs in. */\n role?: string;\n /** `kind: \"agent\"` only: skill-relative template path (under `templates/`)\n * whose prose tells the worker how to refresh the records. */\n template?: string;\n}\n\n/** Declarative retriever kinds a Feed's `ingest.kind` may declare. The host's\n * feeds engine dispatches on these; they live here (with the schema contract)\n * so the schema validator can enforce them. The host re-exports these from\n * `server/workspace/feeds/ingestTypes.ts`. */\nexport const INGEST_KINDS = [\"rss\", \"atom\", \"http-json\"] as const;\nexport type IngestKind = (typeof INGEST_KINDS)[number];\n\n/** The agent-performed ingest kind. Instead of a declarative fetch, the host\n * dispatches a hidden background chat (origin `system`) in `ingest.role`,\n * seeded with `ingest.template` + a summary of every record, on the\n * `ingest.schedule` cadence; the worker edits records via the collections io\n * layer. Kept separate from {@link INGEST_KINDS} (which the declarative\n * retriever registry keys on) so the schema validator can model `ingest` as a\n * discriminated union without the feeds engine gaining an \"agent\" retriever. */\nexport const AGENT_INGEST_KIND = \"agent\" as const;\nexport type AgentIngestKind = typeof AGENT_INGEST_KIND;\n\n/** Refresh cadences a Feed's `ingest.schedule` may declare. */\nexport const FEED_SCHEDULES = [\"hourly\", \"daily\", \"weekly\", \"on-demand\"] as const;\nexport type FeedSchedule = (typeof FEED_SCHEDULES)[number];\n\n// \"feed\" collections live in the non-skill `<workspace>/feeds/` registry\n// and carry an `ingest` block; they reuse the same storage + rendering\n// as skill-backed collections but are never loaded into the agent prompt.\nexport type CollectionSource = \"user\" | \"project\" | \"feed\";\n\n/** One field of a record — a discriminated union on `type`; see the variant\n * docs in `./schemaZ` (`FieldSpecZ`). */\nexport type CollectionFieldSpec = z.infer<typeof FieldSpecZ>;\n\n/** A `table` field's row sub-schema entry — the field union minus `table` /\n * `derived` / display-only types (see `SubFieldSpecZ`). */\nexport type CollectionSubFieldSpec = z.infer<typeof SubFieldSpecZ>;\n\nexport type CollectionFieldType = CollectionFieldSpec[\"type\"];\n\n/** The computed-boolean variant — a `where` predicate bound to a field\n * name; see `FlagFieldZ`. */\nexport type CollectionFlagField = Extract<CollectionFieldSpec, { type: \"flag\" }>;\n\n/** derived/embed/backlinks/rollup/toggle/flag are host-computed or\n * projected — never written to the record JSON, so required / value\n * checks and edit-draft slots must not apply to them. THE single source\n * for \"computed\" — lives here (zod-free at runtime) so browser code\n * (`./draft`) and the zod record compiler (`./recordZ`, which re-exports\n * it) share one set instead of drifting copies. */\nexport const COMPUTED_TYPES: ReadonlySet<CollectionFieldType> = new Set<CollectionFieldType>([\"derived\", \"embed\", \"backlinks\", \"rollup\", \"toggle\", \"flag\"]);\n\n/** Optional visibility predicate: the target (an action button or a\n * field) renders only when the open record's `field` (stringified) is\n * one of `in`. Generic and domain-free — the host evaluates it against\n * the record with no knowledge of what the field means. Absent ⇒\n * always shown. */\nexport type CollectionWhen = z.infer<typeof WhenZ>;\n\n/** @deprecated Name retained for back-compat; use {@link CollectionWhen}.\n * Both actions and fields share the same predicate shape. No in-repo\n * consumers, but the package is public API (MulmoTerminal). */\n// eslint-disable-next-line sonarjs/redundant-type-aliases -- deliberate deprecated back-compat export\nexport type CollectionActionWhen = CollectionWhen;\n\n/** A schema-declared, per-record action rendered as a button in the\n * read-only detail view. Pure UI/behaviour directive — never stored,\n * never validated against record data. All domain specifics (label,\n * role, template — or the declarative `set`) live in the schema / skill\n * folder, so the host stays generic. A discriminated union on `kind`;\n * see `ActionSpecZ`. */\nexport type CollectionAction = z.infer<typeof ActionSpecZ>;\n\n/** The kind of work an action kicks off: `\"chat\"` (visible LLM chat),\n * `\"agent\"` (hidden LLM worker), or `\"mutate\"` (declarative host write,\n * no LLM). */\nexport type CollectionActionKind = CollectionAction[\"kind\"];\n\n/** The LLM-seeded action variants (`role` + `template`). */\nexport type CollectionSeededAction = Extract<CollectionAction, { kind: \"chat\" | \"agent\" }>;\n\n/** The declarative host-write variant (`set` + optional `require`/`params`). */\nexport type CollectionMutateAction = Extract<CollectionAction, { kind: \"mutate\" }>;\n\n/** A custom (LLM-authored) HTML view for a collection. The host renders\n * `file` in a sandboxed iframe over the collection's records; the view\n * reaches its data only through a slug- and capability-scoped token (see\n * `server/api/auth/viewToken.ts`). Pure data — the host holds no\n * view-specific code; meaning lives in the HTML file + this registration.\n * See `CustomViewZ` for the per-key contracts. */\nexport type CollectionCustomView = z.infer<typeof CustomViewZ>;\n\n/** What a custom view's capability token is allowed to do against the\n * collection's data endpoint. `read` returns enriched records (getItems\n * semantics); `write` validates-and-stores rows (putItems semantics).\n * There is deliberately no `delete` — a view can never do more than the\n * agent's own `manageCollection` tool. */\nexport type CollectionViewCapability = NonNullable<CollectionCustomView[\"capabilities\"]>[number];\n\n/** How a `spawn` advances the source item's `triggerField` date to\n * produce the successor's. All arithmetic is done on the civil\n * (year, month, day) triple — never by adding milliseconds — so month\n * lengths and leap years are handled correctly. */\nexport type CollectionEvery = z.infer<typeof EveryLiteralZ>;\n\n/** Recurrence unit for a `spawn.every` advance. */\nexport type CollectionRecurUnit = CollectionEvery[\"unit\"];\n\n/** Field-driven recurrence: the advance interval is selected PER RECORD by\n * the value of an `enum` field (`fromField`), looked up in `map`. See\n * `EveryFieldDrivenZ`. */\nexport type CollectionEveryFieldDriven = z.infer<typeof EveryFieldDrivenZ>;\n\n/** The `every` of a `spawn`: either a single literal interval applied to\n * every record, or a per-record interval selected by an `enum` field. The\n * literal arm is what `advanceTriggerDate` consumes — the field-driven arm\n * is resolved down to one of its `map` values before the date math runs. */\nexport type CollectionSpawnEvery = z.infer<typeof EveryZ>;\n\n/** Narrowing guard: true when `every` is the field-driven arm. */\nexport function isFieldDrivenEvery(every: CollectionSpawnEvery): every is CollectionEveryFieldDriven {\n return \"fromField\" in every;\n}\n\n/** Host-driven recurrence. See `SpawnZ`. */\nexport type CollectionSpawn = z.infer<typeof SpawnZ>;\n\n/** One rule in a `dynamicIcon.rules` list: when the resolved source\n * record matches `where` (an AND of typed conditions, see `./where`),\n * the collection's effective launcher icon becomes `icon`. Evaluated top\n * to bottom — the first match wins. */\nexport type DynamicIconRule = z.infer<typeof DynamicIconRuleZ>;\n\n/** Where a {@link DynamicIconSpec}'s source record comes from: a (possibly\n * cross-collection) pool of records, optionally narrowed by `where` and\n * reduced to a single record by `from`. */\nexport type DynamicIconSource = z.infer<typeof DynamicIconSourceZ>;\n\n/** Declarative \"data state → icon\" mapping for a collection's launcher\n * shortcut icon (see `CollectionSchema.dynamicIcon`). When absent, the\n * launcher icon is the static `schema.icon`. */\nexport type DynamicIconSpec = z.infer<typeof DynamicIconSpecZ>;\n\n/** The `ingest` block as the schema validator accepts it — a discriminated\n * union on `kind` (declarative retrievers | agent worker). The feeds\n * subsystem's `IngestSpec` is the same union under its historical name. */\nexport type CollectionIngestSpec = z.infer<typeof IngestZ>;\n\n/** The `dataSource` block: this collection's records are the rows of an\n * external read-only data file (v1: CSV). See `DataSourceZ`. */\nexport type CollectionDataSource = z.infer<typeof DataSourceZ>;\n\n/** The `storage` block: an alternative WRITABLE record backend (v1:\n * sqlite). See `StorageZ`. */\nexport type CollectionStorage = z.infer<typeof StorageZ>;\n\n/** Every storage backend a schema can select. `file` is the implicit\n * default (`dataPath`); `csv` is implied by `dataSource`; other kinds are\n * named explicitly via `storage.type`. The server's store factory registry\n * (`server/store.ts`) is keyed by this. */\nexport type CollectionStorageKind = \"file\" | \"csv\" | \"sqlite\";\n\n/** Which storage backend serves this schema's records. Derived, not stored:\n * existing schemas carry no `storage` key and must keep resolving exactly\n * as before (`dataSource` ⇒ csv, else file). */\nexport function storageKindFor(schema: Pick<CollectionSchema, \"dataSource\" | \"storage\">): CollectionStorageKind {\n if (schema.dataSource !== undefined) return \"csv\";\n return schema.storage?.type ?? \"file\";\n}\n\n/** The whole `schema.json` contract. Key-level docs live on\n * `CollectionSchemaZ` in `./schemaZ`. */\nexport type CollectionSchema = z.infer<typeof CollectionSchemaZ>;\n\n/** True when `schema` declares an external `dataSource` — i.e. the\n * collection is READ-ONLY through every UI/tool write path (updates\n * happen by editing/replacing the data file itself). Isomorphic: both\n * the server write guards and the client's control hiding key off this\n * one predicate. */\nexport function isReadOnlySchema(schema: Pick<CollectionSchema, \"dataSource\">): boolean {\n return schema.dataSource !== undefined;\n}\n\nexport interface CollectionSummary {\n slug: string;\n title: string;\n icon: string;\n source: CollectionSource;\n /** Present (true) when the collection is backed by an external\n * `dataSource` and therefore read-only in every UI/tool write path.\n * Absent-when-writable, matching the other optional summary flags. */\n readonly?: true;\n /** Slugs of the source collection(s) a `dynamicIcon` icon was computed\n * from — present only when `schema.dynamicIcon` is set. Lets a client\n * know which collection change-channel(s) to watch for a live icon\n * update (see `useDynamicShortcutIcons`). */\n iconSources?: string[];\n}\n\nexport interface CollectionDetail extends CollectionSummary {\n schema: CollectionSchema;\n}\n\nexport type CollectionItem = Record<string, unknown>;\n\n/** Resolve an `embed` field's target record id: the fixed `id`, or the value\n * of the sibling `idField` on this record (empty string when neither applies\n * — the caller renders that as \"no record\"). Pure + isomorphic so the server\n * projection (`derive.ts`) and the client preview (`useCollectionRendering`)\n * resolve embeds identically. Non-`embed` fields resolve to \"no record\". */\nexport function embedTargetId(field: CollectionFieldSpec, record: CollectionItem | null): string {\n if (field.type !== \"embed\") return \"\";\n if (field.id) return field.id;\n if (field.idField && record) return fieldText(record[field.idField]);\n return \"\";\n}\n","// Pure slug / record-id character rules. Shared by the isomorphic schema\n// validator (`./schemaZ`) — which must stay node-free — and the server-side\n// path sanitisers (`../server/paths`), which wrap these patterns with the\n// `path.basename` round-trip CodeQL recognises as a `js/path-injection`\n// sanitiser. Both layers MUST gate on the same patterns; importing them from\n// here is what keeps them in sync.\n\n// The ONE slug pattern — `server/workspace/skills/catalog.ts` imports it\n// for its own sanitiser, so there is no second copy to keep in sync.\n// Bounded character classes, no nested quantifiers; ReDoS-safe.\n// eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping character classes, no catastrophic backtracking\nexport const SAFE_SLUG_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;\n\n// Record ids are a superset of slugs: they're only ever filename stems\n// (`<id>.json`), never directory names or URL segments, so they may carry\n// dots — natural keys like a Slack ts (`1718900000.123456`), a SemVer\n// (`1.2.3`), or a decimal timestamp. The interior class adds `.` to the slug\n// set; the explicit `..` reject in `isSafeRecordId` keeps a\n// parent-dir-looking segment out while still allowing repeated `-`/`_`\n// (`a--b`, `a__b`). Start/end stay alphanumeric so leading/trailing dots\n// (hidden files, the special `.`/`..` names) and `..`-only ids are all\n// excluded.\n// eslint-disable-next-line security/detect-unsafe-regex -- non-overlapping character classes, no catastrophic backtracking\nexport const SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;\n\n/** True when `value` is a well-formed collection slug (alphanumeric /\n * hyphen / underscore, no path separators). The pattern admits no `/`,\n * `\\`, or `.`, so a passing value is trivially also a safe basename —\n * validation callers need no `path.basename` round-trip (path-building\n * callers use `../server/paths#safeSlugName`, which adds it). */\nexport function isSafeSlug(value: string): boolean {\n return typeof value === \"string\" && SAFE_SLUG_PATTERN.test(value);\n}\n\n/** True when `value` is a well-formed record id (slug charset plus interior\n * dots), with any `..` substring rejected explicitly. Validation-only\n * counterpart of `../server/paths#safeRecordId`. */\nexport function isSafeRecordId(value: string): boolean {\n if (typeof value !== \"string\" || !SAFE_RECORD_ID_PATTERN.test(value)) return false;\n return !value.includes(\"..\");\n}\n\nconst DEFAULT_UNIQUE_ID_ATTEMPTS = 8;\n\n/** Pick an id not already in `existing`, re-rolling `generate()` up to\n * `maxAttempts` times before giving up and returning the last candidate.\n * Collisions on a wide id space are astronomically unlikely, so a caller's\n * own overwrite guard is the final backstop rather than an unbounded loop. */\nexport function generateUniqueId(existing: ReadonlySet<string>, generate: () => string, maxAttempts: number = DEFAULT_UNIQUE_ID_ATTEMPTS): string {\n let candidate = generate();\n for (let attempt = 0; attempt < maxAttempts && existing.has(candidate); attempt++) {\n candidate = generate();\n }\n return candidate;\n}\n"],"mappings":";;;AAeA,IAAM,cAAc,UAClB,OAAO,UAAU,YAAY,OAAO,UAAU,YAAY,OAAO,UAAU,aAAa,iBAAiB;;;;;;;AAQ3G,SAAgB,gBAAgB,OAA+B;CAC7D,IAAI,UAAU,KAAA,KAAa,UAAU,MAAM,OAAO;CAClD,IAAI,CAAC,WAAW,KAAK,GAAG,OAAO;CAC/B,IAAI,iBAAiB,MAKnB,OAAO,OAAO,MAAM,MAAM,QAAQ,CAAC,IAAI,OAAO,MAAM,YAAY;CAElE,OAAO,OAAO,KAAK;AACrB;;;;AAKA,SAAgB,UAAU,OAAgB,WAAW,IAAY;CAC/D,OAAO,gBAAgB,KAAK,KAAK;AACnC;;;;;;;AC4BA,IAAa,eAAe;CAAC;CAAO;CAAQ;AAAW;;;;;;;;AAUvD,IAAa,oBAAoB;;AAIjC,IAAa,iBAAiB;CAAC;CAAU;CAAS;CAAU;AAAW;;;;;;;AA4BvE,IAAa,iCAAmD,IAAI,IAAyB;CAAC;CAAW;CAAS;CAAa;CAAU;CAAU;AAAM,CAAC;;AAsE1J,SAAgB,mBAAmB,OAAkE;CACnG,OAAO,eAAe;AACxB;;;;AA2CA,SAAgB,eAAe,QAAiF;CAC9G,IAAI,OAAO,eAAe,KAAA,GAAW,OAAO;CAC5C,OAAO,OAAO,SAAS,QAAQ;AACjC;;;;;;AAWA,SAAgB,iBAAiB,QAAuD;CACtF,OAAO,OAAO,eAAe,KAAA;AAC/B;;;;;;AA6BA,SAAgB,cAAc,OAA4B,QAAuC;CAC/F,IAAI,MAAM,SAAS,SAAS,OAAO;CACnC,IAAI,MAAM,IAAI,OAAO,MAAM;CAC3B,IAAI,MAAM,WAAW,QAAQ,OAAO,UAAU,OAAO,MAAM,QAAQ;CACnE,OAAO;AACT;;;AC1QA,IAAa,oBAAoB;AAYjC,IAAa,yBAAyB;;;;;;AAOtC,SAAgB,WAAW,OAAwB;CACjD,OAAO,OAAO,UAAU,YAAY,kBAAkB,KAAK,KAAK;AAClE;;;;AAKA,SAAgB,eAAe,OAAwB;CACrD,IAAI,OAAO,UAAU,YAAY,CAAC,uBAAuB,KAAK,KAAK,GAAG,OAAO;CAC7E,OAAO,CAAC,MAAM,SAAS,IAAI;AAC7B;AAEA,IAAM,6BAA6B;;;;;AAMnC,SAAgB,iBAAiB,UAA+B,UAAwB,cAAsB,4BAAoC;CAChJ,IAAI,YAAY,SAAS;CACzB,KAAK,IAAI,UAAU,GAAG,UAAU,eAAe,SAAS,IAAI,SAAS,GAAG,WACtE,YAAY,SAAS;CAEvB,OAAO;AACT"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const require_ids = require("./ids-
|
|
1
|
+
const require_ids = require("./ids-BJ7rdrDN.cjs");
|
|
2
2
|
require("./collection/index.cjs");
|
|
3
3
|
//#region src/feeds/ingestTypes.ts
|
|
4
4
|
var AGENT_INGEST_KIND = "agent";
|
|
@@ -29,4 +29,4 @@ Object.defineProperty(exports, "isFeedSchedule", {
|
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
//# sourceMappingURL=ingestTypes-
|
|
32
|
+
//# sourceMappingURL=ingestTypes-CkbM-zrO.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ingestTypes-
|
|
1
|
+
{"version":3,"file":"ingestTypes-CkbM-zrO.cjs","names":[],"sources":["../src/feeds/ingestTypes.ts"],"sourcesContent":["// Declarative retrieval config for the \"Feeds\" mechanism. A Feed is a\n// CollectionSchema plus this `ingest` block, registered as data (NOT as\n// a skill) under `<workspace>/feeds/<slug>/schema.json`. The host's\n// retrieval engine reads it to periodically refill the collection's\n// records via the shared collections io layer.\n//\n// The ingest vocab (INGEST_KINDS / FEED_SCHEDULES + their literal-union types)\n// lives in the sibling `../collection` subpath alongside the schema contract, so\n// the package's schema validator can enforce it. Re-exported here so the feeds\n// engine's existing importers resolve them unchanged.\nimport { INGEST_KINDS, FEED_SCHEDULES, type IngestKind, type FeedSchedule } from \"../collection/index.js\";\n// Type-only: keeps zod out of this module's runtime graph (the feeds engine\n// imports it browser-free through `../collection`'s type surface).\nimport type { z } from \"zod\";\nimport type { AgentIngestZ, DeclarativeIngestZ } from \"../collection/core/schemaZ\";\n//\n// Two flavours: the declarative kinds (`rss`/`atom`/`http-json`) fetch-and-map,\n// and `agent` dispatches a hidden worker. The `code` kind (LLM-generated\n// deterministic transform) is still reserved for a future retriever.\n\n// The agent ingest kind. Defined HERE (not imported from `../collection`) on\n// purpose: the engine only needs the literal to branch, and re-importing it as a\n// VALUE keeps this module free of a value dependency on the collection vocab.\n// Core owns its own copy for the schema validator; this matches the same literal.\nexport const AGENT_INGEST_KIND = \"agent\" as const;\nexport type AgentIngestKind = typeof AGENT_INGEST_KIND;\n\nexport { INGEST_KINDS, FEED_SCHEDULES, type IngestKind, type FeedSchedule };\n\nconst FEED_SCHEDULE_SET: ReadonlySet<string> = new Set(FEED_SCHEDULES);\n\nexport function isFeedSchedule(value: unknown): value is FeedSchedule {\n return typeof value === \"string\" && FEED_SCHEDULE_SET.has(value);\n}\n\n/** Default cap on stored records per feed when `ingest.maxItems` is\n * omitted. Keeps high-volume feeds (news / podcasts) bounded. */\nexport const DEFAULT_FEED_MAX_ITEMS = 100;\n\n/** Declarative field map: target collection field name → source path\n * into the raw item (dot/bracket path, e.g. `\"title\"` or\n * `\"data.name\"`). */\nexport type IngestFieldMap = Record<string, string>;\n\n/** Declarative retrieval (`rss`/`atom`/`http-json`): the host fetches `url`\n * and projects each item through `map` (target field → source path; a\n * `http-json` feed may add `itemsAt`, a dot/bracket path to the items\n * array; `idFrom` derives the primaryKey; `maxItems` caps stored records,\n * default {@link DEFAULT_FEED_MAX_ITEMS}, `0` = keep everything). The\n * canonical loose contract stays the minimal `CollectionIngest`; this\n * feeds subtype is derived from the zod source of truth\n * (`collection/core/schemaZ` `DeclarativeIngestZ`), so the engine and the\n * schema validator can never drift. */\nexport type DeclarativeIngestSpec = z.infer<typeof DeclarativeIngestZ>;\n\n/** Agent-performed retrieval (`kind: \"agent\"`). No `url`/`map`: the host seeds\n * a hidden background worker (origin `system`) in `role` with `template` + a\n * summary of every record, and the worker edits the records itself via the\n * collections io layer. Valid on any collection (primarily skill-backed).\n * Derived from `AgentIngestZ` in `collection/core/schemaZ`. */\nexport type AgentIngestSpec = z.infer<typeof AgentIngestZ>;\n\n/** The `ingest` block carried on a `CollectionSchema`, discriminated on `kind`. */\nexport type IngestSpec = DeclarativeIngestSpec | AgentIngestSpec;\n"],"mappings":";;;AAwBA,IAAa,oBAAoB;AAKjC,IAAM,oBAAyC,IAAI,IAAI,YAAA,cAAc;AAErE,SAAgB,eAAe,OAAuC;CACpE,OAAO,OAAO,UAAU,YAAY,kBAAkB,IAAI,KAAK;AACjE;;;AAIA,IAAa,yBAAyB"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { c as FEED_SCHEDULES } from "./ids-Bv6AOW9Z.js";
|
|
2
2
|
import "./collection/index.js";
|
|
3
3
|
//#region src/feeds/ingestTypes.ts
|
|
4
4
|
var AGENT_INGEST_KIND = "agent";
|
|
@@ -12,4 +12,4 @@ var DEFAULT_FEED_MAX_ITEMS = 100;
|
|
|
12
12
|
//#endregion
|
|
13
13
|
export { DEFAULT_FEED_MAX_ITEMS as n, isFeedSchedule as r, AGENT_INGEST_KIND as t };
|
|
14
14
|
|
|
15
|
-
//# sourceMappingURL=ingestTypes-
|
|
15
|
+
//# sourceMappingURL=ingestTypes-DuCiZqye.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ingestTypes-
|
|
1
|
+
{"version":3,"file":"ingestTypes-DuCiZqye.js","names":[],"sources":["../src/feeds/ingestTypes.ts"],"sourcesContent":["// Declarative retrieval config for the \"Feeds\" mechanism. A Feed is a\n// CollectionSchema plus this `ingest` block, registered as data (NOT as\n// a skill) under `<workspace>/feeds/<slug>/schema.json`. The host's\n// retrieval engine reads it to periodically refill the collection's\n// records via the shared collections io layer.\n//\n// The ingest vocab (INGEST_KINDS / FEED_SCHEDULES + their literal-union types)\n// lives in the sibling `../collection` subpath alongside the schema contract, so\n// the package's schema validator can enforce it. Re-exported here so the feeds\n// engine's existing importers resolve them unchanged.\nimport { INGEST_KINDS, FEED_SCHEDULES, type IngestKind, type FeedSchedule } from \"../collection/index.js\";\n// Type-only: keeps zod out of this module's runtime graph (the feeds engine\n// imports it browser-free through `../collection`'s type surface).\nimport type { z } from \"zod\";\nimport type { AgentIngestZ, DeclarativeIngestZ } from \"../collection/core/schemaZ\";\n//\n// Two flavours: the declarative kinds (`rss`/`atom`/`http-json`) fetch-and-map,\n// and `agent` dispatches a hidden worker. The `code` kind (LLM-generated\n// deterministic transform) is still reserved for a future retriever.\n\n// The agent ingest kind. Defined HERE (not imported from `../collection`) on\n// purpose: the engine only needs the literal to branch, and re-importing it as a\n// VALUE keeps this module free of a value dependency on the collection vocab.\n// Core owns its own copy for the schema validator; this matches the same literal.\nexport const AGENT_INGEST_KIND = \"agent\" as const;\nexport type AgentIngestKind = typeof AGENT_INGEST_KIND;\n\nexport { INGEST_KINDS, FEED_SCHEDULES, type IngestKind, type FeedSchedule };\n\nconst FEED_SCHEDULE_SET: ReadonlySet<string> = new Set(FEED_SCHEDULES);\n\nexport function isFeedSchedule(value: unknown): value is FeedSchedule {\n return typeof value === \"string\" && FEED_SCHEDULE_SET.has(value);\n}\n\n/** Default cap on stored records per feed when `ingest.maxItems` is\n * omitted. Keeps high-volume feeds (news / podcasts) bounded. */\nexport const DEFAULT_FEED_MAX_ITEMS = 100;\n\n/** Declarative field map: target collection field name → source path\n * into the raw item (dot/bracket path, e.g. `\"title\"` or\n * `\"data.name\"`). */\nexport type IngestFieldMap = Record<string, string>;\n\n/** Declarative retrieval (`rss`/`atom`/`http-json`): the host fetches `url`\n * and projects each item through `map` (target field → source path; a\n * `http-json` feed may add `itemsAt`, a dot/bracket path to the items\n * array; `idFrom` derives the primaryKey; `maxItems` caps stored records,\n * default {@link DEFAULT_FEED_MAX_ITEMS}, `0` = keep everything). The\n * canonical loose contract stays the minimal `CollectionIngest`; this\n * feeds subtype is derived from the zod source of truth\n * (`collection/core/schemaZ` `DeclarativeIngestZ`), so the engine and the\n * schema validator can never drift. */\nexport type DeclarativeIngestSpec = z.infer<typeof DeclarativeIngestZ>;\n\n/** Agent-performed retrieval (`kind: \"agent\"`). No `url`/`map`: the host seeds\n * a hidden background worker (origin `system`) in `role` with `template` + a\n * summary of every record, and the worker edits the records itself via the\n * collections io layer. Valid on any collection (primarily skill-backed).\n * Derived from `AgentIngestZ` in `collection/core/schemaZ`. */\nexport type AgentIngestSpec = z.infer<typeof AgentIngestZ>;\n\n/** The `ingest` block carried on a `CollectionSchema`, discriminated on `kind`. */\nexport type IngestSpec = DeclarativeIngestSpec | AgentIngestSpec;\n"],"mappings":";;;AAwBA,IAAa,oBAAoB;AAKjC,IAAM,oBAAyC,IAAI,IAAI,cAAc;AAErE,SAAgB,eAAe,OAAuC;CACpE,OAAO,OAAO,UAAU,YAAY,kBAAkB,IAAI,KAAK;AACjE;;;AAIA,IAAa,yBAAyB"}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
+
import { MinimalLogger } from '@mulmoclaude/common';
|
|
1
2
|
import { WriteJson } from './store.js';
|
|
2
3
|
import { NotifierEntry, NotifierEvent, NotifierHistoryEntry, NotifierSeverity, PublishInput } from './types.js';
|
|
3
4
|
export { NOTIFIER_LIMITS, validatePublishInput } from './validate.js';
|
|
4
5
|
/** Minimal logger the engine needs. The host passes its structured
|
|
5
6
|
* logger; absent one, failures are swallowed (the engine never throws
|
|
6
7
|
* on a fan-out/persist-best-effort path). */
|
|
7
|
-
export
|
|
8
|
-
warn: (message: string, data?: Record<string, unknown>) => void;
|
|
9
|
-
error: (message: string, data?: Record<string, unknown>) => void;
|
|
10
|
-
}
|
|
8
|
+
export type NotifierLogger = Pick<MinimalLogger, "warn" | "error">;
|
|
11
9
|
export interface NotifierConfig {
|
|
12
10
|
/** Atomic JSON writer (the host's `writeJsonAtomic`). */
|
|
13
11
|
writeJson: WriteJson;
|
package/dist/notifier/index.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_notifier = require("../notifier-
|
|
2
|
+
const require_notifier = require("../notifier-tMsAXyXp.cjs");
|
|
3
3
|
exports.HISTORY_CAP = require_notifier.HISTORY_CAP;
|
|
4
4
|
exports.NOTIFIER_LIFECYCLES = require_notifier.NOTIFIER_LIFECYCLES;
|
|
5
5
|
exports.NOTIFIER_LIMITS = require_notifier.NOTIFIER_LIMITS;
|
package/dist/notifier/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
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-
|
|
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-BdA5qzhe.js";
|
|
2
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 };
|
|
@@ -341,34 +341,32 @@ async function publish(input) {
|
|
|
341
341
|
});
|
|
342
342
|
return { id: entryId };
|
|
343
343
|
}
|
|
344
|
-
|
|
344
|
+
/** Remove an active entry and record its terminal history. `clear`
|
|
345
|
+
* (user dismissed) and `cancel` (producer withdrew) differ only in the
|
|
346
|
+
* emitted event / history reason. No-op when the id is unknown. */
|
|
347
|
+
async function terminateEntry(entryId, reason) {
|
|
345
348
|
await enqueue((state) => {
|
|
346
349
|
const entry = state.entries[entryId];
|
|
347
350
|
if (!entry) return null;
|
|
348
351
|
state.entries = removeEntry(state, entryId);
|
|
349
352
|
return {
|
|
350
|
-
event: {
|
|
353
|
+
event: reason === "cleared" ? {
|
|
351
354
|
type: "cleared",
|
|
352
355
|
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: {
|
|
356
|
+
} : {
|
|
365
357
|
type: "cancelled",
|
|
366
358
|
id: entryId
|
|
367
359
|
},
|
|
368
|
-
historyEntry: buildHistoryEntry(entry,
|
|
360
|
+
historyEntry: buildHistoryEntry(entry, reason)
|
|
369
361
|
};
|
|
370
362
|
});
|
|
371
363
|
}
|
|
364
|
+
async function clear(entryId) {
|
|
365
|
+
await terminateEntry(entryId, "cleared");
|
|
366
|
+
}
|
|
367
|
+
async function cancel(entryId) {
|
|
368
|
+
await terminateEntry(entryId, "cancelled");
|
|
369
|
+
}
|
|
372
370
|
/** In-place update for an active entry. Only the fields present on
|
|
373
371
|
* `patch` are rewritten; `id`, `pluginPkg`, `lifecycle`, and
|
|
374
372
|
* `createdAt` stay fixed. Emits a single `"updated"` event with the
|
|
@@ -461,4 +459,4 @@ async function listHistory() {
|
|
|
461
459
|
//#endregion
|
|
462
460
|
export { HISTORY_CAP as _, get as a, listFor as c, publish as d, resetNotifier as f, validatePublishInput as g, NOTIFIER_LIMITS as h, configureNotifier as i, listHistory as l, updateForPlugin as m, clear as n, getForPlugin as o, setNotifierFilePaths as p, clearForPlugin as r, listAll as s, cancel as t, onEvent as u, NOTIFIER_LIFECYCLES as v, NOTIFIER_SEVERITIES as y };
|
|
463
461
|
|
|
464
|
-
//# sourceMappingURL=notifier-
|
|
462
|
+
//# sourceMappingURL=notifier-BdA5qzhe.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notifier-BdA5qzhe.js","names":[],"sources":["../src/notifier/types.ts","../src/notifier/store.ts","../src/notifier/validate.ts","../src/notifier/engine.ts"],"sourcesContent":["// Notifier value types. Kept dependency-free (no node, no fs, no\n// pubsub) so the host's API-route layer and any future browser\n// consumer can validate inbound payloads against the same enum\n// constants the engine accepts without pulling in the engine's I/O.\n\n/** Two notification shapes, distinguished by who fires the close call:\n *\n * `fyi` — informational. The host (bell panel) clears it when the\n * user dismisses the row. No deep-link target.\n * `action` — pending obligation. The plugin clears it when the\n * underlying domain state changes (the user paid the tax,\n * viewed the digest, etc.). The bell row navigates to\n * `navigateTarget` on click.\n *\n * The engine reads `lifecycle` only to enforce two publish-time rules\n * (everything downstream — pubsub fan-out, persistence, history — is\n * lifecycle-blind):\n *\n * 1. `action` requires a non-empty `navigateTarget`. Without one,\n * clicking the row does nothing and the entry is a degraded fyi.\n * 2. `action` cannot use `info` severity. A low-priority obligation\n * is incoherent — fyi if it's a ping, `nudge`/`urgent` if it's a\n * real obligation worth a landing page.\n *\n * Both rules are mirrored in the HTTP layer so plugin-runtime callers\n * and HTTP callers hit the same wall. */\nexport const NOTIFIER_LIFECYCLES = [\"fyi\", \"action\"] as const;\nexport type NotifierLifecycle = (typeof NOTIFIER_LIFECYCLES)[number];\n\n/** Severity drives badge color (gray / amber / red, worst-wins) and\n * in a future iteration channel routing. Mostly stored verbatim by\n * the engine; the one engine-visible interaction is the rule that\n * `action` lifecycle cannot pair with `info` severity (see\n * `NotifierLifecycle` above). */\nexport const NOTIFIER_SEVERITIES = [\"info\", \"nudge\", \"urgent\"] as const;\nexport type NotifierSeverity = (typeof NOTIFIER_SEVERITIES)[number];\n\nexport interface NotifierEntry<TPluginData = unknown> {\n /** Engine-assigned UUID. Generated synchronously inside `publish()`\n * so the caller can use it before persistence completes. */\n id: string;\n /** Plugin namespace (e.g. `\"encore\"`, `\"debug__system\"`). The\n * engine never inspects it — used only for `listFor()` filtering\n * and as a UI grouping key. */\n pluginPkg: string;\n severity: NotifierSeverity;\n lifecycle?: NotifierLifecycle;\n title: string;\n body?: string;\n /** Optional in-app deep-link target (relative URL). The bell popup\n * routes here on row click, with `¬ificationId=<id>` appended\n * so the landing page can identify which entry to clear. The\n * engine doesn't read this — it's a UI hint stored on the entry. */\n navigateTarget?: string;\n /** Opaque to the engine. Round-trips through JSON unchanged; only\n * the originating plugin's UI knows the shape. */\n pluginData?: TPluginData;\n /** ISO-8601 timestamp set at `publish()` time. */\n createdAt: string;\n}\n\n/** A history entry — a `NotifierEntry` after it has been cleared or\n * cancelled, with the terminal type and timestamp recorded. The\n * bell popup's \"History\" section renders these read-only. */\nexport interface NotifierHistoryEntry<TPluginData = unknown> extends NotifierEntry<TPluginData> {\n terminalType: \"cleared\" | \"cancelled\";\n terminalAt: string;\n}\n\n/** Caller-supplied input for `publish()`. The engine fills in `id`\n * and `createdAt`; everything else flows through verbatim.\n *\n * Two publish-time rules apply to `action` lifecycle (see\n * `NotifierLifecycle`):\n *\n * - `navigateTarget` MUST be a non-empty string.\n * - `severity` MUST NOT be `\"info\"`.\n *\n * Violations cause `publish()` to throw. Currently expressed as\n * runtime validation rather than a discriminated-union type, so the\n * fields below are all individually optional / loose at the\n * type-level. */\nexport interface PublishInput<TPluginData = unknown> {\n pluginPkg: string;\n severity: NotifierSeverity;\n title: string;\n body?: string;\n lifecycle?: NotifierLifecycle;\n navigateTarget?: string;\n pluginData?: TPluginData;\n}\n\n/** On-disk shape of `~/mulmoclaude/data/notifier/active.json`. Holds\n * only entries that haven't been cleared or cancelled — the file is\n * a snapshot, not an event log. */\nexport interface NotifierFile {\n entries: Record<string, NotifierEntry>;\n}\n\n/** On-disk shape of `~/mulmoclaude/data/notifier/history.json`. Array\n * of terminated entries newest-first, capped at `HISTORY_CAP` with\n * FIFO eviction (push at index 0, slice from the tail). */\nexport interface NotifierHistoryFile {\n entries: NotifierHistoryEntry[];\n}\n\n/** History size cap. The bell popup's History section renders this\n * many entries; older ones fall off when new terminations land. */\nexport const HISTORY_CAP = 50;\n\n/** Pub-sub event published on the host's notifier channel after every\n * successful state change. Discriminated union — subscribers switch\n * on `type` to keep TypeScript narrowing the rest of the payload.\n *\n * `updated` carries the post-mutation entry — the receiver swaps\n * the matching `id` in their local active set. Reserved for in-\n * place edits via `updateForPlugin`; no history record is written\n * because the entry is still active, just with refreshed content. */\nexport type NotifierEvent =\n { type: \"published\"; entry: NotifierEntry } | { type: \"cleared\"; id: string } | { type: \"cancelled\"; id: string } | { type: \"updated\"; entry: NotifierEntry };\n","// Low-level file I/O for the notifier. Reads use node:fs directly;\n// writes go through an injected atomic-JSON writer (the host owns the\n// rename-based atomic write so it stays single-sourced with its other\n// writers). Kept separate from `engine.ts` so the path can be\n// overridden in tests without monkey-patching.\n\nimport { promises as fsPromises } from \"node:fs\";\nimport type { NotifierFile, NotifierHistoryFile } from \"./types.js\";\n\n/** Injected atomic JSON writer — the host's `writeJsonAtomic`. */\nexport type WriteJson = (filePath: string, data: unknown) => Promise<void>;\n\nfunction isNotFoundError(err: unknown): boolean {\n return typeof err === \"object\" && err !== null && (err as { code?: unknown }).code === \"ENOENT\";\n}\n\n/** Read the active-entries file. Returns an empty store when the file\n * doesn't exist yet (first ever call on a fresh workspace). Any other\n * read or parse failure throws — the caller has to decide whether to\n * surface or recover, since silently treating \"malformed file\" as\n * \"no entries\" would lose data. */\nexport async function loadActive(filePath: string): Promise<NotifierFile> {\n let text: string;\n try {\n text = await fsPromises.readFile(filePath, \"utf-8\");\n } catch (err) {\n if (isNotFoundError(err)) return { entries: {} };\n throw err;\n }\n const parsed: unknown = JSON.parse(text);\n // `typeof null === \"object\"` and `Array.isArray([])` is also true,\n // so a naive `typeof entries !== \"object\"` check would let\n // `{ entries: null }` and `{ entries: [] }` through, which then\n // crash downstream `engine.get` / `list*` mutations. Reject both\n // shapes here at load time so the failure surfaces as a clear\n // \"malformed file\" error.\n if (typeof parsed !== \"object\" || parsed === null || !(\"entries\" in parsed)) {\n throw new Error(`notifier: malformed active.json at ${filePath}`);\n }\n const { entries } = parsed as { entries: unknown };\n if (typeof entries !== \"object\" || entries === null || Array.isArray(entries)) {\n throw new Error(`notifier: malformed active.json at ${filePath}`);\n }\n return parsed as NotifierFile;\n}\n\n/** Write the active-entries file via the injected atomic writer so a\n * half-written file is never visible to readers. The caller serialises\n * writes (engine.ts queues mutations) — this function makes no\n * concurrency guarantees of its own. */\nexport async function saveActive(writeJson: WriteJson, filePath: string, state: NotifierFile): Promise<void> {\n await writeJson(filePath, state);\n}\n\n/** Read the history file. Empty array on first run. Same parse-error\n * policy as `loadActive`. */\nexport async function loadHistory(filePath: string): Promise<NotifierHistoryFile> {\n let text: string;\n try {\n text = await fsPromises.readFile(filePath, \"utf-8\");\n } catch (err) {\n if (isNotFoundError(err)) return { entries: [] };\n throw err;\n }\n const parsed: unknown = JSON.parse(text);\n if (typeof parsed !== \"object\" || parsed === null || !(\"entries\" in parsed) || !Array.isArray((parsed as { entries: unknown }).entries)) {\n throw new Error(`notifier: malformed history.json at ${filePath}`);\n }\n return parsed as NotifierHistoryFile;\n}\n\nexport async function saveHistory(writeJson: WriteJson, filePath: string, state: NotifierHistoryFile): Promise<void> {\n await writeJson(filePath, state);\n}\n","// Publish-input validation — pure, dependency-free. Shared by\n// `engine.publish` (throws on error) and the host's HTTP route\n// (returns 400 on error). Single source of truth so plugin-runtime\n// callers and HTTP callers can't drift.\n\nimport type { PublishInput } from \"./types.js\";\n\n/** Hard caps on publish-input fields. The engine reads each entry on\n * every list/get call (no in-memory cache), so unbounded fields hurt\n * every reader. Caps chosen to be generous for legitimate UX copy\n * while bounding active.json growth: a notification fundamentally is\n * a short blurb, not a document. */\nexport const NOTIFIER_LIMITS = {\n titleMax: 200,\n bodyMax: 4000,\n navigateTargetMax: 1000,\n pluginDataMaxBytes: 16 * 1024,\n} as const;\n\nfunction validateTitle(title: string): string | null {\n if (typeof title !== \"string\" || title.length === 0) return \"title must be a non-empty string\";\n if (title.length > NOTIFIER_LIMITS.titleMax) return `title exceeds max length of ${NOTIFIER_LIMITS.titleMax} chars`;\n return null;\n}\n\nfunction validateBody(body: string | undefined): string | null {\n if (body === undefined) return null;\n if (body.length > NOTIFIER_LIMITS.bodyMax) return `body exceeds max length of ${NOTIFIER_LIMITS.bodyMax} chars`;\n return null;\n}\n\nfunction validateNavigateTarget(target: string | undefined): string | null {\n if (target === undefined) return null;\n if (target.length === 0) return \"navigateTarget must be a non-empty relative path when set\";\n if (target.length > NOTIFIER_LIMITS.navigateTargetMax) {\n return `navigateTarget exceeds max length of ${NOTIFIER_LIMITS.navigateTargetMax} chars`;\n }\n // Must be a same-origin relative path. Reject schemes\n // (`javascript:`, `https://...`) and scheme-relative URLs\n // (`//evil.com/...`, which an `<a href>` would resolve to the\n // attacker's origin). One leading \"/\" only.\n if (!target.startsWith(\"/\") || target.startsWith(\"//\")) {\n return \"navigateTarget must be a relative path beginning with a single '/' (no scheme, no '//')\";\n }\n return null;\n}\n\nfunction validatePluginData(pluginData: unknown): string | null {\n if (pluginData === undefined) return null;\n let serialized: string | undefined;\n try {\n serialized = JSON.stringify(pluginData);\n } catch (err) {\n return `pluginData is not JSON-serialisable: ${String(err)}`;\n }\n // `JSON.stringify` returns `undefined` for non-serialisable roots\n // (e.g. a bare function or symbol). Treat that as a serialisation\n // failure so it doesn't slip through as an empty-string size.\n if (typeof serialized !== \"string\") return \"pluginData is not JSON-serialisable\";\n if (serialized.length > NOTIFIER_LIMITS.pluginDataMaxBytes) {\n return `pluginData JSON exceeds ${NOTIFIER_LIMITS.pluginDataMaxBytes} bytes`;\n }\n return null;\n}\n\nfunction validateActionCoherence(input: PublishInput): string | null {\n if (input.lifecycle !== \"action\") return null;\n if (input.severity === \"info\") {\n return \"action lifecycle is incompatible with info severity (use fyi for low-priority pings)\";\n }\n if (typeof input.navigateTarget !== \"string\" || input.navigateTarget.length === 0) {\n return \"action lifecycle requires a non-empty navigateTarget\";\n }\n return null;\n}\n\n/** Validate a `PublishInput`. Returns `null` if OK, or a\n * human-readable error string. Order matters — shape/size errors are\n * reported before lifecycle/severity coherence errors so the message\n * the caller sees points at the most fundamental problem first. */\nexport function validatePublishInput(input: PublishInput): string | null {\n return (\n validateTitle(input.title) ??\n validateBody(input.body) ??\n validateNavigateTarget(input.navigateTarget) ??\n validatePluginData(input.pluginData) ??\n validateActionCoherence(input)\n );\n}\n","// Notifier engine — single-process, two-file (active + history),\n// single-channel. Host-agnostic: file paths, the atomic JSON writer,\n// the pub-sub event sink, and the logger are all injected via\n// `configureNotifier` + `setNotifierFilePaths` so MulmoClaude and\n// MulmoTerminal share one notification engine over their own\n// workspaces and pub-sub fabrics.\n//\n// API surface: publish / clear / cancel / get / listFor / listAll /\n// listHistory (+ plugin-scoped variants). Mutations queue through a\n// writing-flag + waiter-queue coordinator so concurrent callers can't\n// race on the atomic write's rename. Reads bypass the queue (rename\n// atomicity makes half-reads impossible) and trade strict\n// linearisability for simpler code: the contract is \"after\n// `await publish(x)` resolves, subsequent reads see x\" — which holds\n// because `publish` awaits the persist before returning.\n//\n// `clear` / `cancel` push to history *before* removing from active.\n// History persistence is best-effort: if it fails, the active write\n// still wins and the failure is logged. Active is the source of\n// truth; history is an audit aid.\n\nimport type { MinimalLogger } from \"@mulmoclaude/common\";\nimport { randomUUID } from \"node:crypto\";\nimport { loadActive, loadHistory, saveActive, saveHistory, type WriteJson } from \"./store.js\";\nimport { validatePublishInput } from \"./validate.js\";\nimport {\n HISTORY_CAP,\n type NotifierEntry,\n type NotifierEvent,\n type NotifierFile,\n type NotifierHistoryEntry,\n type NotifierSeverity,\n type PublishInput,\n} from \"./types.js\";\n\nexport { NOTIFIER_LIMITS, validatePublishInput } from \"./validate.js\";\n\n// ── Dependency injection ──────────────────────────────────────────\n\n/** Minimal logger the engine needs. The host passes its structured\n * logger; absent one, failures are swallowed (the engine never throws\n * on a fan-out/persist-best-effort path). */\nexport type NotifierLogger = Pick<MinimalLogger, \"warn\" | \"error\">;\n\nexport interface NotifierConfig {\n /** Atomic JSON writer (the host's `writeJsonAtomic`). */\n writeJson: WriteJson;\n /** Fan-out sink — the host binds this to `pubsub.publish(channel, event)`. */\n publishEvent: (event: NotifierEvent) => void;\n /** Optional logger. */\n log?: NotifierLogger;\n}\n\nconst NOOP_LOG: NotifierLogger = { warn: () => {}, error: () => {} };\n\nlet config: NotifierConfig | null = null;\nlet activeFilePath = \"\";\nlet historyFilePath = \"\";\n\nfunction logger(): NotifierLogger {\n return config?.log ?? NOOP_LOG;\n}\n\n/** Wire the engine's I/O deps. Call once at startup, before the first\n * mutation. Does NOT set file paths — those are set independently via\n * `setNotifierFilePaths` so a host can bind production paths at module\n * load and a test can override them without re-supplying the deps. */\nexport function configureNotifier(injected: NotifierConfig): void {\n config = injected;\n}\n\n// ── In-process event listeners ────────────────────────────────────\n//\n// Separate from the socket.io pubsub so server-side adapters (macOS\n// push, future Encore) can react to state changes without going\n// through a websocket round-trip. The host's pubsub is fan-out-only\n// with no server-side subscribe, so this listener registry is the\n// in-process equivalent. Listeners run synchronously inside `emit`,\n// before the pubsub fan-out.\n\ntype NotifierEventListener = (event: NotifierEvent) => void;\nconst listeners: NotifierEventListener[] = [];\n\n/** Register an in-process listener for engine events. Returns an\n * unsubscribe function the caller can use during teardown. */\nexport function onEvent(listener: NotifierEventListener): () => void {\n listeners.push(listener);\n return () => {\n const idx = listeners.indexOf(listener);\n if (idx >= 0) listeners.splice(idx, 1);\n };\n}\n\nfunction emit(event: NotifierEvent): void {\n // In-process fan-out first. Each listener is wrapped: a throwing\n // listener must not poison the rest, and must not propagate out of\n // `processBatch` and strand the still-unsettled waiters (their\n // resolve/reject is called *after* this emit loop). Fan-out is\n // best-effort by contract — losing one subscriber must not lose\n // the write that already committed.\n for (const listener of listeners) {\n try {\n listener(event);\n } catch (err) {\n logger().error(\"in-process listener failed\", { type: event.type, error: String(err) });\n }\n }\n if (!config) {\n logger().warn(\"emit before init\", { type: event.type });\n return;\n }\n try {\n config.publishEvent(event);\n } catch (err) {\n logger().error(\"emit failed\", { type: event.type, error: String(err) });\n }\n}\n\n// ── Write coordinator ─────────────────────────────────────────────\n\n/** A mutation function applied to the in-memory state object during\n * drain. Returns either:\n *\n * - `null` — no state change (e.g., `clear` on an unknown id).\n * The drainer skips the disk write and the emit if every\n * mutation in a batch returned `null`.\n * - `{ event, historyEntry? }` — state changed. The drainer emits\n * the event after the active write succeeds, and prepends\n * `historyEntry` to history (best-effort) when present.\n *\n * Mutations MUST NOT modify state when returning `null`. Violating\n * this invariant produces a write skip with stale on-disk state. */\ntype MutationOutcome = { event: NotifierEvent; historyEntry?: NotifierHistoryEntry } | null;\ntype Mutation = (state: NotifierFile) => MutationOutcome;\n\ninterface Waiter {\n mutate: Mutation;\n resolve: () => void;\n reject: (err: unknown) => void;\n}\n\ntype MutationResult = { ok: true; outcome: MutationOutcome } | { ok: false; error: unknown };\n\nlet writing = false;\nlet waiters: Waiter[] = [];\n\n/** Point the engine at its active/history files. Resets the write\n * queue, so callers must not have in-flight mutations. The host calls\n * this once with the workspace paths; tests call it per-case with temp\n * files. */\nexport function setNotifierFilePaths(paths: { active: string; history: string }): void {\n activeFilePath = paths.active;\n historyFilePath = paths.history;\n writing = false;\n waiters = [];\n}\n\n/** Test-only: clear config + queue so each suite starts clean. */\nexport function resetNotifier(): void {\n config = null;\n activeFilePath = \"\";\n historyFilePath = \"\";\n writing = false;\n waiters = [];\n listeners.length = 0;\n}\n\nfunction requireWriteJson(): WriteJson {\n if (!config) throw new Error(\"notifier: configureNotifier() not called\");\n return config.writeJson;\n}\n\nfunction applyBatchMutations(batch: Waiter[], state: NotifierFile): MutationResult[] {\n return batch.map((waiter) => {\n try {\n return { ok: true, outcome: waiter.mutate(state) };\n } catch (err) {\n return { ok: false, error: err };\n }\n });\n}\n\nfunction collectEvents(results: MutationResult[]): NotifierEvent[] {\n const events: NotifierEvent[] = [];\n for (const result of results) {\n if (result.ok && result.outcome !== null) events.push(result.outcome.event);\n }\n return events;\n}\n\nfunction collectHistoryEntries(results: MutationResult[]): NotifierHistoryEntry[] {\n const entries: NotifierHistoryEntry[] = [];\n for (const result of results) {\n if (result.ok && result.outcome !== null && result.outcome.historyEntry) {\n entries.push(result.outcome.historyEntry);\n }\n }\n return entries;\n}\n\nfunction settleBatch(batch: Waiter[], results: MutationResult[]): void {\n // Resolves come AFTER any emits so subscribers see the event\n // before the caller's `await` returns.\n for (let index = 0; index < batch.length; index += 1) {\n const result = results[index];\n if (result.ok) batch[index].resolve();\n else batch[index].reject(result.error);\n }\n}\n\nfunction rejectBatch(batch: Waiter[], err: unknown): void {\n for (const waiter of batch) waiter.reject(err);\n}\n\nasync function persistHistory(newEntries: NotifierHistoryEntry[]): Promise<void> {\n const existing = await loadHistory(historyFilePath);\n // Newest-first ordering: a batch contains terminations in arrival\n // order; we want the last one to land at index 0 of history.\n const merged = [...newEntries.slice().reverse(), ...existing.entries].slice(0, HISTORY_CAP);\n await saveHistory(requireWriteJson(), historyFilePath, { entries: merged });\n}\n\nasync function processBatch(batch: Waiter[]): Promise<void> {\n let state: NotifierFile;\n try {\n state = await loadActive(activeFilePath);\n } catch (err) {\n logger().error(\"load failed\", { error: String(err) });\n rejectBatch(batch, err);\n return;\n }\n const results = applyBatchMutations(batch, state);\n const events = collectEvents(results);\n const historyEntries = collectHistoryEntries(results);\n\n if (events.length > 0) {\n try {\n await saveActive(requireWriteJson(), activeFilePath, state);\n } catch (err) {\n logger().error(\"active write failed\", { error: String(err) });\n rejectBatch(batch, err);\n return;\n }\n if (historyEntries.length > 0) {\n // Best-effort: active is the source of truth, history is an\n // audit aid. A failed history write is logged but doesn't\n // unwind the active commit.\n try {\n await persistHistory(historyEntries);\n } catch (err) {\n logger().error(\"history write failed\", { error: String(err) });\n }\n }\n for (const event of events) emit(event);\n }\n settleBatch(batch, results);\n}\n\nasync function drain(): Promise<void> {\n writing = true;\n try {\n while (waiters.length > 0) {\n const batch = waiters;\n waiters = [];\n await processBatch(batch);\n }\n } finally {\n writing = false;\n }\n}\n\nfunction enqueue(mutate: Mutation): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n waiters.push({ mutate, resolve, reject });\n if (!writing) void drain();\n });\n}\n\nfunction removeEntry(state: NotifierFile, entryId: string): NotifierFile[\"entries\"] {\n // Object-rest excludes the key without invoking `delete`.\n const { [entryId]: __removed, ...remaining } = state.entries;\n return remaining;\n}\n\nfunction buildHistoryEntry(entry: NotifierEntry, terminalType: \"cleared\" | \"cancelled\"): NotifierHistoryEntry {\n return { ...entry, terminalType, terminalAt: new Date().toISOString() };\n}\n\n// ── Public API ────────────────────────────────────────────────────\n\nexport async function publish<TPluginData = unknown>(input: PublishInput<TPluginData>): Promise<{ id: string }> {\n // Validate at the engine boundary so plugin-runtime callers and\n // HTTP callers hit the same wall.\n const validationError = validatePublishInput(input as PublishInput);\n if (validationError) {\n throw new Error(`notifier.publish: ${validationError}`);\n }\n const entryId = randomUUID();\n const entry: NotifierEntry<TPluginData> = {\n id: entryId,\n pluginPkg: input.pluginPkg,\n severity: input.severity,\n lifecycle: input.lifecycle,\n title: input.title,\n body: input.body,\n navigateTarget: input.navigateTarget,\n pluginData: input.pluginData,\n createdAt: new Date().toISOString(),\n };\n await enqueue((state) => {\n state.entries[entryId] = entry as NotifierEntry;\n return { event: { type: \"published\", entry: entry as NotifierEntry } };\n });\n return { id: entryId };\n}\n\n/** Remove an active entry and record its terminal history. `clear`\n * (user dismissed) and `cancel` (producer withdrew) differ only in the\n * emitted event / history reason. No-op when the id is unknown. */\nasync function terminateEntry(entryId: string, reason: \"cleared\" | \"cancelled\"): Promise<void> {\n await enqueue((state) => {\n const entry = state.entries[entryId];\n if (!entry) return null;\n state.entries = removeEntry(state, entryId);\n return {\n event: reason === \"cleared\" ? { type: \"cleared\", id: entryId } : { type: \"cancelled\", id: entryId },\n historyEntry: buildHistoryEntry(entry, reason),\n };\n });\n}\n\nexport async function clear(entryId: string): Promise<void> {\n await terminateEntry(entryId, \"cleared\");\n}\n\nexport async function cancel(entryId: string): Promise<void> {\n await terminateEntry(entryId, \"cancelled\");\n}\n\n/** In-place update for an active entry. Only the fields present on\n * `patch` are rewritten; `id`, `pluginPkg`, `lifecycle`, and\n * `createdAt` stay fixed. Emits a single `\"updated\"` event with the\n * post-mutation entry — no history record is written because the\n * entry is still active, just with refreshed content.\n *\n * No-ops (no throw) when the id is unknown, the entry belongs to a\n * different plugin, or the merged shape would violate\n * `validatePublishInput`. The silent skip matches `clearForPlugin`'s\n * isolation semantics; validation failures are logged for diagnosis. */\nexport async function updateForPlugin<TPluginData = unknown>(\n pluginPkg: string,\n entryId: string,\n patch: {\n severity?: NotifierSeverity;\n title?: string;\n body?: string;\n navigateTarget?: string;\n pluginData?: TPluginData;\n },\n): Promise<void> {\n await enqueue((state) => {\n const entry = state.entries[entryId];\n if (!entry) return null;\n if (entry.pluginPkg !== pluginPkg) return null;\n const next: NotifierEntry = {\n ...entry,\n ...(patch.severity !== undefined ? { severity: patch.severity } : {}),\n ...(patch.title !== undefined ? { title: patch.title } : {}),\n ...(patch.body !== undefined ? { body: patch.body } : {}),\n ...(patch.navigateTarget !== undefined ? { navigateTarget: patch.navigateTarget } : {}),\n ...(patch.pluginData !== undefined ? { pluginData: patch.pluginData } : {}),\n };\n // Re-validate the merged shape so an update can't degrade the\n // entry below publish-time invariants.\n const validationError = validatePublishInput({\n pluginPkg: next.pluginPkg,\n severity: next.severity,\n title: next.title,\n body: next.body,\n lifecycle: next.lifecycle,\n navigateTarget: next.navigateTarget,\n pluginData: next.pluginData,\n });\n if (validationError) {\n logger().warn(\"update rejected by validation\", { entryId, pluginPkg, error: validationError });\n return null;\n }\n state.entries[entryId] = next;\n return { event: { type: \"updated\", entry: next } };\n });\n}\n\n/** Plugin-scoped point lookup. Returns the entry by id, but only if it\n * belongs to the caller's plugin; otherwise undefined. Cross-plugin\n * reads return undefined for isolation — same property as\n * `clearForPlugin` / `updateForPlugin`. */\nexport async function getForPlugin(pluginPkg: string, entryId: string): Promise<NotifierEntry | undefined> {\n const state = await loadActive(activeFilePath);\n const entry = state.entries[entryId];\n if (!entry) return undefined;\n if (entry.pluginPkg !== pluginPkg) return undefined;\n return entry;\n}\n\n/** Plugin-scoped clear. Same as `clear` but no-ops if the entry's\n * `pluginPkg` doesn't match the caller's, so a plugin can't dismiss\n * another plugin's notification by guessing or scraping its id. */\nexport async function clearForPlugin(pluginPkg: string, entryId: string): Promise<void> {\n await enqueue((state) => {\n const entry = state.entries[entryId];\n if (!entry) return null;\n if (entry.pluginPkg !== pluginPkg) return null;\n state.entries = removeEntry(state, entryId);\n return {\n event: { type: \"cleared\", id: entryId },\n historyEntry: buildHistoryEntry(entry, \"cleared\"),\n };\n });\n}\n\nexport async function get(entryId: string): Promise<NotifierEntry | undefined> {\n const state = await loadActive(activeFilePath);\n return state.entries[entryId];\n}\n\nexport async function listFor(pluginPkg: string): Promise<NotifierEntry[]> {\n const state = await loadActive(activeFilePath);\n return Object.values(state.entries).filter((entry) => entry.pluginPkg === pluginPkg);\n}\n\nexport async function listAll(): Promise<NotifierEntry[]> {\n const state = await loadActive(activeFilePath);\n return Object.values(state.entries);\n}\n\nexport async function listHistory(): Promise<NotifierHistoryEntry[]> {\n const state = await loadHistory(historyFilePath);\n return state.entries;\n}\n"],"mappings":"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"}
|
|
@@ -341,34 +341,32 @@ async function publish(input) {
|
|
|
341
341
|
});
|
|
342
342
|
return { id: entryId };
|
|
343
343
|
}
|
|
344
|
-
|
|
344
|
+
/** Remove an active entry and record its terminal history. `clear`
|
|
345
|
+
* (user dismissed) and `cancel` (producer withdrew) differ only in the
|
|
346
|
+
* emitted event / history reason. No-op when the id is unknown. */
|
|
347
|
+
async function terminateEntry(entryId, reason) {
|
|
345
348
|
await enqueue((state) => {
|
|
346
349
|
const entry = state.entries[entryId];
|
|
347
350
|
if (!entry) return null;
|
|
348
351
|
state.entries = removeEntry(state, entryId);
|
|
349
352
|
return {
|
|
350
|
-
event: {
|
|
353
|
+
event: reason === "cleared" ? {
|
|
351
354
|
type: "cleared",
|
|
352
355
|
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: {
|
|
356
|
+
} : {
|
|
365
357
|
type: "cancelled",
|
|
366
358
|
id: entryId
|
|
367
359
|
},
|
|
368
|
-
historyEntry: buildHistoryEntry(entry,
|
|
360
|
+
historyEntry: buildHistoryEntry(entry, reason)
|
|
369
361
|
};
|
|
370
362
|
});
|
|
371
363
|
}
|
|
364
|
+
async function clear(entryId) {
|
|
365
|
+
await terminateEntry(entryId, "cleared");
|
|
366
|
+
}
|
|
367
|
+
async function cancel(entryId) {
|
|
368
|
+
await terminateEntry(entryId, "cancelled");
|
|
369
|
+
}
|
|
372
370
|
/** In-place update for an active entry. Only the fields present on
|
|
373
371
|
* `patch` are rewritten; `id`, `pluginPkg`, `lifecycle`, and
|
|
374
372
|
* `createdAt` stay fixed. Emits a single `"updated"` event with the
|
|
@@ -574,4 +572,4 @@ Object.defineProperty(exports, "validatePublishInput", {
|
|
|
574
572
|
}
|
|
575
573
|
});
|
|
576
574
|
|
|
577
|
-
//# sourceMappingURL=notifier-
|
|
575
|
+
//# sourceMappingURL=notifier-tMsAXyXp.cjs.map
|