@mulmoclaude/core 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/assets/helps/custom-view-remote.md +12 -7
  2. package/dist/collection/core/dynamicIcon.d.ts +24 -0
  3. package/dist/collection/core/schema.d.ts +55 -0
  4. package/dist/collection/core/where.d.ts +32 -0
  5. package/dist/collection/index.cjs +7 -10
  6. package/dist/collection/index.cjs.map +1 -1
  7. package/dist/collection/index.d.ts +2 -0
  8. package/dist/collection/index.js +3 -10
  9. package/dist/collection/index.js.map +1 -1
  10. package/dist/collection/registry/server/importWriter.d.ts +2 -1
  11. package/dist/collection/registry/server/index.cjs +16 -3
  12. package/dist/collection/registry/server/index.cjs.map +1 -1
  13. package/dist/collection/registry/server/index.js +16 -3
  14. package/dist/collection/registry/server/index.js.map +1 -1
  15. package/dist/collection/server/discovery.d.ts +51 -0
  16. package/dist/collection/server/dynamicIcon.d.ts +10 -0
  17. package/dist/collection/server/index.cjs +2 -1
  18. package/dist/collection/server/index.d.ts +1 -0
  19. package/dist/collection/server/index.js +2 -2
  20. package/dist/collection-watchers/index.cjs +1 -1
  21. package/dist/collection-watchers/index.js +1 -1
  22. package/dist/{deriveAll-Cb9rWjan.cjs → deriveAll-BQ_p5HSB.cjs} +149 -1
  23. package/dist/deriveAll-BQ_p5HSB.cjs.map +1 -0
  24. package/dist/{deriveAll-D3wFH4Tw.js → deriveAll-CMFXDQ_G.js} +126 -2
  25. package/dist/deriveAll-CMFXDQ_G.js.map +1 -0
  26. package/dist/errorMessage--LvXe_Jx.cjs +16 -0
  27. package/dist/errorMessage--LvXe_Jx.cjs.map +1 -0
  28. package/dist/errorMessage-DIdhheHk.js +11 -0
  29. package/dist/errorMessage-DIdhheHk.js.map +1 -0
  30. package/dist/feeds/index.cjs +2 -2
  31. package/dist/feeds/index.js +2 -2
  32. package/dist/feeds/server/index.cjs +3 -3
  33. package/dist/feeds/server/index.js +3 -3
  34. package/dist/{ingestTypes-DhJ63Ogd.cjs → ingestTypes-C7EheYZX.cjs} +2 -2
  35. package/dist/{ingestTypes-DhJ63Ogd.cjs.map → ingestTypes-C7EheYZX.cjs.map} +1 -1
  36. package/dist/{ingestTypes-BtMZogMX.js → ingestTypes-CmJeOUJc.js} +2 -2
  37. package/dist/{ingestTypes-BtMZogMX.js.map → ingestTypes-CmJeOUJc.js.map} +1 -1
  38. package/dist/remote-host/index.cjs +10 -0
  39. package/dist/remote-host/index.cjs.map +1 -0
  40. package/dist/remote-host/index.d.ts +26 -0
  41. package/dist/remote-host/index.js +8 -0
  42. package/dist/remote-host/index.js.map +1 -0
  43. package/dist/remote-host/server/auth.d.ts +7 -0
  44. package/dist/remote-host/server/firebase.d.ts +11 -0
  45. package/dist/remote-host/server/hostRunner.d.ts +13 -0
  46. package/dist/remote-host/server/index.cjs +194 -0
  47. package/dist/remote-host/server/index.cjs.map +1 -0
  48. package/dist/remote-host/server/index.d.ts +8 -0
  49. package/dist/remote-host/server/index.js +190 -0
  50. package/dist/remote-host/server/index.js.map +1 -0
  51. package/dist/remote-host/server/lifecycle.d.ts +26 -0
  52. package/dist/{server-BDxrLT41.js → server-DKXXFTbw.js} +96 -4
  53. package/dist/server-DKXXFTbw.js.map +1 -0
  54. package/dist/{server-U2d7Fb1h.cjs → server-G6GtOdaW.cjs} +101 -3
  55. package/dist/server-G6GtOdaW.cjs.map +1 -0
  56. package/package.json +21 -3
  57. package/dist/deriveAll-Cb9rWjan.cjs.map +0 -1
  58. package/dist/deriveAll-D3wFH4Tw.js.map +0 -1
  59. package/dist/server-BDxrLT41.js.map +0 -1
  60. package/dist/server-U2d7Fb1h.cjs.map +0 -1
