@diegoaltoworks/chatter 0.29.0 → 0.31.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.
@@ -0,0 +1,177 @@
1
+ // src/personas/greeter.ts
2
+ function capitalize(name) {
3
+ return name.charAt(0).toUpperCase() + name.slice(1);
4
+ }
5
+ function createGreeter({
6
+ resolver,
7
+ templates,
8
+ random = Math.random
9
+ }) {
10
+ return {
11
+ greet(contactId) {
12
+ try {
13
+ const contact = resolver.contactFor(contactId);
14
+ if (!contact?.name) return null;
15
+ const personaId = resolver.rollPersonaId(contactId) ?? resolver.defaultPersonaId();
16
+ const pool = personaId ? templates[personaId] : void 0;
17
+ if (!pool || pool.length === 0) return null;
18
+ const template = pool[Math.floor(random() * pool.length)];
19
+ if (!template) return null;
20
+ return template.replaceAll("{name}", capitalize(contact.name));
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+ };
26
+ }
27
+
28
+ // src/personas/resolver.ts
29
+ import { readFileSync } from "node:fs";
30
+ import { dirname, resolve } from "node:path";
31
+ var DEFAULT_WINDOW_MINUTES = 10;
32
+ var DEFAULT_PROBABILITY = 0.5;
33
+ function interpolate(template, vars) {
34
+ return Object.entries(vars).reduce(
35
+ (acc, [key, value]) => acc.replaceAll(`{{${key}}}`, value),
36
+ template
37
+ );
38
+ }
39
+ function createPersonaResolver(options = {}) {
40
+ if (!options.registry && !options.registryPath) {
41
+ throw new Error("createPersonaResolver requires a registry or registryPath");
42
+ }
43
+ const random = options.random ?? Math.random;
44
+ const now = options.now ?? Date.now;
45
+ const vars = options.vars ?? {};
46
+ const promptsDir = options.promptsDir ?? (options.registryPath ? dirname(options.registryPath) : process.cwd());
47
+ let registry = options.registry;
48
+ let registryLoadError;
49
+ const promptCache = /* @__PURE__ */ new Map();
50
+ const window = /* @__PURE__ */ new Map();
51
+ const warned = /* @__PURE__ */ new Set();
52
+ function warnOnce(kind, message, error) {
53
+ if (warned.has(kind)) return;
54
+ warned.add(kind);
55
+ console.warn(message, error);
56
+ }
57
+ function loadRegistry() {
58
+ if (registry) return registry;
59
+ if (registryLoadError !== void 0) throw registryLoadError;
60
+ try {
61
+ registry = JSON.parse(readFileSync(options.registryPath, "utf-8"));
62
+ return registry;
63
+ } catch (error) {
64
+ registryLoadError = error;
65
+ warnOnce(
66
+ "registry",
67
+ `Persona registry load failed (${options.registryPath}); falling back to the default persona for every contact.`,
68
+ error
69
+ );
70
+ throw error;
71
+ }
72
+ }
73
+ function contactFor(contactId) {
74
+ try {
75
+ return loadRegistry().contacts?.[contactId];
76
+ } catch {
77
+ return void 0;
78
+ }
79
+ }
80
+ function rollPersonaId(contactId) {
81
+ try {
82
+ const reg = loadRegistry();
83
+ const nowMs = now();
84
+ const existing = window.get(contactId);
85
+ if (existing && existing.expiresAt > nowMs) {
86
+ return existing.personaId;
87
+ }
88
+ for (const [key, value] of window) {
89
+ if (value.expiresAt <= nowMs) window.delete(key);
90
+ }
91
+ const contact = reg.contacts?.[contactId];
92
+ let personaId = null;
93
+ const probability = contact?.probability ?? reg.personaProbability ?? DEFAULT_PROBABILITY;
94
+ if (contact && random() < probability) {
95
+ personaId = contact.persona === reg.defaultPersona ? null : contact.persona;
96
+ }
97
+ const windowMs = (reg.personaWindowMinutes ?? DEFAULT_WINDOW_MINUTES) * 60 * 1e3;
98
+ window.set(contactId, { personaId, expiresAt: nowMs + windowMs });
99
+ return personaId;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+ function personaLayerFor(personaId) {
105
+ if (!personaId) return null;
106
+ try {
107
+ const definition = loadRegistry().personas[personaId];
108
+ if (!definition) return null;
109
+ if (promptCache.has(personaId)) return promptCache.get(personaId) ?? null;
110
+ try {
111
+ const prompt = interpolate(
112
+ readFileSync(resolve(promptsDir, definition.prompt), "utf-8"),
113
+ vars
114
+ );
115
+ promptCache.set(personaId, prompt);
116
+ return prompt;
117
+ } catch (error) {
118
+ promptCache.set(personaId, null);
119
+ warnOnce(
120
+ `prompt:${personaId}`,
121
+ `Persona prompt load failed for "${personaId}" (${definition.prompt}); falling back to the default persona.`,
122
+ error
123
+ );
124
+ return null;
125
+ }
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+ function defaultPersonaId() {
131
+ try {
132
+ return loadRegistry().defaultPersona ?? null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+ function resolvePersonaLayer(contactId) {
138
+ return personaLayerFor(rollPersonaId(contactId)) ?? personaLayerFor(defaultPersonaId());
139
+ }
140
+ return {
141
+ rollPersonaId,
142
+ resolvePersonaLayer,
143
+ personaLayerFor,
144
+ contactFor,
145
+ defaultPersonaId
146
+ };
147
+ }
148
+
149
+ // src/personas/timeContext.ts
150
+ function timeContext(zones, now = Date.now()) {
151
+ if (zones.length === 0) return "";
152
+ const at = new Date(now);
153
+ const parts = zones.flatMap((zone) => {
154
+ try {
155
+ const formatted = new Intl.DateTimeFormat("en-GB", {
156
+ timeZone: zone,
157
+ weekday: "long",
158
+ day: "numeric",
159
+ month: "long",
160
+ year: "numeric",
161
+ hour: "2-digit",
162
+ minute: "2-digit",
163
+ hour12: false
164
+ }).format(at);
165
+ return [`${formatted} (${zone})`];
166
+ } catch {
167
+ return [];
168
+ }
169
+ });
170
+ if (parts.length === 0) return "";
171
+ return `Current date and time: ${parts.join(" / ")}.`;
172
+ }
173
+ export {
174
+ createGreeter,
175
+ createPersonaResolver,
176
+ timeContext
177
+ };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Persona registry resolver — windowed persona selection driven by a JSON
3
+ * registry. Framework-free and content-free: it knows how to roll and load a
4
+ * persona layer, never what a persona says. A registry/prompt failure
5
+ * degrades to `null` (the caller's default layer) rather than throwing into
6
+ * a chat request.
7
+ */
8
+ export interface PersonaDefinition {
9
+ name: string;
10
+ /** Path to the persona's prompt file, resolved against `promptsDir`. */
11
+ prompt: string;
12
+ language?: string;
13
+ }
14
+ export interface PersonaContact {
15
+ name?: string;
16
+ role?: string;
17
+ /** Persona id rolled for this contact. */
18
+ persona: string;
19
+ /** Overrides `personaProbability` for this contact when set. */
20
+ probability?: number;
21
+ }
22
+ export interface PersonaRegistry {
23
+ /** Persona id used when a contact is unmapped or its roll doesn't land. */
24
+ defaultPersona: string;
25
+ /** 0–1 chance a mapped contact gets their persona on a fresh roll. Default 0.5. */
26
+ personaProbability?: number;
27
+ /** Minutes a roll is held before the next message re-rolls it. Default 10. */
28
+ personaWindowMinutes?: number;
29
+ contacts?: Record<string, PersonaContact>;
30
+ personas: Record<string, PersonaDefinition>;
31
+ }
32
+ export interface PersonaResolverOptions {
33
+ /** Inline registry — takes priority over `registryPath` when both are set. */
34
+ registry?: PersonaRegistry;
35
+ /** Path to a JSON registry file, read once and memoized. */
36
+ registryPath?: string;
37
+ /** Base directory prompt paths resolve against. Defaults to `registryPath`'s directory, or cwd for an inline registry. */
38
+ promptsDir?: string;
39
+ /** Injectable randomness for tests. Default `Math.random`. */
40
+ random?: () => number;
41
+ /** Injectable clock for tests. Default `Date.now`. */
42
+ now?: () => number;
43
+ /** Template vars (`{{name}}`) interpolated into loaded prompt files. */
44
+ vars?: Record<string, string>;
45
+ }
46
+ export interface PersonaResolver {
47
+ /**
48
+ * The persona id in effect for a contact right now: rolled at
49
+ * `personaProbability` once per window, held for `personaWindowMinutes`.
50
+ * `null` means "use the default persona" (unmapped contact or a lost roll).
51
+ */
52
+ rollPersonaId(contactId: string): string | null;
53
+ /**
54
+ * The interpolated prompt layer for the contact's currently-held persona,
55
+ * falling back to the default persona's layer. Feeds `prepareChat`'s
56
+ * `personaLayer` directly.
57
+ */
58
+ resolvePersonaLayer(contactId: string): string | null;
59
+ /** Deterministic layer for an explicit persona id (no dice roll), or null. */
60
+ personaLayerFor(personaId: string | null): string | null;
61
+ /** Registry contact record, or undefined. */
62
+ contactFor(contactId: string): PersonaContact | undefined;
63
+ /** The registry's configured default persona id, or null on failure. */
64
+ defaultPersonaId(): string | null;
65
+ }
66
+ export declare function createPersonaResolver(options?: PersonaResolverOptions): PersonaResolver;
67
+ //# sourceMappingURL=resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolver.d.ts","sourceRoot":"","sources":["../../src/personas/resolver.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,gEAAgE;IAChE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,2EAA2E;IAC3E,cAAc,EAAE,MAAM,CAAC;IACvB,mFAAmF;IACnF,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,8EAA8E;IAC9E,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC1C,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CAC7C;AAED,MAAM,WAAW,sBAAsB;IACrC,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,4DAA4D;IAC5D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,0HAA0H;IAC1H,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IACtB,sDAAsD;IACtD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,eAAe;IAC9B;;;;OAIG;IACH,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAChD;;;;OAIG;IACH,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACtD,8EAA8E;IAC9E,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAAC;IACzD,6CAA6C;IAC7C,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAAC;IAC1D,wEAAwE;IACxE,gBAAgB,IAAI,MAAM,GAAG,IAAI,CAAC;CACnC;AAYD,wBAAgB,qBAAqB,CAAC,OAAO,GAAE,sBAA2B,GAAG,eAAe,CA+H3F"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=resolver.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolver.test.d.ts","sourceRoot":"","sources":["../../src/personas/resolver.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Live clock line for prompts that answer time-relative questions ("what's
3
+ * next", "today", "tomorrow") — a chat surface has no other way to know the
4
+ * current date/time for the zones it cares about.
5
+ */
6
+ export declare function timeContext(zones: string[], now?: number): string;
7
+ //# sourceMappingURL=timeContext.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timeContext.d.ts","sourceRoot":"","sources":["../../src/personas/timeContext.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,GAAG,GAAE,MAAmB,GAAG,MAAM,CAuB7E"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=timeContext.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timeContext.test.d.ts","sourceRoot":"","sources":["../../src/personas/timeContext.test.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegoaltoworks/chatter",
3
- "version": "0.29.0",
3
+ "version": "0.31.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",
@@ -35,6 +35,11 @@
35
35
  "import": "./dist/usage/index.mjs",
36
36
  "require": "./dist/usage/index.js"
37
37
  },
38
+ "./personas": {
39
+ "types": "./dist/personas/index.d.ts",
40
+ "import": "./dist/personas/index.mjs",
41
+ "require": "./dist/personas/index.js"
42
+ },
38
43
  "./channels": {
39
44
  "types": "./dist/channels/index.d.ts",
40
45
  "import": "./dist/channels/index.mjs",
@@ -53,13 +58,14 @@
53
58
  ],
54
59
  "scripts": {
55
60
  "dev": "bun --watch src/index.ts",
56
- "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:channels && bun run build:whatsapp && bun run build:bin && bun run build:client && bun run build:copy-client",
61
+ "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:personas && bun run build:channels && bun run build:whatsapp && bun run build:bin && bun run build:client && bun run build:copy-client",
57
62
  "build:clean": "rm -rf dist",
58
63
  "build:types": "tsc --emitDeclarationOnly --outDir dist",
59
64
  "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",
60
65
  "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",
61
66
  "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",
62
67
  "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",
68
+ "build:personas": "esbuild src/personas/index.ts --bundle --format=esm --outfile=dist/personas/index.mjs --platform=node --target=node24 && esbuild src/personas/index.ts --bundle --format=cjs --outfile=dist/personas/index.js --platform=node --target=node24",
63
69
  "build:channels": "esbuild src/channels/index.ts --bundle --format=esm --outfile=dist/channels/index.mjs --external:hono --external:openai --external:@libsql/client --platform=node --target=node24 && esbuild src/channels/index.ts --bundle --format=cjs --outfile=dist/channels/index.js --external:hono --external:openai --external:@libsql/client --platform=node --target=node24",
64
70
  "build:whatsapp": "esbuild src/channels/whatsapp/index.ts --bundle --format=esm --outfile=dist/channels/whatsapp/index.mjs --external:@libsql/client --external:@whiskeysockets/baileys --platform=node --target=node24 && esbuild src/channels/whatsapp/index.ts --bundle --format=cjs --outfile=dist/channels/whatsapp/index.js --external:@libsql/client --external:@whiskeysockets/baileys --platform=node --target=node24",
65
71
  "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' && esbuild bin/wa-pair.ts --bundle --format=cjs --outfile=dist/bin/wa-pair.js --external:@libsql/client --external:@whiskeysockets/baileys --external:qrcode-terminal --platform=node --target=node18 --banner:js='#!/usr/bin/env node'",