@firstlovecenter/ai-chat 0.1.0 → 0.2.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.
@@ -453,6 +453,36 @@ type ConfigureAiChatOpts<S = unknown> = {
453
453
  * - `onSessionEnd` — cleanup (always-runs, never throws out).
454
454
  */
455
455
  hooks?: AgentCustomHooks$1<S>;
456
+ /**
457
+ * Persona / role given to the AI as the very first cached system block
458
+ * on every turn. Use this to give the assistant a consistent voice and
459
+ * domain perspective across all conversations in your app.
460
+ *
461
+ * Accepts:
462
+ * - a `string` for a static org-wide role, or
463
+ * - a `(ctx) => string | Promise<string>` function for per-request
464
+ * variation (different role per scope, per locale, per A/B cohort).
465
+ *
466
+ * Token economics: the role text is sent on every turn (Vertex APIs
467
+ * are stateless), but it is marked `cached: true`. On Claude that
468
+ * becomes an ephemeral `cache_control` marker — first turn pays ~1.25×
469
+ * for the cache write; subsequent turns within ~5 min pay ~0.1× for
470
+ * cache reads. On Gemini the hint is informational; Vertex auto-caches
471
+ * stable prefixes regardless. For a typical 200–500 token role, the
472
+ * per-turn marginal cost after the first is negligible. The package
473
+ * caps total `cache_control` markers at Anthropic's 4-per-request
474
+ * limit; if the host already marks 4 blocks cached, the extras
475
+ * (including this one if last) silently drop the cache hint rather
476
+ * than reject the request.
477
+ *
478
+ * Example (static):
479
+ * rolePrompt: `You are the head pastor of First Love Center, a
480
+ * multinational church operating across multiple countries. Frame
481
+ * answers from the perspective of advancing the gospel through
482
+ * sustainable growth, financial discipline, compliance, and church
483
+ * planting. Be direct, pastoral, and action-oriented.`
484
+ */
485
+ rolePrompt?: string | ((ctx: ToolContext<S>) => string | Promise<string>);
456
486
  };
457
487
  type AiChatRuntime<S = unknown> = {
458
488
  /**
@@ -453,6 +453,36 @@ type ConfigureAiChatOpts<S = unknown> = {
453
453
  * - `onSessionEnd` — cleanup (always-runs, never throws out).
454
454
  */
455
455
  hooks?: AgentCustomHooks$1<S>;
456
+ /**
457
+ * Persona / role given to the AI as the very first cached system block
458
+ * on every turn. Use this to give the assistant a consistent voice and
459
+ * domain perspective across all conversations in your app.
460
+ *
461
+ * Accepts:
462
+ * - a `string` for a static org-wide role, or
463
+ * - a `(ctx) => string | Promise<string>` function for per-request
464
+ * variation (different role per scope, per locale, per A/B cohort).
465
+ *
466
+ * Token economics: the role text is sent on every turn (Vertex APIs
467
+ * are stateless), but it is marked `cached: true`. On Claude that
468
+ * becomes an ephemeral `cache_control` marker — first turn pays ~1.25×
469
+ * for the cache write; subsequent turns within ~5 min pay ~0.1× for
470
+ * cache reads. On Gemini the hint is informational; Vertex auto-caches
471
+ * stable prefixes regardless. For a typical 200–500 token role, the
472
+ * per-turn marginal cost after the first is negligible. The package
473
+ * caps total `cache_control` markers at Anthropic's 4-per-request
474
+ * limit; if the host already marks 4 blocks cached, the extras
475
+ * (including this one if last) silently drop the cache hint rather
476
+ * than reject the request.
477
+ *
478
+ * Example (static):
479
+ * rolePrompt: `You are the head pastor of First Love Center, a
480
+ * multinational church operating across multiple countries. Frame
481
+ * answers from the perspective of advancing the gospel through
482
+ * sustainable growth, financial discipline, compliance, and church
483
+ * planting. Be direct, pastoral, and action-oriented.`
484
+ */
485
+ rolePrompt?: string | ((ctx: ToolContext<S>) => string | Promise<string>);
456
486
  };