@@ -0,0 +1,194 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_errorMessage = require("../../errorMessage--LvXe_Jx.cjs");
3
+ const require_remote_host_index = require("../index.cjs");
4
+ let firebase_firestore = require("firebase/firestore");
5
+ let firebase_auth = require("firebase/auth");
6
+ let firebase_app = require("firebase/app");
7
+ let firebase_storage = require("firebase/storage");
8
+ //#region src/remote-host/server/hostRunner.ts
9
+ var DEFAULT_HEARTBEAT_MS = 6e4;
10
+ var noop$1 = () => void 0;
11
+ var writeError = (ref, code, message) => (0, firebase_firestore.updateDoc)(ref, {
12
+ status: "error",
13
+ error: {
14
+ code,
15
+ message
16
+ },
17
+ updatedAt: (0, firebase_firestore.serverTimestamp)()
18
+ }).catch(noop$1);
19
+ var claimCommand = (firestore, ref) => (0, firebase_firestore.runTransaction)(firestore, async (txn) => {
20
+ const data = (await txn.get(ref)).data();
21
+ if (!data || data.status !== "queued") return null;
22
+ txn.update(ref, {
23
+ status: "processing",
24
+ updatedAt: (0, firebase_firestore.serverTimestamp)()
25
+ });
26
+ return {
27
+ method: data.method,
28
+ params: data.params ?? {}
29
+ };
30
+ });
31
+ var runHandler = async (ref, claim, handler) => {
32
+ try {
33
+ await (0, firebase_firestore.updateDoc)(ref, {
34
+ status: "done",
35
+ result: await handler(claim.params) ?? null,
36
+ updatedAt: (0, firebase_firestore.serverTimestamp)()
37
+ });
38
+ return {
39
+ phase: "done",
40
+ method: claim.method
41
+ };
42
+ } catch (error) {
43
+ const message = require_errorMessage.errorMessage(error);
44
+ await writeError(ref, "handler_error", message);
45
+ return {
46
+ phase: "error",
47
+ method: claim.method,
48
+ message
49
+ };
50
+ }
51
+ };
52
+ var processCommand = async (firestore, ref, handlers, onEvent) => {
53
+ const claim = await claimCommand(firestore, ref);
54
+ if (!claim) return;
55
+ onEvent?.({
56
+ phase: "received",
57
+ method: claim.method
58
+ });
59
+ const handler = handlers[claim.method];
60
+ if (!handler) {
61
+ await writeError(ref, "unknown_method", `No handler for method: ${claim.method}`);
62
+ onEvent?.({
63
+ phase: "error",
64
+ method: claim.method,
65
+ message: "unknown method"
66
+ });
67
+ return;
68
+ }
69
+ onEvent?.(await runHandler(ref, claim, handler));
70
+ };
71
+ var startHostRunner = (firestore, channel, handlers, options = {}) => {
72
+ const presence = require_remote_host_index.hostDoc(firestore, channel);
73
+ const announce = () => {
74
+ (0, firebase_firestore.setDoc)(presence, {
75
+ online: true,
76
+ updatedAt: (0, firebase_firestore.serverTimestamp)()
77
+ }).catch(noop$1);
78
+ };
79
+ announce();
80
+ const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
81
+ const unsubscribe = (0, firebase_firestore.onSnapshot)((0, firebase_firestore.query)(require_remote_host_index.commandsCollection(firestore, channel), (0, firebase_firestore.where)("status", "==", "queued")), (snapshot) => {
82
+ snapshot.docChanges().forEach((change) => {
83
+ if (change.type === "added") processCommand(firestore, change.doc.ref, handlers, options.onEvent).catch(noop$1);
84
+ });
85
+ }, (error) => {
86
+ options.onEvent?.({
87
+ phase: "error",
88
+ method: "listen",
89
+ message: error.message
90
+ });
91
+ clearInterval(beat);
92
+ (0, firebase_firestore.setDoc)(presence, {
93
+ online: false,
94
+ updatedAt: (0, firebase_firestore.serverTimestamp)()
95
+ }).catch(noop$1);
96
+ options.onClosed?.();
97
+ });
98
+ return () => {
99
+ clearInterval(beat);
100
+ (0, firebase_firestore.setDoc)(presence, {
101
+ online: false,
102
+ updatedAt: (0, firebase_firestore.serverTimestamp)()
103
+ }).catch(noop$1);
104
+ unsubscribe();
105
+ };
106
+ };
107
+ //#endregion
108
+ //#region src/remote-host/server/lifecycle.ts
109
+ var noop = () => void 0;
110
+ var silentLogger = {
111
+ info: noop,
112
+ warn: noop,
113
+ debug: noop
114
+ };
115
+ var createRemoteHost = (deps) => {
116
+ const log = deps.log ?? silentLogger;
117
+ let stopRunner = null;
118
+ let transition = Promise.resolve();
119
+ const serialize = (operation) => {
120
+ const next = transition.then(operation, operation);
121
+ transition = next.then(noop, noop);
122
+ return next;
123
+ };
124
+ const stopIfRunning = () => {
125
+ if (stopRunner) {
126
+ stopRunner();
127
+ stopRunner = null;
128
+ }
129
+ };
130
+ const status = () => ({
131
+ connected: stopRunner !== null,
132
+ uid: deps.currentUid()
133
+ });
134
+ const startRunner = (uid) => {
135
+ const runner = deps.startRunner({
136
+ uid,
137
+ hostId: deps.hostId
138
+ }, deps.handlers, {
139
+ onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),
140
+ onClosed: () => {
141
+ if (stopRunner === runner) {
142
+ stopRunner = null;
143
+ log.warn("host runner listener died; marked disconnected");
144
+ }
145
+ }
146
+ });
147
+ return runner;
148
+ };
149
+ const connect = (idToken) => serialize(async () => {
150
+ const uid = await deps.signIn(idToken);
151
+ stopIfRunning();
152
+ stopRunner = startRunner(uid);
153
+ log.info(`connected as ${uid}, host runner started (hostId=${deps.hostId})`);
154
+ return status();
155
+ });
156
+ const disconnect = () => serialize(async () => {
157
+ stopIfRunning();
158
+ await deps.signOut();
159
+ log.info("disconnected, host runner stopped");
160
+ return status();
161
+ });
162
+ return {
163
+ connect,
164
+ disconnect,
165
+ status
166
+ };
167
+ };
168
+ //#endregion
169
+ //#region src/remote-host/server/auth.ts
170
+ var createRemoteHostAuth = (auth) => ({
171
+ signInHost: async (idToken) => {
172
+ return (await (0, firebase_auth.signInWithCredential)(auth, firebase_auth.GoogleAuthProvider.credential(idToken))).user.uid;
173
+ },
174
+ signOutHost: () => (0, firebase_auth.signOut)(auth),
175
+ currentUid: () => auth.currentUser?.uid ?? null
176
+ });
177
+ //#endregion
178
+ //#region src/remote-host/server/firebase.ts
179
+ var createRemoteHostFirebase = (config) => {
180
+ const app = (0, firebase_app.initializeApp)(config);
181
+ return {
182
+ app,
183
+ auth: (0, firebase_auth.getAuth)(app),
184
+ firestore: (0, firebase_firestore.getFirestore)(app),
185
+ storage: (0, firebase_storage.getStorage)(app)
186
+ };
187
+ };
188
+ //#endregion
189
+ exports.createRemoteHost = createRemoteHost;
190
+ exports.createRemoteHostAuth = createRemoteHostAuth;
191
+ exports.createRemoteHostFirebase = createRemoteHostFirebase;
192
+ exports.startHostRunner = startHostRunner;
193
+
194
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../../src/remote-host/server/hostRunner.ts","../../../src/remote-host/server/lifecycle.ts","../../../src/remote-host/server/auth.ts","../../../src/remote-host/server/firebase.ts"],"sourcesContent":["// Host side of the command channel: claim queued commands, run handlers, write\n// results back, and announce presence via heartbeat.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/hostRunner.ts (itself\n// ported from ../mulmoserver). The only signature change vs. that copy: the\n// `firestore` instance is a parameter (each host supplies its own Firebase init),\n// and the heartbeat interval is an option (defaults to one minute).\nimport { DocumentReference, Firestore, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from \"firebase/firestore\";\n\nimport { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport { Channel, Command, CommandHandler, CommandHandlers, JsonObject, commandsCollection, hostDoc } from \"../index.js\";\n\nconst DEFAULT_HEARTBEAT_MS = 60_000;\n\nexport interface HostEvent {\n phase: \"received\" | \"done\" | \"error\";\n method: string;\n message?: string;\n}\n\nexport interface HostRunnerOptions {\n onEvent?: (event: HostEvent) => void;\n // Called once when the listener dies fatally (after presence has been set\n // offline), so the lifecycle owner can reconcile its own state — e.g. clear\n // the runner handle so status() no longer reports connected. NOT called on a\n // normal stop().\n onClosed?: () => void;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number;\n}\n\ninterface Claim {\n method: string;\n params: JsonObject;\n}\n\nconst noop = () => undefined;\n\n// The remote may have deleted the doc on timeout, so ignore write-after-delete.\nconst writeError = (ref: DocumentReference, code: string, message: string) =>\n updateDoc(ref, { status: \"error\", error: { code, message }, updatedAt: serverTimestamp() }).catch(noop);\n\n// Atomically move a command queued -> processing so it is handled exactly once.\n// Returns the method/params to run, or null if another handler already took it.\nconst claimCommand = (firestore: Firestore, ref: DocumentReference): Promise<Claim | null> =>\n runTransaction(firestore, async (txn) => {\n const data = (await txn.get(ref)).data() as Command | undefined;\n if (!data || data.status !== \"queued\") {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return { method: data.method, params: data.params ?? {} };\n });\n\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler): Promise<HostEvent> => {\n try {\n const result = await handler(claim.params);\n await updateDoc(ref, { status: \"done\", result: result ?? null, updatedAt: serverTimestamp() });\n return { phase: \"done\", method: claim.method };\n } catch (error) {\n const message = errorMessage(error);\n await writeError(ref, \"handler_error\", message);\n return { phase: \"error\", method: claim.method, message };\n }\n};\n\nconst processCommand = async (firestore: Firestore, ref: DocumentReference, handlers: CommandHandlers, onEvent?: HostRunnerOptions[\"onEvent\"]) => {\n const claim = await claimCommand(firestore, ref);\n if (!claim) {\n return;\n }\n onEvent?.({ phase: \"received\", method: claim.method });\n const handler: CommandHandler | undefined = handlers[claim.method];\n if (!handler) {\n await writeError(ref, \"unknown_method\", `No handler for method: ${claim.method}`);\n onEvent?.({ phase: \"error\", method: claim.method, message: \"unknown method\" });\n return;\n }\n onEvent?.(await runHandler(ref, claim, handler));\n};\n\n// startHostRunner subscribes to queued commands for the given channel and runs\n// each one through the supplied handler table. It also announces presence (a\n// heartbeat on users/{uid}/hosts/{hostId}) so the remote can tell it is online.\n// Returns a stop function that goes offline and detaches the listener.\nexport const startHostRunner = (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions = {}): (() => void) => {\n const presence = hostDoc(firestore, channel);\n const announce = () => {\n setDoc(presence, { online: true, updatedAt: serverTimestamp() }).catch(noop);\n };\n announce();\n const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const unsubscribe = onSnapshot(\n queuedCommands,\n (snapshot) => {\n snapshot.docChanges().forEach((change) => {\n if (change.type === \"added\") {\n processCommand(firestore, change.doc.ref, handlers, options.onEvent).catch(noop);\n }\n });\n },\n (error) => {\n options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n // A Firestore onSnapshot error terminates the listener and it does not\n // recover on its own. Stop advertising presence (clear the heartbeat +\n // write online:false) so remotes see the host as offline instead of a\n // live host that silently consumes no commands.\n clearInterval(beat);\n setDoc(presence, { online: false, updatedAt: serverTimestamp() }).catch(noop);\n options.onClosed?.();\n },\n );\n\n return () => {\n clearInterval(beat);\n setDoc(presence, { online: false, updatedAt: serverTimestamp() }).catch(noop);\n unsubscribe();\n };\n};\n","// Remote-host lifecycle: wire a Firebase session to the Firestore host runner so\n// connecting starts the command loop + presence heartbeat, and disconnecting\n// stops both.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/index.ts. The factory\n// takes injected collaborators — real hosts bind them to Firebase; tests pass\n// fakes to exercise the invariants (non-destructive connect, serialized\n// transitions, status/liveness reconciliation on fatal listener death) without a\n// network. `hostId` and the logger are injected too, so this file imports no\n// Firebase and no host logger and stays trivially unit-testable.\n//\n// Single-account, single-host per instance, in-memory session: a host restart\n// drops the session and needs a re-connect.\nimport type { Channel, CommandHandlers } from \"../index.js\";\nimport type { HostRunnerOptions } from \"./hostRunner.js\";\n\nexport interface RemoteHostStatus {\n connected: boolean;\n uid: string | null;\n}\n\n// Minimal logger the factory calls; each host adapts its own logger to this\n// shape (or omits it to run silently, as the tests do).\nexport interface RemoteHostLogger {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n debug: (msg: string) => void;\n}\n\n// Injectable collaborators — a real host binds these to Firebase + its own\n// hostId; tests pass fakes to exercise the lifecycle without a network.\n// `startRunner` is the host's `startHostRunner` pre-bound with its Firestore\n// instance, so this module needs no Firebase of its own.\nexport interface RemoteHostDeps {\n hostId: string;\n signIn: (idToken: string) => Promise<string>;\n signOut: () => Promise<void>;\n currentUid: () => string | null;\n startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;\n handlers: CommandHandlers;\n log?: RemoteHostLogger;\n}\n\nexport interface RemoteHostLifecycle {\n connect: (idToken: string) => Promise<RemoteHostStatus>;\n disconnect: () => Promise<RemoteHostStatus>;\n status: () => RemoteHostStatus;\n}\n\nconst noop = () => undefined;\nconst silentLogger: RemoteHostLogger = { info: noop, warn: noop, debug: noop };\n\nexport const createRemoteHost = (deps: RemoteHostDeps): RemoteHostLifecycle => {\n const log = deps.log ?? silentLogger;\n\n // The running host runner's stop() handle, or null when disconnected. Keeps\n // the single-host invariant — one runner at a time.\n let stopRunner: (() => void) | null = null;\n\n // Serialize connect/disconnect so overlapping requests can't both mutate\n // stopRunner (which would leak a second runner) or race auth against teardown.\n // Each transition runs only after the previous one settles; a failed\n // transition does not block the next (both handlers run the next op).\n let transition: Promise<unknown> = Promise.resolve();\n\n const serialize = <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n\n const stopIfRunning = () => {\n if (stopRunner) {\n stopRunner();\n stopRunner = null;\n }\n };\n\n const status = (): RemoteHostStatus => ({ connected: stopRunner !== null, uid: deps.currentUid() });\n\n const startRunner = (uid: string) => {\n const runner = deps.startRunner({ uid, hostId: deps.hostId }, deps.handlers, {\n onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),\n // The listener died fatally (heartbeat already stopped + offline written);\n // clear the handle so status() stops reporting connected — but only if it\n // still points at THIS runner (a later reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n return runner;\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n // Authenticate BEFORE any teardown, so a failed connect (expired/rejected\n // token) leaves an existing healthy session untouched instead of dropping it.\n const uid = await deps.signIn(idToken);\n stopIfRunning();\n stopRunner = startRunner(uid);\n log.info(`connected as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n"],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ export { startHostRunner } from './hostRunner.js';
2
+ export type { HostEvent, HostRunnerOptions } from './hostRunner.js';
3
+ export { createRemoteHost } from './lifecycle.js';
4
+ export type { RemoteHostStatus, RemoteHostLogger, RemoteHostDeps, RemoteHostLifecycle } from './lifecycle.js';
5
+ export { createRemoteHostAuth } from './auth.js';
6
+ export type { RemoteHostAuth } from './auth.js';
7
+ export { createRemoteHostFirebase } from './firebase.js';
8
+ export type { RemoteHostFirebase } from './firebase.js';
@@ -0,0 +1,190 @@
1
+ import { t as errorMessage } from "../../errorMessage-DIdhheHk.js";
2
+ import { commandsCollection, hostDoc } from "../index.js";
3
+ import { getFirestore, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from "firebase/firestore";
4
+ import { GoogleAuthProvider, getAuth, signInWithCredential, signOut } from "firebase/auth";
5
+ import { initializeApp } from "firebase/app";
6
+ import { getStorage } from "firebase/storage";
7
+ //#region src/remote-host/server/hostRunner.ts
8
+ var DEFAULT_HEARTBEAT_MS = 6e4;
9
+ var noop$1 = () => void 0;
10
+ var writeError = (ref, code, message) => updateDoc(ref, {
11
+ status: "error",
12
+ error: {
13
+ code,
14
+ message
15
+ },
16
+ updatedAt: serverTimestamp()
17
+ }).catch(noop$1);
18
+ var claimCommand = (firestore, ref) => runTransaction(firestore, async (txn) => {
19
+ const data = (await txn.get(ref)).data();
20
+ if (!data || data.status !== "queued") return null;
21
+ txn.update(ref, {
22
+ status: "processing",
23
+ updatedAt: serverTimestamp()
24
+ });
25
+ return {
26
+ method: data.method,
27
+ params: data.params ?? {}
28
+ };
29
+ });
30
+ var runHandler = async (ref, claim, handler) => {
31
+ try {
32
+ await updateDoc(ref, {
33
+ status: "done",
34
+ result: await handler(claim.params) ?? null,
35
+ updatedAt: serverTimestamp()
36
+ });
37
+ return {
38
+ phase: "done",
39
+ method: claim.method
40
+ };
41
+ } catch (error) {
42
+ const message = errorMessage(error);
43
+ await writeError(ref, "handler_error", message);
44
+ return {
45
+ phase: "error",
46
+ method: claim.method,
47
+ message
48
+ };
49
+ }
50
+ };
51
+ var processCommand = async (firestore, ref, handlers, onEvent) => {
52
+ const claim = await claimCommand(firestore, ref);
53
+ if (!claim) return;
54
+ onEvent?.({
55
+ phase: "received",
56
+ method: claim.method
57
+ });
58
+ const handler = handlers[claim.method];
59
+ if (!handler) {
60
+ await writeError(ref, "unknown_method", `No handler for method: ${claim.method}`);
61
+ onEvent?.({
62
+ phase: "error",
63
+ method: claim.method,
64
+ message: "unknown method"
65
+ });
66
+ return;
67
+ }
68
+ onEvent?.(await runHandler(ref, claim, handler));
69
+ };
70
+ var startHostRunner = (firestore, channel, handlers, options = {}) => {
71
+ const presence = hostDoc(firestore, channel);
72
+ const announce = () => {
73
+ setDoc(presence, {
74
+ online: true,
75
+ updatedAt: serverTimestamp()
76
+ }).catch(noop$1);
77
+ };
78
+ announce();
79
+ const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);
80
+ const unsubscribe = onSnapshot(query(commandsCollection(firestore, channel), where("status", "==", "queued")), (snapshot) => {
81
+ snapshot.docChanges().forEach((change) => {
82
+ if (change.type === "added") processCommand(firestore, change.doc.ref, handlers, options.onEvent).catch(noop$1);
83
+ });
84
+ }, (error) => {
85
+ options.onEvent?.({
86
+ phase: "error",
87
+ method: "listen",
88
+ message: error.message
89
+ });
90
+ clearInterval(beat);
91
+ setDoc(presence, {
92
+ online: false,
93
+ updatedAt: serverTimestamp()
94
+ }).catch(noop$1);
95
+ options.onClosed?.();
96
+ });
97
+ return () => {
98
+ clearInterval(beat);
99
+ setDoc(presence, {
100
+ online: false,
101
+ updatedAt: serverTimestamp()
102
+ }).catch(noop$1);
103
+ unsubscribe();
104
+ };
105
+ };
106
+ //#endregion
107
+ //#region src/remote-host/server/lifecycle.ts
108
+ var noop = () => void 0;
109
+ var silentLogger = {
110
+ info: noop,
111
+ warn: noop,
112
+ debug: noop
113
+ };
114
+ var createRemoteHost = (deps) => {
115
+ const log = deps.log ?? silentLogger;
116
+ let stopRunner = null;
117
+ let transition = Promise.resolve();
118
+ const serialize = (operation) => {
119
+ const next = transition.then(operation, operation);
120
+ transition = next.then(noop, noop);
121
+ return next;
122
+ };
123
+ const stopIfRunning = () => {
124
+ if (stopRunner) {
125
+ stopRunner();
126
+ stopRunner = null;
127
+ }
128
+ };
129
+ const status = () => ({
130
+ connected: stopRunner !== null,
131
+ uid: deps.currentUid()
132
+ });
133
+ const startRunner = (uid) => {
134
+ const runner = deps.startRunner({
135
+ uid,
136
+ hostId: deps.hostId
137
+ }, deps.handlers, {
138
+ onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),
139
+ onClosed: () => {
140
+ if (stopRunner === runner) {
141
+ stopRunner = null;
142
+ log.warn("host runner listener died; marked disconnected");
143
+ }
144
+ }
145
+ });
146
+ return runner;
147
+ };
148
+ const connect = (idToken) => serialize(async () => {
149
+ const uid = await deps.signIn(idToken);
150
+ stopIfRunning();
151
+ stopRunner = startRunner(uid);
152
+ log.info(`connected as ${uid}, host runner started (hostId=${deps.hostId})`);
153
+ return status();
154
+ });
155
+ const disconnect = () => serialize(async () => {
156
+ stopIfRunning();
157
+ await deps.signOut();
158
+ log.info("disconnected, host runner stopped");
159
+ return status();
160
+ });
161
+ return {
162
+ connect,
163
+ disconnect,
164
+ status
165
+ };
166
+ };
167
+ //#endregion
168
+ //#region src/remote-host/server/auth.ts
169
+ var createRemoteHostAuth = (auth) => ({
170
+ signInHost: async (idToken) => {
171
+ return (await signInWithCredential(auth, GoogleAuthProvider.credential(idToken))).user.uid;
172
+ },
173
+ signOutHost: () => signOut(auth),
174
+ currentUid: () => auth.currentUser?.uid ?? null
175
+ });
176
+ //#endregion
177
+ //#region src/remote-host/server/firebase.ts
178
+ var createRemoteHostFirebase = (config) => {
179
+ const app = initializeApp(config);
180
+ return {
181
+ app,
182
+ auth: getAuth(app),
183
+ firestore: getFirestore(app),
184
+ storage: getStorage(app)
185
+ };
186
+ };
187
+ //#endregion
188
+ export { createRemoteHost, createRemoteHostAuth, createRemoteHostFirebase, startHostRunner };
189
+
190
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/remote-host/server/hostRunner.ts","../../../src/remote-host/server/lifecycle.ts","../../../src/remote-host/server/auth.ts","../../../src/remote-host/server/firebase.ts"],"sourcesContent":["// Host side of the command channel: claim queued commands, run handlers, write\n// results back, and announce presence via heartbeat.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/hostRunner.ts (itself\n// ported from ../mulmoserver). The only signature change vs. that copy: the\n// `firestore` instance is a parameter (each host supplies its own Firebase init),\n// and the heartbeat interval is an option (defaults to one minute).\nimport { DocumentReference, Firestore, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from \"firebase/firestore\";\n\nimport { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport { Channel, Command, CommandHandler, CommandHandlers, JsonObject, commandsCollection, hostDoc } from \"../index.js\";\n\nconst DEFAULT_HEARTBEAT_MS = 60_000;\n\nexport interface HostEvent {\n phase: \"received\" | \"done\" | \"error\";\n method: string;\n message?: string;\n}\n\nexport interface HostRunnerOptions {\n onEvent?: (event: HostEvent) => void;\n // Called once when the listener dies fatally (after presence has been set\n // offline), so the lifecycle owner can reconcile its own state — e.g. clear\n // the runner handle so status() no longer reports connected. NOT called on a\n // normal stop().\n onClosed?: () => void;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number;\n}\n\ninterface Claim {\n method: string;\n params: JsonObject;\n}\n\nconst noop = () => undefined;\n\n// The remote may have deleted the doc on timeout, so ignore write-after-delete.\nconst writeError = (ref: DocumentReference, code: string, message: string) =>\n updateDoc(ref, { status: \"error\", error: { code, message }, updatedAt: serverTimestamp() }).catch(noop);\n\n// Atomically move a command queued -> processing so it is handled exactly once.\n// Returns the method/params to run, or null if another handler already took it.\nconst claimCommand = (firestore: Firestore, ref: DocumentReference): Promise<Claim | null> =>\n runTransaction(firestore, async (txn) => {\n const data = (await txn.get(ref)).data() as Command | undefined;\n if (!data || data.status !== \"queued\") {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return { method: data.method, params: data.params ?? {} };\n });\n\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler): Promise<HostEvent> => {\n try {\n const result = await handler(claim.params);\n await updateDoc(ref, { status: \"done\", result: result ?? null, updatedAt: serverTimestamp() });\n return { phase: \"done\", method: claim.method };\n } catch (error) {\n const message = errorMessage(error);\n await writeError(ref, \"handler_error\", message);\n return { phase: \"error\", method: claim.method, message };\n }\n};\n\nconst processCommand = async (firestore: Firestore, ref: DocumentReference, handlers: CommandHandlers, onEvent?: HostRunnerOptions[\"onEvent\"]) => {\n const claim = await claimCommand(firestore, ref);\n if (!claim) {\n return;\n }\n onEvent?.({ phase: \"received\", method: claim.method });\n const handler: CommandHandler | undefined = handlers[claim.method];\n if (!handler) {\n await writeError(ref, \"unknown_method\", `No handler for method: ${claim.method}`);\n onEvent?.({ phase: \"error\", method: claim.method, message: \"unknown method\" });\n return;\n }\n onEvent?.(await runHandler(ref, claim, handler));\n};\n\n// startHostRunner subscribes to queued commands for the given channel and runs\n// each one through the supplied handler table. It also announces presence (a\n// heartbeat on users/{uid}/hosts/{hostId}) so the remote can tell it is online.\n// Returns a stop function that goes offline and detaches the listener.\nexport const startHostRunner = (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions = {}): (() => void) => {\n const presence = hostDoc(firestore, channel);\n const announce = () => {\n setDoc(presence, { online: true, updatedAt: serverTimestamp() }).catch(noop);\n };\n announce();\n const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const unsubscribe = onSnapshot(\n queuedCommands,\n (snapshot) => {\n snapshot.docChanges().forEach((change) => {\n if (change.type === \"added\") {\n processCommand(firestore, change.doc.ref, handlers, options.onEvent).catch(noop);\n }\n });\n },\n (error) => {\n options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n // A Firestore onSnapshot error terminates the listener and it does not\n // recover on its own. Stop advertising presence (clear the heartbeat +\n // write online:false) so remotes see the host as offline instead of a\n // live host that silently consumes no commands.\n clearInterval(beat);\n setDoc(presence, { online: false, updatedAt: serverTimestamp() }).catch(noop);\n options.onClosed?.();\n },\n );\n\n return () => {\n clearInterval(beat);\n setDoc(presence, { online: false, updatedAt: serverTimestamp() }).catch(noop);\n unsubscribe();\n };\n};\n","// Remote-host lifecycle: wire a Firebase session to the Firestore host runner so\n// connecting starts the command loop + presence heartbeat, and disconnecting\n// stops both.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/index.ts. The factory\n// takes injected collaborators — real hosts bind them to Firebase; tests pass\n// fakes to exercise the invariants (non-destructive connect, serialized\n// transitions, status/liveness reconciliation on fatal listener death) without a\n// network. `hostId` and the logger are injected too, so this file imports no\n// Firebase and no host logger and stays trivially unit-testable.\n//\n// Single-account, single-host per instance, in-memory session: a host restart\n// drops the session and needs a re-connect.\nimport type { Channel, CommandHandlers } from \"../index.js\";\nimport type { HostRunnerOptions } from \"./hostRunner.js\";\n\nexport interface RemoteHostStatus {\n connected: boolean;\n uid: string | null;\n}\n\n// Minimal logger the factory calls; each host adapts its own logger to this\n// shape (or omits it to run silently, as the tests do).\nexport interface RemoteHostLogger {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n debug: (msg: string) => void;\n}\n\n// Injectable collaborators — a real host binds these to Firebase + its own\n// hostId; tests pass fakes to exercise the lifecycle without a network.\n// `startRunner` is the host's `startHostRunner` pre-bound with its Firestore\n// instance, so this module needs no Firebase of its own.\nexport interface RemoteHostDeps {\n hostId: string;\n signIn: (idToken: string) => Promise<string>;\n signOut: () => Promise<void>;\n currentUid: () => string | null;\n startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;\n handlers: CommandHandlers;\n log?: RemoteHostLogger;\n}\n\nexport interface RemoteHostLifecycle {\n connect: (idToken: string) => Promise<RemoteHostStatus>;\n disconnect: () => Promise<RemoteHostStatus>;\n status: () => RemoteHostStatus;\n}\n\nconst noop = () => undefined;\nconst silentLogger: RemoteHostLogger = { info: noop, warn: noop, debug: noop };\n\nexport const createRemoteHost = (deps: RemoteHostDeps): RemoteHostLifecycle => {\n const log = deps.log ?? silentLogger;\n\n // The running host runner's stop() handle, or null when disconnected. Keeps\n // the single-host invariant — one runner at a time.\n let stopRunner: (() => void) | null = null;\n\n // Serialize connect/disconnect so overlapping requests can't both mutate\n // stopRunner (which would leak a second runner) or race auth against teardown.\n // Each transition runs only after the previous one settles; a failed\n // transition does not block the next (both handlers run the next op).\n let transition: Promise<unknown> = Promise.resolve();\n\n const serialize = <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n\n const stopIfRunning = () => {\n if (stopRunner) {\n stopRunner();\n stopRunner = null;\n }\n };\n\n const status = (): RemoteHostStatus => ({ connected: stopRunner !== null, uid: deps.currentUid() });\n\n const startRunner = (uid: string) => {\n const runner = deps.startRunner({ uid, hostId: deps.hostId }, deps.handlers, {\n onEvent: (event) => log.debug(`host event: ${event.phase} ${event.method}`),\n // The listener died fatally (heartbeat already stopped + offline written);\n // clear the handle so status() stops reporting connected — but only if it\n // still points at THIS runner (a later reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n return runner;\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n // Authenticate BEFORE any teardown, so a failed connect (expired/rejected\n // token) leaves an existing healthy session untouched instead of dropping it.\n const uid = await deps.signIn(idToken);\n stopIfRunning();\n stopRunner = startRunner(uid);\n log.info(`connected as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n"],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ import { Channel, CommandHandlers } from '../index.js';
2
+ import { HostRunnerOptions } from './hostRunner.js';
3
+ export interface RemoteHostStatus {
4
+ connected: boolean;
5
+ uid: string | null;
6
+ }
7
+ export interface RemoteHostLogger {
8
+ info: (msg: string) => void;
9
+ warn: (msg: string) => void;
10
+ debug: (msg: string) => void;
11
+ }
12
+ export interface RemoteHostDeps {
13
+ hostId: string;
14
+ signIn: (idToken: string) => Promise<string>;
15
+ signOut: () => Promise<void>;
16
+ currentUid: () => string | null;
17
+ startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;
18
+ handlers: CommandHandlers;
19
+ log?: RemoteHostLogger;
20
+ }
21
+ export interface RemoteHostLifecycle {
22
+ connect: (idToken: string) => Promise<RemoteHostStatus>;
23
+ disconnect: () => Promise<RemoteHostStatus>;
24
+ status: () => RemoteHostStatus;
25
+ }
26
+ export declare const createRemoteHost: (deps: RemoteHostDeps) => RemoteHostLifecycle;
@@ -1,4 +1,4 @@
1
- import { a as FEED_SCHEDULES, c as isFieldDrivenEvery, i as AGENT_INGEST_KIND, o as INGEST_KINDS, s as embedTargetId, t as deriveAll } from "./deriveAll-D3wFH4Tw.js";
1
+ import { a as resolveIcon, c as AGENT_INGEST_KIND, d as embedTargetId, f as isFieldDrivenEvery, i as firstDateField, l as FEED_SCHEDULES, o as selectDynamicRecord, t as deriveAll, u as INGEST_KINDS } from "./deriveAll-CMFXDQ_G.js";
2
2
  import { isSafeActionTemplatePath, isSafeCustomViewI18nPath, isSafeCustomViewPath } from "./collection/paths.js";
