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.
- package/CHANGELOG.md +194 -0
- package/dist/handlers/commands.js +51 -1
- package/dist/handlers/message.js +24 -6
- package/dist/handlers/platform-message.js +48 -14
- package/dist/index.js +18 -1
- package/dist/paths.js +19 -1
- package/dist/platforms/slack.js +67 -3
- package/dist/services/compaction.js +13 -0
- package/dist/services/memory-extractor.js +178 -0
- package/dist/services/memory-layers.js +147 -0
- package/dist/services/memory.js +15 -8
- package/dist/services/personality.js +52 -18
- package/dist/services/session-persistence.js +194 -0
- package/dist/services/session.js +78 -0
- package/dist/services/workspaces.js +247 -0
- package/dist/web/server.js +25 -0
- package/package.json +2 -2
- package/test/memory-extractor.test.ts +151 -0
- package/test/memory-layers.test.ts +169 -0
- package/test/memory-sdk-injection.test.ts +146 -0
- package/test/memory-stress-restart.test.ts +337 -0
- package/test/multi-session-stress.test.ts +255 -0
- package/test/platform-session-key.test.ts +69 -0
- package/test/session-persistence.test.ts +195 -0
- package/test/slack-progress-ticker.test.ts +123 -0
- package/test/telegram-workspace-command.test.ts +78 -0
- package/test/workspaces.test.ts +196 -0
- package/web/public/index.html +9 -0
- package/web/public/js/app.js +44 -1
package/dist/platforms/slack.js
CHANGED
|
@@ -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
|
-
|
|
303
|
-
|
|
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
|
+
}
|
package/dist/services/memory.js
CHANGED
|
@@ -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
|
|
69
|
-
*
|
|
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
|
-
//
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
|
|
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
|
-
//
|
|
230
|
-
// (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
252
|
-
//
|
|
253
|
-
|
|
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 {
|