457
487
  type AiChatRuntime<S = unknown> = {
458
488
  /**
@@ -37,11 +37,26 @@ async function runAgent(input) {
37
37
  }
38
38
  if (response.toolCalls.length === 0) {
39
39
  if (presentPayload) break;
40
+ const text = (response.text ?? "").trim();
41
+ if (text) {
42
+ const topic = input.question.length > 80 ? input.question.slice(0, 77) + "..." : input.question;
43
+ presentPayload = {
44
+ blocks: [
45
+ {
46
+ kind: "paragraph_brief",
47
+ topic,
48
+ key_facts: [text]
49
+ }
50
+ ],
51
+ raw_numbers: {}
52
+ };
53
+ break;
54
+ }
40
55
  return {
41
56
  ok: false,
42
57
  error: {
43
58
  code: "AGENT_NO_PRESENT",
44
- message: "The agent ended without calling present(). Try rephrasing."
59
+ message: "The agent produced no response. Try rephrasing."
45
60
  },
46
61
  transcript
47
62
  };
@@ -129,11 +144,19 @@ var ClaudeToolProvider = class {
129
144
  patchVertexBuildRequestSync(this.client);
130
145
  }
131
146
  async runTurn(input) {
132
- const system = input.system.map((b) => ({
133
- type: "text",
134
- text: b.text,
135
- ...b.cached ? { cache_control: { type: "ephemeral" } } : {}
136
- }));
147
+ let cacheMarkersUsed = 0;
148
+ const MAX_CACHE_MARKERS = 4;
149
+ const system = input.system.map((b) => {
150
+ if (b.cached && cacheMarkersUsed < MAX_CACHE_MARKERS) {
151
+ cacheMarkersUsed++;
152
+ return {
153
+ type: "text",
154
+ text: b.text,
155
+ cache_control: { type: "ephemeral" }
156
+ };
157
+ }
158
+ return { type: "text", text: b.text };
159
+ });
137
160
  const messages = toAnthropicMessages(input.messages);
138
161
  const response = await this.client.messages.create({
139
162
  model: this.modelId,
@@ -1376,6 +1399,16 @@ function configureAiChat(opts) {
1376
1399
  ];
1377
1400
  const getProvider = (id) => toolProviders2.find((p) => p.id === id) ?? getToolProvider(id);
1378
1401
  const chatInterfaces = opts.chatInterfaces ?? BUILTIN_CHAT_INTERFACE_IDS.map((id) => ({ id }));
1402
+ const tools = opts.rolePrompt ? {
1403
+ tools: opts.tools.tools,
1404
+ async buildSystemBlocks(ctx) {
1405
+ const inner = await opts.tools.buildSystemBlocks(ctx);
1406
+ const rolePrompt = opts.rolePrompt;
1407
+ const role = typeof rolePrompt === "function" ? await rolePrompt(ctx) : rolePrompt;
1408
+ if (!role || !role.trim()) return inner;
1409
+ return [{ text: role, cached: true }, ...inner];
1410
+ }
1411
+ } : opts.tools;
1379
1412
  const runAgentBound = async ({
1380
1413
  question,
1381
1414
  ctx,
@@ -1398,11 +1431,11 @@ function configureAiChat(opts) {
1398
1431
  modelIds: opts.vertex.modelIds,
1399
1432
  location: location ?? settings.gcpLocation
1400
1433
  });
1401
- const systemBlocks = await opts.tools.buildSystemBlocks(ctx);
1434
+ const systemBlocks = await tools.buildSystemBlocks(ctx);
1402
1435
  const input = {
1403
1436
  question,
1404
1437
  ctx,
1405
- tools: opts.tools.tools,
1438
+ tools: tools.tools,
1406
1439
  systemBlocks,
1407
1440
  provider,
1408
1441
  maxToolTurns,
@@ -1418,7 +1451,7 @@ function configureAiChat(opts) {
1418
1451
  persistence: opts.persistence,
1419
1452
  auth: opts.auth,
1420
1453
  scope: opts.scope,
1421
- tools: opts.tools,
1454
+ tools,
1422
1455
  vertex: opts.vertex,
1423
1456
  logger: opts.logger,
1424
1457
  resolveNarratorId: opts.resolveNarratorId,