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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.11.0 — Auto-fact-extraction.
|
|
3
|
+
*
|
|
4
|
+
* When compaction archives messages, instead of just dumping prose into
|
|
5
|
+
* the daily log, run a structured extraction pass that pulls user_facts,
|
|
6
|
+
* preferences, and decisions out of the chunk and appends them to MEMORY.md
|
|
7
|
+
* (de-duplicated by exact-string match).
|
|
8
|
+
*
|
|
9
|
+
* Marked experimental in v4.11.0. Opt out via MEMORY_EXTRACTION_DISABLED=1.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import os from "os";
|
|
14
|
+
import { resolve } from "path";
|
|
15
|
+
|
|
16
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-mem-extract-${process.pid}-${Date.now()}`);
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
20
|
+
fs.mkdirSync(resolve(TEST_DATA_DIR, "memory"), { recursive: true });
|
|
21
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
22
|
+
delete process.env.MEMORY_EXTRACTION_DISABLED;
|
|
23
|
+
vi.resetModules();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("memory-extractor (v4.11.0)", () => {
|
|
27
|
+
it("parseExtractedFacts handles a clean JSON response", async () => {
|
|
28
|
+
const { parseExtractedFacts } = await import("../src/services/memory-extractor.js");
|
|
29
|
+
const json = JSON.stringify({
|
|
30
|
+
user_facts: ["User Ali lives in Berlin"],
|
|
31
|
+
preferences: ["Replies in German"],
|
|
32
|
+
decisions: ["Use Hostinger VPS for production"],
|
|
33
|
+
});
|
|
34
|
+
const facts = parseExtractedFacts(json);
|
|
35
|
+
expect(facts.user_facts).toEqual(["User Ali lives in Berlin"]);
|
|
36
|
+
expect(facts.preferences).toEqual(["Replies in German"]);
|
|
37
|
+
expect(facts.decisions).toEqual(["Use Hostinger VPS for production"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("parseExtractedFacts handles JSON wrapped in markdown code fences", async () => {
|
|
41
|
+
const { parseExtractedFacts } = await import("../src/services/memory-extractor.js");
|
|
42
|
+
const wrapped = "```json\n" + JSON.stringify({
|
|
43
|
+
user_facts: ["fact 1"],
|
|
44
|
+
}) + "\n```";
|
|
45
|
+
const facts = parseExtractedFacts(wrapped);
|
|
46
|
+
expect(facts.user_facts).toEqual(["fact 1"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("parseExtractedFacts handles JSON with surrounding prose", async () => {
|
|
50
|
+
const { parseExtractedFacts } = await import("../src/services/memory-extractor.js");
|
|
51
|
+
const messy = `Sure, here are the extracted facts:
|
|
52
|
+
${JSON.stringify({ user_facts: ["x"], preferences: [], decisions: [] })}
|
|
53
|
+
Hope this helps!`;
|
|
54
|
+
const facts = parseExtractedFacts(messy);
|
|
55
|
+
expect(facts.user_facts).toEqual(["x"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("parseExtractedFacts returns empty arrays on garbage input", async () => {
|
|
59
|
+
const { parseExtractedFacts } = await import("../src/services/memory-extractor.js");
|
|
60
|
+
const facts = parseExtractedFacts("not json at all");
|
|
61
|
+
expect(facts.user_facts).toEqual([]);
|
|
62
|
+
expect(facts.preferences).toEqual([]);
|
|
63
|
+
expect(facts.decisions).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("parseExtractedFacts filters non-string entries from arrays", async () => {
|
|
67
|
+
const { parseExtractedFacts } = await import("../src/services/memory-extractor.js");
|
|
68
|
+
const messy = JSON.stringify({
|
|
69
|
+
user_facts: ["good", 42, null, "good2"],
|
|
70
|
+
preferences: [],
|
|
71
|
+
decisions: [],
|
|
72
|
+
});
|
|
73
|
+
const facts = parseExtractedFacts(messy);
|
|
74
|
+
expect(facts.user_facts).toEqual(["good", "good2"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("appendFactsToMemoryFile writes new facts under structured headers", async () => {
|
|
78
|
+
const { appendFactsToMemoryFile } = await import("../src/services/memory-extractor.js");
|
|
79
|
+
await appendFactsToMemoryFile({
|
|
80
|
+
user_facts: ["Ali uses launchd for the bot"],
|
|
81
|
+
preferences: [],
|
|
82
|
+
decisions: ["v4.11.0 ships memory persistence"],
|
|
83
|
+
});
|
|
84
|
+
const memFile = resolve(TEST_DATA_DIR, "memory", "MEMORY.md");
|
|
85
|
+
expect(fs.existsSync(memFile)).toBe(true);
|
|
86
|
+
const content = fs.readFileSync(memFile, "utf-8");
|
|
87
|
+
expect(content).toMatch(/Ali uses launchd for the bot/);
|
|
88
|
+
expect(content).toMatch(/v4\.11\.0 ships memory persistence/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("appendFactsToMemoryFile dedupes on exact-string match", async () => {
|
|
92
|
+
const { appendFactsToMemoryFile } = await import("../src/services/memory-extractor.js");
|
|
93
|
+
await appendFactsToMemoryFile({
|
|
94
|
+
user_facts: ["Ali uses launchd for the bot"],
|
|
95
|
+
preferences: [],
|
|
96
|
+
decisions: [],
|
|
97
|
+
});
|
|
98
|
+
await appendFactsToMemoryFile({
|
|
99
|
+
user_facts: ["Ali uses launchd for the bot", "Ali drinks coffee"],
|
|
100
|
+
preferences: [],
|
|
101
|
+
decisions: [],
|
|
102
|
+
});
|
|
103
|
+
const content = fs.readFileSync(resolve(TEST_DATA_DIR, "memory", "MEMORY.md"), "utf-8");
|
|
104
|
+
const matches = content.match(/Ali uses launchd for the bot/g);
|
|
105
|
+
expect(matches).toHaveLength(1); // not duplicated
|
|
106
|
+
expect(content).toMatch(/Ali drinks coffee/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("appendFactsToMemoryFile returns 0 when all facts are duplicates", async () => {
|
|
110
|
+
const { appendFactsToMemoryFile } = await import("../src/services/memory-extractor.js");
|
|
111
|
+
await appendFactsToMemoryFile({
|
|
112
|
+
user_facts: ["unique fact"],
|
|
113
|
+
preferences: [],
|
|
114
|
+
decisions: [],
|
|
115
|
+
});
|
|
116
|
+
const stored = await appendFactsToMemoryFile({
|
|
117
|
+
user_facts: ["unique fact"],
|
|
118
|
+
preferences: [],
|
|
119
|
+
decisions: [],
|
|
120
|
+
});
|
|
121
|
+
expect(stored).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("extractAndStoreFacts is a no-op when MEMORY_EXTRACTION_DISABLED=1", async () => {
|
|
125
|
+
process.env.MEMORY_EXTRACTION_DISABLED = "1";
|
|
126
|
+
vi.resetModules();
|
|
127
|
+
const { extractAndStoreFacts } = await import("../src/services/memory-extractor.js");
|
|
128
|
+
const result = await extractAndStoreFacts("some conversation text");
|
|
129
|
+
expect(result.disabled).toBe(true);
|
|
130
|
+
expect(result.factsStored).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("extractAndStoreFacts returns 0 stored on too-short input", async () => {
|
|
134
|
+
const { extractAndStoreFacts } = await import("../src/services/memory-extractor.js");
|
|
135
|
+
const result = await extractAndStoreFacts("hi");
|
|
136
|
+
expect(result.disabled).toBe(false);
|
|
137
|
+
expect(result.factsStored).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("extractAndStoreFacts gracefully handles AI provider failure", async () => {
|
|
141
|
+
// No API keys in test env — provider will fail, extractor should swallow
|
|
142
|
+
const { extractAndStoreFacts } = await import("../src/services/memory-extractor.js");
|
|
143
|
+
const result = await extractAndStoreFacts(
|
|
144
|
+
"This is a long enough conversation about Berlin, Postgres databases, " +
|
|
145
|
+
"and how to set up nginx properly. Should be more than 50 characters easily.",
|
|
146
|
+
);
|
|
147
|
+
expect(result.disabled).toBe(false);
|
|
148
|
+
expect(result).toHaveProperty("factsStored");
|
|
149
|
+
// Either succeeded or failed silently — but didn't throw
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.11.0 — Layered memory loader.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the monolithic MEMORY.md → System Prompt with a structured
|
|
5
|
+
* 4-layer architecture (inspired by mempalace's L0–L3 stack):
|
|
6
|
+
*
|
|
7
|
+
* L0 identity.md — always loaded, ~200 tokens (who the user is)
|
|
8
|
+
* L1 preferences.md — always loaded (how to communicate)
|
|
9
|
+
* L1 MEMORY.md — backwards-compat: existing curated knowledge
|
|
10
|
+
* L2 projects/*.md — loaded on topic match
|
|
11
|
+
* L3 daily logs — only via vector search (existing embeddings.ts)
|
|
12
|
+
*
|
|
13
|
+
* If the new files don't exist, this falls back to the monolithic MEMORY.md
|
|
14
|
+
* so existing setups keep working without migration.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
17
|
+
import fs from "fs";
|
|
18
|
+
import os from "os";
|
|
19
|
+
import { resolve } from "path";
|
|
20
|
+
|
|
21
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-mem-layers-${process.pid}-${Date.now()}`);
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
25
|
+
fs.mkdirSync(resolve(TEST_DATA_DIR, "memory"), { recursive: true });
|
|
26
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
27
|
+
vi.resetModules();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("memory-layers (v4.11.0)", () => {
|
|
31
|
+
it("returns empty when nothing exists", async () => {
|
|
32
|
+
const { loadMemoryLayers } = await import("../src/services/memory-layers.js");
|
|
33
|
+
const layered = loadMemoryLayers();
|
|
34
|
+
expect(layered.identity).toBe("");
|
|
35
|
+
expect(layered.preferences).toBe("");
|
|
36
|
+
expect(layered.longTerm).toBe("");
|
|
37
|
+
expect(layered.projects).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("loads identity.md as L0 always", async () => {
|
|
41
|
+
fs.writeFileSync(
|
|
42
|
+
resolve(TEST_DATA_DIR, "memory", "identity.md"),
|
|
43
|
+
"# Identity\n\nName: Ali Levin\nLocation: Berlin",
|
|
44
|
+
);
|
|
45
|
+
const { loadMemoryLayers } = await import("../src/services/memory-layers.js");
|
|
46
|
+
const layered = loadMemoryLayers();
|
|
47
|
+
expect(layered.identity).toMatch(/Ali Levin/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("loads preferences.md as L1 always", async () => {
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
resolve(TEST_DATA_DIR, "memory", "preferences.md"),
|
|
53
|
+
"- Reply in German\n- No 'Gerne' in responses",
|
|
54
|
+
);
|
|
55
|
+
const { loadMemoryLayers } = await import("../src/services/memory-layers.js");
|
|
56
|
+
const layered = loadMemoryLayers();
|
|
57
|
+
expect(layered.preferences).toMatch(/Reply in German/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to monolithic MEMORY.md when split files are missing", async () => {
|
|
61
|
+
fs.writeFileSync(
|
|
62
|
+
resolve(TEST_DATA_DIR, "memory", "MEMORY.md"),
|
|
63
|
+
"# Old monolithic\n\n- Some legacy fact",
|
|
64
|
+
);
|
|
65
|
+
const { loadMemoryLayers } = await import("../src/services/memory-layers.js");
|
|
66
|
+
const layered = loadMemoryLayers();
|
|
67
|
+
expect(layered.longTerm).toMatch(/legacy fact/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("loads projects/*.md and exposes them with their filename as topic", async () => {
|
|
71
|
+
fs.mkdirSync(resolve(TEST_DATA_DIR, "memory", "projects"), { recursive: true });
|
|
72
|
+
fs.writeFileSync(
|
|
73
|
+
resolve(TEST_DATA_DIR, "memory", "projects", "alev-b.md"),
|
|
74
|
+
"# Alev-B\nVPS: 72.62.34.230, runs nginx + pm2",
|
|
75
|
+
);
|
|
76
|
+
fs.writeFileSync(
|
|
77
|
+
resolve(TEST_DATA_DIR, "memory", "projects", "homes.md"),
|
|
78
|
+
"# HOMES\nDB: homes_production (Postgres)",
|
|
79
|
+
);
|
|
80
|
+
const { loadMemoryLayers } = await import("../src/services/memory-layers.js");
|
|
81
|
+
const layered = loadMemoryLayers();
|
|
82
|
+
expect(layered.projects).toHaveLength(2);
|
|
83
|
+
const topics = layered.projects.map(p => p.topic).sort();
|
|
84
|
+
expect(topics).toEqual(["alev-b", "homes"]);
|
|
85
|
+
expect(layered.projects.find(p => p.topic === "homes")?.content).toMatch(/homes_production/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("buildLayeredContext returns all L0+L1 plus matching L2 by topic keyword", async () => {
|
|
89
|
+
fs.writeFileSync(
|
|
90
|
+
resolve(TEST_DATA_DIR, "memory", "identity.md"),
|
|
91
|
+
"Name: Ali",
|
|
92
|
+
);
|
|
93
|
+
fs.writeFileSync(
|
|
94
|
+
resolve(TEST_DATA_DIR, "memory", "preferences.md"),
|
|
95
|
+
"Be terse.",
|
|
96
|
+
);
|
|
97
|
+
fs.mkdirSync(resolve(TEST_DATA_DIR, "memory", "projects"), { recursive: true });
|
|
98
|
+
fs.writeFileSync(
|
|
99
|
+
resolve(TEST_DATA_DIR, "memory", "projects", "homes.md"),
|
|
100
|
+
"HOMES uses Postgres",
|
|
101
|
+
);
|
|
102
|
+
fs.writeFileSync(
|
|
103
|
+
resolve(TEST_DATA_DIR, "memory", "projects", "alev-b.md"),
|
|
104
|
+
"Alev-B uses MySQL",
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const { buildLayeredContext } = await import("../src/services/memory-layers.js");
|
|
108
|
+
|
|
109
|
+
// Query mentions HOMES → only the homes project should be loaded
|
|
110
|
+
const ctx = buildLayeredContext("Tell me about HOMES backups");
|
|
111
|
+
expect(ctx).toMatch(/Name: Ali/); // L0
|
|
112
|
+
expect(ctx).toMatch(/Be terse/); // L1
|
|
113
|
+
expect(ctx).toMatch(/HOMES uses Postgres/); // L2 matched
|
|
114
|
+
expect(ctx).not.toMatch(/Alev-B uses MySQL/); // L2 not matched
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("buildLayeredContext without a query returns L0+L1 only (boot-up brief)", async () => {
|
|
118
|
+
fs.writeFileSync(
|
|
119
|
+
resolve(TEST_DATA_DIR, "memory", "identity.md"),
|
|
120
|
+
"Name: Ali",
|
|
121
|
+
);
|
|
122
|
+
fs.writeFileSync(
|
|
123
|
+
resolve(TEST_DATA_DIR, "memory", "preferences.md"),
|
|
124
|
+
"Be terse.",
|
|
125
|
+
);
|
|
126
|
+
fs.mkdirSync(resolve(TEST_DATA_DIR, "memory", "projects"), { recursive: true });
|
|
127
|
+
fs.writeFileSync(
|
|
128
|
+
resolve(TEST_DATA_DIR, "memory", "projects", "homes.md"),
|
|
129
|
+
"HOMES uses Postgres",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const { buildLayeredContext } = await import("../src/services/memory-layers.js");
|
|
133
|
+
const ctx = buildLayeredContext();
|
|
134
|
+
expect(ctx).toMatch(/Name: Ali/);
|
|
135
|
+
expect(ctx).not.toMatch(/Postgres/); // L2 only loaded with a query
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("token budget: layered context truncates long projects to fit budget", async () => {
|
|
139
|
+
fs.writeFileSync(
|
|
140
|
+
resolve(TEST_DATA_DIR, "memory", "identity.md"),
|
|
141
|
+
"Name: Ali",
|
|
142
|
+
);
|
|
143
|
+
fs.mkdirSync(resolve(TEST_DATA_DIR, "memory", "projects"), { recursive: true });
|
|
144
|
+
const longContent = "homes ".repeat(2000); // ~10000 chars
|
|
145
|
+
fs.writeFileSync(
|
|
146
|
+
resolve(TEST_DATA_DIR, "memory", "projects", "homes.md"),
|
|
147
|
+
longContent,
|
|
148
|
+
);
|
|
149
|
+
const { buildLayeredContext } = await import("../src/services/memory-layers.js");
|
|
150
|
+
const ctx = buildLayeredContext("HOMES");
|
|
151
|
+
// Total context should be capped (~6000 chars max for L0+L1+L2)
|
|
152
|
+
expect(ctx.length).toBeLessThan(8000);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("monolithic MEMORY.md and split files coexist (split takes priority, mono is secondary)", async () => {
|
|
156
|
+
fs.writeFileSync(
|
|
157
|
+
resolve(TEST_DATA_DIR, "memory", "identity.md"),
|
|
158
|
+
"Name: Ali",
|
|
159
|
+
);
|
|
160
|
+
fs.writeFileSync(
|
|
161
|
+
resolve(TEST_DATA_DIR, "memory", "MEMORY.md"),
|
|
162
|
+
"# Legacy\n\n- Old fact still there",
|
|
163
|
+
);
|
|
164
|
+
const { buildLayeredContext } = await import("../src/services/memory-layers.js");
|
|
165
|
+
const ctx = buildLayeredContext("anything");
|
|
166
|
+
expect(ctx).toMatch(/Name: Ali/); // L0
|
|
167
|
+
expect(ctx).toMatch(/Old fact still there/); // legacy still included
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.11.0 — SDK system prompts now receive MEMORY.md context.
|
|
3
|
+
*
|
|
4
|
+
* Before v4.11.0, only non-SDK providers (Groq, Gemini, NVIDIA) got
|
|
5
|
+
* buildMemoryContext() injected into their system prompt — the SDK was
|
|
6
|
+
* expected to read memory files via tools. In practice it rarely did,
|
|
7
|
+
* resulting in "frickelig" memory after restart even with persisted
|
|
8
|
+
* sessions. v4.11.0 closes this gap.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import os from "os";
|
|
13
|
+
import { resolve } from "path";
|
|
14
|
+
|
|
15
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-sdk-mem-inject-${process.pid}-${Date.now()}`);
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
19
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
20
|
+
fs.mkdirSync(resolve(TEST_DATA_DIR, "memory"), { recursive: true });
|
|
21
|
+
fs.writeFileSync(
|
|
22
|
+
resolve(TEST_DATA_DIR, "memory", "MEMORY.md"),
|
|
23
|
+
"# Long-term Memory\n\n- User Ali prefers terse answers.\n- HOMES uses Postgres `homes_production`.\n",
|
|
24
|
+
);
|
|
25
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
26
|
+
vi.resetModules();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("SDK memory injection (v4.11.0)", () => {
|
|
30
|
+
it("buildSystemPrompt(isSDK=true) now includes MEMORY.md content", async () => {
|
|
31
|
+
const { buildSystemPrompt } = await import("../src/services/personality.js");
|
|
32
|
+
const prompt = buildSystemPrompt(true, "en", "1234");
|
|
33
|
+
expect(prompt).toMatch(/User Ali prefers terse answers/);
|
|
34
|
+
expect(prompt).toMatch(/HOMES uses Postgres/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("non-SDK still gets memory injection (regression check)", async () => {
|
|
38
|
+
const { buildSystemPrompt } = await import("../src/services/personality.js");
|
|
39
|
+
const prompt = buildSystemPrompt(false, "en", "1234");
|
|
40
|
+
expect(prompt).toMatch(/User Ali prefers terse answers/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("no MEMORY.md → SDK prompt builds without crash and without memory section", async () => {
|
|
44
|
+
fs.unlinkSync(resolve(TEST_DATA_DIR, "memory", "MEMORY.md"));
|
|
45
|
+
vi.resetModules();
|
|
46
|
+
const { buildSystemPrompt } = await import("../src/services/personality.js");
|
|
47
|
+
const prompt = buildSystemPrompt(true, "en", "1234");
|
|
48
|
+
expect(prompt).toBeTruthy();
|
|
49
|
+
expect(prompt).not.toMatch(/Your Memory \(auto-loaded\)/);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("SDK smart prompt (semantic recall) on first turn (v4.11.0)", () => {
|
|
54
|
+
it("buildSmartSystemPrompt for SDK with isFirstTurn=false does NOT call searchMemory", async () => {
|
|
55
|
+
let searchCalls = 0;
|
|
56
|
+
vi.doMock("../src/services/embeddings.js", () => ({
|
|
57
|
+
searchMemory: async () => {
|
|
58
|
+
searchCalls++;
|
|
59
|
+
return [];
|
|
60
|
+
},
|
|
61
|
+
reindexMemory: async () => ({ indexed: 0, total: 0 }),
|
|
62
|
+
initEmbeddings: async () => {},
|
|
63
|
+
getIndexStats: () => ({ entries: 0, files: 0, lastReindex: 0, sizeBytes: 0 }),
|
|
64
|
+
}));
|
|
65
|
+
vi.resetModules();
|
|
66
|
+
const { buildSmartSystemPrompt } = await import("../src/services/personality.js");
|
|
67
|
+
|
|
68
|
+
await buildSmartSystemPrompt(true, "en", "tell me about HOMES", "1234", false);
|
|
69
|
+
expect(searchCalls).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("buildSmartSystemPrompt for SDK with isFirstTurn=true CALLS searchMemory", async () => {
|
|
73
|
+
let searchCalls = 0;
|
|
74
|
+
vi.doMock("../src/services/embeddings.js", () => ({
|
|
75
|
+
searchMemory: async () => {
|
|
76
|
+
searchCalls++;
|
|
77
|
+
return [
|
|
78
|
+
{ text: "HOMES uses homes_production database", source: "MEMORY.md", score: 0.9 },
|
|
79
|
+
];
|
|
80
|
+
},
|
|
81
|
+
reindexMemory: async () => ({ indexed: 0, total: 0 }),
|
|
82
|
+
initEmbeddings: async () => {},
|
|
83
|
+
getIndexStats: () => ({ entries: 0, files: 0, lastReindex: 0, sizeBytes: 0 }),
|
|
84
|
+
}));
|
|
85
|
+
vi.resetModules();
|
|
86
|
+
const { buildSmartSystemPrompt } = await import("../src/services/personality.js");
|
|
87
|
+
|
|
88
|
+
const prompt = await buildSmartSystemPrompt(true, "en", "tell me about HOMES", "1234", true);
|
|
89
|
+
expect(searchCalls).toBe(1);
|
|
90
|
+
expect(prompt).toMatch(/Relevant Memories \(auto-retrieved\)/);
|
|
91
|
+
expect(prompt).toMatch(/homes_production/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("non-SDK calls searchMemory regardless of isFirstTurn flag", async () => {
|
|
95
|
+
let searchCalls = 0;
|
|
96
|
+
vi.doMock("../src/services/embeddings.js", () => ({
|
|
97
|
+
searchMemory: async () => {
|
|
98
|
+
searchCalls++;
|
|
99
|
+
return [];
|
|
100
|
+
},
|
|
101
|
+
reindexMemory: async () => ({ indexed: 0, total: 0 }),
|
|
102
|
+
initEmbeddings: async () => {},
|
|
103
|
+
getIndexStats: () => ({ entries: 0, files: 0, lastReindex: 0, sizeBytes: 0 }),
|
|
104
|
+
}));
|
|
105
|
+
vi.resetModules();
|
|
106
|
+
const { buildSmartSystemPrompt } = await import("../src/services/personality.js");
|
|
107
|
+
|
|
108
|
+
await buildSmartSystemPrompt(false, "en", "HOMES backup question", "1234", false);
|
|
109
|
+
expect(searchCalls).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("buildSmartSystemPrompt for SDK with no userMessage skips search even when isFirstTurn=true", async () => {
|
|
113
|
+
let searchCalls = 0;
|
|
114
|
+
vi.doMock("../src/services/embeddings.js", () => ({
|
|
115
|
+
searchMemory: async () => {
|
|
116
|
+
searchCalls++;
|
|
117
|
+
return [];
|
|
118
|
+
},
|
|
119
|
+
reindexMemory: async () => ({ indexed: 0, total: 0 }),
|
|
120
|
+
initEmbeddings: async () => {},
|
|
121
|
+
getIndexStats: () => ({ entries: 0, files: 0, lastReindex: 0, sizeBytes: 0 }),
|
|
122
|
+
}));
|
|
123
|
+
vi.resetModules();
|
|
124
|
+
const { buildSmartSystemPrompt } = await import("../src/services/personality.js");
|
|
125
|
+
|
|
126
|
+
await buildSmartSystemPrompt(true, "en", undefined, "1234", true);
|
|
127
|
+
expect(searchCalls).toBe(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("graceful failure: SDK first turn search throws → returns base prompt without crashing", async () => {
|
|
131
|
+
vi.doMock("../src/services/embeddings.js", () => ({
|
|
132
|
+
searchMemory: async () => {
|
|
133
|
+
throw new Error("Embedding API down");
|
|
134
|
+
},
|
|
135
|
+
reindexMemory: async () => ({ indexed: 0, total: 0 }),
|
|
136
|
+
initEmbeddings: async () => {},
|
|
137
|
+
getIndexStats: () => ({ entries: 0, files: 0, lastReindex: 0, sizeBytes: 0 }),
|
|
138
|
+
}));
|
|
139
|
+
vi.resetModules();
|
|
140
|
+
const { buildSmartSystemPrompt } = await import("../src/services/personality.js");
|
|
141
|
+
|
|
142
|
+
const prompt = await buildSmartSystemPrompt(true, "en", "test query", "1234", true);
|
|
143
|
+
expect(prompt).toBeTruthy();
|
|
144
|
+
expect(prompt).toMatch(/User Ali prefers terse answers/); // base still works
|
|
145
|
+
});
|
|
146
|
+
});
|