@diegoaltoworks/chatter 0.25.0 → 0.27.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/README.md CHANGED
@@ -25,6 +25,7 @@
25
25
  - 📱 **Mobile Optimized**: Responsive design with iOS/Android-specific fixes
26
26
  - ⚡ **High Performance**: Built on Hono with streaming support
27
27
  - 🛡️ **Security First**: Rate limiting, CORS, referrer checking, and input guardrails
28
+ - 💸 **Usage Metering**: Per-caller and global daily caps for paid features, multi-instance safe, via `@diegoaltoworks/chatter/usage` ([guide](./docs/usage.md))
28
29
  - 📦 **Framework Agnostic**: Works with any JavaScript framework or vanilla JS
29
30
  - 🎯 **TypeScript**: Fully typed for excellent developer experience
30
31
 
@@ -71,8 +72,9 @@ Complete guides for setup, deployment, and integration:
71
72
  - **[Requirements](./docs/requirements.md)** - OpenAI, Turso, Clerk setup and pricing
72
73
  - **[Server Setup](./docs/server.md)** - Configuration, knowledge base, prompts, API keys
73
74
  - **[Client Integration](./docs/client.md)** - Widgets, React components, theming
75
+ - **[Usage Metering](./docs/usage.md)** - Daily spend caps for paid features
74
76
  - **[Deployment](./docs/deployment.md)** - Google Cloud Run, Fly.io, Railway, VPS
75
- - **[Testing](./docs/testing.md)** - Comprehensive testing guide (254 tests, 98% coverage)
77
+ - **[Testing](./docs/testing.md)** - Comprehensive testing guide
76
78
  - **[Code Quality](./docs/CODE_QUALITY.md)** - Security assessment and architecture review
77
79
  - **[FAQs](./docs/faqs.md)** - Troubleshooting and common questions