3
3
  import path from "node:path";
4
4
  import { promises, realpathSync } from "node:fs";
@@ -970,6 +970,51 @@ var AgentIngestZ = z.object({
970
970
  template: z.string().trim().min(1).refine(isSafeActionTemplatePath, "must be a safe path under `templates/` (e.g. `templates/refresh.md`; no `..`, no leading `/`, no backslash)")
971
971
  });
972
972
  var IngestSchemaZ = z.discriminatedUnion("kind", [DeclarativeIngestZ, AgentIngestZ]);
973
+ var ValueRefZ = z.object({
974
+ record: z.string().trim().min(1).optional(),
975
+ field: z.string().trim().min(1)
976
+ });
977
+ var WhereCondZ = z.object({
978
+ field: z.string().trim().min(1),
979
+ op: z.enum([
980
+ "eq",
981
+ "ne",
982
+ "in",
983
+ "gt",
984
+ "gte",
985
+ "lt",
986
+ "lte",
987
+ "contains"
988
+ ]),
989
+ value: z.union([z.string(), z.array(z.string())]).optional(),
990
+ valueFrom: ValueRefZ.optional()
991
+ }).refine((cond) => cond.value !== void 0 !== (cond.valueFrom !== void 0), {
992
+ message: "a where condition must declare exactly one of `value` (a literal) or `valueFrom` (a reference to another record's field), never both or neither",
993
+ path: ["value"]
994
+ }).refine((cond) => cond.value === void 0 || cond.op === "in" === Array.isArray(cond.value), {
995
+ message: "`in` requires an array `value` (the allowed set); every other op requires a single string `value`",
996
+ path: ["value"]
997
+ });
998
+ var WhereZ = z.array(WhereCondZ);
999
+ var DynamicIconSourceZ = z.object({
1000
+ collection: z.string().trim().min(1),
1001
+ from: z.enum([
1002
+ "latest",
1003
+ "first",
1004
+ "when"
1005
+ ]).optional(),
1006
+ orderBy: z.string().trim().min(1).optional(),
1007
+ where: WhereZ.optional()
1008
+ });
1009
+ var DynamicIconRuleZ = z.object({
1010
+ where: WhereZ,
1011
+ icon: z.string().trim().min(1)
1012
+ });
1013
+ var DynamicIconSpecZ = z.object({
1014
+ source: DynamicIconSourceZ,
1015
+ rules: z.array(DynamicIconRuleZ),
1016
+ fallback: z.string().trim().min(1).optional()
1017
+ });
973
1018
  var CollectionSchemaZ = z.object({
974
1019
  title: z.string().min(1),
975
1020
  icon: z.string().min(1),
@@ -991,7 +1036,8 @@ var CollectionSchemaZ = z.object({
991
1036
  kanbanField: z.string().trim().min(1).optional(),
992
1037
  views: z.array(CustomViewSchema).optional(),
993
1038
  notifyWhen: WhenSchema.optional(),
994
- ingest: IngestSchemaZ.optional()
1039
+ ingest: IngestSchemaZ.optional(),
1040
+ dynamicIcon: DynamicIconSpecZ.optional()
995
1041
  }).refine((schema) => schema.singleton === void 0 || safeRecordId(schema.singleton) !== null, {
996
1042
  message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
997
1043
  path: ["singleton"]
@@ -1347,6 +1393,52 @@ async function enrichItems(collection, items, opts = {}) {
1347
1393
  return items.map((item) => projectComputed(schema, deriveAll(schema, item, refRecords), linked));
1348
1394
  }
1349
1395
  //#endregion
1396
+ //#region src/collection/server/dynamicIcon.ts
1397
+ /** Index `items` by their `primaryKey` value — the `recordsById` map a
1398
+ * `valueFrom` reference (e.g. `_config.defaultCity`) resolves against.
1399
+ * Items whose primary key isn't a string (shouldn't happen for a valid
1400
+ * schema, but records are untyped storage) are skipped rather than
1401
+ * coerced, so a broken key never silently shadows a real one. */
1402
+ function buildRecordsById(items, primaryKey) {
1403
+ const entries = items.filter((item) => typeof item[primaryKey] === "string").map((item) => [String(item[primaryKey]), item]);
1404
+ return Object.fromEntries(entries);
1405
+ }
1406
+ /** Order records by their `primaryKey` so record selection is deterministic:
1407
+ * `listItems` returns filesystem `readdir` order (arbitrary across machines),
1408
+ * which would let `from: "first"`, the no-`orderBy` `latest`, and `orderBy`
1409
+ * ties pick a different record — and thus a different icon — between
1410
+ * reconciles. A stable id sort pins one answer. */
1411
+ function sortByPrimaryKey(items, primaryKey) {
1412
+ return [...items].sort((left, right) => String(left[primaryKey] ?? "").localeCompare(String(right[primaryKey] ?? "")));
1413
+ }
1414
+ /** Compute the effective launcher icon for `collection`: its static
1415
+ * `schema.icon` when it declares no `dynamicIcon`, else the icon
1416
+ * resolved from `dynamicIcon.source`'s RAW stored records (no
1417
+ * derive/enrich — the icon rules match against stored values) via the
1418
+ * pure resolver. Fails soft on any read/discovery error (missing source
1419
+ * collection, filesystem error): falls back to `dynamicIcon.fallback ??
1420
+ * schema.icon` rather than surfacing to the collections list. */
1421
+ async function computeCollectionIcon(collection, opts = {}) {
1422
+ const { schema } = collection;
1423
+ const spec = schema.dynamicIcon;
1424
+ if (!spec) return schema.icon;
1425
+ try {
1426
+ const source = await loadCollection(spec.source.collection, opts);
1427
+ if (!source) return spec.fallback ?? schema.icon;
1428
+ const ordered = sortByPrimaryKey(await listItems(source.dataDir, { workspaceRoot: opts.workspaceRoot }), source.schema.primaryKey);
1429
+ const orderBy = spec.source.orderBy ?? firstDateField(source.schema);
1430
+ const recordsById = buildRecordsById(ordered, source.schema.primaryKey);
1431
+ return resolveIcon(selectDynamicRecord(ordered, spec.source, orderBy, recordsById), spec, schema.icon, recordsById);
1432
+ } catch (err) {
1433
+ log.warn("collections", "dynamic icon compute failed, falling back", {
1434
+ slug: collection.slug,
1435
+ source: spec.source.collection,
1436
+ error: String(err)
1437
+ });
1438
+ return spec.fallback ?? schema.icon;
1439
+ }
1440
+ }
1441
+ //#endregion
1350
1442
  //#region src/collection/server/util.ts
1351
1443
  /** Human-readable message from an unknown thrown value. */
1352
1444
  function errorMessage(err) {
@@ -1823,6 +1915,6 @@ async function deleteCustomView(collection, viewId, opts = {}) {
1823
1915
  };
1824
1916
  }
1825
1917
  //#endregion
1826
- export { promptPathsFor as A, isContainedInWorkspace as B, validateCollectionRecords as C, deleteItem as D, buildCollectionActionSeedPrompt as E, resolveCreateItemId as F, safeSlugName as G, resolveDataDir as H, writeItem as I, getWorkspaceRoot as J, collectionsRegistriesConfigPath as K, writeFileAtomic as L, readCustomViewI18n as M, readItem as N, generateItemId as O, readSkillTemplate as P, SCHEMA_FILE as R, COMPUTED_TYPES as S, buildActionSeedPrompt as T, resolveTemplatePath as U, itemFilePath as V, safeRecordId as W, publishCollectionChange as X, log as Y, setCollectionChangePublisher as Z, acceptParsedSchema as _, computeSuccessor as a, toDetail as b, isTriggerDue as c, resolveEvery as d, successorId as f, CollectionSchemaZ as g, enrichItems as h, advanceTriggerDate as i, readCustomViewHtml as j, listItems as k, maybeSpawnSuccessor as l, errorMessage as m, deleteCollection as n, daysInMonth as o, ONE_SECOND_MS as p, configureCollectionHost as q, deleteCollectionRefusalMessage as r, formatCivil as s, deleteCustomView as t, parseCivil as u, discoverCollections as v, validateRecordObject as w, toSummary as x, loadCollection as y, isContainedInRoot as z };
1918
+ export { listItems as A, isContainedInRoot as B, COMPUTED_TYPES as C, buildCollectionActionSeedPrompt as D, buildActionSeedPrompt as E, readSkillTemplate as F, safeRecordId as G, itemFilePath as H, resolveCreateItemId as I, configureCollectionHost as J, safeSlugName as K, writeItem as L, readCustomViewHtml as M, readCustomViewI18n as N, deleteItem as O, readItem as P, setCollectionChangePublisher as Q, writeFileAtomic as R, toSummary as S, validateRecordObject as T, resolveDataDir as U, isContainedInWorkspace as V, resolveTemplatePath as W, log as X, getWorkspaceRoot as Y, publishCollectionChange as Z, CollectionSchemaZ as _, computeSuccessor as a, loadCollection as b, isTriggerDue as c, resolveEvery as d, successorId as f, enrichItems as g, computeCollectionIcon as h, advanceTriggerDate as i, promptPathsFor as j, generateItemId as k, maybeSpawnSuccessor as l, errorMessage as m, deleteCollection as n, daysInMonth as o, ONE_SECOND_MS as p, collectionsRegistriesConfigPath as q, deleteCollectionRefusalMessage as r, formatCivil as s, deleteCustomView as t, parseCivil as u, acceptParsedSchema as v, validateCollectionRecords as w, toDetail as x, discoverCollections as y, SCHEMA_FILE as z };
1827
1919
 
1828
- //# sourceMappingURL=server-BDxrLT41.js.map
1920
+ //# sourceMappingURL=server-DKXXFTbw.js.map