alvin-bot 4.10.0 → 4.12.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.
@@ -36,6 +36,8 @@ export class SlackAdapter {
36
36
  botUserId = "";
37
37
  botToken;
38
38
  appToken;
39
+ /** v4.12.0 — channelId → channelName cache, refreshed on miss via conversations.info */
40
+ channelNameCache = new Map();
39
41
  constructor(botToken, appToken) {
40
42
  this.botToken = botToken;
41
43
  this.appToken = appToken;
@@ -221,8 +223,9 @@ export class SlackAdapter {
221
223
  const chunks = text.length > 3800
222
224
  ? text.match(/.{1,3800}/gs) || [text]
223
225
  : [text];
226
+ let lastTs;
224
227
  for (const chunk of chunks) {
225
- await this.app.client.chat.postMessage({
228
+ const result = await this.app.client.chat.postMessage({
226
229
  token: this.botToken,
227
230
  channel: chatId,
228
231
  text: chunk,
@@ -231,7 +234,30 @@ export class SlackAdapter {
231
234
  // Convert markdown bold/italic to Slack mrkdwn
232
235
  mrkdwn: true,
233
236
  });
237
+ if (result?.ts)
238
+ lastTs = result.ts;
234
239
  }
240
+ return lastTs;
241
+ }
242
+ /** Edit a previously-sent message (for progress tickers on long queries).
243
+ * Fail-silent: ticker UX shouldn't crash the query. */
244
+ async editMessage(chatId, messageId, newText) {
245
+ if (!this.app)
246
+ return messageId;
247
+ try {
248
+ const safeText = newText.length > 3800 ? newText.slice(0, 3800) + "..." : newText;
249
+ await this.app.client.chat.update({
250
+ token: this.botToken,
251
+ channel: chatId,
252
+ ts: messageId,
253
+ text: safeText,
254
+ mrkdwn: true,
255
+ });
256
+ }
257
+ catch {
258
+ // Silent failure — ticker UX shouldn't crash the query
259
+ }
260
+ return messageId;
235
261
  }
236
262
  async sendPhoto(chatId, photo, caption) {
237
263
  if (!this.app)
@@ -299,8 +325,46 @@ export class SlackAdapter {
299
325
  catch { /* ignore — emoji might not exist */ }
300
326
  }
301
327
  async setTyping(chatId) {
302
- // Slack doesn't have a public typing indicator API for bots
303
- // The closest is the "is typing" shown during Web API calls
328
+ if (!this.app)
329
+ return;
330
+ // v4.12.0 — Slack's official "is thinking" API for bots is
331
+ // assistant.threads.setStatus which shows "Alvin is thinking…" under
332
+ // the message. Only works in assistant-enabled channels (scope:
333
+ // assistant:write) — silently no-ops in channels where the bot
334
+ // isn't enabled as an assistant.
335
+ try {
336
+ await this.app.client.apiCall("assistant.threads.setStatus", {
337
+ channel_id: chatId,
338
+ status: "is thinking…",
339
+ });
340
+ }
341
+ catch {
342
+ // Not every channel supports assistant threads — that's fine
343
+ }
344
+ }
345
+ /** v4.12.0 — Look up a Slack channel's name by ID, using a small in-memory
346
+ * cache. Used by platform-message.ts for workspace resolution. */
347
+ async getChannelName(channelId) {
348
+ if (!this.app)
349
+ return undefined;
350
+ const cached = this.channelNameCache.get(channelId);
351
+ if (cached)
352
+ return cached;
353
+ try {
354
+ const result = await this.app.client.conversations.info({
355
+ token: this.botToken,
356
+ channel: channelId,
357
+ });
358
+ const name = result?.channel?.name;
359
+ if (name) {
360
+ this.channelNameCache.set(channelId, name);
361
+ return name;
362
+ }
363
+ }
364
+ catch {
365
+ // IM channels return channel_not_found here — that's expected
366
+ }
367
+ return undefined;
304
368
  }
305
369
  async stop() {
306
370
  if (this.app) {
@@ -65,6 +65,19 @@ export async function compactSession(session) {
65
65
  catch (err) {
66
66
  console.error("Compaction: failed to flush to memory:", err);
67
67
  }
68
+ // v4.11.0 P1 #5 — Auto-extract structured facts from the archived chunk
69
+ // and persist them to MEMORY.md. Experimental feature, opt-out via
70
+ // MEMORY_EXTRACTION_DISABLED=1. Safe wrapper — never throws.
71
+ try {
72
+ const { extractAndStoreFacts } = await import("./memory-extractor.js");
73
+ const result = await extractAndStoreFacts(summaryInput);
74
+ if (result.factsStored > 0) {
75
+ console.log(`🧠 memory-extractor: stored ${result.factsStored} new fact(s) in MEMORY.md`);
76
+ }
77
+ }
78
+ catch (err) {
79
+ console.warn("memory-extractor failed (non-fatal):", err instanceof Error ? err.message : err);
80
+ }
68
81
  // Try AI-powered summary
69
82
  let summaryText = null;
70
83
  try {
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Memory Extractor (v4.11.0, experimental)
3
+ *
4
+ * When the compaction service archives old conversation chunks, it normally
5
+ * dumps prose into the daily log. This extractor adds a structured pass that
6
+ * pulls user_facts, preferences, and decisions out of the chunk and appends
7
+ * them to MEMORY.md (de-duplicated by exact-string match).
8
+ *
9
+ * Pattern inspired by Mem0's auto-extraction. Designed to be safe:
10
+ * - Opt-out via MEMORY_EXTRACTION_DISABLED=1
11
+ * - Uses the active provider with effort=low
12
+ * - Failures are swallowed; compaction continues regardless
13
+ * - Dedup is exact-string only (no embedding-based semantic dedup yet)
14
+ */
15
+ import fs from "fs";
16
+ import { dirname } from "path";
17
+ import { MEMORY_FILE } from "../paths.js";
18
+ const EMPTY_FACTS = {
19
+ user_facts: [],
20
+ preferences: [],
21
+ decisions: [],
22
+ };
23
+ const EXTRACTION_PROMPT = `Extract structured facts from this conversation chunk. Return ONLY a JSON object with these keys:
24
+
25
+ {
26
+ "user_facts": ["concrete facts about the user that should persist forever"],
27
+ "preferences": ["communication style or workflow preferences the user expressed"],
28
+ "decisions": ["explicit decisions made (e.g., 'use X instead of Y')"]
29
+ }
30
+
31
+ Rules:
32
+ - Each entry must be ONE short, declarative sentence (max 100 chars).
33
+ - Skip transient conversation details (questions, todos, ephemeral state).
34
+ - Skip facts that are obvious from context (e.g., "user asked a question").
35
+ - Empty arrays are fine — don't invent facts.
36
+ - Output ONLY the JSON, no commentary.
37
+
38
+ Conversation chunk:
39
+ `;
40
+ /**
41
+ * Parse the JSON output from the AI extractor. Tolerates markdown code-fence
42
+ * wrapping and surrounding prose. Returns empty arrays on any parse failure.
43
+ */
44
+ export function parseExtractedFacts(text) {
45
+ if (!text || typeof text !== "string")
46
+ return { ...EMPTY_FACTS };
47
+ // Strip markdown code fences if present
48
+ let cleaned = text.trim();
49
+ const fenceMatch = cleaned.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/);
50
+ if (fenceMatch)
51
+ cleaned = fenceMatch[1].trim();
52
+ // Try to find the first { ... } block if there's surrounding prose
53
+ const braceMatch = cleaned.match(/\{[\s\S]*\}/);
54
+ if (braceMatch)
55
+ cleaned = braceMatch[0];
56
+ try {
57
+ const parsed = JSON.parse(cleaned);
58
+ return {
59
+ user_facts: Array.isArray(parsed.user_facts)
60
+ ? parsed.user_facts.filter((s) => typeof s === "string")
61
+ : [],
62
+ preferences: Array.isArray(parsed.preferences)
63
+ ? parsed.preferences.filter((s) => typeof s === "string")
64
+ : [],
65
+ decisions: Array.isArray(parsed.decisions)
66
+ ? parsed.decisions.filter((s) => typeof s === "string")
67
+ : [],
68
+ };
69
+ }
70
+ catch {
71
+ return { ...EMPTY_FACTS };
72
+ }
73
+ }
74
+ /**
75
+ * Append extracted facts to MEMORY.md under structured headers, deduplicated
76
+ * by exact-string match against existing content.
77
+ */
78
+ export async function appendFactsToMemoryFile(facts) {
79
+ const total = facts.user_facts.length + facts.preferences.length + facts.decisions.length;
80
+ if (total === 0)
81
+ return 0;
82
+ // Read existing content for dedup
83
+ let existing = "";
84
+ try {
85
+ existing = fs.readFileSync(MEMORY_FILE, "utf-8");
86
+ }
87
+ catch {
88
+ // File doesn't exist yet — that's fine, mkdir parent
89
+ fs.mkdirSync(dirname(MEMORY_FILE), { recursive: true });
90
+ }
91
+ const isDuplicate = (line) => existing.includes(line);
92
+ const newLines = [];
93
+ const todayIso = new Date().toISOString().slice(0, 10);
94
+ const sectionHeader = `\n\n## Auto-extracted (${todayIso})\n`;
95
+ let stored = 0;
96
+ if (facts.user_facts.length > 0) {
97
+ const newOnes = facts.user_facts.filter(f => !isDuplicate(f));
98
+ if (newOnes.length > 0) {
99
+ newLines.push("\n### User Facts");
100
+ for (const f of newOnes) {
101
+ newLines.push(`- ${f}`);
102
+ stored++;
103
+ }
104
+ }
105
+ }
106
+ if (facts.preferences.length > 0) {
107
+ const newOnes = facts.preferences.filter(p => !isDuplicate(p));
108
+ if (newOnes.length > 0) {
109
+ newLines.push("\n### Preferences");
110
+ for (const p of newOnes) {
111
+ newLines.push(`- ${p}`);
112
+ stored++;
113
+ }
114
+ }
115
+ }
116
+ if (facts.decisions.length > 0) {
117
+ const newOnes = facts.decisions.filter(d => !isDuplicate(d));
118
+ if (newOnes.length > 0) {
119
+ newLines.push("\n### Decisions");
120
+ for (const d of newOnes) {
121
+ newLines.push(`- ${d}`);
122
+ stored++;
123
+ }
124
+ }
125
+ }
126
+ if (stored > 0) {
127
+ const block = sectionHeader + newLines.join("\n") + "\n";
128
+ fs.appendFileSync(MEMORY_FILE, block, "utf-8");
129
+ }
130
+ return stored;
131
+ }
132
+ /**
133
+ * Extract facts from a conversation chunk and store them in MEMORY.md.
134
+ * Safe wrapper — never throws, always returns an ExtractionResult.
135
+ */
136
+ export async function extractAndStoreFacts(conversationText) {
137
+ if (process.env.MEMORY_EXTRACTION_DISABLED === "1") {
138
+ return { disabled: true, factsStored: 0 };
139
+ }
140
+ if (!conversationText || conversationText.trim().length < 50) {
141
+ return { disabled: false, factsStored: 0 };
142
+ }
143
+ let extractedText = "";
144
+ try {
145
+ // Lazy-import the registry so test environments without an engine init
146
+ // don't crash on module load.
147
+ const { getRegistry } = await import("../engine.js");
148
+ const registry = getRegistry();
149
+ const opts = {
150
+ prompt: EXTRACTION_PROMPT + conversationText.slice(0, 8000),
151
+ systemPrompt: "You are a fact extractor. Output only valid JSON, no commentary.",
152
+ effort: "low",
153
+ };
154
+ for await (const chunk of registry.queryWithFallback(opts)) {
155
+ if (chunk.type === "text" && chunk.text) {
156
+ extractedText = chunk.text;
157
+ }
158
+ if (chunk.type === "error") {
159
+ // Provider failed — silent fallback
160
+ return { disabled: false, factsStored: 0 };
161
+ }
162
+ }
163
+ }
164
+ catch {
165
+ return { disabled: false, factsStored: 0 };
166
+ }
167
+ if (!extractedText)
168
+ return { disabled: false, factsStored: 0 };
169
+ const facts = parseExtractedFacts(extractedText);
170
+ let stored = 0;
171
+ try {
172
+ stored = await appendFactsToMemoryFile(facts);
173
+ }
174
+ catch {
175
+ // appendFactsToMemoryFile failed — non-fatal
176
+ }
177
+ return { disabled: false, factsStored: stored };
178
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Memory Layers Service (v4.11.0)
3
+ *
4
+ * Layered memory loader inspired by mempalace's L0–L3 stack:
5
+ *
6
+ * L0 identity.md always loaded, ~200 tokens (core user facts)
7
+ * L1 preferences.md always loaded (communication style)
8
+ * L1 MEMORY.md backwards-compat: monolithic curated knowledge
9
+ * L2 projects/*.md loaded on topic match against the user's query
10
+ * L3 daily logs only via vector search (handled by embeddings.ts)
11
+ *
12
+ * If neither identity.md nor preferences.md exists, this loader still works
13
+ * via the monolithic MEMORY.md fallback, so existing setups need no migration.
14
+ *
15
+ * Token budget: capped at ~5000 chars for L0+L1, +~3000 chars for matched L2.
16
+ */
17
+ import fs from "fs";
18
+ import path from "path";
19
+ import { IDENTITY_FILE, PREFERENCES_FILE, PROJECTS_MEMORY_DIR, MEMORY_FILE, } from "../paths.js";
20
+ const MAX_L0_L1_CHARS = 5000;
21
+ const MAX_L2_PROJECT_CHARS = 1500;
22
+ const MAX_L2_TOTAL_CHARS = 3000;
23
+ function readSafe(file) {
24
+ try {
25
+ return fs.readFileSync(file, "utf-8");
26
+ }
27
+ catch {
28
+ return "";
29
+ }
30
+ }
31
+ /**
32
+ * Load all memory layers from disk. Cheap — no API calls, just file reads.
33
+ */
34
+ export function loadMemoryLayers() {
35
+ const identity = readSafe(IDENTITY_FILE);
36
+ const preferences = readSafe(PREFERENCES_FILE);
37
+ const longTerm = readSafe(MEMORY_FILE);
38
+ const projects = [];
39
+ try {
40
+ if (fs.existsSync(PROJECTS_MEMORY_DIR)) {
41
+ const entries = fs.readdirSync(PROJECTS_MEMORY_DIR);
42
+ for (const entry of entries) {
43
+ if (!entry.endsWith(".md") || entry.startsWith("."))
44
+ continue;
45
+ const fullPath = path.resolve(PROJECTS_MEMORY_DIR, entry);
46
+ const content = readSafe(fullPath);
47
+ if (content.trim()) {
48
+ projects.push({
49
+ topic: entry.replace(/\.md$/, ""),
50
+ content,
51
+ });
52
+ }
53
+ }
54
+ }
55
+ }
56
+ catch {
57
+ // projects dir missing or unreadable — fine
58
+ }
59
+ return { identity, preferences, longTerm, projects };
60
+ }
61
+ /**
62
+ * Match L2 projects against the user query.
63
+ * Topic match is naive substring (case-insensitive) on filename + first 200 chars
64
+ * of the project content. For v4.11.0 this is intentionally simple — vector
65
+ * search via embeddings.ts handles the deep cases.
66
+ */
67
+ function matchProjectsToQuery(projects, query) {
68
+ if (!query)
69
+ return [];
70
+ const q = query.toLowerCase();
71
+ const matched = [];
72
+ for (const p of projects) {
73
+ const topicLower = p.topic.toLowerCase();
74
+ if (q.includes(topicLower)) {
75
+ matched.push(p);
76
+ continue;
77
+ }
78
+ // Also check the first 200 chars of project content — this catches cases
79
+ // where the user mentions a project's headline term that isn't the
80
+ // filename (e.g., "VPS" matching alev-b.md which mentions "VPS:" upfront).
81
+ const head = p.content.slice(0, 200).toLowerCase();
82
+ const headWords = head.split(/[\s\W]+/).filter(w => w.length >= 4);
83
+ if (headWords.some(w => q.includes(w))) {
84
+ matched.push(p);
85
+ }
86
+ }
87
+ return matched;
88
+ }
89
+ /**
90
+ * Build a token-budgeted layered context string suitable for system prompt injection.
91
+ *
92
+ * @param query Optional user query. If provided, L2 projects matching the query
93
+ * get included. If omitted, only L0+L1 are loaded (boot-up brief).
94
+ */
95
+ export function buildLayeredContext(query) {
96
+ const layers = loadMemoryLayers();
97
+ const parts = [];
98
+ let l0l1Chars = 0;
99
+ if (layers.identity) {
100
+ const truncated = layers.identity.length > MAX_L0_L1_CHARS
101
+ ? layers.identity.slice(0, MAX_L0_L1_CHARS) + "\n[...truncated]"
102
+ : layers.identity;
103
+ parts.push("## Identity (L0)\n" + truncated);
104
+ l0l1Chars += truncated.length;
105
+ }
106
+ if (layers.preferences && l0l1Chars < MAX_L0_L1_CHARS) {
107
+ const remaining = MAX_L0_L1_CHARS - l0l1Chars;
108
+ const truncated = layers.preferences.length > remaining
109
+ ? layers.preferences.slice(0, remaining) + "\n[...truncated]"
110
+ : layers.preferences;
111
+ parts.push("## Preferences (L1)\n" + truncated);
112
+ l0l1Chars += truncated.length;
113
+ }
114
+ // Backwards-compat: if no identity AND no preferences, use the monolithic
115
+ // MEMORY.md as L1 fully (existing user setups). If split files exist,
116
+ // include MEMORY.md as a secondary L1 with tighter truncation.
117
+ if (!layers.identity && !layers.preferences && layers.longTerm) {
118
+ const truncated = layers.longTerm.length > MAX_L0_L1_CHARS
119
+ ? layers.longTerm.slice(0, MAX_L0_L1_CHARS) + "\n[...truncated]"
120
+ : layers.longTerm;
121
+ parts.push("## Long-term Memory (L1, monolithic)\n" + truncated);
122
+ }
123
+ else if (layers.longTerm) {
124
+ const SECONDARY_CAP = 1500;
125
+ const truncated = layers.longTerm.length > SECONDARY_CAP
126
+ ? layers.longTerm.slice(0, SECONDARY_CAP) + "\n[...truncated]"
127
+ : layers.longTerm;
128
+ parts.push("## Long-term Memory (L1, legacy MEMORY.md)\n" + truncated);
129
+ }
130
+ // L2: project-specific, only when a query is provided
131
+ if (query && layers.projects.length > 0) {
132
+ const matched = matchProjectsToQuery(layers.projects, query);
133
+ let l2TotalChars = 0;
134
+ for (const p of matched) {
135
+ if (l2TotalChars >= MAX_L2_TOTAL_CHARS)
136
+ break;
137
+ const remaining = MAX_L2_TOTAL_CHARS - l2TotalChars;
138
+ const cap = Math.min(MAX_L2_PROJECT_CHARS, remaining);
139
+ const content = p.content.length > cap
140
+ ? p.content.slice(0, cap) + "\n[...truncated]"
141
+ : p.content;
142
+ parts.push(`## Project: ${p.topic} (L2)\n${content}`);
143
+ l2TotalChars += content.length;
144
+ }
145
+ }
146
+ return parts.join("\n\n");
147
+ }
@@ -11,6 +11,7 @@ import fs from "fs";
11
11
  import { resolve } from "path";
12
12
  import { MEMORY_DIR, MEMORY_FILE } from "../paths.js";
13
13
  import { reindexMemory } from "./embeddings.js";
14
+ import { buildLayeredContext } from "./memory-layers.js";
14
15
  // Ensure dirs exist
15
16
  if (!fs.existsSync(MEMORY_DIR))
16
17
  fs.mkdirSync(MEMORY_DIR, { recursive: true });
@@ -65,16 +66,22 @@ export function appendDailyLog(entry) {
65
66
  reindexMemory().catch(() => { });
66
67
  }
67
68
  /**
68
- * Build memory context for injection into non-SDK prompts.
69
- * Returns relevant memory as a compact string.
69
+ * Build memory context for injection into prompts.
70
+ *
71
+ * v4.11.0 — Now uses the layered memory loader (memory-layers.ts) which
72
+ * combines L0 (identity.md), L1 (preferences.md + legacy MEMORY.md), and
73
+ * optional L2 (projects/*.md matched against the query). Falls back to the
74
+ * monolithic MEMORY.md alone if the split files don't exist.
75
+ *
76
+ * @param query Optional user query — when provided, L2 projects matching
77
+ * the query get included. When omitted, only L0+L1 are loaded.
70
78
  */
71
- export function buildMemoryContext() {
79
+ export function buildMemoryContext(query) {
72
80
  const parts = [];
73
- // Long-term memory (truncate if too long)
74
- const ltm = loadLongTermMemory();
75
- if (ltm) {
76
- const truncated = ltm.length > 2000 ? ltm.slice(0, 2000) + "\n[...truncated]" : ltm;
77
- parts.push(`## Long-term Memory\n${truncated}`);
81
+ // L0+L1 (+ matched L2 if query) via layered loader
82
+ const layered = buildLayeredContext(query);
83
+ if (layered) {
84
+ parts.push(layered);
78
85
  }
79
86
  // Today's log
80
87
  const todayLog = loadDailyLog();
@@ -186,7 +186,7 @@ Always ask yourself first: "Can I solve this with my own intelligence?" If yes
186
186
  * @param isSDK Whether the active provider is the Claude SDK (has tool use)
187
187
  * @param language Preferred language ('de' or 'en')
188
188
  */
189
- export function buildSystemPrompt(isSDK, language = "en", chatId) {
189
+ export function buildSystemPrompt(isSDK, language = "en", chatId, query, workspacePersona) {
190
190
  // The deep base prompt has only de/en variants (writing four full
191
191
  // personality templates is out of scope). For es/fr we fall back to
192
192
  // the English base — the LLM mirrors the user's conversational language
@@ -214,6 +214,12 @@ export function buildSystemPrompt(isSDK, language = "en", chatId) {
214
214
  if (standingOrders) {
215
215
  parts.push("## Standing Orders\n\n" + standingOrders);
216
216
  }
217
+ // v4.12.0 — Workspace persona: per-channel system prompt override from
218
+ // ~/.alvin-bot/workspaces/<name>.md body. Only injected when a workspace
219
+ // is resolved for the channel; default workspace passes empty string.
220
+ if (workspacePersona && workspacePersona.trim().length > 0) {
221
+ parts.push("## Workspace Persona\n\n" + workspacePersona.trim());
222
+ }
217
223
  if (isSDK) {
218
224
  parts.push(SDK_ADDON);
219
225
  // Stage 1 — teach Claude to use run_in_background for long-running
@@ -226,31 +232,59 @@ export function buildSystemPrompt(isSDK, language = "en", chatId) {
226
232
  if (chatId) {
227
233
  parts.push(`Current chat: Platform=telegram, ChatID=${chatId}. Use this ChatID when creating cron jobs that should send results to this chat.`);
228
234
  }
229
- // Non-SDK providers get memory injected into system prompt
230
- // (SDK provider reads memory files directly via tools)
231
- if (!isSDK) {
232
- const memoryCtx = buildMemoryContext();
233
- if (memoryCtx) {
234
- parts.push(memoryCtx);
235
- }
235
+ // Memory context: ALL providers (SDK + non-SDK) get long-term memory
236
+ // injected (v4.11.0 P0 #2). Before v4.11.0 this was non-SDK only; the SDK
237
+ // was expected to read memory via tools but in practice rarely did, leading
238
+ // to "frickelig" UX after session restarts. The injected MEMORY.md gives
239
+ // Claude immediate context without spending a tool-call round-trip on Read.
240
+ //
241
+ // The optional `query` argument enables L2 project loading (v4.11.0 P1 #4):
242
+ // memory-layers.ts matches the user's question against project filenames
243
+ // in ~/.alvin-bot/memory/projects/ and only loads matching ones — keeping
244
+ // the system prompt small while still surfacing relevant per-project facts.
245
+ // See docs/superpowers/plans/2026-04-13-memory-persistence.md
246
+ const memoryCtx = buildMemoryContext(query);
247
+ if (memoryCtx) {
248
+ parts.push(memoryCtx);
236
249
  }
237
- // Non-SDK: inject compact asset awareness
238
- if (!isSDK) {
239
- const assetMd = getAssetIndexMd();
240
- if (assetMd) {
241
- parts.push(assetMd);
242
- }
250
+ // Asset awareness: also extended to SDK in v4.11.0 — same rationale as
251
+ // memory above. Cheap injection beats hoping Claude uses Glob/Read on
252
+ // ~/.alvin-bot/assets/ proactively.
253
+ const assetMd = getAssetIndexMd();
254
+ if (assetMd) {
255
+ parts.push(assetMd);
243
256
  }
244
257
  return parts.join("\n\n");
245
258
  }
246
259
  /**
247
260
  * Build a system prompt enhanced with semantically relevant memories.
248
261
  * Searches the vector index for context related to the user's message.
262
+ *
263
+ * @param isSDK true → Claude SDK provider (has tool use)
264
+ * @param language preferred UI language
265
+ * @param userMessage the user's incoming message (used as the search query)
266
+ * @param chatId Telegram chat id (for cron job context)
267
+ * @param isFirstTurn v4.11.0 P0 #3 — whether this is the very first turn of
268
+ * a (rehydrated or fresh) session. SDK only runs the
269
+ * semantic search on first-turn to avoid spamming the
270
+ * embeddings API on every subsequent turn — once the
271
+ * session has resumed, Claude already has the recalled
272
+ * context in its conversation history. Non-SDK providers
273
+ * run the search on every turn (cheap, no resume).
249
274
  */
250
- export async function buildSmartSystemPrompt(isSDK, language = "en", userMessage, chatId) {
251
- const base = buildSystemPrompt(isSDK, language, chatId);
252
- // SDK providers read memory directly via tools skip
253
- if (isSDK || !userMessage)
275
+ export async function buildSmartSystemPrompt(isSDK, language = "en", userMessage, chatId, isFirstTurn = false, workspacePersona) {
276
+ // Pass userMessage as query so L2 project memories matching the topic
277
+ // get loaded into the base prompt automatically. Workspace persona (v4.12.0)
278
+ // is also threaded through so per-channel personas land in the system prompt.
279
+ const base = buildSystemPrompt(isSDK, language, chatId, userMessage, workspacePersona);
280
+ if (!userMessage)
281
+ return base;
282
+ // Decide whether to run the semantic search:
283
+ // non-SDK → always (cheap, no session resume to lean on)
284
+ // SDK → only on the first turn of a session (Claude carries context
285
+ // across turns within the same session via SDK resume)
286
+ const shouldSearch = !isSDK || isFirstTurn;
287
+ if (!shouldSearch)
254
288
  return base;
255
289
  // Search for relevant memories
256
290
  try {