78
80
 
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Channel-agnostic reply gates — pure and testable, zero imports from the
3
+ * rest of chatter. A transport (WhatsApp, or any future channel) resolves
4
+ * its own wire format into a {@link ChannelMessage} and calls
5
+ * {@link decideChannelAction} to learn whether to answer, stay quiet, or
6
+ * flip a mute switch. Nothing here knows about jids, Baileys, or any
7
+ * specific bot's name — mention/reply-to detection is the transport's job;
8
+ * this module only combines the already-resolved booleans with allowlist,
9
+ * mute, and rate-limit policy.
10
+ */
11
+ /** The slice of an inbound message every channel can resolve into this shape. */
12
+ export interface ChannelMessage {
13
+ /** The conversation this message belongs to — a DM thread or a group. */
14
+ chatId: string;
15
+ senderId: string;
16
+ text: string;
17
+ /** True for a 1:1 conversation; false for a group/channel with other members. */
18
+ isDirectMessage: boolean;
19
+ /** Resolved by the caller: does this message @-mention the bot? */
20
+ mentionsBot: boolean;
21
+ /** Resolved by the caller: is this message a reply to one of the bot's own messages? */
22
+ isReplyToBot: boolean;
23
+ /** True when the bot itself sent this message (including via another session — see {@link isEffectivelyFromSelf}). */
24
+ fromBot: boolean;
25
+ }
26
+ export type ChannelAction = "reply" | "ignore" | "mute" | "unmute";
27
+ export interface ChannelGateConfig {
28
+ /** Chats eligible for a reply. Empty = every chat is eligible (the master allowlist switch). */
29
+ allowedChats: string[];
30
+ /** Group chats currently muted — addressed messages there are ignored until unmuted. Has no effect on DMs, which always reply. */
31
+ mutedChats: Set<string>;
32
+ /**
33
+ * Caller-supplied trigger for "go quiet in this group chat". No default:
34
+ * this module ships no bot name or in-character phrasing, so mute/unmute
35
+ * are inert unless a host configures a pattern. Never applied to DMs.
36
+ */
37
+ muteRegex?: RegExp;
38
+ unmuteRegex?: RegExp;
39
+ }
40
+ /**
41
+ * DMs always reply. In groups, only an allowlisted, unmuted chat gets a
42
+ * reply, and only when addressed (mentioned or replying to the bot) —
43
+ * merely mentioning the bot's name in third person is not an invitation.
44
+ * The unmute check runs before the muted-chat gate so an unmute phrase can
45
+ * un-silence the bot in the same message, and both run after the allowlist
46
+ * so a chat the bot isn't eligible for can't toggle its mute state.
47
+ */
48
+ export declare function decideChannelAction(msg: ChannelMessage, config: ChannelGateConfig): ChannelAction;
49
+ /**
50
+ * Sliding-window rate limiter (pure, injectable clock): true = allowed and
51
+ * counted. One instance guards one budget — callers wanting separate DM and
52
+ * group budgets create two (see {@link underReplyRateLimit}).
53
+ */
54
+ export declare function createSlidingWindowRateLimiter(maxPerWindow: number, windowMs: number, now?: () => number): (key: string) => boolean;
55
+ /**
56
+ * Routes a chat to its DM or group rate limiter so a flood in one can't
57
+ * spend the other's budget — a DM flood used to be entirely unthrottled
58
+ * before this split existed. Each limiter is expected to be its own
59
+ * {@link createSlidingWindowRateLimiter} instance.
60
+ */
61
+ export declare function underReplyRateLimit(msg: Pick<ChannelMessage, "chatId" | "isDirectMessage">, limiters: {
62
+ dm: (chatId: string) => boolean;
63
+ group: (chatId: string) => boolean;
64
+ }): boolean;
65
+ /**
66
+ * Every session's own identities ever seen, keyed by session id. A
67
+ * multi-session deployment (e.g. two linked numbers in one process) gives
68
+ * each session only its own identity from its transport — a message that
69
+ * actually came from a *different* session's identity looks like a
70
+ * stranger, not "us", and the two sessions can ping-pong replies at each
71
+ * other indefinitely. Callers populate this directly (`registry.set(sessionId, ids)`)
72
+ * as each session's identities become known, typically on connect and kept
73
+ * in sync as they change.
74
+ *
75
+ * Entries are NOT expected to be removed when a session disconnects: a
76
+ * session's own identity is still "us" while it reconnects, and losing that
77
+ * during exactly the disconnect/reconnect window this guard exists for
78
+ * would defeat it.
79
+ */
80
+ export type SessionIdentityRegistry = Map<string, string[]>;
81
+ /** True when `id` matches an identity of ANY session ever registered, not just the one that received the message. */
82
+ export declare function isFromAnySession(id: string | undefined, registry: SessionIdentityRegistry): boolean;
83
+ /**
84
+ * The one predicate transports need before deciding fromBot: a message is
85
+ * effectively from the bot itself if the transport already flagged it so,
86
+ * or if its sender id matches an identity registered by any session (the
87
+ * loop-guard case a single session's own flag can't catch).
88
+ */
89
+ export declare function isEffectivelyFromSelf(msg: Pick<ChannelMessage, "fromBot" | "senderId">, registry: SessionIdentityRegistry): boolean;
90
+ //# sourceMappingURL=gates.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gates.d.ts","sourceRoot":"","sources":["../../src/channels/gates.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,iFAAiF;IACjF,eAAe,EAAE,OAAO,CAAC;IACzB,mEAAmE;IACnE,WAAW,EAAE,OAAO,CAAC;IACrB,wFAAwF;IACxF,YAAY,EAAE,OAAO,CAAC;IACtB,sHAAsH;IACtH,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEnE,MAAM,WAAW,iBAAiB;IAChC,gGAAgG;IAChG,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,kIAAkI;IAClI,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACxB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAOD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,cAAc,EAAE,MAAM,EAAE,iBAAiB,GAAG,aAAa,CAYjG;AAED;;;;GAIG;AACH,wBAAgB,8BAA8B,CAC5C,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,GAAG,GAAE,MAAM,MAAiB,SAGD,MAAM,KAAG,OAAO,CAY5C;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,QAAQ,GAAG,iBAAiB,CAAC,EACvD,QAAQ,EAAE;IAAE,EAAE,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC;IAAC,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAA;CAAE,GAChF,OAAO,CAET;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,uBAAuB,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;AAE5D,qHAAqH;AACrH,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,MAAM,GAAG,SAAS,EACtB,QAAQ,EAAE,uBAAuB,GAChC,OAAO,CAMT;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,SAAS,GAAG,UAAU,CAAC,EACjD,QAAQ,EAAE,uBAAuB,GAChC,OAAO,CAET"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=gates.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gates.test.d.ts","sourceRoot":"","sources":["../../src/channels/gates.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Usage metering — daily spend guards for anything that costs money per call.
3
+ *
4
+ * Published as the `@diegoaltoworks/chatter/usage` subpath so the core install
5
+ * stays untouched by it. The limiter itself is pure; the Turso store is the
6
+ * shipped persistence binding and needs `@libsql/client`, which chatter
7
+ * already expects for retrieval.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+ export type { DailyLimitCheck, DailyLimiter, DailyLimiterDeps, DailyLimitReason, DailyLimitsConfig, DailyLimitsStore, } from "./limits";
12
+ export { createDailyLimiter, GLOBAL_LIMIT_KEY, pickDailyLimit, utcDayKey } from "./limits";
13
+ export { createTursoUsageStore } from "./tursoStore";
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/usage/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,YAAY,EACV,eAAe,EACf,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC3F,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC"}
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/usage/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ GLOBAL_LIMIT_KEY: () => GLOBAL_LIMIT_KEY,
24
+ createDailyLimiter: () => createDailyLimiter,
25
+ createTursoUsageStore: () => createTursoUsageStore,
26
+ pickDailyLimit: () => pickDailyLimit,
27
+ utcDayKey: () => utcDayKey
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/usage/limits.ts
32
+ var GLOBAL_LIMIT_KEY = "global";
33
+ function utcDayKey(now) {
34
+ return new Date(now).toISOString().slice(0, 10);
35
+ }
36
+ function createDailyLimiter(config, deps) {
37
+ const now = deps.now ?? Date.now;
38
+ return {
39
+ async checkAndReserve(key) {
40
+ const day = utcDayKey(now());
41
+ const perKeyCount = await deps.store.incrementAndGet("key", key, day);
42
+ if (perKeyCount > config.perKeyDailyLimit) {
43
+ return { allowed: false, reason: "per-key" };
44
+ }
45
+ const globalCount = await deps.store.incrementAndGet("global", GLOBAL_LIMIT_KEY, day);
46
+ if (globalCount > config.globalDailyLimit) {
47
+ return { allowed: false, reason: "global" };
48
+ }
49
+ return { allowed: true, reason: null };
50
+ }
51
+ };
52
+ }
53
+ function pickDailyLimit(value, fallback) {
54
+ const trimmed = value?.trim();
55
+ if (trimmed === void 0 || trimmed === "") return fallback;
56
+ const parsed = Number(trimmed);
57
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
58
+ }
59
+
60
+ // src/usage/tursoStore.ts
61
+ var schemaReady = /* @__PURE__ */ new WeakMap();
62
+ function ensureUsageSchema(client, tableName) {
63
+ let perTable = schemaReady.get(client);
64
+ if (!perTable) {
65
+ perTable = /* @__PURE__ */ new Map();
66
+ schemaReady.set(client, perTable);
67
+ }
68
+ const tables = perTable;
69
+ let ready = tables.get(tableName);
70
+ if (!ready) {
71
+ ready = client.execute(
72
+ `CREATE TABLE IF NOT EXISTS ${tableName} (
73
+ scope TEXT NOT NULL,
74
+ key TEXT NOT NULL,
75
+ day TEXT NOT NULL,
76
+ count INTEGER NOT NULL DEFAULT 0,
77
+ PRIMARY KEY (scope, key, day)
78
+ )`
79
+ ).catch((error) => {
80
+ tables.delete(tableName);
81
+ throw error;
82
+ });
83
+ tables.set(tableName, ready);
84
+ }
85
+ return ready;
86
+ }
87
+ var VALID_TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
88
+ function createTursoUsageStore(client, tableName) {
89
+ if (!VALID_TABLE_NAME.test(tableName)) {
90
+ throw new Error(`Invalid usage store table name: ${tableName}`);
91
+ }
92
+ return {
93
+ async incrementAndGet(scope, key, day) {
94
+ await ensureUsageSchema(client, tableName);
95
+ const result = await client.execute({
96
+ sql: `INSERT INTO ${tableName} (scope, key, day, count) VALUES (?, ?, ?, 1)
97
+ ON CONFLICT(scope, key, day) DO UPDATE SET count = count + 1
98
+ RETURNING count`,
99
+ args: [scope, key, day]
100
+ });
101
+ const row = result.rows[0];
102
+ if (!row) {
103
+ throw new Error(`${tableName} returned no row from upsert`);
104
+ }
105
+ return Number(row.count);
106
+ }
107
+ };
108
+ }
109
+ // Annotate the CommonJS export names for ESM import in node:
110
+ 0 && (module.exports = {
111
+ GLOBAL_LIMIT_KEY,
112
+ createDailyLimiter,
113
+ createTursoUsageStore,
114
+ pickDailyLimit,
115
+ utcDayKey
116
+ });
@@ -0,0 +1,85 @@
1
+ // src/usage/limits.ts
2
+ var GLOBAL_LIMIT_KEY = "global";
3
+ function utcDayKey(now) {
4
+ return new Date(now).toISOString().slice(0, 10);
5
+ }
6
+ function createDailyLimiter(config, deps) {
7
+ const now = deps.now ?? Date.now;
8
+ return {
9
+ async checkAndReserve(key) {
10
+ const day = utcDayKey(now());
11
+ const perKeyCount = await deps.store.incrementAndGet("key", key, day);
12
+ if (perKeyCount > config.perKeyDailyLimit) {
13
+ return { allowed: false, reason: "per-key" };
14
+ }
15
+ const globalCount = await deps.store.incrementAndGet("global", GLOBAL_LIMIT_KEY, day);
16
+ if (globalCount > config.globalDailyLimit) {
17
+ return { allowed: false, reason: "global" };
18
+ }
19
+ return { allowed: true, reason: null };
20
+ }
21
+ };
22
+ }
23
+ function pickDailyLimit(value, fallback) {
24
+ const trimmed = value?.trim();
25
+ if (trimmed === void 0 || trimmed === "") return fallback;
26
+ const parsed = Number(trimmed);
27
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
28
+ }
29
+
30
+ // src/usage/tursoStore.ts
31
+ var schemaReady = /* @__PURE__ */ new WeakMap();
32
+ function ensureUsageSchema(client, tableName) {
33
+ let perTable = schemaReady.get(client);
34
+ if (!perTable) {
35
+ perTable = /* @__PURE__ */ new Map();
36
+ schemaReady.set(client, perTable);
37
+ }
38
+ const tables = perTable;
39
+ let ready = tables.get(tableName);
40
+ if (!ready) {
41
+ ready = client.execute(
42
+ `CREATE TABLE IF NOT EXISTS ${tableName} (
43
+ scope TEXT NOT NULL,
44
+ key TEXT NOT NULL,
45
+ day TEXT NOT NULL,
46
+ count INTEGER NOT NULL DEFAULT 0,
47
+ PRIMARY KEY (scope, key, day)
48
+ )`
49
+ ).catch((error) => {
50
+ tables.delete(tableName);
51
+ throw error;
52
+ });
53
+ tables.set(tableName, ready);
54
+ }
55
+ return ready;
56
+ }
57
+ var VALID_TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
58
+ function createTursoUsageStore(client, tableName) {
59
+ if (!VALID_TABLE_NAME.test(tableName)) {
60
+ throw new Error(`Invalid usage store table name: ${tableName}`);
61
+ }
62
+ return {
63
+ async incrementAndGet(scope, key, day) {
64
+ await ensureUsageSchema(client, tableName);
65
+ const result = await client.execute({
66
+ sql: `INSERT INTO ${tableName} (scope, key, day, count) VALUES (?, ?, ?, 1)
67
+ ON CONFLICT(scope, key, day) DO UPDATE SET count = count + 1
68
+ RETURNING count`,
69
+ args: [scope, key, day]
70
+ });
71
+ const row = result.rows[0];
72
+ if (!row) {
73
+ throw new Error(`${tableName} returned no row from upsert`);
74
+ }
75
+ return Number(row.count);
76
+ }
77
+ };
78
+ }
79
+ export {
80
+ GLOBAL_LIMIT_KEY,
81
+ createDailyLimiter,
82
+ createTursoUsageStore,
83
+ pickDailyLimit,
84
+ utcDayKey
85
+ };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Daily spend guards — the counting half of "this feature costs money, cap it".
3
+ *
4
+ * Framework-free and storage-free: this module knows how to decide, never how
5
+ * to persist. A host supplies a {@link DailyLimitsStore} (see ./tursoStore for
6
+ * the shipped one) and instantiates one limiter per metered resource — image
7
+ * generation, voice minutes, anything with a per-unit price — by giving each
8
+ * its own store table. Nothing here is chat-specific.
9
+ */
10
+ /** The two caps every metered resource is subject to. */
11
+ export interface DailyLimitsConfig {
12
+ /** Units one key may reserve per UTC day. */
13
+ perKeyDailyLimit: number;
14
+ /** Units all keys together may reserve per UTC day. */
15
+ globalDailyLimit: number;
16
+ }
17
+ /** The key the global counter is stored under — that scope has no caller key. */
18
+ export declare const GLOBAL_LIMIT_KEY = "global";
19
+ /** Which cap blocked the request, or null when it was allowed. */
20
+ export type DailyLimitReason = "per-key" | "global" | null;
21
+ export interface DailyLimitCheck {
22
+ allowed: boolean;
23
+ reason: DailyLimitReason;
24
+ }
25
+ /**
26
+ * Structural dependency — a host implements this against its own storage.
27
+ *
28
+ * Must increment and read back atomically. Deployments run multiple instances,
29
+ * so a read-then-write from this process would race with another instance's
30
+ * and both would see room under a cap that only had space for one.
31
+ */
32
+ export interface DailyLimitsStore {
33
+ /** Increments the counter for (scope, key, day) and returns the count after incrementing. */
34
+ incrementAndGet(scope: "key" | "global", key: string, day: string): Promise<number>;
35
+ }
36
+ /** The UTC calendar day counters roll over on, e.g. "2026-08-01". */
37
+ export declare function utcDayKey(now: number): string;
38
+ export interface DailyLimiterDeps {
39
+ store: DailyLimitsStore;
40
+ /** Clock, injectable so day-rollover tests don't depend on real time. */
41
+ now?: () => number;
42
+ }
43
+ export interface DailyLimiter {
44
+ /**
45
+ * Reserves one unit for `key`, subject to both daily caps.
46
+ *
47
+ * Call this exactly once per logical request: the increment is permanent and
48
+ * unconditional (there is no release path), so a duplicate call — or a retry
49
+ * after a transient failure — permanently burns a second unit of quota for
50
+ * one request never delivered. Blocked calls still count: a call that trips
51
+ * the per-key cap still consumes one of that key's daily units, and (by
52
+ * design — see below) a call that trips the global cap after passing the
53
+ * per-key check also consumes one of that key's units, for nothing.
54
+ *
55
+ * The per-key counter increments before the global one, so a key blocked at
56
+ * its own cap never touches the shared global budget. The mirror case is
57
+ * accepted, not fixed: once the global cap is exhausted for the day, every
58
+ * further caller still burns one of their own per-key units on the way to
59
+ * being told no. This favours guarding against the more common cost risk
60
+ * (one key hammering the endpoint) over the rarer one (the global cap
61
+ * saturating and clipping everyone's personal quota).
62
+ *
63
+ * Reserve before spending, not after: the ordering is what makes the cap a
64
+ * cap. Callers with a cache should check it first, so a cache hit costs
65
+ * neither money nor quota.
66
+ */
67
+ checkAndReserve(key: string): Promise<DailyLimitCheck>;
68
+ }
69
+ export declare function createDailyLimiter(config: DailyLimitsConfig, deps: DailyLimiterDeps): DailyLimiter;
70
+ /**
71
+ * Reads a cap out of loosely-typed configuration (an env var, a JSON field).
72
+ *
73
+ * A configured non-negative integer wins over the default; anything else —
74
+ * unset, blank, negative, fractional, unparseable — falls back. A malformed
75
+ * cap must not silently become `NaN`, which compares false against every
76
+ * count and would disable the guard entirely.
77
+ *
78
+ * Blank is trimmed before parsing rather than compared raw: `Number(" ")` is
79
+ * `0`, so a config value that is whitespace would otherwise be read as a cap
80
+ * of zero and switch the feature off entirely instead of falling back.
81
+ *
82
+ * This is the supported way to build a {@link DailyLimitsConfig}. The limiter
83
+ * itself does not validate its caps, so a `NaN` reaching it from somewhere
84
+ * else fails open.
85
+ */
86
+ export declare function pickDailyLimit(value: string | undefined, fallback: number): number;
87
+ //# sourceMappingURL=limits.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"limits.d.ts","sourceRoot":"","sources":["../../src/usage/limits.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,yDAAyD;AACzD,MAAM,WAAW,iBAAiB;IAChC,6CAA6C;IAC7C,gBAAgB,EAAE,MAAM,CAAC;IACzB,uDAAuD;IACvD,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,WAAW,CAAC;AAEzC,kEAAkE;AAClE,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,QAAQ,GAAG,IAAI,CAAC;AAE3D,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,gBAAgB,CAAC;CAC1B;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,6FAA6F;IAC7F,eAAe,CAAC,KAAK,EAAE,KAAK,GAAG,QAAQ,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACrF;AAED,qEAAqE;AACrE,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,gBAAgB,CAAC;IACxB,yEAAyE;IACzE,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;CACxD;AAED,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,iBAAiB,EACzB,IAAI,EAAE,gBAAgB,GACrB,YAAY,CAoBd;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAKlF"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Daily limiter tests.
3
+ *
4
+ * Two properties matter more than the arithmetic and are asserted directly:
5
+ * the *order* of the two increments (a key blocked at its own cap must not
6
+ * burn shared budget), and the reserve-once semantics (every call counts,
7
+ * including blocked ones — there is no release path to lean on).
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=limits.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"limits.test.d.ts","sourceRoot":"","sources":["../../src/usage/limits.test.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Turso/libsql-backed {@link DailyLimitsStore}.
3
+ *
4
+ * Schema and upsert only — error typing and wrapping stay with the caller,
5
+ * because each metered feature wants its own error contract and this layer
6
+ * should not invent one for them.
7
+ *
8
+ * `@libsql/client` is imported for its type alone, so this module adds no
9
+ * runtime dependency to anyone importing `./usage` for the pure limiter.
10
+ */
11
+ import type { Client } from "@libsql/client";
12
+ import type { DailyLimitsStore } from "./limits";
13
+ /**
14
+ * Creates a store that counts into `tableName` on `client`.
15
+ *
16
+ * The upsert is a single atomic statement returning the post-increment count,
17
+ * so concurrent instances each get a distinct number and the cap holds across
18
+ * a multi-instance deployment. Table creation is idempotent and happens once
19
+ * per (client, table).
20
+ *
21
+ * Give each metered resource its own table — sharing one would make separate
22
+ * features compete for a single global counter.
23
+ */
24
+ export declare function createTursoUsageStore(client: Client, tableName: string): DailyLimitsStore;
25
+ //# sourceMappingURL=tursoStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tursoStore.d.ts","sourceRoot":"","sources":["../../src/usage/tursoStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAyCjD;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,gBAAgB,CAuBzF"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Turso usage store tests.
3
+ *
4
+ * Run against a real in-memory libsql client rather than a fake, because the
5
+ * properties worth proving here are SQL-level: the upsert is atomic, it hands
6
+ * every concurrent caller a distinct number, and table creation is idempotent.
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=tursoStore.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tursoStore.test.d.ts","sourceRoot":"","sources":["../../src/usage/tursoStore.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegoaltoworks/chatter",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Embeddable AI chatbot framework with RAG, authentication, and customizable widgets",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -28,6 +28,11 @@
28
28
  "types": "./dist/mcp-server.d.ts",
29
29
  "import": "./dist/mcp-server.mjs",
30
30
  "require": "./dist/mcp-server.js"
31
+ },
32
+ "./usage": {
33
+ "types": "./dist/usage/index.d.ts",
34
+ "import": "./dist/usage/index.mjs",
35
+ "require": "./dist/usage/index.js"
31
36
  }
