@frankieai/server 0.2.6 → 0.2.7

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.
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  createControlApp
4
- } from "./chunk-EYR3BV7U.js";
4
+ } from "./chunk-4XAOVSTL.js";
5
5
  import {
6
6
  createFrankieApp,
7
7
  resolveStores
8
- } from "./chunk-WEHJH4FL.js";
8
+ } from "./chunk-TPVDQKD3.js";
9
9
 
10
10
  // src/control-server.ts
11
11
  import { serve } from "@hono/node-server";
@@ -17,10 +17,10 @@ async function main() {
17
17
  console.error("[frankie] Set FRANKIE_ADMIN_TOKEN to run the control plane.");
18
18
  process.exit(1);
19
19
  }
20
- const { tenants: store, sessions, knowledge, describe } = await resolveStores();
20
+ const { tenants: store, sessions, knowledge, rateLimiter, turnSignals, describe } = await resolveStores();
21
21
  const app = new Hono();
22
22
  app.route("/", createControlApp({ store, sessions, knowledge }));
23
- app.route("/", createFrankieApp({ tenants: store, sessions, knowledge }));
23
+ app.route("/", createFrankieApp({ tenants: store, sessions, knowledge, ...rateLimiter ? { rateLimiter } : {}, ...turnSignals ? { turnSignals } : {} }));
24
24
  serve({ fetch: app.fetch, port, hostname: host }, () => {
25
25
  console.log(
26
26
  JSON.stringify({
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/control-server.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * The control plane, as its own process: the admin API the dashboard and the\n * setup CLI talk to. It writes tenants into FRANKIE_TENANTS_DIR — the same\n * directory the data-plane server reads — so what you create here is served\n * there with no further wiring.\n *\n * FRANKIE_ADMIN_TOKEN=… FRANKIE_TENANTS_DIR=./.frankie/tenants \\\n * pnpm --filter @frankieai/server start:control\n */\nimport { serve } from \"@hono/node-server\";\nimport { Hono } from \"hono\";\nimport { createFrankieApp } from \"./app.js\";\nimport { createControlApp } from \"./control/app.js\";\nimport { resolveStores } from \"./control/resolve-store.js\";\n\nasync function main(): Promise<void> {\n const port = Number(process.env[\"PORT\"] ?? process.env[\"CONTROL_PORT\"] ?? 8081);\n const host = process.env[\"HOST\"] ?? \"0.0.0.0\";\n\n if (!process.env[\"FRANKIE_ADMIN_TOKEN\"]) {\n console.error(\"[frankie] Set FRANKIE_ADMIN_TOKEN to run the control plane.\");\n process.exit(1);\n }\n\n const { tenants: store, sessions, knowledge, describe } = await resolveStores();\n const app = new Hono();\n app.route(\"/\", createControlApp({ store, sessions, knowledge }));\n app.route(\"/\", createFrankieApp({ tenants: store, sessions, knowledge }));\n\n serve({ fetch: app.fetch, port, hostname: host }, () => {\n console.log(\n JSON.stringify({\n ts: new Date().toISOString(),\n level: \"info\",\n event: \"control_listening\",\n store: describe,\n url: `http://${host}:${port}`,\n }),\n );\n });\n}\n\nvoid main();\n"],"mappings":";;;;;;;;;;AAUA,SAAS,aAAa;AACtB,SAAS,YAAY;AAKrB,eAAe,OAAsB;AACnC,QAAM,OAAO,OAAO,QAAQ,IAAI,MAAM,KAAK,QAAQ,IAAI,cAAc,KAAK,IAAI;AAC9E,QAAM,OAAO,QAAQ,IAAI,MAAM,KAAK;AAEpC,MAAI,CAAC,QAAQ,IAAI,qBAAqB,GAAG;AACvC,YAAQ,MAAM,6DAA6D;AAC3E,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,QAAM,EAAE,SAAS,OAAO,UAAU,WAAW,SAAS,IAAI,MAAM,cAAc;AAC9E,QAAM,MAAM,IAAI,KAAK;AACrB,MAAI,MAAM,KAAK,iBAAiB,EAAE,OAAO,UAAU,UAAU,CAAC,CAAC;AAC/D,MAAI,MAAM,KAAK,iBAAiB,EAAE,SAAS,OAAO,UAAU,UAAU,CAAC,CAAC;AAExE,QAAM,EAAE,OAAO,IAAI,OAAO,MAAM,UAAU,KAAK,GAAG,MAAM;AACtD,YAAQ;AAAA,MACN,KAAK,UAAU;AAAA,QACb,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,QAC3B,OAAO;AAAA,QACP,OAAO;AAAA,QACP,OAAO;AAAA,QACP,KAAK,UAAU,IAAI,IAAI,IAAI;AAAA,MAC7B,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AACH;AAEA,KAAK,KAAK;","names":[]}
1
+ {"version":3,"sources":["../src/control-server.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * The control plane, as its own process: the admin API the dashboard and the\n * setup CLI talk to. It writes tenants into FRANKIE_TENANTS_DIR — the same\n * directory the data-plane server reads — so what you create here is served\n * there with no further wiring.\n *\n * FRANKIE_ADMIN_TOKEN=… FRANKIE_TENANTS_DIR=./.frankie/tenants \\\n * pnpm --filter @frankieai/server start:control\n */\nimport { serve } from \"@hono/node-server\";\nimport { Hono } from \"hono\";\nimport { createFrankieApp } from \"./app.js\";\nimport { createControlApp } from \"./control/app.js\";\nimport { resolveStores } from \"./control/resolve-store.js\";\n\nasync function main(): Promise<void> {\n const port = Number(process.env[\"PORT\"] ?? process.env[\"CONTROL_PORT\"] ?? 8081);\n const host = process.env[\"HOST\"] ?? \"0.0.0.0\";\n\n if (!process.env[\"FRANKIE_ADMIN_TOKEN\"]) {\n console.error(\"[frankie] Set FRANKIE_ADMIN_TOKEN to run the control plane.\");\n process.exit(1);\n }\n\n const { tenants: store, sessions, knowledge, rateLimiter, turnSignals, describe } = await resolveStores();\n const app = new Hono();\n app.route(\"/\", createControlApp({ store, sessions, knowledge }));\n app.route(\"/\", createFrankieApp({ tenants: store, sessions, knowledge, ...(rateLimiter ? { rateLimiter } : {}), ...(turnSignals ? { turnSignals } : {}) }));\n\n serve({ fetch: app.fetch, port, hostname: host }, () => {\n console.log(\n JSON.stringify({\n ts: new Date().toISOString(),\n level: \"info\",\n event: \"control_listening\",\n store: describe,\n url: `http://${host}:${port}`,\n }),\n );\n });\n}\n\nvoid main();\n"],"mappings":";;;;;;;;;;AAUA,SAAS,aAAa;AACtB,SAAS,YAAY;AAKrB,eAAe,OAAsB;AACnC,QAAM,OAAO,OAAO,QAAQ,IAAI,MAAM,KAAK,QAAQ,IAAI,cAAc,KAAK,IAAI;AAC9E,QAAM,OAAO,QAAQ,IAAI,MAAM,KAAK;AAEpC,MAAI,CAAC,QAAQ,IAAI,qBAAqB,GAAG;AACvC,YAAQ,MAAM,6DAA6D;AAC3E,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,QAAM,EAAE,SAAS,OAAO,UAAU,WAAW,aAAa,aAAa,SAAS,IAAI,MAAM,cAAc;AACxG,QAAM,MAAM,IAAI,KAAK;AACrB,MAAI,MAAM,KAAK,iBAAiB,EAAE,OAAO,UAAU,UAAU,CAAC,CAAC;AAC/D,MAAI,MAAM,KAAK,iBAAiB,EAAE,SAAS,OAAO,UAAU,WAAW,GAAI,cAAc,EAAE,YAAY,IAAI,CAAC,GAAI,GAAI,cAAc,EAAE,YAAY,IAAI,CAAC,EAAG,CAAC,CAAC;AAE1J,QAAM,EAAE,OAAO,IAAI,OAAO,MAAM,UAAU,KAAK,GAAG,MAAM;AACtD,YAAQ;AAAA,MACN,KAAK,UAAU;AAAA,QACb,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,QAC3B,OAAO;AAAA,QACP,OAAO;AAAA,QACP,OAAO;AAAA,QACP,KAAK,UAAU,IAAI,IAAI,IAAI;AAAA,MAC7B,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AACH;AAEA,KAAK,KAAK;","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Hono } from 'hono';
2
- import { FrankieConfig, AuthConfig, WorkspaceSkill, HostToolRequest, HostToolResult, ChatAttachment, FrankieServerEvent, McpServerConfig, ToolPolicy, ApiToolInventory, ApiSourceConfig } from '@frankieai/core';
2
+ import { FrankieConfig, AuthConfig, HostToolResult, ActionPreview, WorkspaceSkill, HostToolRequest, ChatAttachment, FrankieServerEvent, McpServerConfig, ToolPolicy, ApiToolInventory, ApiSourceConfig } from '@frankieai/core';
3
3
  export { matchPath } from '@frankieai/core';
4
4
  import { AgentMessage, AgentTool } from '@earendil-works/pi-agent-core';
5
5
  import { JWTPayload } from 'jose';
@@ -112,122 +112,6 @@ declare function safeEqual(a: string, b: string): boolean;
112
112
  /** Stable pseudonymous id for an anonymous visitor. */
113
113
  declare function anonymousUser(seed: string): VerifiedUser;
114
114
 
115
- /** One recorded hand-off to the workspace's team. Stored on the conversation. */
116
- interface EscalationRecord {
117
- id: string;
118
- at: number;
119
- /** Why the agent escalated (its own words / the visitor's ask). */
120
- reason: string;
121
- /** A short recap of what the visitor needs and what was already tried. */
122
- summary: string;
123
- /** How to reach the visitor, when they provided it. */
124
- email?: string;
125
- name?: string;
126
- }
127
- /** What the tool hands back to the app: the record plus who/where it came from. */
128
- interface EscalationEvent extends EscalationRecord {
129
- tenantId: string;
130
- conversationId: string;
131
- userId: string;
132
- }
133
- type EscalationSink = (event: EscalationEvent) => Promise<void>;
134
-
135
- interface ChatSession {
136
- id: string;
137
- tenantId: string;
138
- user: VerifiedUser;
139
- /** Raw host-signed token, forwarded only to tenant tools that explicitly opt in. */
140
- userToken?: string;
141
- origin?: string;
142
- context: Record<string, string | number | boolean | null>;
143
- /** Agent transcript used to continue the model conversation on the server. */
144
- messages: AgentMessage[];
145
- /** UI transcript kept separately so the exact rendered chat can sync across devices. */
146
- clientMessages?: unknown[];
147
- /** User-editable title, otherwise derived from the first user message. */
148
- title?: string;
149
- createdAt: number;
150
- lastActiveAt: number;
151
- messageCount: number;
152
- costUsd: number;
153
- /** True while a chat turn holds this session; see `busySince` for how long. */
154
- busy: boolean;
155
- /**
156
- * When the running turn took the lock. A turn can only hold the session for
157
- * as long as its timeout, so a lock older than that is one whose release
158
- * never landed (the instance was frozen or restarted mid-turn) and is free
159
- * for the taking — otherwise the session would answer 409 forever.
160
- */
161
- busySince?: number;
162
- /** Human hand-offs the agent made in this conversation (the escalation inbox). */
163
- escalations?: EscalationRecord[];
164
- }
165
- interface SessionStore {
166
- create(session: ChatSession): Promise<void>;
167
- get(id: string): Promise<ChatSession | undefined>;
168
- /** List one user's conversations for a tenant, newest first. */
169
- listByUser(tenantId: string, userId: string): Promise<ChatSession[]>;
170
- /** Workspace-owner history view. This is only exposed through the authenticated control plane. */
171
- listByTenant(tenantId: string): Promise<ChatSession[]>;
172
- /**
173
- * Non-expired conversation count per tenant id, in one pass. Used by the
174
- * workspace directory to show a workspace as "Live" once it has real chats —
175
- * cheaper than a listByTenant call per workspace.
176
- */
177
- countsByTenant(): Promise<Record<string, number>>;
178
- update(id: string, patch: Partial<ChatSession>): Promise<void>;
179
- delete(id: string): Promise<void>;
180
- }
181
- declare class MemorySessionStore implements SessionStore {
182
- private readonly ttlMs;
183
- private readonly sessions;
184
- constructor(ttlMs?: number);
185
- create(session: ChatSession): Promise<void>;
186
- get(id: string): Promise<ChatSession | undefined>;
187
- listByUser(tenantId: string, userId: string): Promise<ChatSession[]>;
188
- listByTenant(tenantId: string): Promise<ChatSession[]>;
189
- countsByTenant(): Promise<Record<string, number>>;
190
- update(id: string, patch: Partial<ChatSession>): Promise<void>;
191
- delete(id: string): Promise<void>;
192
- private evictExpired;
193
- }
194
- declare function newSessionId(): string;
195
-
196
- /** One indexed passage of a document, with its embedding vector. */
197
- interface KnowledgeChunk {
198
- id: string;
199
- url: string;
200
- title: string;
201
- text: string;
202
- embedding: number[];
203
- }
204
- /** A retrieval result: the passage and how close it was to the query. */
205
- interface KnowledgeHit {
206
- url: string;
207
- title: string;
208
- text: string;
209
- score: number;
210
- }
211
- /** What the dashboard shows about a workspace's index. */
212
- interface KnowledgeStats {
213
- chunks: number;
214
- pages: number;
215
- sources: string[];
216
- ingestedAt?: number;
217
- }
218
- interface KnowledgeStore {
219
- /** Replace a workspace's whole index with a freshly ingested set (atomic swap). */
220
- replace(tenantId: string, chunks: KnowledgeChunk[], meta: {
221
- sources: string[];
222
- pages: number;
223
- ingestedAt: number;
224
- }): Promise<void>;
225
- /** Top-k passages by cosine similarity to the query vector. */
226
- search(tenantId: string, queryVector: readonly number[], k: number): Promise<KnowledgeHit[]>;
227
- stats(tenantId: string): Promise<KnowledgeStats>;
228
- clear(tenantId: string): Promise<void>;
229
- }
230
-
231
115
  declare class ModelResolutionError extends Error {
232
116
  }
233
117
  interface ResolvedModel {
@@ -319,34 +203,6 @@ declare class MemoryTenantStore implements TenantStore {
319
203
  byPublishableKey(key: string): Promise<ResolvedConfig | null>;
320
204
  }
321
205
 
322
- interface CreateAppOptions {
323
- /** A single tenant, resolved at startup — the self-hosted case. */
324
- resolved?: ResolvedConfig;
325
- /** Many tenants, each resolved by publishable key at request time — the SaaS case. */
326
- tenants?: TenantStore | TenantRegistry;
327
- sessions?: SessionStore;
328
- /** Knowledge-base index for the docs-search tool; in-memory when omitted. */
329
- knowledge?: KnowledgeStore;
330
- rateLimiter?: RateLimiter;
331
- logger?: (event: Record<string, unknown>) => void;
332
- }
333
- type AppEnv = {
334
- Variables: {
335
- tenant: TenantRuntime;
336
- };
337
- };
338
- declare function createFrankieApp(options: CreateAppOptions): Hono<AppEnv>;
339
-
340
- interface McpHostOptions {
341
- tenants: TenantStore | TenantRegistry;
342
- logger?: (event: Record<string, unknown>) => void;
343
- /** Injected in tests so generated API tools hit a stub rather than the network. */
344
- fetchImpl?: typeof fetch;
345
- /** Shared with the chat API; each request counts against the tenant's message budget. */
346
- rateLimiter?: RateLimiter;
347
- }
348
- declare function createMcpHostApp(options: McpHostOptions): Hono;
349
-
350
206
  /**
351
207
  * What the control plane stores per tenant. The model provider key is *not*
352
208
  * here — in the hosted shape that key is Frankie's own, one shared key read
@@ -410,6 +266,13 @@ interface SuggestedThemeRecord {
410
266
  repository: string;
411
267
  inferredAt: string;
412
268
  }
269
+ /** An uploaded file's text, kept so every re-index includes it. */
270
+ interface KnowledgeDocument {
271
+ id: string;
272
+ name: string;
273
+ text: string;
274
+ addedAt: string;
275
+ }
413
276
  interface TenantRecord {
414
277
  apiSpecSync?: Record<string, {
415
278
  repository: string;
@@ -444,6 +307,12 @@ interface TenantRecord {
444
307
  * open a separate scoped-install PR per agent.
445
308
  */
446
309
  personaInstalls?: Record<string, RepositoryInstallRecord>;
310
+ /**
311
+ * Files the team uploaded for its agents to answer from (an FAQ, a guide),
312
+ * as extracted text. Indexed alongside the crawled pages; never sent to the
313
+ * dashboard in full.
314
+ */
315
+ knowledgeDocuments?: KnowledgeDocument[];
447
316
  /** Deployment provider connection, when the workspace has connected Vercel. */
448
317
  vercel?: VercelLinkRecord;
449
318
  createdAt: string;
@@ -513,6 +382,13 @@ interface PublicTenant {
513
382
  repositoryInstall?: RepositoryInstallRecord;
514
383
  /** A submitted or in-progress scoped-install PR per persona id (`config.personas`). */
515
384
  personaInstalls?: Record<string, RepositoryInstallRecord>;
385
+ /** Uploaded files agents answer from: names and sizes only. */
386
+ knowledgeDocuments: Array<{
387
+ id: string;
388
+ name: string;
389
+ chars: number;
390
+ addedAt: string;
391
+ }>;
516
392
  /** The connected Vercel installation and matched project — never its token. */
517
393
  vercel?: PublicVercelLink;
518
394
  /** The customer's brand theme for the widget, so the dashboard can edit it. */
@@ -592,6 +468,12 @@ interface ControlStore extends TenantStore {
592
468
  list(ownerId?: string): Promise<TenantRecord[]>;
593
469
  get(id: string): Promise<TenantRecord | null>;
594
470
  put(record: TenantRecord): Promise<void>;
471
+ /**
472
+ * Store a brand-new tenant, never replacing one: false when the id (or its
473
+ * publishable key) is already taken. Creating a workspace must use this, so
474
+ * a colliding id can't overwrite someone else's workspace.
475
+ */
476
+ insert(record: TenantRecord): Promise<boolean>;
595
477
  /** Remove a tenant for good. A no-op if it does not exist. */
596
478
  delete(id: string): Promise<void>;
597
479
  /** All members of the org owned by `org`. */
@@ -651,6 +533,7 @@ declare class FileTenantStore implements ControlStore {
651
533
  list(ownerId?: string): Promise<TenantRecord[]>;
652
534
  get(id: string): Promise<TenantRecord | null>;
653
535
  put(record: TenantRecord): Promise<void>;
536
+ insert(record: TenantRecord): Promise<boolean>;
654
537
  compareAndSwap(previous: TenantRecord, next: TenantRecord): Promise<boolean>;
655
538
  /**
656
539
  * A record holds the workspace's signing and session secrets and source
@@ -718,11 +601,211 @@ declare class PostgresTenantStore implements ControlStore {
718
601
  byPublishableKey(key: string): Promise<ResolvedConfig | null>;
719
602
  list(ownerId?: string): Promise<TenantRecord[]>;
720
603
  get(id: string): Promise<TenantRecord | null>;
604
+ insert(record: TenantRecord): Promise<boolean>;
721
605
  put(record: TenantRecord): Promise<void>;
722
606
  delete(id: string): Promise<void>;
723
607
  compareAndSwap(previous: TenantRecord, next: TenantRecord): Promise<boolean>;
724
608
  }
725
609
 
610
+ /**
611
+ * Something said about a running turn from a request that may have landed on a
612
+ * different server instance than the one running it: the user pressed Stop,
613
+ * or their browser finished a tool call the turn is waiting on.
614
+ */
615
+ type TurnSignal = {
616
+ kind: "stop";
617
+ } | {
618
+ kind: "tool_result";
619
+ callId: string;
620
+ result: HostToolResult;
621
+ };
622
+ /**
623
+ * Carries turn signals between instances. A turn polls for its session's
624
+ * signals while it runs; a request that can't reach the turn locally sends one.
625
+ * Single-instance deployments don't need it: everything is local.
626
+ */
627
+ interface TurnSignals {
628
+ send(sessionId: string, signal: TurnSignal): Promise<void>;
629
+ /** Signals for the session after `afterId`, oldest first. */
630
+ poll(sessionId: string, afterId: number): Promise<Array<{
631
+ id: number;
632
+ signal: TurnSignal;
633
+ }>>;
634
+ /** The newest signal id, so a turn starting now ignores everything before it. */
635
+ latestId(sessionId: string): Promise<number>;
636
+ }
637
+
638
+ /** One recorded hand-off to the workspace's team. Stored on the conversation. */
639
+ interface EscalationRecord {
640
+ id: string;
641
+ at: number;
642
+ /** Why the agent escalated (its own words / the visitor's ask). */
643
+ reason: string;
644
+ /** A short recap of what the visitor needs and what was already tried. */
645
+ summary: string;
646
+ /** How to reach the visitor, when they provided it. */
647
+ email?: string;
648
+ name?: string;
649
+ }
650
+ /** What the tool hands back to the app: the record plus who/where it came from. */
651
+ interface EscalationEvent extends EscalationRecord {
652
+ tenantId: string;
653
+ conversationId: string;
654
+ userId: string;
655
+ }
656
+ type EscalationSink = (event: EscalationEvent) => Promise<void>;
657
+
658
+ interface ChatSession {
659
+ id: string;
660
+ tenantId: string;
661
+ user: VerifiedUser;
662
+ /** Raw host-signed token, forwarded only to tenant tools that explicitly opt in. */
663
+ userToken?: string;
664
+ origin?: string;
665
+ /**
666
+ * The publishable key the chat was opened with: the workspace's own key or
667
+ * one of its agents' keys. Lets the dashboard say which agent answered.
668
+ */
669
+ agentKey?: string;
670
+ context: Record<string, string | number | boolean | null>;
671
+ /** Agent transcript used to continue the model conversation on the server. */
672
+ messages: AgentMessage[];
673
+ /** UI transcript kept separately so the exact rendered chat can sync across devices. */
674
+ clientMessages?: unknown[];
675
+ /** User-editable title, otherwise derived from the first user message. */
676
+ title?: string;
677
+ createdAt: number;
678
+ lastActiveAt: number;
679
+ messageCount: number;
680
+ costUsd: number;
681
+ /** True while a chat turn holds this session; see `busySince` for how long. */
682
+ busy: boolean;
683
+ /**
684
+ * When the running turn took the lock. A turn can only hold the session for
685
+ * as long as its timeout, so a lock older than that is one whose release
686
+ * never landed (the instance was frozen or restarted mid-turn) and is free
687
+ * for the taking — otherwise the session would answer 409 forever.
688
+ */
689
+ busySince?: number;
690
+ /** When someone pressed Stop for a turn running on another instance; that turn then doesn't save its reply. */
691
+ stopRequestedAt?: number;
692
+ /** Human hand-offs the agent made in this conversation (the escalation inbox). */
693
+ escalations?: EscalationRecord[];
694
+ /** An action that changes data, paused until the user approves or cancels it. */
695
+ pendingAction?: PendingAction;
696
+ }
697
+ /**
698
+ * A write the agent asked for and the user hasn't answered yet. On Approve the
699
+ * server runs exactly this call, with these inputs, not whatever the model
700
+ * would ask for next time.
701
+ */
702
+ interface PendingAction {
703
+ toolUseId: string;
704
+ name: string;
705
+ label: string;
706
+ args: unknown;
707
+ preview: ActionPreview;
708
+ createdAt: number;
709
+ }
710
+ interface SessionStore {
711
+ create(session: ChatSession): Promise<void>;
712
+ get(id: string): Promise<ChatSession | undefined>;
713
+ /** List one user's conversations for a tenant, newest first. */
714
+ listByUser(tenantId: string, userId: string): Promise<ChatSession[]>;
715
+ /** Workspace-owner history view. This is only exposed through the authenticated control plane. */
716
+ listByTenant(tenantId: string): Promise<ChatSession[]>;
717
+ /**
718
+ * Non-expired conversation count per tenant id, in one pass. Used by the
719
+ * workspace directory to show a workspace as "Live" once it has real chats —
720
+ * cheaper than a listByTenant call per workspace.
721
+ */
722
+ countsByTenant(): Promise<Record<string, number>>;
723
+ update(id: string, patch: Partial<ChatSession>): Promise<void>;
724
+ delete(id: string): Promise<void>;
725
+ }
726
+ declare class MemorySessionStore implements SessionStore {
727
+ private readonly ttlMs;
728
+ private readonly sessions;
729
+ constructor(ttlMs?: number);
730
+ create(session: ChatSession): Promise<void>;
731
+ get(id: string): Promise<ChatSession | undefined>;
732
+ listByUser(tenantId: string, userId: string): Promise<ChatSession[]>;
733
+ listByTenant(tenantId: string): Promise<ChatSession[]>;
734
+ countsByTenant(): Promise<Record<string, number>>;
735
+ update(id: string, patch: Partial<ChatSession>): Promise<void>;
736
+ delete(id: string): Promise<void>;
737
+ private evictExpired;
738
+ }
739
+ declare function newSessionId(): string;
740
+
741
+ /** One indexed passage of a document, with its embedding vector. */
742
+ interface KnowledgeChunk {
743
+ id: string;
744
+ url: string;
745
+ title: string;
746
+ text: string;
747
+ embedding: number[];
748
+ }
749
+ /** A retrieval result: the passage and how close it was to the query. */
750
+ interface KnowledgeHit {
751
+ url: string;
752
+ title: string;
753
+ text: string;
754
+ score: number;
755
+ }
756
+ /** What the dashboard shows about a workspace's index. */
757
+ interface KnowledgeStats {
758
+ chunks: number;
759
+ pages: number;
760
+ sources: string[];
761
+ ingestedAt?: number;
762
+ }
763
+ interface KnowledgeStore {
764
+ /** Replace a workspace's whole index with a freshly ingested set (atomic swap). */
765
+ replace(tenantId: string, chunks: KnowledgeChunk[], meta: {
766
+ sources: string[];
767
+ pages: number;
768
+ ingestedAt: number;
769
+ }): Promise<void>;
770
+ /** Top-k passages by cosine similarity to the query vector. */
771
+ search(tenantId: string, queryVector: readonly number[], k: number): Promise<KnowledgeHit[]>;
772
+ stats(tenantId: string): Promise<KnowledgeStats>;
773
+ clear(tenantId: string): Promise<void>;
774
+ }
775
+
776
+ interface CreateAppOptions {
777
+ /** A single tenant, resolved at startup — the self-hosted case. */
778
+ resolved?: ResolvedConfig;
779
+ /** Many tenants, each resolved by publishable key at request time — the SaaS case. */
780
+ tenants?: TenantStore | TenantRegistry;
781
+ sessions?: SessionStore;
782
+ /** Knowledge-base index for the docs-search tool; in-memory when omitted. */
783
+ knowledge?: KnowledgeStore;
784
+ rateLimiter?: RateLimiter;
785
+ /**
786
+ * Reaches a turn running on another instance: Stop, and browser tool
787
+ * results. Needed whenever more than one instance serves chat.
788
+ */
789
+ turnSignals?: TurnSignals;
790
+ logger?: (event: Record<string, unknown>) => void;
791
+ }
792
+ type AppEnv = {
793
+ Variables: {
794
+ tenant: TenantRuntime;
795
+ };
796
+ };
797
+ declare function createFrankieApp(options: CreateAppOptions): Hono<AppEnv>;
798
+
799
+ interface McpHostOptions {
800
+ tenants: TenantStore | TenantRegistry;
801
+ logger?: (event: Record<string, unknown>) => void;
802
+ /** Injected in tests so generated API tools hit a stub rather than the network. */
803
+ fetchImpl?: typeof fetch;
804
+ /** Shared with the chat API; each request counts against the tenant's message budget. */
805
+ rateLimiter?: RateLimiter;
806
+ }
807
+ declare function createMcpHostApp(options: McpHostOptions): Hono;
808
+
726
809
  interface ResolvedStore {
727
810
  store: ControlStore;
728
811
  describe: string;
@@ -731,6 +814,9 @@ interface ResolvedStores {
731
814
  tenants: ControlStore;
732
815
  sessions: SessionStore;
733
816
  knowledge: KnowledgeStore;
817
+ /** Shared across instances with Postgres; undefined means in-memory (one instance). */
818
+ rateLimiter?: RateLimiter;
819
+ turnSignals?: TurnSignals;
734
820
  describe: string;
735
821
  }
736
822
  /**
@@ -759,10 +845,21 @@ declare class PostgresSessionStore implements SessionStore {
759
845
  private readonly db;
760
846
  private readonly ttlMs;
761
847
  private ready?;
848
+ private lastPurge;
762
849
  constructor(options: {
763
850
  db: Queryable;
764
851
  ttlMs?: number;
765
852
  });
853
+ /** Deletes every conversation idle past the TTL. Returns how many went. */
854
+ purgeExpired(): Promise<number>;
855
+ /**
856
+ * Purges at most hourly, piggybacking on the reads that scan the table
857
+ * anyway. Housekeeping must never fail the read it rides on: a slow DELETE
858
+ * hitting a statement timeout, or a role that can read but not delete,
859
+ * would otherwise turn a user's conversation list into an error. The
860
+ * expired rows are still filtered out of every read regardless.
861
+ */
862
+ private purgeIfDue;
766
863
  private ensureReady;
767
864
  init(): Promise<void>;
768
865
  create(session: ChatSession): Promise<void>;
@@ -796,10 +893,44 @@ interface SkillGenerationInput {
796
893
  }
797
894
  declare function generateSkill(resolved: ResolvedConfig, input: SkillGenerationInput): Promise<WorkspaceSkill>;
798
895
 
896
+ interface ConversationQuestion {
897
+ /** The full conversation log (see `conversationLog` in @frankieai/core), built server-side. */
898
+ log: string;
899
+ question: string;
900
+ /** Earlier questions and answers in this thread, oldest first. */
901
+ history: Array<{
902
+ role: "user" | "assistant";
903
+ text: string;
904
+ }>;
905
+ }
906
+ /** Answer one question about one conversation with the workspace's own model. */
907
+ declare function askAboutConversation(resolved: ResolvedConfig, input: ConversationQuestion): Promise<string>;
908
+
909
+ /** What a GitHub Actions OIDC token says about the run that minted it. */
910
+ interface GithubRunClaims {
911
+ /** "owner/name" of the repository the workflow ran in. */
912
+ repository: string;
913
+ /** e.g. "refs/heads/main". */
914
+ ref: string;
915
+ /** The commit the run is for. */
916
+ sha: string;
917
+ /** "push", "workflow_dispatch", … */
918
+ eventName: string;
919
+ }
920
+ /**
921
+ * Verify a token GitHub Actions minted for a workflow run (`id-token: write`).
922
+ * It proves which repository and commit the request comes from without any
923
+ * secret stored in the repository: GitHub signs it, we check the signature
924
+ * against GitHub's published keys, the issuer, the audience and its expiry.
925
+ */
926
+ declare function verifyGithubRunToken(token: string): Promise<GithubRunClaims>;
927
+
799
928
  interface ControlOptions {
800
929
  /** Immutable GitHub IDs configured by the platform operator, never by a client. */
801
930
  bootstrapSuperAdmins?: string[];
802
931
  generateSkill?: typeof generateSkill;
932
+ askAboutConversation?: typeof askAboutConversation;
933
+ verifyGithubRunToken?: typeof verifyGithubRunToken;
803
934
  store: ControlStore;
804
935
  /** Durable session store shared with the data plane when Postgres is configured. */
805
936
  sessions?: SessionStore;
@@ -888,6 +1019,18 @@ interface RunTurnParams {
888
1019
  onWarning?: (message: string, detail: Record<string, unknown>) => void;
889
1020
  /** Knowledge-base index + embedding config for the docs-search tool. */
890
1021
  knowledge?: KnowledgeDeps;
1022
+ /**
1023
+ * Pause every action that changes data until the user approves it. Set when
1024
+ * the workspace asks for it and the chat can show the approval card.
1025
+ */
1026
+ confirmWrites?: boolean;
1027
+ /** The user's answer to the action the previous turn paused. */
1028
+ decision?: {
1029
+ action: PendingAction;
1030
+ approved: boolean;
1031
+ };
1032
+ /** Called when this turn pauses an action for approval, so the caller can keep it. */
1033
+ onPendingAction?: (action: PendingAction) => void;
891
1034
  }
892
1035
  /**
893
1036
  * Run one user turn and stream it back as widget-facing events.
@@ -907,6 +1050,8 @@ interface McpConnection {
907
1050
  name: string;
908
1051
  error: string;
909
1052
  }>;
1053
+ /** Tools that may change data: every tool its server doesn't mark `readOnlyHint`. */
1054
+ writes: Set<string>;
910
1055
  close(): Promise<void>;
911
1056
  }
912
1057
  interface ConnectMcpOptions {
@@ -953,6 +1098,11 @@ interface ApiToolSet {
953
1098
  }>;
954
1099
  /** One entry per source that built successfully, for chat-facing visibility. */
955
1100
  apis: ApiToolInventory[];
1101
+ /** Tools that change data (any method but GET), by tool name, for the approval card. */
1102
+ writes: Map<string, {
1103
+ method: string;
1104
+ path: string;
1105
+ }>;
956
1106
  }
957
1107
  /**
958
1108
  * Turn a tenant's OpenAPI document into agent tools.
@@ -997,6 +1147,14 @@ interface CollectedTools {
997
1147
  }>;
998
1148
  /** OpenAPI sources that built successfully this turn, for chat-facing visibility. */
999
1149
  apis: ApiToolInventory[];
1150
+ /**
1151
+ * Tools that change data, by name, with the HTTP method and path when there
1152
+ * is one. These ask the user before they run (`agent.confirmWrites`).
1153
+ */
1154
+ writes: Map<string, {
1155
+ method?: string;
1156
+ path?: string;
1157
+ }>;
1000
1158
  close(): Promise<void>;
1001
1159
  }
1002
1160
  /**
@@ -1029,11 +1187,8 @@ interface ForwardingConfig {
1029
1187
  */
1030
1188
  declare function buildForwardedHeaders(source: ForwardingConfig, session: ChatSession): Record<string, string>;
1031
1189
 
1032
- /**
1033
- * The runtime's own operating instructions, followed by whatever the tenant
1034
- * configured. Host-supplied page context is fenced off as data: it comes from
1035
- * a web page and must never be able to redirect the agent.
1036
- */
1037
- declare function buildSystemPrompt(config: FrankieConfig, session: ChatSession, availableTools?: string[]): string;
1190
+ declare function buildSystemPrompt(config: FrankieConfig, session: ChatSession, availableTools?: string[], options?: {
1191
+ confirmWrites?: boolean;
1192
+ }): string;
1038
1193
 
1039
1194
  export { API_TOOL_PREFIX, type ApiToolSet, AuthError, type BuildApiToolsOptions, type ChatSession, type CollectToolsOptions, type CollectedTools, ConfigError, type ConnectMcpOptions, type ControlOptions, type ControlStore, type CreateAppOptions, FileTenantStore, type ForwardingConfig, MCP_TOOL_PREFIX, type McpConnection, type McpHostOptions, MemoryRateLimiter, MemorySessionStore, MemoryTenantStore, ModelResolutionError, OpenApiError, PostgresSessionStore, PostgresTenantStore, type PublicTenant, type Queryable, type RateLimiter, type ResolvedConfig, type ResolvedModel, type ResolvedStore, type ResolvedStores, type RunTurnParams, type SessionStore, SingleTenantStore, type TenantRecord, TenantRegistry, type TenantRuntime, type TenantStore, TokenService, type VerifiedUser, anonymousUser, applyEnvOverrides, buildApiTools, buildForwardedHeaders, buildSystemPrompt, collectTools, connectMcpTools, createControlApp, createFrankieApp, createMcpHostApp, isOriginAllowed, loadConfigFromFile, loadResolvedConfig, loadSpec, modelHasApi, newSessionId, resolveModel, resolveSecrets, resolveStores, resolveTenantStore, runTurn, safeEqual, toPublicTenant };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createControlApp
3
- } from "./chunk-EYR3BV7U.js";
3
+ } from "./chunk-4XAOVSTL.js";
4
4
  import {
5
5
  ConfigError,
6
6
  applyEnvOverrides,
@@ -42,7 +42,7 @@ import {
42
42
  runTurn,
43
43
  safeEqual,
44
44
  toPublicTenant
45
- } from "./chunk-WEHJH4FL.js";
45
+ } from "./chunk-TPVDQKD3.js";
46
46
  export {
47
47
  API_TOOL_PREFIX,
48
48
  AuthError,
package/dist/server.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  ModelResolutionError,
8
8
  createFrankieApp,
9
9
  resolveStores
10
- } from "./chunk-WEHJH4FL.js";
10
+ } from "./chunk-TPVDQKD3.js";
11
11
 
12
12
  // src/server.ts
13
13
  import { serve } from "@hono/node-server";
@@ -21,7 +21,13 @@ async function main() {
21
21
  if (multiTenant) {
22
22
  const resolved = await resolveStores();
23
23
  describe = `multi-tenant (${resolved.describe})`;
24
- app = createFrankieApp({ tenants: resolved.tenants, sessions: resolved.sessions, knowledge: resolved.knowledge });
24
+ app = createFrankieApp({
25
+ tenants: resolved.tenants,
26
+ sessions: resolved.sessions,
27
+ knowledge: resolved.knowledge,
28
+ ...resolved.rateLimiter ? { rateLimiter: resolved.rateLimiter } : {},
29
+ ...resolved.turnSignals ? { turnSignals: resolved.turnSignals } : {}
30
+ });
25
31
  } else {
26
32
  app = createFrankieApp({ resolved: await loadResolvedConfig() });
27
33
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { serve } from \"@hono/node-server\";\nimport { createFrankieApp } from \"./app.js\";\nimport { ModelResolutionError } from \"./agent/models.js\";\nimport { ConfigError, loadResolvedConfig } from \"./config.js\";\nimport { resolveStores } from \"./control/resolve-store.js\";\n\nasync function main(): Promise<void> {\n const port = Number(process.env[\"PORT\"] ?? 8080);\n const host = process.env[\"HOST\"] ?? \"0.0.0.0\";\n\n // Two shapes, one binary. With DATABASE_URL (or FRANKIE_TENANTS_DIR) set, this\n // serves every tenant the control plane wrote, resolved by publishable key —\n // the hosted, multi-tenant shape. Otherwise it loads one frankie.config.json,\n // the self-hosted, single-tenant shape.\n const multiTenant = Boolean(process.env[\"DATABASE_URL\"] || process.env[\"FRANKIE_TENANTS_DIR\"]);\n let app;\n let describe = \"single-tenant\";\n try {\n if (multiTenant) {\n const resolved = await resolveStores();\n describe = `multi-tenant (${resolved.describe})`;\n app = createFrankieApp({ tenants: resolved.tenants, sessions: resolved.sessions, knowledge: resolved.knowledge });\n } else {\n app = createFrankieApp({ resolved: await loadResolvedConfig() });\n }\n } catch (error) {\n if (error instanceof ConfigError || error instanceof ModelResolutionError) {\n console.error(`[frankie] ${error.message}`);\n process.exit(1);\n }\n throw error;\n }\n\n serve({ fetch: app.fetch, port, hostname: host }, () => {\n console.log(\n JSON.stringify({\n ts: new Date().toISOString(),\n level: \"info\",\n event: \"listening\",\n mode: describe,\n url: `http://${host}:${port}`,\n }),\n );\n });\n}\n\nvoid main();\n"],"mappings":";;;;;;;;;;;;AACA,SAAS,aAAa;AAMtB,eAAe,OAAsB;AACnC,QAAM,OAAO,OAAO,QAAQ,IAAI,MAAM,KAAK,IAAI;AAC/C,QAAM,OAAO,QAAQ,IAAI,MAAM,KAAK;AAMpC,QAAM,cAAc,QAAQ,QAAQ,IAAI,cAAc,KAAK,QAAQ,IAAI,qBAAqB,CAAC;AAC7F,MAAI;AACJ,MAAI,WAAW;AACf,MAAI;AACF,QAAI,aAAa;AACf,YAAM,WAAW,MAAM,cAAc;AACrC,iBAAW,iBAAiB,SAAS,QAAQ;AAC7C,YAAM,iBAAiB,EAAE,SAAS,SAAS,SAAS,UAAU,SAAS,UAAU,WAAW,SAAS,UAAU,CAAC;AAAA,IAClH,OAAO;AACL,YAAM,iBAAiB,EAAE,UAAU,MAAM,mBAAmB,EAAE,CAAC;AAAA,IACjE;AAAA,EACF,SAAS,OAAO;AACd,QAAI,iBAAiB,eAAe,iBAAiB,sBAAsB;AACzE,cAAQ,MAAM,aAAa,MAAM,OAAO,EAAE;AAC1C,cAAQ,KAAK,CAAC;AAAA,IAChB;AACA,UAAM;AAAA,EACR;AAEA,QAAM,EAAE,OAAO,IAAI,OAAO,MAAM,UAAU,KAAK,GAAG,MAAM;AACtD,YAAQ;AAAA,MACN,KAAK,UAAU;AAAA,QACb,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,QAC3B,OAAO;AAAA,QACP,OAAO;AAAA,QACP,MAAM;AAAA,QACN,KAAK,UAAU,IAAI,IAAI,IAAI;AAAA,MAC7B,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AACH;AAEA,KAAK,KAAK;","names":[]}
1
+ {"version":3,"sources":["../src/server.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { serve } from \"@hono/node-server\";\nimport { createFrankieApp } from \"./app.js\";\nimport { ModelResolutionError } from \"./agent/models.js\";\nimport { ConfigError, loadResolvedConfig } from \"./config.js\";\nimport { resolveStores } from \"./control/resolve-store.js\";\n\nasync function main(): Promise<void> {\n const port = Number(process.env[\"PORT\"] ?? 8080);\n const host = process.env[\"HOST\"] ?? \"0.0.0.0\";\n\n // Two shapes, one binary. With DATABASE_URL (or FRANKIE_TENANTS_DIR) set, this\n // serves every tenant the control plane wrote, resolved by publishable key —\n // the hosted, multi-tenant shape. Otherwise it loads one frankie.config.json,\n // the self-hosted, single-tenant shape.\n const multiTenant = Boolean(process.env[\"DATABASE_URL\"] || process.env[\"FRANKIE_TENANTS_DIR\"]);\n let app;\n let describe = \"single-tenant\";\n try {\n if (multiTenant) {\n const resolved = await resolveStores();\n describe = `multi-tenant (${resolved.describe})`;\n app = createFrankieApp({\n tenants: resolved.tenants,\n sessions: resolved.sessions,\n knowledge: resolved.knowledge,\n ...(resolved.rateLimiter ? { rateLimiter: resolved.rateLimiter } : {}),\n ...(resolved.turnSignals ? { turnSignals: resolved.turnSignals } : {}),\n });\n } else {\n app = createFrankieApp({ resolved: await loadResolvedConfig() });\n }\n } catch (error) {\n if (error instanceof ConfigError || error instanceof ModelResolutionError) {\n console.error(`[frankie] ${error.message}`);\n process.exit(1);\n }\n throw error;\n }\n\n serve({ fetch: app.fetch, port, hostname: host }, () => {\n console.log(\n JSON.stringify({\n ts: new Date().toISOString(),\n level: \"info\",\n event: \"listening\",\n mode: describe,\n url: `http://${host}:${port}`,\n }),\n );\n });\n}\n\nvoid main();\n"],"mappings":";;;;;;;;;;;;AACA,SAAS,aAAa;AAMtB,eAAe,OAAsB;AACnC,QAAM,OAAO,OAAO,QAAQ,IAAI,MAAM,KAAK,IAAI;AAC/C,QAAM,OAAO,QAAQ,IAAI,MAAM,KAAK;AAMpC,QAAM,cAAc,QAAQ,QAAQ,IAAI,cAAc,KAAK,QAAQ,IAAI,qBAAqB,CAAC;AAC7F,MAAI;AACJ,MAAI,WAAW;AACf,MAAI;AACF,QAAI,aAAa;AACf,YAAM,WAAW,MAAM,cAAc;AACrC,iBAAW,iBAAiB,SAAS,QAAQ;AAC7C,YAAM,iBAAiB;AAAA,QACrB,SAAS,SAAS;AAAA,QAClB,UAAU,SAAS;AAAA,QACnB,WAAW,SAAS;AAAA,QACpB,GAAI,SAAS,cAAc,EAAE,aAAa,SAAS,YAAY,IAAI,CAAC;AAAA,QACpE,GAAI,SAAS,cAAc,EAAE,aAAa,SAAS,YAAY,IAAI,CAAC;AAAA,MACtE,CAAC;AAAA,IACH,OAAO;AACL,YAAM,iBAAiB,EAAE,UAAU,MAAM,mBAAmB,EAAE,CAAC;AAAA,IACjE;AAAA,EACF,SAAS,OAAO;AACd,QAAI,iBAAiB,eAAe,iBAAiB,sBAAsB;AACzE,cAAQ,MAAM,aAAa,MAAM,OAAO,EAAE;AAC1C,cAAQ,KAAK,CAAC;AAAA,IAChB;AACA,UAAM;AAAA,EACR;AAEA,QAAM,EAAE,OAAO,IAAI,OAAO,MAAM,UAAU,KAAK,GAAG,MAAM;AACtD,YAAQ;AAAA,MACN,KAAK,UAAU;AAAA,QACb,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,QAC3B,OAAO;AAAA,QACP,OAAO;AAAA,QACP,MAAM;AAAA,QACN,KAAK,UAAU,IAAI,IAAI,IAAI;AAAA,MAC7B,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AACH;AAEA,KAAK,KAAK;","names":[]}