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,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.11.0 — Session persistence across bot restarts.
|
|
3
|
+
*
|
|
4
|
+
* Sessions live in an in-memory Map that gets wiped on every bot restart.
|
|
5
|
+
* This persistence layer flushes the Map to disk (debounced) and rehydrates
|
|
6
|
+
* it on bot startup so Claude SDK's `resume: sessionId` keeps working,
|
|
7
|
+
* conversation history survives, and user preferences (language, effort,
|
|
8
|
+
* voiceReply) don't reset.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach, 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-session-persist-${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
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
21
|
+
vi.resetModules();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("session-persistence (v4.11.0)", () => {
|
|
29
|
+
it("flushSessions writes a JSON file with all session fields that survive restart", async () => {
|
|
30
|
+
const sessionMod = await import("../src/services/session.js");
|
|
31
|
+
const persistMod = await import("../src/services/session-persistence.js");
|
|
32
|
+
|
|
33
|
+
const s = sessionMod.getSession("user-1");
|
|
34
|
+
s.sessionId = "sdk-abc-123";
|
|
35
|
+
s.language = "de";
|
|
36
|
+
s.effort = "high";
|
|
37
|
+
s.voiceReply = true;
|
|
38
|
+
s.workingDir = "/tmp/test-cwd";
|
|
39
|
+
s.history = [
|
|
40
|
+
{ role: "user", content: "hi" },
|
|
41
|
+
{ role: "assistant", content: "hello" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
await persistMod.flushSessions();
|
|
45
|
+
|
|
46
|
+
const stateFile = resolve(TEST_DATA_DIR, "state", "sessions.json");
|
|
47
|
+
expect(fs.existsSync(stateFile)).toBe(true);
|
|
48
|
+
|
|
49
|
+
// v4.12.0 — Format is now an envelope: { version: 2, sessions: {...}, telegramWorkspaces: {...} }
|
|
50
|
+
const envelope = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
|
|
51
|
+
expect(envelope.version).toBe(2);
|
|
52
|
+
const parsed = envelope.sessions;
|
|
53
|
+
expect(parsed).toHaveProperty("user-1");
|
|
54
|
+
expect(parsed["user-1"].sessionId).toBe("sdk-abc-123");
|
|
55
|
+
expect(parsed["user-1"].language).toBe("de");
|
|
56
|
+
expect(parsed["user-1"].effort).toBe("high");
|
|
57
|
+
expect(parsed["user-1"].voiceReply).toBe(true);
|
|
58
|
+
expect(parsed["user-1"].history).toHaveLength(2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("loadPersistedSessions rehydrates the sessions Map from disk", async () => {
|
|
62
|
+
fs.mkdirSync(resolve(TEST_DATA_DIR, "state"), { recursive: true });
|
|
63
|
+
fs.writeFileSync(
|
|
64
|
+
resolve(TEST_DATA_DIR, "state", "sessions.json"),
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
"user-7": {
|
|
67
|
+
sessionId: "sdk-restored",
|
|
68
|
+
language: "en",
|
|
69
|
+
effort: "medium",
|
|
70
|
+
voiceReply: false,
|
|
71
|
+
workingDir: "/home/test",
|
|
72
|
+
lastActivity: Date.now() - 60_000,
|
|
73
|
+
lastSdkHistoryIndex: 3,
|
|
74
|
+
history: [{ role: "user", content: "from past life" }],
|
|
75
|
+
messageCount: 5,
|
|
76
|
+
toolUseCount: 2,
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const persistMod = await import("../src/services/session-persistence.js");
|
|
82
|
+
const sessionMod = await import("../src/services/session.js");
|
|
83
|
+
|
|
84
|
+
const loaded = persistMod.loadPersistedSessions();
|
|
85
|
+
expect(loaded).toBe(1);
|
|
86
|
+
|
|
87
|
+
const s = sessionMod.getSession("user-7");
|
|
88
|
+
expect(s.sessionId).toBe("sdk-restored");
|
|
89
|
+
expect(s.language).toBe("en");
|
|
90
|
+
expect(s.history).toHaveLength(1);
|
|
91
|
+
expect(s.history[0].content).toBe("from past life");
|
|
92
|
+
expect(s.messageCount).toBe(5);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("survives a corrupt sessions.json file (does not crash)", async () => {
|
|
96
|
+
fs.mkdirSync(resolve(TEST_DATA_DIR, "state"), { recursive: true });
|
|
97
|
+
fs.writeFileSync(
|
|
98
|
+
resolve(TEST_DATA_DIR, "state", "sessions.json"),
|
|
99
|
+
"{ this is not valid json",
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const persistMod = await import("../src/services/session-persistence.js");
|
|
103
|
+
const loaded = persistMod.loadPersistedSessions();
|
|
104
|
+
expect(loaded).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("survives missing sessions.json file (returns 0 loaded)", async () => {
|
|
108
|
+
const persistMod = await import("../src/services/session-persistence.js");
|
|
109
|
+
const loaded = persistMod.loadPersistedSessions();
|
|
110
|
+
expect(loaded).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does NOT persist runtime-only fields (abortController, isProcessing)", async () => {
|
|
114
|
+
const sessionMod = await import("../src/services/session.js");
|
|
115
|
+
const persistMod = await import("../src/services/session-persistence.js");
|
|
116
|
+
|
|
117
|
+
const s = sessionMod.getSession("user-2");
|
|
118
|
+
s.isProcessing = true;
|
|
119
|
+
s.abortController = new AbortController();
|
|
120
|
+
s.sessionId = "abc";
|
|
121
|
+
|
|
122
|
+
await persistMod.flushSessions();
|
|
123
|
+
|
|
124
|
+
const stateFile = resolve(TEST_DATA_DIR, "state", "sessions.json");
|
|
125
|
+
const parsed = JSON.parse(fs.readFileSync(stateFile, "utf-8")).sessions;
|
|
126
|
+
expect(parsed["user-2"]).not.toHaveProperty("abortController");
|
|
127
|
+
expect(parsed["user-2"]).not.toHaveProperty("isProcessing");
|
|
128
|
+
expect(parsed["user-2"].sessionId).toBe("abc");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("caps history at MAX_PERSISTED_HISTORY (50) so the file doesn't grow unbounded", async () => {
|
|
132
|
+
const sessionMod = await import("../src/services/session.js");
|
|
133
|
+
const persistMod = await import("../src/services/session-persistence.js");
|
|
134
|
+
|
|
135
|
+
const s = sessionMod.getSession("user-3");
|
|
136
|
+
s.sessionId = "needs-some-state-to-be-persisted";
|
|
137
|
+
for (let i = 0; i < 200; i++) {
|
|
138
|
+
s.history.push({ role: i % 2 === 0 ? "user" : "assistant", content: `msg ${i}` });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await persistMod.flushSessions();
|
|
142
|
+
|
|
143
|
+
const parsed = JSON.parse(fs.readFileSync(resolve(TEST_DATA_DIR, "state", "sessions.json"), "utf-8")).sessions;
|
|
144
|
+
expect(parsed["user-3"].history.length).toBeLessThanOrEqual(50);
|
|
145
|
+
// Last message should still be there
|
|
146
|
+
expect(parsed["user-3"].history.at(-1).content).toContain("199");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("debounce: schedulePersist coalesces multiple rapid mutations into one flush", async () => {
|
|
150
|
+
const persistMod = await import("../src/services/session-persistence.js");
|
|
151
|
+
const sessionMod = await import("../src/services/session.js");
|
|
152
|
+
|
|
153
|
+
sessionMod.getSession("user-4").sessionId = "v1";
|
|
154
|
+
persistMod.schedulePersist();
|
|
155
|
+
sessionMod.getSession("user-4").sessionId = "v2";
|
|
156
|
+
persistMod.schedulePersist();
|
|
157
|
+
sessionMod.getSession("user-4").sessionId = "v3";
|
|
158
|
+
persistMod.schedulePersist();
|
|
159
|
+
|
|
160
|
+
// Force the debounced flush
|
|
161
|
+
await persistMod.flushSessions();
|
|
162
|
+
|
|
163
|
+
const parsed = JSON.parse(fs.readFileSync(resolve(TEST_DATA_DIR, "state", "sessions.json"), "utf-8")).sessions;
|
|
164
|
+
expect(parsed["user-4"].sessionId).toBe("v3");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("atomic write: tmp+rename, never leaves a half-written file on crash", async () => {
|
|
168
|
+
const persistMod = await import("../src/services/session-persistence.js");
|
|
169
|
+
const sessionMod = await import("../src/services/session.js");
|
|
170
|
+
|
|
171
|
+
sessionMod.getSession("user-5").sessionId = "abc";
|
|
172
|
+
await persistMod.flushSessions();
|
|
173
|
+
|
|
174
|
+
// After successful flush: no .tmp leftover
|
|
175
|
+
const tmpFile = resolve(TEST_DATA_DIR, "state", "sessions.json.tmp");
|
|
176
|
+
expect(fs.existsSync(tmpFile)).toBe(false);
|
|
177
|
+
expect(fs.existsSync(resolve(TEST_DATA_DIR, "state", "sessions.json"))).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("does not persist sessions that have never been activated (only defaults)", async () => {
|
|
181
|
+
const sessionMod = await import("../src/services/session.js");
|
|
182
|
+
const persistMod = await import("../src/services/session-persistence.js");
|
|
183
|
+
|
|
184
|
+
// Touching getSession creates an empty default session — but we don't want to
|
|
185
|
+
// persist it if it has no meaningful state (no sessionId, no history)
|
|
186
|
+
sessionMod.getSession("noop-user");
|
|
187
|
+
await persistMod.flushSessions();
|
|
188
|
+
|
|
189
|
+
const stateFile = resolve(TEST_DATA_DIR, "state", "sessions.json");
|
|
190
|
+
if (fs.existsSync(stateFile)) {
|
|
191
|
+
const parsed = JSON.parse(fs.readFileSync(stateFile, "utf-8")).sessions;
|
|
192
|
+
expect(parsed).not.toHaveProperty("noop-user");
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.12.0 — Slack adapter editMessage support for progress tickers.
|
|
3
|
+
*
|
|
4
|
+
* Slack doesn't stream text like the OpenAI API does; the idiom is to
|
|
5
|
+
* post an initial message, capture its `ts` (timestamp), then edit it
|
|
6
|
+
* with growing content via chat.update. This mirrors Telegram's
|
|
7
|
+
* editMessageText approach used in the cron progress ticker.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.resetModules();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("SlackAdapter.editMessage (v4.12.0)", () => {
|
|
16
|
+
it("calls chat.update with the correct channel + ts when editMessage is invoked", async () => {
|
|
17
|
+
const updateSpy = vi.fn().mockResolvedValue({ ok: true, ts: "1234567890.123456" });
|
|
18
|
+
const postSpy = vi.fn().mockResolvedValue({ ok: true, ts: "1234567890.123456" });
|
|
19
|
+
const authSpy = vi.fn().mockResolvedValue({ ok: true, user_id: "U_BOT", user: "alvin", team: "Test" });
|
|
20
|
+
|
|
21
|
+
vi.doMock("@slack/bolt", () => ({
|
|
22
|
+
App: class {
|
|
23
|
+
client = {
|
|
24
|
+
auth: { test: authSpy },
|
|
25
|
+
chat: { postMessage: postSpy, update: updateSpy },
|
|
26
|
+
users: { info: vi.fn() },
|
|
27
|
+
reactions: { add: vi.fn() },
|
|
28
|
+
filesUploadV2: vi.fn(),
|
|
29
|
+
conversations: { info: vi.fn() },
|
|
30
|
+
apiCall: vi.fn(),
|
|
31
|
+
};
|
|
32
|
+
constructor(_opts: unknown) {}
|
|
33
|
+
message(_h: unknown) {}
|
|
34
|
+
event(_k: string, _h: unknown) {}
|
|
35
|
+
async start() {}
|
|
36
|
+
async stop() {}
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const { SlackAdapter } = await import("../src/platforms/slack.js");
|
|
41
|
+
const adapter = new SlackAdapter("xoxb-test", "xapp-test");
|
|
42
|
+
await adapter.start();
|
|
43
|
+
|
|
44
|
+
const returnedId = await adapter.editMessage!("C_TEST", "1234567890.123456", "updated text");
|
|
45
|
+
expect(updateSpy).toHaveBeenCalledWith(
|
|
46
|
+
expect.objectContaining({
|
|
47
|
+
channel: "C_TEST",
|
|
48
|
+
ts: "1234567890.123456",
|
|
49
|
+
text: "updated text",
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
expect(returnedId).toBe("1234567890.123456");
|
|
53
|
+
|
|
54
|
+
await adapter.stop();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("sendText returns the message ts so it can be edited later", async () => {
|
|
58
|
+
const postSpy = vi.fn().mockResolvedValue({ ok: true, ts: "9876543210.555555" });
|
|
59
|
+
const authSpy = vi.fn().mockResolvedValue({ ok: true, user_id: "U_BOT", user: "alvin", team: "Test" });
|
|
60
|
+
|
|
61
|
+
vi.doMock("@slack/bolt", () => ({
|
|
62
|
+
App: class {
|
|
63
|
+
client = {
|
|
64
|
+
auth: { test: authSpy },
|
|
65
|
+
chat: { postMessage: postSpy, update: vi.fn() },
|
|
66
|
+
users: { info: vi.fn() },
|
|
67
|
+
reactions: { add: vi.fn() },
|
|
68
|
+
filesUploadV2: vi.fn(),
|
|
69
|
+
conversations: { info: vi.fn() },
|
|
70
|
+
apiCall: vi.fn(),
|
|
71
|
+
};
|
|
72
|
+
constructor(_opts: unknown) {}
|
|
73
|
+
message(_h: unknown) {}
|
|
74
|
+
event(_k: string, _h: unknown) {}
|
|
75
|
+
async start() {}
|
|
76
|
+
async stop() {}
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
const { SlackAdapter } = await import("../src/platforms/slack.js");
|
|
81
|
+
const adapter = new SlackAdapter("xoxb-test", "xapp-test");
|
|
82
|
+
await adapter.start();
|
|
83
|
+
|
|
84
|
+
const id = await adapter.sendText("C_TEST", "first message");
|
|
85
|
+
expect(id).toBe("9876543210.555555");
|
|
86
|
+
|
|
87
|
+
await adapter.stop();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("editMessage returns messageId unchanged when chat.update fails", async () => {
|
|
91
|
+
const updateSpy = vi.fn().mockRejectedValue(new Error("slack down"));
|
|
92
|
+
const authSpy = vi.fn().mockResolvedValue({ ok: true, user_id: "U_BOT", user: "alvin", team: "Test" });
|
|
93
|
+
|
|
94
|
+
vi.doMock("@slack/bolt", () => ({
|
|
95
|
+
App: class {
|
|
96
|
+
client = {
|
|
97
|
+
auth: { test: authSpy },
|
|
98
|
+
chat: { postMessage: vi.fn().mockResolvedValue({ ok: true, ts: "x" }), update: updateSpy },
|
|
99
|
+
users: { info: vi.fn() },
|
|
100
|
+
reactions: { add: vi.fn() },
|
|
101
|
+
filesUploadV2: vi.fn(),
|
|
102
|
+
conversations: { info: vi.fn() },
|
|
103
|
+
apiCall: vi.fn(),
|
|
104
|
+
};
|
|
105
|
+
constructor(_opts: unknown) {}
|
|
106
|
+
message(_h: unknown) {}
|
|
107
|
+
event(_k: string, _h: unknown) {}
|
|
108
|
+
async start() {}
|
|
109
|
+
async stop() {}
|
|
110
|
+
},
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
const { SlackAdapter } = await import("../src/platforms/slack.js");
|
|
114
|
+
const adapter = new SlackAdapter("xoxb-test", "xapp-test");
|
|
115
|
+
await adapter.start();
|
|
116
|
+
|
|
117
|
+
// Should not throw
|
|
118
|
+
const result = await adapter.editMessage!("C_TEST", "123.456", "new text");
|
|
119
|
+
expect(result).toBe("123.456");
|
|
120
|
+
|
|
121
|
+
await adapter.stop();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.12.0 — Telegram /workspace command + workspace-aware session key.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import os from "os";
|
|
7
|
+
import { resolve } from "path";
|
|
8
|
+
|
|
9
|
+
const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-tgws-${process.pid}-${Date.now()}`);
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
13
|
+
fs.mkdirSync(resolve(TEST_DATA_DIR, "workspaces"), { recursive: true });
|
|
14
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
15
|
+
vi.resetModules();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("Telegram workspace state (v4.12.0)", () => {
|
|
19
|
+
it("getTelegramWorkspace returns null by default", async () => {
|
|
20
|
+
const { getTelegramWorkspace } = await import("../src/services/session.js");
|
|
21
|
+
expect(getTelegramWorkspace("42")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("setTelegramWorkspace stores the name", async () => {
|
|
25
|
+
const { getTelegramWorkspace, setTelegramWorkspace } = await import("../src/services/session.js");
|
|
26
|
+
setTelegramWorkspace("42", "alev-b");
|
|
27
|
+
expect(getTelegramWorkspace("42")).toBe("alev-b");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("setTelegramWorkspace(userId, null) clears the mapping", async () => {
|
|
31
|
+
const { getTelegramWorkspace, setTelegramWorkspace } = await import("../src/services/session.js");
|
|
32
|
+
setTelegramWorkspace("42", "alev-b");
|
|
33
|
+
setTelegramWorkspace("42", null);
|
|
34
|
+
expect(getTelegramWorkspace("42")).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("persistence: setTelegramWorkspace + flush + reload roundtrips", async () => {
|
|
38
|
+
const { setTelegramWorkspace, attachPersistHook } = await import("../src/services/session.js");
|
|
39
|
+
const { flushSessions, schedulePersist } = await import("../src/services/session-persistence.js");
|
|
40
|
+
attachPersistHook(schedulePersist);
|
|
41
|
+
|
|
42
|
+
setTelegramWorkspace("42", "alev-b");
|
|
43
|
+
setTelegramWorkspace("99", "homes");
|
|
44
|
+
await flushSessions();
|
|
45
|
+
|
|
46
|
+
vi.resetModules();
|
|
47
|
+
const s2 = await import("../src/services/session.js");
|
|
48
|
+
const p2 = await import("../src/services/session-persistence.js");
|
|
49
|
+
p2.loadPersistedSessions();
|
|
50
|
+
|
|
51
|
+
expect(s2.getTelegramWorkspace("42")).toBe("alev-b");
|
|
52
|
+
expect(s2.getTelegramWorkspace("99")).toBe("homes");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("legacy flat session file still loads (backwards compat)", async () => {
|
|
56
|
+
fs.mkdirSync(resolve(TEST_DATA_DIR, "state"), { recursive: true });
|
|
57
|
+
fs.writeFileSync(
|
|
58
|
+
resolve(TEST_DATA_DIR, "state", "sessions.json"),
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
"legacy-user": {
|
|
61
|
+
sessionId: "abc",
|
|
62
|
+
history: [{ role: "user", content: "from v4.11 era" }],
|
|
63
|
+
language: "en",
|
|
64
|
+
effort: "medium",
|
|
65
|
+
voiceReply: false,
|
|
66
|
+
workingDir: "/tmp",
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const { loadPersistedSessions } = await import("../src/services/session-persistence.js");
|
|
72
|
+
const { getSession } = await import("../src/services/session.js");
|
|
73
|
+
const loaded = loadPersistedSessions();
|
|
74
|
+
expect(loaded).toBe(1);
|
|
75
|
+
expect(getSession("legacy-user").sessionId).toBe("abc");
|
|
76
|
+
expect(getSession("legacy-user").history[0].content).toBe("from v4.11 era");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.12.0 — Workspace registry tests.
|
|
3
|
+
*
|
|
4
|
+
* A workspace is a markdown file under ~/.alvin-bot/workspaces/<name>.md
|
|
5
|
+
* with YAML frontmatter defining name, purpose, cwd, color, emoji, and
|
|
6
|
+
* an optional "channels" array for explicit channel-ID mapping. The
|
|
7
|
+
* markdown body (below the frontmatter) is the persona instruction
|
|
8
|
+
* that gets injected into the system prompt for that workspace.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach, 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-workspaces-${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, "workspaces"), { recursive: true });
|
|
21
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
22
|
+
vi.resetModules();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function writeWorkspace(name: string, frontmatter: Record<string, unknown>, body: string): void {
|
|
30
|
+
const fm = Object.entries(frontmatter)
|
|
31
|
+
.map(([k, v]) => `${k}: ${Array.isArray(v) ? JSON.stringify(v) : JSON.stringify(v)}`)
|
|
32
|
+
.join("\n");
|
|
33
|
+
const content = `---\n${fm}\n---\n${body}`;
|
|
34
|
+
fs.writeFileSync(resolve(TEST_DATA_DIR, "workspaces", `${name}.md`), content, "utf-8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("workspace registry (v4.12.0)", () => {
|
|
38
|
+
it("returns a default workspace when nothing is configured", async () => {
|
|
39
|
+
const { loadWorkspaces, getDefaultWorkspace } = await import("../src/services/workspaces.js");
|
|
40
|
+
loadWorkspaces();
|
|
41
|
+
const def = getDefaultWorkspace();
|
|
42
|
+
expect(def.name).toBe("default");
|
|
43
|
+
expect(def.purpose).toBe("");
|
|
44
|
+
expect(def.systemPromptOverride).toBe("");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("loads a workspace from a markdown file with frontmatter", async () => {
|
|
48
|
+
writeWorkspace(
|
|
49
|
+
"alev-b",
|
|
50
|
+
{ purpose: "Alev-B consulting website dev", cwd: "~/Projects/alev-b-website", emoji: "🏢", color: "#6366f1" },
|
|
51
|
+
"You are the Alev-B dev assistant. Stack: React + Express + Drizzle.",
|
|
52
|
+
);
|
|
53
|
+
const { loadWorkspaces, getWorkspace } = await import("../src/services/workspaces.js");
|
|
54
|
+
loadWorkspaces();
|
|
55
|
+
const ws = getWorkspace("alev-b");
|
|
56
|
+
expect(ws).not.toBeNull();
|
|
57
|
+
expect(ws!.name).toBe("alev-b");
|
|
58
|
+
expect(ws!.purpose).toBe("Alev-B consulting website dev");
|
|
59
|
+
expect(ws!.cwd).toContain("alev-b-website");
|
|
60
|
+
expect(ws!.emoji).toBe("🏢");
|
|
61
|
+
expect(ws!.color).toBe("#6366f1");
|
|
62
|
+
expect(ws!.systemPromptOverride).toContain("Alev-B dev assistant");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("loads multiple workspaces and listWorkspaces returns all of them", async () => {
|
|
66
|
+
writeWorkspace("alev-b", { purpose: "p1" }, "body1");
|
|
67
|
+
writeWorkspace("homes", { purpose: "p2" }, "body2");
|
|
68
|
+
writeWorkspace("jobsnack", { purpose: "p3" }, "body3");
|
|
69
|
+
const { loadWorkspaces, listWorkspaces } = await import("../src/services/workspaces.js");
|
|
70
|
+
loadWorkspaces();
|
|
71
|
+
const names = listWorkspaces().map(w => w.name).sort();
|
|
72
|
+
expect(names).toEqual(["alev-b", "homes", "jobsnack"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("expands ~ in cwd to the user's home directory", async () => {
|
|
76
|
+
writeWorkspace("tilde", { purpose: "p", cwd: "~/some/path" }, "");
|
|
77
|
+
const { loadWorkspaces, getWorkspace } = await import("../src/services/workspaces.js");
|
|
78
|
+
loadWorkspaces();
|
|
79
|
+
const ws = getWorkspace("tilde");
|
|
80
|
+
expect(ws!.cwd).toBe(resolve(os.homedir(), "some/path"));
|
|
81
|
+
expect(ws!.cwd).not.toContain("~");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("matchWorkspaceForChannel matches by explicit channel ID in frontmatter", async () => {
|
|
85
|
+
writeWorkspace(
|
|
86
|
+
"alev-b",
|
|
87
|
+
{ purpose: "p", channels: ["C01ALEVABC", "C01ALEVXYZ"] },
|
|
88
|
+
"",
|
|
89
|
+
);
|
|
90
|
+
const { loadWorkspaces, matchWorkspaceForChannel } = await import("../src/services/workspaces.js");
|
|
91
|
+
loadWorkspaces();
|
|
92
|
+
const ws = matchWorkspaceForChannel("slack", "C01ALEVABC", undefined);
|
|
93
|
+
expect(ws?.name).toBe("alev-b");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("matchWorkspaceForChannel falls back to channel name match (case-insensitive, # stripped)", async () => {
|
|
97
|
+
writeWorkspace("alev-b", { purpose: "p" }, "");
|
|
98
|
+
const { loadWorkspaces, matchWorkspaceForChannel } = await import("../src/services/workspaces.js");
|
|
99
|
+
loadWorkspaces();
|
|
100
|
+
const byHash = matchWorkspaceForChannel("slack", "C_UNKNOWN", "#alev-b");
|
|
101
|
+
const noHash = matchWorkspaceForChannel("slack", "C_UNKNOWN", "ALEV-B");
|
|
102
|
+
expect(byHash?.name).toBe("alev-b");
|
|
103
|
+
expect(noHash?.name).toBe("alev-b");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("matchWorkspaceForChannel returns null for unknown channel with no name match", async () => {
|
|
107
|
+
writeWorkspace("alev-b", { purpose: "p" }, "");
|
|
108
|
+
const { loadWorkspaces, matchWorkspaceForChannel } = await import("../src/services/workspaces.js");
|
|
109
|
+
loadWorkspaces();
|
|
110
|
+
const ws = matchWorkspaceForChannel("slack", "C_MYSTERY", "#unmapped");
|
|
111
|
+
expect(ws).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("resolveWorkspaceOrDefault returns the matched workspace when one is found", async () => {
|
|
115
|
+
writeWorkspace("alev-b", { purpose: "p" }, "persona body");
|
|
116
|
+
const { loadWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
|
|
117
|
+
loadWorkspaces();
|
|
118
|
+
const ws = resolveWorkspaceOrDefault("slack", "C_UNKNOWN", "#alev-b");
|
|
119
|
+
expect(ws.name).toBe("alev-b");
|
|
120
|
+
expect(ws.systemPromptOverride).toContain("persona body");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("resolveWorkspaceOrDefault returns the default workspace when no match", async () => {
|
|
124
|
+
const { loadWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
|
|
125
|
+
loadWorkspaces();
|
|
126
|
+
const ws = resolveWorkspaceOrDefault("slack", "C_UNKNOWN", "#whatever");
|
|
127
|
+
expect(ws.name).toBe("default");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("reloadWorkspaces picks up a newly created file", async () => {
|
|
131
|
+
const { loadWorkspaces, reloadWorkspaces, listWorkspaces } = await import("../src/services/workspaces.js");
|
|
132
|
+
loadWorkspaces();
|
|
133
|
+
expect(listWorkspaces()).toHaveLength(0);
|
|
134
|
+
writeWorkspace("new-one", { purpose: "p" }, "body");
|
|
135
|
+
reloadWorkspaces();
|
|
136
|
+
expect(listWorkspaces()).toHaveLength(1);
|
|
137
|
+
expect(listWorkspaces()[0].name).toBe("new-one");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("skips files that aren't .md", async () => {
|
|
141
|
+
fs.writeFileSync(resolve(TEST_DATA_DIR, "workspaces", "notes.txt"), "ignored");
|
|
142
|
+
writeWorkspace("real", { purpose: "p" }, "body");
|
|
143
|
+
const { loadWorkspaces, listWorkspaces } = await import("../src/services/workspaces.js");
|
|
144
|
+
loadWorkspaces();
|
|
145
|
+
expect(listWorkspaces()).toHaveLength(1);
|
|
146
|
+
expect(listWorkspaces()[0].name).toBe("real");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("malformed frontmatter: workspace is skipped or loaded with defaults, other workspaces still load", async () => {
|
|
150
|
+
fs.writeFileSync(
|
|
151
|
+
resolve(TEST_DATA_DIR, "workspaces", "broken.md"),
|
|
152
|
+
"---\nthis is: not: valid: yaml:\n---\nbody",
|
|
153
|
+
);
|
|
154
|
+
writeWorkspace("good", { purpose: "p" }, "body");
|
|
155
|
+
const { loadWorkspaces, listWorkspaces } = await import("../src/services/workspaces.js");
|
|
156
|
+
loadWorkspaces();
|
|
157
|
+
const names = listWorkspaces().map(w => w.name);
|
|
158
|
+
expect(names).toContain("good");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("missing workspaces directory is handled gracefully", async () => {
|
|
162
|
+
fs.rmSync(resolve(TEST_DATA_DIR, "workspaces"), { recursive: true, force: true });
|
|
163
|
+
const { loadWorkspaces, listWorkspaces } = await import("../src/services/workspaces.js");
|
|
164
|
+
const count = loadWorkspaces();
|
|
165
|
+
expect(count).toBe(0);
|
|
166
|
+
expect(listWorkspaces()).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("workspace resolver integration with session (v4.12.0)", () => {
|
|
171
|
+
it("two channels resolve to two different workspaces", async () => {
|
|
172
|
+
fs.writeFileSync(
|
|
173
|
+
resolve(TEST_DATA_DIR, "workspaces", "alev-b.md"),
|
|
174
|
+
`---\npurpose: Alev-B\ncwd: ~/tmp/alev-b\nchannels: ["C_ALEV"]\n---\nAlev-B persona`,
|
|
175
|
+
);
|
|
176
|
+
fs.writeFileSync(
|
|
177
|
+
resolve(TEST_DATA_DIR, "workspaces", "homes.md"),
|
|
178
|
+
`---\npurpose: HOMES\ncwd: ~/tmp/homes\nchannels: ["C_HOMES"]\n---\nHOMES persona`,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
vi.resetModules();
|
|
182
|
+
const { initWorkspaces, resolveWorkspaceOrDefault } = await import("../src/services/workspaces.js");
|
|
183
|
+
initWorkspaces();
|
|
184
|
+
|
|
185
|
+
const alev = resolveWorkspaceOrDefault("slack", "C_ALEV", undefined);
|
|
186
|
+
const homes = resolveWorkspaceOrDefault("slack", "C_HOMES", undefined);
|
|
187
|
+
const unknown = resolveWorkspaceOrDefault("slack", "C_MYSTERY", undefined);
|
|
188
|
+
|
|
189
|
+
expect(alev.name).toBe("alev-b");
|
|
190
|
+
expect(alev.systemPromptOverride).toBe("Alev-B persona");
|
|
191
|
+
expect(homes.name).toBe("homes");
|
|
192
|
+
expect(homes.systemPromptOverride).toBe("HOMES persona");
|
|
193
|
+
expect(unknown.name).toBe("default");
|
|
194
|
+
expect(unknown.systemPromptOverride).toBe("");
|
|
195
|
+
});
|
|
196
|
+
});
|
package/web/public/index.html
CHANGED
|
@@ -82,6 +82,15 @@
|
|
|
82
82
|
<div id="sessions-list"></div>
|
|
83
83
|
</div>
|
|
84
84
|
|
|
85
|
+
<!-- Workspaces (v4.12.0) -->
|
|
86
|
+
<div class="page content" id="page-workspaces">
|
|
87
|
+
<div class="card" style="max-width:900px">
|
|
88
|
+
<h2 style="margin-top:0">🧭 Workspaces</h2>
|
|
89
|
+
<p class="hint" style="color:var(--fg2);font-size:0.9em">Active sessions grouped by workspace. Each workspace can have its own purpose, working directory, and persona.</p>
|
|
90
|
+
<div id="workspaces-list" style="display:flex;flex-direction:column;gap:0.5rem;margin-top:1rem"></div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
85
94
|
<!-- Files -->
|
|
86
95
|
<div class="page content" id="page-files">
|
|
87
96
|
<div class="editor-toolbar">
|
package/web/public/js/app.js
CHANGED
|
@@ -30,6 +30,7 @@ function initUI() {
|
|
|
30
30
|
{ section: 'nav.data', items: [
|
|
31
31
|
{ page: 'memory', icon: 'brain', label: 'nav.memory' },
|
|
32
32
|
{ page: 'sessions', icon: 'clipboard', label: 'nav.sessions' },
|
|
33
|
+
{ page: 'workspaces', icon: 'compass', label: 'nav.workspaces' },
|
|
33
34
|
{ page: 'files', icon: 'folder', label: 'nav.files' },
|
|
34
35
|
]},
|
|
35
36
|
{ section: 'nav.system', items: [
|
|
@@ -228,7 +229,8 @@ function bindNavEvents() {
|
|
|
228
229
|
const loaders = { dashboard: loadDashboard, memory: loadMemory, models: loadModels,
|
|
229
230
|
sessions: loadSessions, plugins: loadPlugins, tools: loadTools, cron: loadCron,
|
|
230
231
|
files: () => navigateFiles('.'), users: loadUsers, settings: loadSettings,
|
|
231
|
-
platforms: loadPlatforms, personality: loadPersonality, maintenance: loadMaintenance
|
|
232
|
+
platforms: loadPlatforms, personality: loadPersonality, maintenance: loadMaintenance,
|
|
233
|
+
workspaces: loadWorkspaces };
|
|
232
234
|
if (loaders[page]) loaders[page]();
|
|
233
235
|
});
|
|
234
236
|
});
|
|
@@ -1002,6 +1004,47 @@ async function loadSessions() {
|
|
|
1002
1004
|
}).join('');
|
|
1003
1005
|
}
|
|
1004
1006
|
|
|
1007
|
+
// ── Workspaces (v4.12.0) ────────────────────────────────
|
|
1008
|
+
async function loadWorkspaces() {
|
|
1009
|
+
try {
|
|
1010
|
+
const res = await fetch(API + '/api/workspaces');
|
|
1011
|
+
const data = await res.json();
|
|
1012
|
+
const list = document.getElementById('workspaces-list');
|
|
1013
|
+
if (!list) return;
|
|
1014
|
+
if (!data.workspaces || data.workspaces.length === 0) {
|
|
1015
|
+
list.innerHTML = `<div class="hint" style="color:var(--fg2)">No workspaces yet. Create one at <code>~/.alvin-bot/workspaces/<name>.md</code> with YAML frontmatter (purpose, cwd, emoji, color, channels). See <code>docs/install/slack-setup.md</code>.</div>`;
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
list.innerHTML = data.workspaces.map(ws => {
|
|
1019
|
+
const emoji = ws.emoji || '🧭';
|
|
1020
|
+
const color = ws.color || '#6366f1';
|
|
1021
|
+
const purpose = ws.purpose || '<em style="opacity:0.6">(no purpose set)</em>';
|
|
1022
|
+
const cost = (ws.stats.totalCost || 0).toFixed(4);
|
|
1023
|
+
const sessionCount = ws.stats.sessionCount || 0;
|
|
1024
|
+
const messageCount = ws.stats.messageCount || 0;
|
|
1025
|
+
const channelsList = (ws.channels && ws.channels.length > 0)
|
|
1026
|
+
? `<div class="sub" style="font-size:0.75em;opacity:0.6;margin-top:4px">channels: ${ws.channels.join(', ')}</div>`
|
|
1027
|
+
: '';
|
|
1028
|
+
return `
|
|
1029
|
+
<div class="list-item" style="border-left:4px solid ${color};padding-left:12px">
|
|
1030
|
+
<div class="info" style="flex:1">
|
|
1031
|
+
<div class="name" style="font-weight:600">${emoji} ${ws.name}</div>
|
|
1032
|
+
<div class="desc">${purpose}</div>
|
|
1033
|
+
<div class="sub" style="font-size:0.78em;opacity:0.7;margin-top:2px">cwd: <code>${ws.cwd}</code></div>
|
|
1034
|
+
${channelsList}
|
|
1035
|
+
</div>
|
|
1036
|
+
<div style="text-align:right;font-family:monospace;font-size:0.85em;opacity:0.8;line-height:1.5">
|
|
1037
|
+
<div>${sessionCount} session${sessionCount === 1 ? '' : 's'}</div>
|
|
1038
|
+
<div>${messageCount} msg</div>
|
|
1039
|
+
<div>$${cost}</div>
|
|
1040
|
+
</div>
|
|
1041
|
+
</div>`;
|
|
1042
|
+
}).join('');
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
console.error('loadWorkspaces failed:', err);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1005
1048
|
// ── Plugins ─────────────────────────────────────────────
|
|
1006
1049
|
async function loadPlugins() {
|
|
1007
1050
|
const [pluginsRes, mcpRes, skillsRes] = await Promise.all([
|