32
37
  },
33
38
  "files": [
@@ -37,12 +42,13 @@
37
42
  ],
38
43
  "scripts": {
39
44
  "dev": "bun --watch src/index.ts",
40
- "build": "bun run build:clean && bun run build:types && bun run build:esm && bun run build:cjs && bun run build:mcp && bun run build:bin && bun run build:client && bun run build:copy-client",
45
+ "build": "bun run build:clean && bun run build:types && bun run build:esm && bun run build:cjs && bun run build:mcp && bun run build:usage && bun run build:bin && bun run build:client && bun run build:copy-client",
41
46
  "build:clean": "rm -rf dist",
42
47
  "build:types": "tsc --emitDeclarationOnly --outDir dist",
43
48
  "build:esm": "esbuild src/index.ts --bundle --format=esm --outfile=dist/index.mjs --external:hono --external:openai --external:@libsql/client --external:jose --platform=node --target=node24",
44
49
  "build:cjs": "esbuild src/index.ts --bundle --format=cjs --outfile=dist/index.js --external:hono --external:openai --external:@libsql/client --external:jose --platform=node --target=node24",
45
50
  "build:mcp": "esbuild src/mcp-server.ts --bundle --format=esm --outfile=dist/mcp-server.mjs --external:@modelcontextprotocol/sdk --external:openai --external:@libsql/client --external:zod --platform=node --target=node24 && esbuild src/mcp-server.ts --bundle --format=cjs --outfile=dist/mcp-server.js --external:@modelcontextprotocol/sdk --external:openai --external:@libsql/client --external:zod --platform=node --target=node24",
51
+ "build:usage": "esbuild src/usage/index.ts --bundle --format=esm --outfile=dist/usage/index.mjs --external:@libsql/client --platform=node --target=node24 && esbuild src/usage/index.ts --bundle --format=cjs --outfile=dist/usage/index.js --external:@libsql/client --platform=node --target=node24",
46
52
  "build:bin": "esbuild bin/create-apikey.ts --bundle --format=cjs --outfile=dist/bin/create-apikey.js --external:jose --platform=node --target=node18 --banner:js='#!/usr/bin/env node'",
47
53
  "build:client": "cd src/client && bun run build",
48
54
  "build:copy-client": "mkdir -p dist/static && cp src/client/dist/chatter.js src/client/dist/chatter.css dist/static/",