@mangtre/core 0.1.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/LICENSE ADDED
@@ -0,0 +1,26 @@
1
+ Măng — Proprietary License
2
+
3
+ Copyright © 2026 RumitX. All rights reserved.
4
+
5
+ This software and its source code (the "Software"), including the Măng shell,
6
+ the @mangtre/* packages, and the bundled mini-apps in this repository, are the
7
+ confidential and proprietary property of RumitX.
8
+
9
+ No license, right, or permission is granted to any person to use, copy, modify,
10
+ merge, publish, distribute, sublicense, sell, or create derivative works of the
11
+ Software, in whole or in part, without the prior written consent of RumitX.
12
+
13
+ Unauthorized copying, distribution, or use of the Software, via any medium, is
14
+ strictly prohibited.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
18
+ FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL RUMITX BE LIABLE
19
+ FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+
22
+ Note: this proprietary notice protects the codebase during the early (NOW)
23
+ horizon. When Măng opens to third-party creators, a separate license may be
24
+ issued for the fork-able starter kit.
25
+
26
+ For licensing inquiries: RumitX — https://rumitx.com
@@ -0,0 +1,706 @@
1
+ /**
2
+ * @mangtre/core — the stable contract between the Măng shell and every mini-app.
3
+ *
4
+ * Golden rule: a mini-app NEVER touches real storage, `fetch`, or native APIs
5
+ * directly — always through `sdk.*`. Today these are backed by the monolith
6
+ * (dynamic import); later by a sandbox bridge. The app changes zero lines.
7
+ *
8
+ * See `sot/Mang_Tech_Foundations_v0.1.1.md` §2.
9
+ */
10
+ /** Capabilities a mini-app may request. Declared up front, even if granted freely now. */
11
+ type Permission = "storage" | "theme" | "ai" | "identity" | "context" | "data";
12
+ /**
13
+ * Languages Măng ships. The shell owns the active locale (detect + switch + persist) and
14
+ * hands it to every mini-app at mount via `MangCore.locale`. Vietnamese-first; English second.
15
+ */
16
+ type Locale = "vi" | "en";
17
+ /** A short string in every language Măng ships. */
18
+ type LocalizedText = Record<Locale, string>;
19
+ /**
20
+ * Where a mini-app feature can run. The shell can host an app on different *surfaces*, each with a
21
+ * different capability ceiling: `desktop` (Tauri Win/Mac — native sidecar, filesystem, heavy/local
22
+ * AI) is highest; `web` (browser + PWA — sandboxed, no heavy local AI) is mid; `zalo` (Zalo Mini
23
+ * App runtime) is most constrained. `"web"` covers PWA too (install/offline add nothing to the
24
+ * capability ceiling). Mobile-native is LATER — deliberately not in this union yet.
25
+ *
26
+ * Declared per feature so a single app can mix surfaces (e.g. "generate quiz with AI" → desktop
27
+ * only; "view/answer quiz" → everywhere). See `sot/Mang_Tech_Foundations` §2.1.
28
+ */
29
+ type Surface = "desktop" | "web" | "zalo";
30
+ /**
31
+ * A feature a mini-app exposes to the shell so it can be searched and launched directly from the
32
+ * host (e.g. the command palette). Declared STATICALLY on the manifest, so the shell can index
33
+ * every app's features WITHOUT loading its (code-split) runtime — keep this module data-only.
34
+ * The shell hands the chosen `id` back to the app at mount via `MangCore.launch`.
35
+ */
36
+ interface MangCommand {
37
+ /** Unique within the app, e.g. "history", "export", "jwt". Becomes the `/app/<id>/<command>` segment. */
38
+ id: string;
39
+ /** Shown in the palette — the app owns its own copy (vi + en). */
40
+ title: LocalizedText;
41
+ /** Extra search terms beyond the title ("csv", "bảng công", "decode"). */
42
+ keywords?: string[];
43
+ /** Emoji for the palette row; falls back to the app icon. */
44
+ icon?: string;
45
+ /**
46
+ * Surfaces this feature runs on. Omitted = ALL surfaces (the default — runs everywhere).
47
+ * Narrows within the app's `MangManifest.surfaces` envelope; a command should not claim a
48
+ * surface its app doesn't support. The shell will LATER use this to badge/disable unsupported
49
+ * features (an unavailable feature stays discoverable, never silently hidden) — for now it is a
50
+ * declared seam with no runtime gating. See `sot/Mang_Tech_Foundations` §2.1.
51
+ */
52
+ surfaces?: Surface[];
53
+ }
54
+ /**
55
+ * One screen in a mini-app's **main flow** (luồng chính) — the canonical happy-path a reviewer (and
56
+ * LATER a bot) walks to PROVE the real screens render and to AUTO-CAPTURE the store gallery/guide.
57
+ * Declared STATICALLY on the manifest (data-only, like `commands`) so the platform can read the flow
58
+ * WITHOUT loading the app's runtime.
59
+ *
60
+ * The contract a step asserts is a single DOM convention: when the screen is ready, the app MUST
61
+ * render an element carrying `data-mang-checkpoint="<step.checkpoint>"`. The walker navigates to the
62
+ * step (re-mounting with `launch: { command }`), waits for that element, screenshots, and records
63
+ * pass/fail. Populated screens come from the optional `MiniAppModule.prepareDemo` seed — steps do NOT
64
+ * simulate typing/clicking. See `sot/Mang_Tech_Foundations` §2.8.
65
+ */
66
+ interface FlowStep {
67
+ /** Unique within the app, e.g. "home", "settle". Becomes the screenshot/file key. */
68
+ id: string;
69
+ /** Step label shown in the generated guide (vi + en). */
70
+ title: LocalizedText;
71
+ /**
72
+ * A `MangCommand.id` to activate this screen via the launch seam. Omitted = the app's default
73
+ * screen on a plain mount (use for the first step / single-screen apps).
74
+ */
75
+ command?: string;
76
+ /**
77
+ * The app MUST render `[data-mang-checkpoint="<this>"]` when this screen is ready. This single DOM
78
+ * marker is the whole contract the walker waits on; a missing checkpoint fails the step.
79
+ */
80
+ checkpoint: string;
81
+ /** Optional caption shown under the captured screenshot in the guide. */
82
+ caption?: LocalizedText;
83
+ /**
84
+ * Surfaces this step applies to. Omitted = ALL (default). Narrows within the app's `surfaces`
85
+ * envelope — lets a desktop-only screen be skipped when walking the web surface.
86
+ */
87
+ surfaces?: Surface[];
88
+ }
89
+ /**
90
+ * An app's declared AI needs. DECLARATION-ONLY this phase (mirrors `surfaces`): the shell will
91
+ * LATER use it to badge / route / pre-warm / provision, but nothing gates on it now. This is the
92
+ * developer-facing contract that lets a creator TARGET the platform's AI seam ("I need a 'quality'
93
+ * power") instead of shipping their own model — local today, possibly remote LATER, zero app change.
94
+ * See `sot/Mang_Tech_Foundations` §2.4.
95
+ */
96
+ interface MangAiDeclaration {
97
+ /** Capability tiers the app's AI features rely on. The shell may use this to recommend downloads. */
98
+ capabilities: AICapability[];
99
+ }
100
+ /**
101
+ * An app's declared remote-data needs. DECLARATION-ONLY this phase (mirrors `ai`/`surfaces`): the
102
+ * shell uses it to BADGE the app (☁️ cloud-backed, ⚡ realtime) and LATER to route/provision, but
103
+ * nothing gates on it at runtime. This is the developer-facing contract that lets a creator TARGET
104
+ * the platform's hosted-data seam ("my app keeps data on Măng's server / is multiplayer") instead
105
+ * of shipping their own backend — the implementation (polling today, realtime LATER) swaps with zero
106
+ * app change. See `sot/Mang_Tech_Foundations` §2.7.
107
+ */
108
+ interface MangDataDeclaration {
109
+ /**
110
+ * The app coordinates many users on ONE shared dataset (a room shared by link). Drives the ⚡
111
+ * "Thời gian thực / nhiều người" badge. Omitted/false = private cross-device save only.
112
+ */
113
+ shared?: boolean;
114
+ /**
115
+ * The app benefits from live updates (vs. occasional save/restore). Today every transport is
116
+ * near-live (poll); a future WebSocket transport makes it truly realtime — declaration is stable.
117
+ */
118
+ realtime?: boolean;
119
+ }
120
+ /** A mini-app's identity + declared permissions + the features it exposes to the shell. */
121
+ interface MangManifest {
122
+ /** Stable slug, e.g. "chia-tien-nhom". Also the storage namespace. */
123
+ id: string;
124
+ name: string;
125
+ /** Emoji or asset reference for the launcher. */
126
+ icon: string;
127
+ version: string;
128
+ permissions: Permission[];
129
+ /** Features the app exposes to the host (searchable + launchable). Optional + additive. */
130
+ commands?: MangCommand[];
131
+ /**
132
+ * Surfaces the app supports overall (its envelope). Omitted = ALL surfaces. A `MangCommand`'s own
133
+ * `surfaces` narrows within this. See `sot/Mang_Tech_Foundations` §2.1.
134
+ */
135
+ surfaces?: Surface[];
136
+ /**
137
+ * The app's **main flow** (luồng chính) — the canonical happy-path screens, in order, that the
138
+ * platform walks to verify the app renders and to auto-capture its gallery/guide. A standard part
139
+ * of the mini-app contract (declared in code, not just docs); see `sot/Mang_Tech_Foundations` §2.8.
140
+ * Optional in the type but REQUIRED by curation policy (validation warns when absent, will fail).
141
+ */
142
+ flow?: FlowStep[];
143
+ /**
144
+ * Declared AI capabilities (developer-facing contract). Present only when `permissions` includes
145
+ * "ai". Declaration-only now (no runtime gate), like `surfaces`. See `sot/Mang_Tech_Foundations` §2.4.
146
+ */
147
+ ai?: MangAiDeclaration;
148
+ /**
149
+ * Declared remote-data needs (developer-facing contract). Present only when `permissions` includes
150
+ * "data". Declaration-only now (drives badges), like `ai`. See `sot/Mang_Tech_Foundations` §2.7.
151
+ */
152
+ data?: MangDataDeclaration;
153
+ }
154
+ /**
155
+ * What the shell asks a mini-app to do at mount. Set when the app is opened via a command
156
+ * (palette, deep link `/app/<id>/<command>`); absent on a plain open. The app reads it once on
157
+ * mount and routes to that feature. A focused launch seam — distinct from the reserved
158
+ * `context?` (Tre SDK) seam.
159
+ */
160
+ interface LaunchIntent {
161
+ /** The `MangCommand.id` the app should activate. */
162
+ command: string;
163
+ /** Optional extra arguments for the command (reserved; unused by current apps). */
164
+ params?: Record<string, string>;
165
+ }
166
+ /**
167
+ * Per-app key/value storage. Local now (e.g. localStorage/IndexedDB), sync later —
168
+ * the app cannot tell the difference. Keys are scoped to the owning app.
169
+ */
170
+ interface ScopedStorage {
171
+ get<T>(key: string): Promise<T | null>;
172
+ set<T>(key: string, val: T): Promise<void>;
173
+ list(prefix?: string): Promise<string[]>;
174
+ }
175
+ /**
176
+ * Măng brand design tokens, supplied by the shell (values live in `@mangtre/ui`).
177
+ * Palette per `sot/Mang_Art_Direction_v0.2.md` §5 — the Măng *product* identity
178
+ * ("Mầm số" — clean-modern, organic-bamboo), NOT the RumitX studio palette. The shell chrome wears
179
+ * these; a mini-app MAY use them but isn't required to (creator apps style
180
+ * themselves). The single shared thread with RumitX is `mangGreen`.
181
+ */
182
+ interface ThemeTokens {
183
+ color: {
184
+ mangGreen: string;
185
+ shoot: string;
186
+ bamboo: string;
187
+ soil: string;
188
+ sun: string;
189
+ paper: string;
190
+ dark: string;
191
+ };
192
+ font: {
193
+ heading: string;
194
+ body: string;
195
+ };
196
+ }
197
+ /** Sign-in / identity client. Reserved seam. */
198
+ type IdentityClient = Record<string, never>;
199
+ /** Shared context/memory layer ("Tre SDK", a future bet — NOT `tre-mem`). Reserved seam. */
200
+ type TreContext = Record<string, never>;
201
+ /** Why local AI isn't usable right now — drives the app's degraded-mode copy. */
202
+ type AIUnavailableReason = "no-runtime" | "no-model" | "unknown";
203
+ /**
204
+ * What KIND of work a request needs — a device-/transport-neutral tier the resolver maps to a
205
+ * concrete model. The app expresses INTENT ("I need quality Vietnamese text"); it never names a
206
+ * model. The resolver (TS transport + Rust `ai.rs`) routes tier → model and DEGRADES to a smaller
207
+ * installed model rather than hard-failing. User-facing, each tier is a white-label "power"
208
+ * ("Năng lực AI") — the user never sees the tier id or the model. Researched local roles today:
209
+ * - "fast" → tiny router/classifier (qwen3:0.6b)
210
+ * - "balanced" → everyday default (qwen3:1.7b) ← the seam's default tier
211
+ * - "quality" → best text + strong Vietnamese (qwen3:4b-instruct-2507-q4_K_M)
212
+ * - "code" → code generation/editing (qwen2.5-coder:1.5b / 3b)
213
+ * - "reasoning" → deep/multi-step (qwen2.5 ~7B)
214
+ * Open by intent, NOT by model: a future remote transport maps the SAME tiers to its own models.
215
+ */
216
+ type AICapability = "fast" | "balanced" | "quality" | "code" | "reasoning";
217
+ /** The tier assumed when a request omits `capability` — keeps `generate()` fully backward-compatible. */
218
+ declare const DEFAULT_AI_CAPABILITY: AICapability;
219
+ /**
220
+ * The user-facing "power" (năng lực AI) — the white-label face of an `AICapability`, presented
221
+ * WITHOUT naming a model or runtime. The setup/onboarding UI renders these chips instead of model
222
+ * ids; one power may be backed by several downloadable models (the resolver's table decides). Labels
223
+ * are localized because they're a primary user surface (Vietnamese-first).
224
+ */
225
+ interface AIPower {
226
+ /** The capability tier this power represents. */
227
+ capability: AICapability;
228
+ /** Short user-facing name, e.g. { vi: "Trả lời nhanh", en: "Quick answers" }. */
229
+ label: LocalizedText;
230
+ /** One-line description of what this power does — no model/runtime jargon. */
231
+ description: LocalizedText;
232
+ /** Emoji/asset for the power chip. */
233
+ icon: string;
234
+ }
235
+ /** Whether local AI can run right now, and which models / powers are available. */
236
+ interface AIStatus {
237
+ /** True only when a runtime is reachable AND at least one model is pulled. */
238
+ ready: boolean;
239
+ /** Model ids the runtime reports (e.g. "llama3.2", "qwen2.5"); empty when none. */
240
+ models: string[];
241
+ /** Set when `ready` is false, so the app can tell the user exactly what to fix. */
242
+ reason?: AIUnavailableReason;
243
+ /**
244
+ * Which capability tiers can run RIGHT NOW given what's installed (every entry resolves to some
245
+ * model after degradation). Empty/absent when not ready. Lets the UI show available powers without
246
+ * leaking model ids. Optional → shells that don't populate it still type-check.
247
+ */
248
+ capabilities?: AICapability[];
249
+ /**
250
+ * Where the ready AI runs. "local" = on-device (Ollama today; nothing leaves the device).
251
+ * "remote" is RESERVED for a LATER hosted transport — no remote impl ships now. Optional; absent
252
+ * is treated as "local" by consumers. This is the drop-in seam for the local/remote split.
253
+ */
254
+ source?: "local" | "remote";
255
+ }
256
+ /** One text-generation request. The app owns prompt wording + any output parsing. */
257
+ interface AIGenerateRequest {
258
+ /** The user/content prompt. */
259
+ prompt: string;
260
+ /** Optional system instruction (role / format guidance). */
261
+ system?: string;
262
+ /** Ask the model to return strict JSON (enables the runtime's JSON mode where supported). */
263
+ json?: boolean;
264
+ /** Cooperative cancellation (e.g. the user navigates away mid-generation). */
265
+ signal?: AbortSignal;
266
+ /**
267
+ * What KIND of work this needs. The transport resolves it to a concrete model (tier-aware +
268
+ * device-aware: degrades to the best smaller installed model, never hard-fails). Omitted →
269
+ * `DEFAULT_AI_CAPABILITY` ("balanced"). The app NEVER names a model — that's the resolver's job.
270
+ */
271
+ capability?: AICapability;
272
+ }
273
+ /** A completed generation. `text` is RAW model output — the app parses/validates it. */
274
+ interface AIGenerateResult {
275
+ text: string;
276
+ /** The model that actually answered (for display / a non-PII analytics dimension). */
277
+ model: string;
278
+ /** The tier this request resolved against (after any device degradation). Optional, display-only. */
279
+ capability?: AICapability;
280
+ }
281
+ /**
282
+ * A model the shell recommends the user download — a curated, transport-owned whitelist (the app
283
+ * never hardcodes model ids). Small/fast variants come first so first-run generation is quick.
284
+ */
285
+ interface AIModelOption {
286
+ /** The runtime's model id to pull, e.g. "llama3.2:3b". */
287
+ id: string;
288
+ /** Short display name, e.g. "Llama 3.2". */
289
+ label: string;
290
+ /** Human download-size hint, e.g. "~2 GB". */
291
+ sizeLabel: string;
292
+ /** The default pick the UI should highlight. */
293
+ recommended?: boolean;
294
+ /**
295
+ * The capability tier(s) this model best serves — lets setup group downloads by power
296
+ * ("install this to unlock the 'Chất lượng cao' power"). Optional + additive.
297
+ */
298
+ capabilities?: AICapability[];
299
+ }
300
+ /** Progress of a model download. `percent` is 0..100 when the runtime reports byte totals. */
301
+ interface AIPullProgress {
302
+ /** Coarse phase label from the runtime (e.g. "downloading", "verifying"). */
303
+ status: string;
304
+ /** 0..100 when known; omitted while the runtime hasn't reported totals yet. */
305
+ percent?: number;
306
+ }
307
+ /** Options for a model download: progress callback + cooperative cancellation. */
308
+ interface AIPullOptions {
309
+ onProgress?: (progress: AIPullProgress) => void;
310
+ signal?: AbortSignal;
311
+ }
312
+ /**
313
+ * Local-first AI capability ("the brain on the user's own machine"). Present ONLY on a shell that
314
+ * can host a model runtime — today the Tauri desktop shell talking to a local **Ollama** server, so
315
+ * NOTHING leaves the device. Web / PWA / Zalo leave this `undefined`; guard at call sites:
316
+ * `sdk.ai?.generate(...)`. The app MUST degrade gracefully when absent (manual paths stay usable).
317
+ *
318
+ * **Transport-agnostic by design:** the implementation (local Ollama today; a hosted/metered
319
+ * provider is a LATER swap — see Roadmap) lives behind this interface, so the mini-app never
320
+ * changes. Text-in / text-out on purpose — prompt templates and output parsing belong to the app,
321
+ * not the seam. See `sot/Mang_Tech_Foundations` §2.4.
322
+ *
323
+ * The onboarding members (`recommendedModels` / `pull` / `openInstall`) are OPTIONAL: they let an
324
+ * app guide a first-run user to install the runtime + download a model in-app, but a future hosted
325
+ * transport (nothing to install) simply omits them. Guard with optional chaining.
326
+ */
327
+ interface AIClient {
328
+ /** Probe the local runtime: is a model reachable right now? Cheap; safe to call on mount. */
329
+ status(): Promise<AIStatus>;
330
+ /** Generate text from a prompt. Rejects on failure (no runtime, timeout, model error). */
331
+ generate(req: AIGenerateRequest): Promise<AIGenerateResult>;
332
+ /** The curated download whitelist for this runtime (small/fast first). Absent on hosted transports. */
333
+ recommendedModels?(): AIModelOption[];
334
+ /**
335
+ * The user-facing powers ("năng lực AI") this transport can offer — the white-label face of its
336
+ * capability tiers. The setup UI renders these instead of model ids. Absent on transports that
337
+ * don't expose a power list. See `sot/Mang_Tech_Foundations` §2.4.
338
+ */
339
+ powers?(): AIPower[];
340
+ /**
341
+ * Download a model into the local runtime, reporting progress. Resolves when the model is ready;
342
+ * rejects on failure or cancellation. Present only when the runtime supports in-app downloads.
343
+ */
344
+ pull?(model: string, opts?: AIPullOptions): Promise<void>;
345
+ /** Open the runtime's install page in the OS browser (guided setup when no runtime is found). */
346
+ openInstall?(): Promise<void>;
347
+ }
348
+ /**
349
+ * A single usage event. NON-PII by contract: describe WHAT happened (a feature was used), never
350
+ * WHO did it or sensitive values (no names, no amounts, no free text). `props` carry small
351
+ * dimensions only — enums / counts / booleans — so the same event is safe to send anywhere a
352
+ * remote sink lands later.
353
+ */
354
+ interface AnalyticsEvent {
355
+ /** Dotted name the emitter owns, e.g. "expense.added", "report.exported". */
356
+ name: string;
357
+ /** Non-PII dimensions only: small enums / counts / booleans. */
358
+ props?: Record<string, string | number | boolean>;
359
+ }
360
+ /**
361
+ * What the shell's sink receives — an `AnalyticsEvent` plus provenance the emitter shouldn't set
362
+ * itself. `source` is `"shell"` for host events, or the mini-app id for app events (the SDK tags it).
363
+ */
364
+ interface TrackedEvent extends AnalyticsEvent {
365
+ source: string;
366
+ }
367
+ /**
368
+ * Mini-app-facing analytics capability. Fire-and-forget: `track` never throws and never blocks the
369
+ * UI. Like the other optional seams, it's present only when the shell supplies a sink — guard with
370
+ * `sdk.analytics?.track(...)`. The default sink keeps events on-device (local-first); a remote sink
371
+ * (Cloudflare Workers Analytics Engine via the shell's `_worker.js`, or a vendor) is a LATER swap
372
+ * that changes zero lines here. See `sot/Mang_Tech_Foundations` §2.2.
373
+ */
374
+ interface AnalyticsClient {
375
+ track(event: AnalyticsEvent): void;
376
+ }
377
+ /** The kind of payload a nearby device handed off — mirrors Smart Paste's own input space. */
378
+ type HandoffKind = "text" | "json" | "image";
379
+ /**
380
+ * One payload handed off from a nearby device on the same network. NON-PII by contract:
381
+ * `sourceDevice` is a coarse display label ("iPhone"), never an id / IP / account. The envelope
382
+ * is **versioned** (`v`) so a transport can evolve its shape without breaking older receivers
383
+ * (cf. the chia-tien-nhom share-link wire v1/v2). This is a ONE-SHOT handoff, NOT live sync.
384
+ */
385
+ interface HandoffPayload {
386
+ /** Envelope version. Bump when the shape changes; receivers validate it. */
387
+ v: 1;
388
+ kind: HandoffKind;
389
+ /** `text`/`json`: the raw string. `image`: a `data:image/<mime>;base64,...` data URL. */
390
+ data: string;
391
+ /** Short human label for the sender ("iPhone", "Pixel"). Display-only, non-PII. */
392
+ sourceDevice: string;
393
+ /** Epoch ms when the receiving shell got it (stamped by the sink). */
394
+ receivedAt: number;
395
+ }
396
+ /** A subscriber the mini-app registers to receive handed-off payloads. */
397
+ type HandoffListener = (payload: HandoffPayload) => void;
398
+ /**
399
+ * How a sender connects — rendered by the receiver UI, NOT assumed to be a LAN URL. Today's LAN
400
+ * transport encodes a `http://<lan-ip>:<port>/h#t=<token>` URL as a QR (`mode: "lan-qr"`); a future
401
+ * WebRTC transport could instead surface a short pairing `code`. Transport-neutral so the seam
402
+ * outlives any single mechanism.
403
+ */
404
+ interface HandoffPairing {
405
+ /** Open union so new transports add modes additively without a breaking change. */
406
+ mode: "lan-qr" | string;
407
+ /** Opaque payload to render as a QR (LAN: the URL + token). */
408
+ qr?: string;
409
+ /** Human-readable pairing code (e.g. WebRTC signaling). Reserved — unused by the LAN transport. */
410
+ code?: string;
411
+ }
412
+ /**
413
+ * Receive-only inbound channel for device handoff ("paste from a nearby device"). Present ONLY
414
+ * when the shell can host a transport — today the Tauri desktop shell + a local LAN server.
415
+ * Web / PWA / Zalo builds leave this `undefined`; guard at call sites: `sdk.handoff?.onPayload(...)`.
416
+ *
417
+ * **Transport-agnostic by design:** the implementation (LAN-HTTP today, WebRTC/BLE/relay later)
418
+ * swaps behind this interface, so the mini-app never changes. This is a ONE-SHOT handoff seam,
419
+ * NOT live multi-device sync (sync stays parked — see `sot/Mang_Tech_Foundations` §2.3 / Roadmap).
420
+ */
421
+ interface HandoffInbox {
422
+ /** Subscribe to incoming payloads. Returns an unsubscribe fn. Fires once per payload. */
423
+ onPayload(listener: HandoffListener): () => void;
424
+ /** Current connect-descriptor for the sender to scan/enter, or `null` until the transport is ready. */
425
+ pairing(): HandoffPairing | null;
426
+ /**
427
+ * Subscribe to "a sender device connected" signals (a nearby device opened the sender page) so the
428
+ * receiver can show a live "📱 connected" state. Carries the coarse, non-PII device label. Optional:
429
+ * present only when the transport reports sender presence. Returns an unsubscribe fn.
430
+ */
431
+ onSenderSeen?(listener: (device: string) => void): () => void;
432
+ }
433
+ /**
434
+ * How a room is shared. `"private"` = one creator/device's cross-device save (a personal cloud
435
+ * backup); `"shared"` = many users coordinate on ONE dataset via a room link (the multiplayer case).
436
+ * The shell injects the owning app id server-side, so a room is always scoped to its app.
437
+ */
438
+ type DataMode = "private" | "shared";
439
+ /** A room the app is currently joined to. The `roomKey` capability lives in the SHELL, never here. */
440
+ interface DataRoomInfo {
441
+ /** Public id (safe to put in a share link). */
442
+ roomId: string;
443
+ mode: DataMode;
444
+ }
445
+ /**
446
+ * Whether the data seam can talk to the server right now, and over which transport. `transport`
447
+ * is informational only — the app behaves identically whether it's near-live polling ("poll") or
448
+ * a future WebSocket ("ws"). Absent/`ready:false` → the app should fall back to local-only paths.
449
+ */
450
+ interface DataStatus {
451
+ ready: boolean;
452
+ reason?: string;
453
+ transport?: "poll" | "ws";
454
+ }
455
+ /**
456
+ * One change observed in the joined room — a key was written (`value` set) or deleted (`value: null`).
457
+ * `rev` is the room-wide monotonic revision after the change (also the optimistic-concurrency token).
458
+ */
459
+ interface DataChange<T = unknown> {
460
+ key: string;
461
+ value: T | null;
462
+ rev: number;
463
+ }
464
+ /**
465
+ * Hosted key/value data with optional live sharing — the SERVER-backed sibling of `ScopedStorage`.
466
+ * Present ONLY when the shell supplies a data transport (web/desktop with the platform API reachable
467
+ * + the app holds the `"data"` permission). Web/PWA before this lands, or unsupported surfaces, leave
468
+ * it `undefined`; guard at call sites: `sdk.data?.set(...)`. The app MUST degrade to local-only when
469
+ * absent — the seam is additive, never load-bearing for the core experience.
470
+ *
471
+ * **A room is a capability, not an account.** `createRoom` returns a `roomId` + an unguessable
472
+ * `roomKey`; whoever holds the key can read/write/subscribe (the same trust model as today's
473
+ * `#g1=`/`#tkb=` share links, now server-backed and live). No end-user login, no PII — an anonymous
474
+ * device id tags writes for presence only. The SHELL stores the `roomKey` and brokers every call;
475
+ * the app passes only keys + values. The app can only ever touch ITS OWN app's rooms (the host
476
+ * injects the app id).
477
+ *
478
+ * **Transport-agnostic by design:** near-live polling today, WebSocket realtime + presence LATER —
479
+ * the implementation swaps behind this interface with zero app change. Values are JSON-serializable;
480
+ * the app owns its own data schema. See `sot/Mang_Tech_Foundations` §2.7.
481
+ */
482
+ interface DataClient {
483
+ /** Can the seam reach the server right now? Cheap; safe to call on mount. */
484
+ status(): Promise<DataStatus>;
485
+ /**
486
+ * Create a fresh room and join it. Returns the public `roomId` plus the secret `roomKey` capability
487
+ * (put both in a share link to invite others). The shell remembers the key for subsequent calls.
488
+ */
489
+ createRoom(opts?: {
490
+ mode?: DataMode;
491
+ }): Promise<{
492
+ roomId: string;
493
+ roomKey: string;
494
+ mode: DataMode;
495
+ }>;
496
+ /** Join an existing room with its `roomKey` (e.g. from an opened share link). */
497
+ joinRoom(roomId: string, roomKey: string): Promise<DataRoomInfo>;
498
+ /** The room the app is joined to, or `null` before any create/join. */
499
+ current(): DataRoomInfo | null;
500
+ /** Read a key from the joined room. `null` when the key is absent (no room joined → rejects). */
501
+ get<T>(key: string): Promise<{
502
+ value: T | null;
503
+ rev: number;
504
+ } | null>;
505
+ /**
506
+ * Write a key. `expectedRev` enables optimistic concurrency: pass the `rev` you last saw and the
507
+ * write rejects with a conflict if someone else wrote in between (omit to force-write). Resolves
508
+ * with the new room-wide `rev`.
509
+ */
510
+ set<T>(key: string, val: T, expectedRev?: number): Promise<{
511
+ rev: number;
512
+ }>;
513
+ /** Delete a key (optionally guarded by `expectedRev`). */
514
+ remove(key: string, expectedRev?: number): Promise<{
515
+ rev: number;
516
+ }>;
517
+ /** List keys in the joined room (optionally by prefix) with each key's current `rev`. */
518
+ list(prefix?: string): Promise<{
519
+ key: string;
520
+ rev: number;
521
+ }[]>;
522
+ /**
523
+ * Revoke the joined room: its capability stops working for everyone and live subscribers are cut. Use
524
+ * to "stop sharing". The app should drop its local room pointer after this. No-op if no room is joined.
525
+ */
526
+ revoke(): Promise<void>;
527
+ /**
528
+ * Rotate the joined room's capability (e.g. a shared link leaked): returns a NEW `roomKey` to re-share;
529
+ * old links stop working and live subscribers are cut. The shell keeps using the room with the new key.
530
+ */
531
+ rotateKey(): Promise<{
532
+ roomKey: string;
533
+ }>;
534
+ /**
535
+ * Subscribe to changes in the joined room. Fires once per observed change. Returns an unsubscribe
536
+ * fn. Near-live (poll) today; a future transport makes it instantaneous — the callback shape is
537
+ * identical, so the app never changes.
538
+ */
539
+ subscribe<T>(listener: (change: DataChange<T>) => void): () => void;
540
+ }
541
+ /** Everything the shell hands a mini-app at mount time. */
542
+ interface MangCore {
543
+ storage: ScopedStorage;
544
+ theme: ThemeTokens;
545
+ /**
546
+ * Active UI language, chosen by the shell. Ambient runtime config (always present, like
547
+ * `theme`) — NOT a gated `Permission`. A mini-app SHOULD localize against it (e.g. via the
548
+ * `@mangtre/i18n` helper); the shell re-mounts the app when the user switches language.
549
+ */
550
+ locale: Locale;
551
+ /**
552
+ * The feature the shell wants active for THIS mount (set when opened via a command/deep link).
553
+ * Ambient like `theme`/`locale`, not a gated `Permission`. The shell re-mounts the app when the
554
+ * launch command changes, so reading it once on mount is enough.
555
+ */
556
+ launch?: LaunchIntent;
557
+ /**
558
+ * Local-first AI. Present ONLY on a shell that can host a model runtime — today the Tauri desktop
559
+ * shell + a local Ollama server (nothing leaves the device). Web / PWA / Zalo leave it `undefined`.
560
+ * Guard at call sites: `sdk.ai?.generate(...)`; the app degrades to manual paths when absent.
561
+ */
562
+ ai?: AIClient;
563
+ identity?: IdentityClient;
564
+ context?: TreContext;
565
+ /**
566
+ * Usage analytics. Present only when the shell supplies a sink. Local-first today (events buffer
567
+ * on-device); remote sink is a LATER swap. Guard at call sites: `sdk.analytics?.track(...)`.
568
+ */
569
+ analytics?: AnalyticsClient;
570
+ /**
571
+ * Inbound device handoff ("paste from a nearby device"). Present ONLY on a shell that can host a
572
+ * transport — today the Tauri desktop shell (LAN server). Receive-only, one-shot (NOT sync), and
573
+ * transport-agnostic. Guard at call sites: `sdk.handoff?.onPayload(...)`. See Tech Foundations §2.3.
574
+ */
575
+ handoff?: HandoffInbox;
576
+ /**
577
+ * Hosted data + live rooms ("Phòng sống") — the server-backed sibling of `storage`. Present ONLY
578
+ * when the shell supplies a data transport and the app holds the `"data"` permission. Lets a
579
+ * mini-app keep data on Măng's server and (with a room link) share ONE live dataset across many
580
+ * users — no backend, no end-user login, capability-based. Guard at call sites: `sdk.data?.set(...)`;
581
+ * the app degrades to local-only when absent. See Tech Foundations §2.7.
582
+ */
583
+ data?: DataClient;
584
+ }
585
+ /** Tear-down handle returned by a mini-app's `mount`. */
586
+ type Unmount = () => void;
587
+ /** The single entry every mini-app exports. */
588
+ type Mount = (root: HTMLElement, sdk: MangCore) => Unmount;
589
+ /**
590
+ * Optional hook a mini-app exports to seed DEMO state before its main flow is walked. Runs ONCE
591
+ * (before the first flow step) against the same `sdk.storage` the app reads on mount, so the
592
+ * subsequent re-mounts show populated screens for screenshots. Reuse the app's existing sample/seed
593
+ * logic. No-op / absent ⇒ the flow walks against whatever a fresh mount shows. See Tech Foundations §2.8.
594
+ */
595
+ type PrepareDemo = (sdk: MangCore) => Promise<void>;
596
+ /** Shape of a mini-app module loaded by the runtime. */
597
+ interface MiniAppModule {
598
+ manifest: MangManifest;
599
+ mount: Mount;
600
+ /** Seeds demo state for the flow walker; see {@link PrepareDemo}. */
601
+ prepareDemo?: PrepareDemo;
602
+ }
603
+
604
+ /**
605
+ * Tiny runtime guards for narrowing **untrusted JSON at a boundary** into typed values.
606
+ *
607
+ * A mini-app owns its own data schema (Măng stores opaque JSON — see {@link ScopedStorage} and
608
+ * `DataClient`). But data coming *back in* — a live-room snapshot another device wrote, a decoded
609
+ * share link, an old value with a since-changed shape — is semi-trusted input. Validate it before
610
+ * trusting it, or risk corrupting local state (or worse, an injection if the value reaches the DOM).
611
+ *
612
+ * These helpers follow the codebase's existing defensive style: **return `undefined` on anything
613
+ * malformed** (never throw), so a caller drops a bad payload instead of crashing. They are
614
+ * dependency-free on purpose — mini-app bundles are size-capped and statically scanned, so pulling a
615
+ * full schema library (zod, etc.) is the wrong trade here. Compose them to validate a whole record:
616
+ *
617
+ * ```ts
618
+ * import { isRecord, asString, asFiniteNumber, asArrayOf } from "@mangtre/core";
619
+ *
620
+ * interface Todo { id: string; text: string; done: boolean }
621
+ *
622
+ * function parseTodo(v: unknown): Todo | undefined {
623
+ * if (!isRecord(v)) return undefined;
624
+ * const id = asString(v.id);
625
+ * const text = asString(v.text);
626
+ * const done = asBoolean(v.done);
627
+ * if (id === undefined || text === undefined || done === undefined) return undefined;
628
+ * return { id, text, done };
629
+ * }
630
+ *
631
+ * const todos = asArrayOf(incoming, parseTodo); // undefined if ANY item is malformed
632
+ * ```
633
+ */
634
+ /** True when `v` is a non-null, non-array object — safe to index its keys as `unknown`. */
635
+ declare function isRecord(v: unknown): v is Record<string, unknown>;
636
+ /** A string, else `undefined`. */
637
+ declare function asString(v: unknown): string | undefined;
638
+ /** A finite number (rejects `NaN` and `±Infinity`), else `undefined`. */
639
+ declare function asFiniteNumber(v: unknown): number | undefined;
640
+ /** A boolean, else `undefined`. */
641
+ declare function asBoolean(v: unknown): boolean | undefined;
642
+ /**
643
+ * An array whose every item parses with `item`, else `undefined`. Short-circuits: if any item
644
+ * returns `undefined`, the whole array is rejected (a partially-valid list is still malformed
645
+ * input). Returns a fresh array; the input is never mutated.
646
+ */
647
+ declare function asArrayOf<T>(v: unknown, item: (x: unknown) => T | undefined): T[] | undefined;
648
+
649
+ /**
650
+ * Thrown when a write fails because the device storage quota is exhausted. Mini-apps
651
+ * can catch this to show a "storage full" message instead of a generic failure. (Other
652
+ * write errors propagate as-is.)
653
+ */
654
+ declare class StorageQuotaError extends Error {
655
+ constructor(message?: string);
656
+ }
657
+ /**
658
+ * Monolith implementation of {@link ScopedStorage} over `localStorage`.
659
+ * Keys are namespaced per app so mini-apps cannot read each other's data.
660
+ *
661
+ * This is the "local now" backing. Swapping to a synced backend later means
662
+ * replacing this factory — mini-apps are unaffected.
663
+ */
664
+ declare function createScopedLocalStorage(appId: string): ScopedStorage;
665
+
666
+ interface MonolithSdkOptions {
667
+ manifest: MangManifest;
668
+ theme: ThemeTokens;
669
+ locale: Locale;
670
+ /** Feature to activate on mount (set when opened via a command/deep link). */
671
+ launch?: LaunchIntent;
672
+ /**
673
+ * Low-level analytics sink supplied by the shell. Absent = the `analytics` seam stays dormant
674
+ * (no-op via optional chaining). The SDK wraps it into the mini-app's `AnalyticsClient`, tagging
675
+ * each event's `source` with the app id so the app never sets provenance itself.
676
+ */
677
+ track?: (event: TrackedEvent) => void;
678
+ /**
679
+ * Inbound device-handoff channel supplied by the shell. Absent = the `handoff` seam stays dormant
680
+ * (no-op via optional chaining). Receive-only and transport-agnostic; the SDK passes it through
681
+ * unchanged (no per-app tagging — dev-tools-only this phase). Present only on the desktop shell.
682
+ */
683
+ handoff?: HandoffInbox;
684
+ /**
685
+ * Local-first AI client supplied by the shell. Absent = the `ai` seam stays dormant (no-op via
686
+ * optional chaining). Transport-agnostic (local Ollama today); the SDK passes it through unchanged.
687
+ * Present only on a shell that can host a model runtime — today the Tauri desktop shell.
688
+ */
689
+ ai?: AIClient;
690
+ /**
691
+ * Hosted-data client supplied by the shell. Absent = the `data` seam stays dormant (no-op via
692
+ * optional chaining). Transport-agnostic (near-live polling today, WebSocket realtime later); the
693
+ * SDK passes it through unchanged. The shell builds it per-app (it already knows the app id), so
694
+ * cross-app isolation is enforced host-side. Present only when the platform API is reachable AND
695
+ * the app declared the `"data"` permission.
696
+ */
697
+ data?: DataClient;
698
+ }
699
+ /**
700
+ * Build the current (monolith) `MangCore` for a mini-app. Today this wires up
701
+ * local storage + injected theme. Later, a sandbox bridge produces the same
702
+ * shape — so mini-apps never change.
703
+ */
704
+ declare function createMonolithSdk(opts: MonolithSdkOptions): MangCore;
705
+
706
+ export { type AICapability, type AIClient, type AIGenerateRequest, type AIGenerateResult, type AIModelOption, type AIPower, type AIPullOptions, type AIPullProgress, type AIStatus, type AIUnavailableReason, type AnalyticsClient, type AnalyticsEvent, DEFAULT_AI_CAPABILITY, type DataChange, type DataClient, type DataMode, type DataRoomInfo, type DataStatus, type FlowStep, type HandoffInbox, type HandoffKind, type HandoffListener, type HandoffPairing, type HandoffPayload, type IdentityClient, type LaunchIntent, type Locale, type LocalizedText, type MangAiDeclaration, type MangCommand, type MangCore, type MangDataDeclaration, type MangManifest, type MiniAppModule, type MonolithSdkOptions, type Mount, type Permission, type PrepareDemo, type ScopedStorage, StorageQuotaError, type Surface, type ThemeTokens, type TrackedEvent, type TreContext, type Unmount, asArrayOf, asBoolean, asFiniteNumber, asString, createMonolithSdk, createScopedLocalStorage, isRecord };
package/dist/index.js ADDED
@@ -0,0 +1,102 @@
1
+ // src/types.ts
2
+ var DEFAULT_AI_CAPABILITY = "balanced";
3
+
4
+ // src/guards.ts
5
+ function isRecord(v) {
6
+ return typeof v === "object" && v !== null && !Array.isArray(v);
7
+ }
8
+ function asString(v) {
9
+ return typeof v === "string" ? v : void 0;
10
+ }
11
+ function asFiniteNumber(v) {
12
+ return typeof v === "number" && Number.isFinite(v) ? v : void 0;
13
+ }
14
+ function asBoolean(v) {
15
+ return typeof v === "boolean" ? v : void 0;
16
+ }
17
+ function asArrayOf(v, item) {
18
+ if (!Array.isArray(v)) return void 0;
19
+ const out = [];
20
+ for (const x of v) {
21
+ const parsed = item(x);
22
+ if (parsed === void 0) return void 0;
23
+ out.push(parsed);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ // src/storage.ts
29
+ var StorageQuotaError = class extends Error {
30
+ constructor(message = "Local storage quota exceeded") {
31
+ super(message);
32
+ this.name = "StorageQuotaError";
33
+ }
34
+ };
35
+ function isQuotaError(e) {
36
+ return e instanceof DOMException && (e.name === "QuotaExceededError" || e.name === "NS_ERROR_DOM_QUOTA_REACHED" || e.code === 22 || e.code === 1014);
37
+ }
38
+ function createScopedLocalStorage(appId) {
39
+ const ns = `mang:${appId}:`;
40
+ return {
41
+ async get(key) {
42
+ const raw = localStorage.getItem(ns + key);
43
+ if (raw === null) return null;
44
+ try {
45
+ return JSON.parse(raw);
46
+ } catch {
47
+ return null;
48
+ }
49
+ },
50
+ async set(key, val) {
51
+ try {
52
+ localStorage.setItem(ns + key, JSON.stringify(val));
53
+ } catch (e) {
54
+ if (isQuotaError(e)) throw new StorageQuotaError();
55
+ throw e;
56
+ }
57
+ },
58
+ async list(prefix = "") {
59
+ const full = ns + prefix;
60
+ const keys = [];
61
+ for (let i = 0; i < localStorage.length; i++) {
62
+ const k = localStorage.key(i);
63
+ if (k?.startsWith(full)) {
64
+ keys.push(k.slice(ns.length));
65
+ }
66
+ }
67
+ return keys;
68
+ }
69
+ };
70
+ }
71
+
72
+ // src/sdk.ts
73
+ function createMonolithSdk(opts) {
74
+ const track = opts.track;
75
+ return {
76
+ storage: createScopedLocalStorage(opts.manifest.id),
77
+ theme: opts.theme,
78
+ locale: opts.locale,
79
+ // `launch` is only set on the property when present, so a plain open leaves it `undefined`.
80
+ ...opts.launch ? { launch: opts.launch } : {},
81
+ // `analytics` only when the shell supplied a sink; events are tagged with the app id.
82
+ ...track ? { analytics: { track: (e) => track({ ...e, source: opts.manifest.id }) } } : {},
83
+ // `handoff` only when the shell can host a transport (desktop); passed through unchanged.
84
+ ...opts.handoff ? { handoff: opts.handoff } : {},
85
+ // `ai` only when the shell can host a model runtime (desktop + local Ollama); passed through unchanged.
86
+ ...opts.ai ? { ai: opts.ai } : {},
87
+ // `data` only when the shell supplies a hosted-data transport; built per-app (app-scoped server-side).
88
+ ...opts.data ? { data: opts.data } : {}
89
+ // identity / context: reserved seams, not provided yet.
90
+ };
91
+ }
92
+ export {
93
+ DEFAULT_AI_CAPABILITY,
94
+ StorageQuotaError,
95
+ asArrayOf,
96
+ asBoolean,
97
+ asFiniteNumber,
98
+ asString,
99
+ createMonolithSdk,
100
+ createScopedLocalStorage,
101
+ isRecord
102
+ };
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@mangtre/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "devDependencies": {
20
+ "tsup": "^8.3.5",
21
+ "vitest": "^2.1.8"
22
+ },
23
+ "scripts": {
24
+ "build": "tsup",
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "vitest run"
27
+ }
28
+ }