alvin-bot 4.18.0 → 4.18.2
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/AEC-PLUGINS-SOURCES.md +53 -0
- package/CHANGELOG.md +37 -2
- package/DESIGN-SKILLS-SOURCES.md +81 -0
- package/bin/cli.js +1 -1
- package/dist/providers/claude-sdk-provider.js +24 -0
- package/package.json +3 -1
- package/test/allowed-users-gate.test.ts +0 -98
- package/test/alvin-dispatch.test.ts +0 -220
- package/test/async-agent-chunk-flow.test.ts +0 -244
- package/test/async-agent-parser-staleness.test.ts +0 -412
- package/test/async-agent-parser-streamjson.test.ts +0 -273
- package/test/async-agent-parser.test.ts +0 -322
- package/test/async-agent-watcher.test.ts +0 -229
- package/test/background-bypass-integration.test.ts +0 -443
- package/test/background-bypass-stress.test.ts +0 -417
- package/test/background-bypass.test.ts +0 -127
- package/test/browser-webfetch.test.ts +0 -121
- package/test/claude-sdk-provider.test.ts +0 -115
- package/test/claude-sdk-tool-use-id.test.ts +0 -180
- package/test/console-timestamps.test.ts +0 -98
- package/test/cron-progress-ticker.test.ts +0 -76
- package/test/cron-restart-resilience.test.ts +0 -191
- package/test/cron-run-resolver.test.ts +0 -133
- package/test/cron-runjobnow-throw.test.ts +0 -100
- package/test/debounce.test.ts +0 -60
- package/test/delivery-registry.test.ts +0 -71
- package/test/exec-guard-metachars.test.ts +0 -110
- package/test/file-permissions.test.ts +0 -130
- package/test/i18n.test.ts +0 -108
- package/test/list-subagents-merged.test.ts +0 -172
- package/test/memory-extractor.test.ts +0 -151
- package/test/memory-layers.test.ts +0 -169
- package/test/memory-sdk-injection.test.ts +0 -146
- package/test/memory-stress-restart.test.ts +0 -337
- package/test/multi-session-stress.test.ts +0 -255
- package/test/platform-session-key.test.ts +0 -69
- package/test/process-manager.test.ts +0 -186
- package/test/registry.test.ts +0 -201
- package/test/session-pending-background.test.ts +0 -59
- package/test/session-persistence.test.ts +0 -195
- package/test/slack-progress-ticker.test.ts +0 -123
- package/test/slack-slash-command.test.ts +0 -61
- package/test/slack-test-connection.test.ts +0 -176
- package/test/stress-scenarios.test.ts +0 -356
- package/test/stuck-timer.test.ts +0 -116
- package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
- package/test/subagent-delivery-platform-routing.test.ts +0 -232
- package/test/subagent-delivery.test.ts +0 -273
- package/test/subagent-final-text.test.ts +0 -132
- package/test/subagent-stats.test.ts +0 -119
- package/test/subagent-toolset-allowlist.test.ts +0 -146
- package/test/subagents-commands.test.ts +0 -64
- package/test/subagents-config.test.ts +0 -114
- package/test/subagents-depth.test.ts +0 -58
- package/test/subagents-inheritance.test.ts +0 -67
- package/test/subagents-name-resolver.test.ts +0 -122
- package/test/subagents-priority-reject.test.ts +0 -88
- package/test/subagents-queue.test.ts +0 -127
- package/test/subagents-shutdown.test.ts +0 -126
- package/test/subagents-toolset.test.ts +0 -71
- package/test/sync-task-timeout.test.ts +0 -153
- package/test/system-prompt-background-hint.test.ts +0 -65
- package/test/telegram-error-filter.test.ts +0 -85
- package/test/telegram-workspace-command.test.ts +0 -78
- package/test/timing-safe-bearer.test.ts +0 -65
- package/test/watchdog-brake.test.ts +0 -157
- package/test/watcher-pending-count.test.ts +0 -228
- package/test/watcher-zombie-fix.test.ts +0 -252
- package/test/web-server-integration.test.ts +0 -189
- package/test/web-server-resilience.test.ts +0 -118
- package/test/web-server-shutdown.test.ts +0 -117
- package/test/whatsapp-auth-resilience.test.ts +0 -96
- package/test/workspaces.test.ts +0 -196
- package/vitest.config.ts +0 -17
package/test/i18n.test.ts
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
-
import { t, initI18n, setLocale, getLocale, LOCALE_NAMES, LOCALE_FLAGS } from "../src/i18n.js";
|
|
3
|
-
|
|
4
|
-
describe("i18n", () => {
|
|
5
|
-
beforeEach(() => {
|
|
6
|
-
// Reset to a known state before each test
|
|
7
|
-
setLocale("en");
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
describe("LOCALE_NAMES", () => {
|
|
11
|
-
it("exposes all four supported locales", () => {
|
|
12
|
-
expect(LOCALE_NAMES.en).toBe("English");
|
|
13
|
-
expect(LOCALE_NAMES.de).toBe("Deutsch");
|
|
14
|
-
expect(LOCALE_NAMES.es).toBe("Español");
|
|
15
|
-
expect(LOCALE_NAMES.fr).toBe("Français");
|
|
16
|
-
});
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
describe("LOCALE_FLAGS", () => {
|
|
20
|
-
it("has a flag emoji for every locale", () => {
|
|
21
|
-
expect(LOCALE_FLAGS.en).toBe("🇬🇧");
|
|
22
|
-
expect(LOCALE_FLAGS.de).toBe("🇩🇪");
|
|
23
|
-
expect(LOCALE_FLAGS.es).toBe("🇪🇸");
|
|
24
|
-
expect(LOCALE_FLAGS.fr).toBe("🇫🇷");
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe("t() — translation lookup", () => {
|
|
29
|
-
it("returns the English string when locale is en", () => {
|
|
30
|
-
const msg = t("bot.cancel.cancelling", "en");
|
|
31
|
-
expect(msg).toContain("Cancelling");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("returns the German string when locale is de", () => {
|
|
35
|
-
const msg = t("bot.cancel.cancelling", "de");
|
|
36
|
-
expect(msg).toContain("abgebrochen");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("returns the Spanish string when locale is es", () => {
|
|
40
|
-
const msg = t("bot.cancel.cancelling", "es");
|
|
41
|
-
expect(msg.toLowerCase()).toContain("cancelando");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("returns the French string when locale is fr", () => {
|
|
45
|
-
const msg = t("bot.cancel.cancelling", "fr");
|
|
46
|
-
expect(msg.toLowerCase()).toContain("annulation");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("falls back to English when locale is missing for a key", () => {
|
|
50
|
-
// Use a TUI key which only has en+de — request es, should fall through
|
|
51
|
-
// to en since tui.* keys aren't translated for es/fr.
|
|
52
|
-
const msg = t("tui.title", "es");
|
|
53
|
-
expect(msg).toContain("Alvin Bot TUI");
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("returns the key itself if no locale has it at all", () => {
|
|
57
|
-
const msg = t("bot.nonexistent.key.for.test", "en");
|
|
58
|
-
expect(msg).toBe("bot.nonexistent.key.for.test");
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("uses the global currentLocale when no locale is passed", () => {
|
|
62
|
-
setLocale("de");
|
|
63
|
-
const msg = t("bot.cancel.cancelling");
|
|
64
|
-
expect(msg).toContain("abgebrochen");
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe("t() — interpolation", () => {
|
|
69
|
-
it("substitutes a single {var} placeholder", () => {
|
|
70
|
-
const msg = t("bot.error.timeoutStuck", "en", { min: 10 });
|
|
71
|
-
expect(msg).toContain("10 minutes");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("substitutes multiple {var} placeholders", () => {
|
|
75
|
-
const msg = t("bot.error.midStream", "en", {
|
|
76
|
-
name: "claude-sdk",
|
|
77
|
-
detail: "connection reset",
|
|
78
|
-
});
|
|
79
|
-
expect(msg).toContain("claude-sdk");
|
|
80
|
-
expect(msg).toContain("connection reset");
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("interpolation works in all four locales", () => {
|
|
84
|
-
const vars = { min: 7 };
|
|
85
|
-
expect(t("bot.error.timeoutStuck", "en", vars)).toContain("7");
|
|
86
|
-
expect(t("bot.error.timeoutStuck", "de", vars)).toContain("7");
|
|
87
|
-
expect(t("bot.error.timeoutStuck", "es", vars)).toContain("7");
|
|
88
|
-
expect(t("bot.error.timeoutStuck", "fr", vars)).toContain("7");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("leaves {placeholder} visible if the var is not provided", () => {
|
|
92
|
-
const msg = t("bot.error.timeoutStuck", "en", {});
|
|
93
|
-
expect(msg).toContain("{min}");
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
describe("initI18n / setLocale / getLocale", () => {
|
|
98
|
-
it("initI18n with explicit locale sets currentLocale", () => {
|
|
99
|
-
initI18n("fr");
|
|
100
|
-
expect(getLocale()).toBe("fr");
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("setLocale updates the global locale", () => {
|
|
104
|
-
setLocale("es");
|
|
105
|
-
expect(getLocale()).toBe("es");
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
});
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* v4.14.1 — `/subagents list` must show v4.13+ dispatch agents too.
|
|
3
|
-
*
|
|
4
|
-
* Root cause: `listSubAgents()` in subagents.ts only iterates the
|
|
5
|
-
* `activeAgents` Map (B1+B2 from v4.0.0). v4.13's `alvin_dispatch_agent`
|
|
6
|
-
* MCP tool writes into `async-agent-watcher.ts`'s `pending` Map instead.
|
|
7
|
-
* User-facing impact: "no subagents running" while the bot is visibly
|
|
8
|
-
* dispatching sub-agents.
|
|
9
|
-
*
|
|
10
|
-
* Fix strategy: a new `listActiveSubAgents()` helper that merges both
|
|
11
|
-
* registries into a unified SubAgentInfo-shaped list. The `/subagents
|
|
12
|
-
* list` handler uses this instead of the bare `listSubAgents()`.
|
|
13
|
-
* Cancel/result operations keep using the old registry — we can't
|
|
14
|
-
* cancel a detached `claude -p` subprocess anyway without knowing its
|
|
15
|
-
* PID, which isn't tracked.
|
|
16
|
-
*/
|
|
17
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
18
|
-
import fs from "fs";
|
|
19
|
-
import os from "os";
|
|
20
|
-
import { resolve } from "path";
|
|
21
|
-
|
|
22
|
-
const TEST_DATA_DIR = resolve(
|
|
23
|
-
os.tmpdir(),
|
|
24
|
-
`alvin-list-merged-${process.pid}-${Date.now()}`,
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
beforeEach(async () => {
|
|
28
|
-
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
29
|
-
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
30
|
-
}
|
|
31
|
-
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
32
|
-
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
33
|
-
vi.resetModules();
|
|
34
|
-
|
|
35
|
-
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
36
|
-
deliverSubAgentResult: async () => {},
|
|
37
|
-
attachBotApi: () => {},
|
|
38
|
-
__setBotApiForTest: () => {},
|
|
39
|
-
}));
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
afterEach(async () => {
|
|
43
|
-
try {
|
|
44
|
-
const mod = await import("../src/services/async-agent-watcher.js");
|
|
45
|
-
mod.stopWatcher();
|
|
46
|
-
mod.__resetForTest();
|
|
47
|
-
} catch {}
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe("listActiveSubAgents merged view (v4.14.1)", () => {
|
|
51
|
-
it("returns empty list when neither registry has agents", async () => {
|
|
52
|
-
const mod = await import("../src/services/subagents.js");
|
|
53
|
-
expect(await mod.listActiveSubAgents()).toEqual([]);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("includes async-agent-watcher pending agents in the merged list", async () => {
|
|
57
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
58
|
-
watcher.registerPendingAgent({
|
|
59
|
-
agentId: "alvin-abc123",
|
|
60
|
-
outputFile: `${TEST_DATA_DIR}/out.jsonl`,
|
|
61
|
-
description: "Research Higgsfield",
|
|
62
|
-
prompt: "...",
|
|
63
|
-
chatId: "C012SLACK",
|
|
64
|
-
userId: "U123",
|
|
65
|
-
toolUseId: null,
|
|
66
|
-
sessionKey: "slack:C012SLACK",
|
|
67
|
-
platform: "slack",
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
const mod = await import("../src/services/subagents.js");
|
|
71
|
-
const agents = await mod.listActiveSubAgents();
|
|
72
|
-
expect(agents).toHaveLength(1);
|
|
73
|
-
expect(agents[0].id).toBe("alvin-abc123");
|
|
74
|
-
expect(agents[0].name).toBe("Research Higgsfield");
|
|
75
|
-
expect(agents[0].status).toBe("running");
|
|
76
|
-
expect(agents[0].depth).toBe(0);
|
|
77
|
-
expect(agents[0].platform).toBe("slack");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("merges multiple agents from both registries without dupes", async () => {
|
|
81
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
82
|
-
watcher.registerPendingAgent({
|
|
83
|
-
agentId: "alvin-one",
|
|
84
|
-
outputFile: `${TEST_DATA_DIR}/a.jsonl`,
|
|
85
|
-
description: "Agent One",
|
|
86
|
-
prompt: "p",
|
|
87
|
-
chatId: 42,
|
|
88
|
-
userId: 42,
|
|
89
|
-
toolUseId: null,
|
|
90
|
-
sessionKey: "s",
|
|
91
|
-
platform: "telegram",
|
|
92
|
-
});
|
|
93
|
-
watcher.registerPendingAgent({
|
|
94
|
-
agentId: "alvin-two",
|
|
95
|
-
outputFile: `${TEST_DATA_DIR}/b.jsonl`,
|
|
96
|
-
description: "Agent Two",
|
|
97
|
-
prompt: "p",
|
|
98
|
-
chatId: 42,
|
|
99
|
-
userId: 42,
|
|
100
|
-
toolUseId: null,
|
|
101
|
-
sessionKey: "s",
|
|
102
|
-
platform: "telegram",
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
const mod = await import("../src/services/subagents.js");
|
|
106
|
-
const agents = await mod.listActiveSubAgents();
|
|
107
|
-
const ids = agents.map((a) => a.id).sort();
|
|
108
|
-
expect(ids).toEqual(["alvin-one", "alvin-two"]);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("preserves startedAt timestamp for age rendering", async () => {
|
|
112
|
-
const fixedTs = Date.now() - 45_000; // 45 seconds ago
|
|
113
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
114
|
-
watcher.registerPendingAgent({
|
|
115
|
-
agentId: "alvin-aged",
|
|
116
|
-
outputFile: `${TEST_DATA_DIR}/aged.jsonl`,
|
|
117
|
-
description: "Old agent",
|
|
118
|
-
prompt: "p",
|
|
119
|
-
chatId: 1,
|
|
120
|
-
userId: 1,
|
|
121
|
-
toolUseId: null,
|
|
122
|
-
sessionKey: "s",
|
|
123
|
-
platform: "slack",
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
const mod = await import("../src/services/subagents.js");
|
|
127
|
-
const agents = await mod.listActiveSubAgents();
|
|
128
|
-
expect(agents[0].startedAt).toBeGreaterThan(fixedTs - 1000);
|
|
129
|
-
expect(agents[0].startedAt).toBeLessThan(Date.now() + 1000);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("tags async dispatch agents with source='cron' (matches v4.12 banner format)", async () => {
|
|
133
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
134
|
-
watcher.registerPendingAgent({
|
|
135
|
-
agentId: "alvin-sourced",
|
|
136
|
-
outputFile: `${TEST_DATA_DIR}/s.jsonl`,
|
|
137
|
-
description: "sourced",
|
|
138
|
-
prompt: "p",
|
|
139
|
-
chatId: 1,
|
|
140
|
-
userId: 1,
|
|
141
|
-
toolUseId: null,
|
|
142
|
-
sessionKey: "s",
|
|
143
|
-
platform: "telegram",
|
|
144
|
-
});
|
|
145
|
-
const mod = await import("../src/services/subagents.js");
|
|
146
|
-
const agents = await mod.listActiveSubAgents();
|
|
147
|
-
// source='cron' = the ⏰ badge in /subagents list rendering. Matches
|
|
148
|
-
// the existing v4.12.x watcher delivery's SubAgentInfo.source value.
|
|
149
|
-
expect(agents[0].source).toBe("cron");
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("listSubAgents() (v4.0.0 API) is unchanged and doesn't include pending dispatches", async () => {
|
|
153
|
-
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
154
|
-
watcher.registerPendingAgent({
|
|
155
|
-
agentId: "alvin-isolated",
|
|
156
|
-
outputFile: `${TEST_DATA_DIR}/iso.jsonl`,
|
|
157
|
-
description: "isolated",
|
|
158
|
-
prompt: "p",
|
|
159
|
-
chatId: 1,
|
|
160
|
-
userId: 1,
|
|
161
|
-
toolUseId: null,
|
|
162
|
-
sessionKey: "s",
|
|
163
|
-
platform: "telegram",
|
|
164
|
-
});
|
|
165
|
-
const mod = await import("../src/services/subagents.js");
|
|
166
|
-
// The original listSubAgents is kept pure — only the merged helper
|
|
167
|
-
// returns combined results. Cancel/result paths still use the
|
|
168
|
-
// bot-level registry.
|
|
169
|
-
expect(mod.listSubAgents()).toHaveLength(0);
|
|
170
|
-
expect(await mod.listActiveSubAgents()).toHaveLength(1);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
@@ -1,151 +0,0 @@
|
|
|
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 User lives in Berlin"],
|
|
31
|
-
preferences: ["Replies in German"],
|
|
32
|
-
decisions: ["Use VPS VPS for production"],
|
|
33
|
-
});
|
|
34
|
-
const facts = parseExtractedFacts(json);
|
|
35
|
-
expect(facts.user_facts).toEqual(["User User lives in Berlin"]);
|
|
36
|
-
expect(facts.preferences).toEqual(["Replies in German"]);
|
|
37
|
-
expect(facts.decisions).toEqual(["Use VPS 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: ["User 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(/User 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: ["User uses launchd for the bot"],
|
|
95
|
-
preferences: [],
|
|
96
|
-
decisions: [],
|
|
97
|
-
});
|
|
98
|
-
await appendFactsToMemoryFile({
|
|
99
|
-
user_facts: ["User uses launchd for the bot", "User 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(/User uses launchd for the bot/g);
|
|
105
|
-
expect(matches).toHaveLength(1); // not duplicated
|
|
106
|
-
expect(content).toMatch(/User 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
|
-
});
|
|
@@ -1,169 +0,0 @@
|
|
|
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: Test User\nLocation: Berlin",
|
|
44
|
-
);
|
|
45
|
-
const { loadMemoryLayers } = await import("../src/services/memory-layers.js");
|
|
46
|
-
const layered = loadMemoryLayers();
|
|
47
|
-
expect(layered.identity).toMatch(/Test User/);
|
|
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", "my-project.md"),
|
|
74
|
-
"# my-project\nVPS: 10.0.0.1, 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(["homes", "my-project"]);
|
|
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: User",
|
|
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", "my-project.md"),
|
|
104
|
-
"my-project 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: User/); // L0
|
|
112
|
-
expect(ctx).toMatch(/Be terse/); // L1
|
|
113
|
-
expect(ctx).toMatch(/HOMES uses Postgres/); // L2 matched
|
|
114
|
-
expect(ctx).not.toMatch(/my-project 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: User",
|
|
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: User/);
|
|
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: User",
|
|
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: User",
|
|
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: User/); // L0
|
|
167
|
-
expect(ctx).toMatch(/Old fact still there/); // legacy still included
|
|
168
|
-
});
|
|
169
|
-
});
|