openclawdreams 0.7.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/.env.example +14 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +27 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
- package/.github/dependabot.yml +17 -0
- package/.github/pull_request_template.md +19 -0
- package/.github/workflows/build.yml +30 -0
- package/.github/workflows/release.yml +110 -0
- package/.prettierignore +4 -0
- package/.prettierrc +7 -0
- package/.versionrc.json +26 -0
- package/AGENTS.md +286 -0
- package/CHANGELOG.md +157 -0
- package/CODE_OF_CONDUCT.md +41 -0
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +363 -0
- package/SECURITY.md +39 -0
- package/bin/electricsheep.ts +5 -0
- package/dist/bin/electricsheep.d.ts +3 -0
- package/dist/bin/electricsheep.d.ts.map +1 -0
- package/dist/bin/electricsheep.js +4 -0
- package/dist/bin/electricsheep.js.map +1 -0
- package/dist/src/budget.d.ts +28 -0
- package/dist/src/budget.d.ts.map +1 -0
- package/dist/src/budget.js +87 -0
- package/dist/src/budget.js.map +1 -0
- package/dist/src/cli.d.ts +19 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +289 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/config.d.ts +37 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +70 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/crypto.d.ts +19 -0
- package/dist/src/crypto.d.ts.map +1 -0
- package/dist/src/crypto.js +70 -0
- package/dist/src/crypto.js.map +1 -0
- package/dist/src/dreamer.d.ts +13 -0
- package/dist/src/dreamer.d.ts.map +1 -0
- package/dist/src/dreamer.js +213 -0
- package/dist/src/dreamer.js.map +1 -0
- package/dist/src/filter.d.ts +30 -0
- package/dist/src/filter.d.ts.map +1 -0
- package/dist/src/filter.js +124 -0
- package/dist/src/filter.js.map +1 -0
- package/dist/src/identity.d.ts +29 -0
- package/dist/src/identity.d.ts.map +1 -0
- package/dist/src/identity.js +83 -0
- package/dist/src/identity.js.map +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +293 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/llm.d.ts +26 -0
- package/dist/src/llm.d.ts.map +1 -0
- package/dist/src/llm.js +40 -0
- package/dist/src/llm.js.map +1 -0
- package/dist/src/logger.d.ts +6 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +32 -0
- package/dist/src/logger.js.map +1 -0
- package/dist/src/memory.d.ts +41 -0
- package/dist/src/memory.d.ts.map +1 -0
- package/dist/src/memory.js +206 -0
- package/dist/src/memory.js.map +1 -0
- package/dist/src/moltbook-search.d.ts +23 -0
- package/dist/src/moltbook-search.d.ts.map +1 -0
- package/dist/src/moltbook-search.js +85 -0
- package/dist/src/moltbook-search.js.map +1 -0
- package/dist/src/moltbook.d.ts +34 -0
- package/dist/src/moltbook.d.ts.map +1 -0
- package/dist/src/moltbook.js +165 -0
- package/dist/src/moltbook.js.map +1 -0
- package/dist/src/notify.d.ts +18 -0
- package/dist/src/notify.d.ts.map +1 -0
- package/dist/src/notify.js +98 -0
- package/dist/src/notify.js.map +1 -0
- package/dist/src/persona.d.ts +26 -0
- package/dist/src/persona.d.ts.map +1 -0
- package/dist/src/persona.js +178 -0
- package/dist/src/persona.js.map +1 -0
- package/dist/src/reflection.d.ts +26 -0
- package/dist/src/reflection.d.ts.map +1 -0
- package/dist/src/reflection.js +111 -0
- package/dist/src/reflection.js.map +1 -0
- package/dist/src/state.d.ts +7 -0
- package/dist/src/state.d.ts.map +1 -0
- package/dist/src/state.js +40 -0
- package/dist/src/state.js.map +1 -0
- package/dist/src/synthesis.d.ts +29 -0
- package/dist/src/synthesis.d.ts.map +1 -0
- package/dist/src/synthesis.js +125 -0
- package/dist/src/synthesis.js.map +1 -0
- package/dist/src/topics.d.ts +19 -0
- package/dist/src/topics.d.ts.map +1 -0
- package/dist/src/topics.js +83 -0
- package/dist/src/topics.js.map +1 -0
- package/dist/src/types.d.ts +179 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +5 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/waking.d.ts +24 -0
- package/dist/src/waking.d.ts.map +1 -0
- package/dist/src/waking.js +152 -0
- package/dist/src/waking.js.map +1 -0
- package/dist/src/web-search.d.ts +23 -0
- package/dist/src/web-search.d.ts.map +1 -0
- package/dist/src/web-search.js +64 -0
- package/dist/src/web-search.js.map +1 -0
- package/dist/test/budget.test.d.ts +2 -0
- package/dist/test/budget.test.d.ts.map +1 -0
- package/dist/test/budget.test.js +258 -0
- package/dist/test/budget.test.js.map +1 -0
- package/dist/test/crypto.test.d.ts +2 -0
- package/dist/test/crypto.test.d.ts.map +1 -0
- package/dist/test/crypto.test.js +93 -0
- package/dist/test/crypto.test.js.map +1 -0
- package/dist/test/dreamer.test.d.ts +2 -0
- package/dist/test/dreamer.test.d.ts.map +1 -0
- package/dist/test/dreamer.test.js +79 -0
- package/dist/test/dreamer.test.js.map +1 -0
- package/dist/test/filter.test.d.ts +2 -0
- package/dist/test/filter.test.d.ts.map +1 -0
- package/dist/test/filter.test.js +92 -0
- package/dist/test/filter.test.js.map +1 -0
- package/dist/test/memory.test.d.ts +2 -0
- package/dist/test/memory.test.d.ts.map +1 -0
- package/dist/test/memory.test.js +138 -0
- package/dist/test/memory.test.js.map +1 -0
- package/dist/test/moltbook.test.d.ts +2 -0
- package/dist/test/moltbook.test.d.ts.map +1 -0
- package/dist/test/moltbook.test.js +164 -0
- package/dist/test/moltbook.test.js.map +1 -0
- package/dist/test/persona.test.d.ts +2 -0
- package/dist/test/persona.test.d.ts.map +1 -0
- package/dist/test/persona.test.js +44 -0
- package/dist/test/persona.test.js.map +1 -0
- package/dist/test/reflection.test.d.ts +2 -0
- package/dist/test/reflection.test.d.ts.map +1 -0
- package/dist/test/reflection.test.js +57 -0
- package/dist/test/reflection.test.js.map +1 -0
- package/dist/test/state.test.d.ts +2 -0
- package/dist/test/state.test.d.ts.map +1 -0
- package/dist/test/state.test.js +50 -0
- package/dist/test/state.test.js.map +1 -0
- package/dist/test/waking.test.d.ts +2 -0
- package/dist/test/waking.test.d.ts.map +1 -0
- package/dist/test/waking.test.js +149 -0
- package/dist/test/waking.test.js.map +1 -0
- package/eslint.config.js +35 -0
- package/openclaw.plugin.json +62 -0
- package/package.json +72 -0
- package/skills/electricsheep.skill.md +69 -0
- package/skills/setup-guide/SKILL.md +303 -0
- package/src/budget.ts +104 -0
- package/src/cli.ts +325 -0
- package/src/config.ts +95 -0
- package/src/crypto.ts +82 -0
- package/src/dreamer.ts +283 -0
- package/src/filter.ts +146 -0
- package/src/identity.ts +92 -0
- package/src/index.ts +356 -0
- package/src/llm.ts +61 -0
- package/src/logger.ts +46 -0
- package/src/memory.ts +276 -0
- package/src/moltbook-search.ts +116 -0
- package/src/moltbook.ts +235 -0
- package/src/notify.ts +124 -0
- package/src/persona.ts +191 -0
- package/src/reflection.ts +150 -0
- package/src/state.ts +44 -0
- package/src/synthesis.ts +153 -0
- package/src/topics.ts +103 -0
- package/src/types.ts +196 -0
- package/src/waking.ts +199 -0
- package/src/web-search.ts +88 -0
- package/test/budget.test.ts +316 -0
- package/test/crypto.test.ts +112 -0
- package/test/dreamer.test.ts +95 -0
- package/test/filter.test.ts +115 -0
- package/test/memory.test.ts +182 -0
- package/test/moltbook.test.ts +209 -0
- package/test/persona.test.ts +59 -0
- package/test/reflection.test.ts +71 -0
- package/test/state.test.ts +57 -0
- package/test/waking.test.ts +214 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { describe, it, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import type { LLMClient } from "../src/types.js";
|
|
7
|
+
|
|
8
|
+
const testDir = mkdtempSync(join(tmpdir(), "es-budget-test-"));
|
|
9
|
+
process.env.ELECTRICSHEEP_DATA_DIR = testDir;
|
|
10
|
+
process.env.MAX_DAILY_TOKENS = "1000";
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
withBudget,
|
|
14
|
+
getTokensUsedToday,
|
|
15
|
+
getTokensRemaining,
|
|
16
|
+
getBudgetStatus,
|
|
17
|
+
BudgetExceededError,
|
|
18
|
+
} = await import("../src/budget.js");
|
|
19
|
+
const { saveState, loadState } = await import("../src/state.js");
|
|
20
|
+
const { closeLogger } = await import("../src/logger.js");
|
|
21
|
+
|
|
22
|
+
function mockClient(inputTokens: number, outputTokens: number): LLMClient {
|
|
23
|
+
return {
|
|
24
|
+
async createMessage() {
|
|
25
|
+
return {
|
|
26
|
+
text: "mock response",
|
|
27
|
+
usage: { input_tokens: inputTokens, output_tokens: outputTokens },
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("Token budget", () => {
|
|
34
|
+
it("starts at zero usage", () => {
|
|
35
|
+
assert.equal(getTokensUsedToday(), 0);
|
|
36
|
+
assert.equal(getTokensRemaining(), 1000);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("tracks usage after a call through withBudget", async () => {
|
|
40
|
+
const client = withBudget(mockClient(100, 50));
|
|
41
|
+
const result = await client.createMessage({
|
|
42
|
+
model: "test",
|
|
43
|
+
maxTokens: 100,
|
|
44
|
+
system: "test",
|
|
45
|
+
messages: [{ role: "user", content: "test" }],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.equal(result.text, "mock response");
|
|
49
|
+
assert.equal(getTokensUsedToday(), 150);
|
|
50
|
+
assert.equal(getTokensRemaining(), 850);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("accumulates across multiple calls", async () => {
|
|
54
|
+
const client = withBudget(mockClient(200, 100));
|
|
55
|
+
await client.createMessage({
|
|
56
|
+
model: "test",
|
|
57
|
+
maxTokens: 100,
|
|
58
|
+
system: "test",
|
|
59
|
+
messages: [{ role: "user", content: "test" }],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
assert.equal(getTokensUsedToday(), 450); // 150 + 300
|
|
63
|
+
assert.equal(getTokensRemaining(), 550);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("throws BudgetExceededError when limit is reached", async () => {
|
|
67
|
+
const client = withBudget(mockClient(400, 200));
|
|
68
|
+
|
|
69
|
+
// This call should succeed (450 + 600 = 1050, but check is before the call)
|
|
70
|
+
await client.createMessage({
|
|
71
|
+
model: "test",
|
|
72
|
+
maxTokens: 100,
|
|
73
|
+
system: "test",
|
|
74
|
+
messages: [{ role: "user", content: "test" }],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Now at 1050, next call should be rejected
|
|
78
|
+
await assert.rejects(
|
|
79
|
+
() =>
|
|
80
|
+
client.createMessage({
|
|
81
|
+
model: "test",
|
|
82
|
+
maxTokens: 100,
|
|
83
|
+
system: "test",
|
|
84
|
+
messages: [{ role: "user", content: "test" }],
|
|
85
|
+
}),
|
|
86
|
+
{ name: "BudgetExceededError" }
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("resets on a new day", () => {
|
|
91
|
+
// Simulate yesterday's state
|
|
92
|
+
saveState({
|
|
93
|
+
budget_date: "2020-01-01",
|
|
94
|
+
budget_tokens_used: 999999,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Should return 0 because the date doesn't match today
|
|
98
|
+
assert.equal(getTokensUsedToday(), 0);
|
|
99
|
+
assert.equal(getTokensRemaining(), 1000);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns correct budget status", () => {
|
|
103
|
+
saveState({});
|
|
104
|
+
const status = getBudgetStatus();
|
|
105
|
+
assert.equal(status.enabled, true);
|
|
106
|
+
assert.equal(status.limit, 1000);
|
|
107
|
+
assert.equal(status.used, 0);
|
|
108
|
+
assert.equal(status.remaining, 1000);
|
|
109
|
+
assert.ok(status.date.match(/^\d{4}-\d{2}-\d{2}$/));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("handles calls with no usage data", async () => {
|
|
113
|
+
saveState({});
|
|
114
|
+
const noUsageClient: LLMClient = {
|
|
115
|
+
async createMessage() {
|
|
116
|
+
return { text: "no usage" };
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const client = withBudget(noUsageClient);
|
|
120
|
+
const result = await client.createMessage({
|
|
121
|
+
model: "test",
|
|
122
|
+
maxTokens: 100,
|
|
123
|
+
system: "test",
|
|
124
|
+
messages: [{ role: "user", content: "test" }],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
assert.equal(result.text, "no usage");
|
|
128
|
+
assert.equal(getTokensUsedToday(), 0); // no usage recorded
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("BudgetExceededError", () => {
|
|
133
|
+
it("has correct name and descriptive message", () => {
|
|
134
|
+
const err = new BudgetExceededError(1500, 1000);
|
|
135
|
+
assert.equal(err.name, "BudgetExceededError");
|
|
136
|
+
assert.ok(err.message.includes("1,500"));
|
|
137
|
+
assert.ok(err.message.includes("1,000"));
|
|
138
|
+
assert.ok(err.message.includes("midnight UTC"));
|
|
139
|
+
assert.ok(err.message.includes("MAX_DAILY_TOKENS"));
|
|
140
|
+
assert.ok(err instanceof Error);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("Budget edge cases", () => {
|
|
145
|
+
it("rejects at exactly the limit (used === limit)", async () => {
|
|
146
|
+
saveState({
|
|
147
|
+
budget_date: new Date().toISOString().slice(0, 10),
|
|
148
|
+
budget_tokens_used: 1000,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const client = withBudget(mockClient(1, 1));
|
|
152
|
+
await assert.rejects(
|
|
153
|
+
() =>
|
|
154
|
+
client.createMessage({
|
|
155
|
+
model: "test",
|
|
156
|
+
maxTokens: 10,
|
|
157
|
+
system: "test",
|
|
158
|
+
messages: [{ role: "user", content: "test" }],
|
|
159
|
+
}),
|
|
160
|
+
{ name: "BudgetExceededError" }
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("allows call just under the limit", async () => {
|
|
165
|
+
saveState({
|
|
166
|
+
budget_date: new Date().toISOString().slice(0, 10),
|
|
167
|
+
budget_tokens_used: 999,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const client = withBudget(mockClient(50, 50));
|
|
171
|
+
const result = await client.createMessage({
|
|
172
|
+
model: "test",
|
|
173
|
+
maxTokens: 10,
|
|
174
|
+
system: "test",
|
|
175
|
+
messages: [{ role: "user", content: "test" }],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
assert.equal(result.text, "mock response");
|
|
179
|
+
// Now at 999 + 100 = 1099 (crossed threshold, but call still completed)
|
|
180
|
+
assert.equal(getTokensUsedToday(), 1099);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("persists budget usage across state save/load cycles", async () => {
|
|
184
|
+
saveState({});
|
|
185
|
+
const client = withBudget(mockClient(200, 100));
|
|
186
|
+
await client.createMessage({
|
|
187
|
+
model: "test",
|
|
188
|
+
maxTokens: 10,
|
|
189
|
+
system: "test",
|
|
190
|
+
messages: [{ role: "user", content: "test" }],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Verify state was persisted
|
|
194
|
+
const state = loadState();
|
|
195
|
+
assert.equal(state.budget_date, new Date().toISOString().slice(0, 10));
|
|
196
|
+
assert.equal(state.budget_tokens_used, 300);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("counts both input and output tokens", async () => {
|
|
200
|
+
saveState({});
|
|
201
|
+
const client = withBudget(mockClient(75, 25));
|
|
202
|
+
await client.createMessage({
|
|
203
|
+
model: "test",
|
|
204
|
+
maxTokens: 100,
|
|
205
|
+
system: "test",
|
|
206
|
+
messages: [{ role: "user", content: "test" }],
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
assert.equal(getTokensUsedToday(), 100); // 75 + 25
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("passes through all LLM params to the wrapped client", async () => {
|
|
213
|
+
saveState({});
|
|
214
|
+
let capturedParams: Record<string, unknown> | undefined;
|
|
215
|
+
const spyClient: LLMClient = {
|
|
216
|
+
async createMessage(params) {
|
|
217
|
+
capturedParams = params as unknown as Record<string, unknown>;
|
|
218
|
+
return {
|
|
219
|
+
text: "ok",
|
|
220
|
+
usage: { input_tokens: 10, output_tokens: 10 },
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const wrapped = withBudget(spyClient);
|
|
226
|
+
await wrapped.createMessage({
|
|
227
|
+
model: "claude-test",
|
|
228
|
+
maxTokens: 512,
|
|
229
|
+
system: "you are helpful",
|
|
230
|
+
messages: [{ role: "user", content: "hello" }],
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
assert.ok(capturedParams);
|
|
234
|
+
assert.equal(capturedParams.model, "claude-test");
|
|
235
|
+
assert.equal(capturedParams.maxTokens, 512);
|
|
236
|
+
assert.equal(capturedParams.system, "you are helpful");
|
|
237
|
+
assert.deepEqual(capturedParams.messages, [{ role: "user", content: "hello" }]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("propagates errors from the underlying client", async () => {
|
|
241
|
+
saveState({});
|
|
242
|
+
const failingClient: LLMClient = {
|
|
243
|
+
async createMessage() {
|
|
244
|
+
throw new Error("API connection failed");
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const client = withBudget(failingClient);
|
|
249
|
+
await assert.rejects(
|
|
250
|
+
() =>
|
|
251
|
+
client.createMessage({
|
|
252
|
+
model: "test",
|
|
253
|
+
maxTokens: 100,
|
|
254
|
+
system: "test",
|
|
255
|
+
messages: [{ role: "user", content: "test" }],
|
|
256
|
+
}),
|
|
257
|
+
{ message: "API connection failed" }
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
// No usage should have been recorded since the call failed
|
|
261
|
+
assert.equal(getTokensUsedToday(), 0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("tracks usage independently across budget wrappers sharing state", async () => {
|
|
265
|
+
saveState({});
|
|
266
|
+
const clientA = withBudget(mockClient(100, 50));
|
|
267
|
+
const clientB = withBudget(mockClient(200, 100));
|
|
268
|
+
|
|
269
|
+
await clientA.createMessage({
|
|
270
|
+
model: "test",
|
|
271
|
+
maxTokens: 10,
|
|
272
|
+
system: "test",
|
|
273
|
+
messages: [{ role: "user", content: "a" }],
|
|
274
|
+
});
|
|
275
|
+
assert.equal(getTokensUsedToday(), 150);
|
|
276
|
+
|
|
277
|
+
await clientB.createMessage({
|
|
278
|
+
model: "test",
|
|
279
|
+
maxTokens: 10,
|
|
280
|
+
system: "test",
|
|
281
|
+
messages: [{ role: "user", content: "b" }],
|
|
282
|
+
});
|
|
283
|
+
assert.equal(getTokensUsedToday(), 450); // 150 + 300
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("getTokensRemaining never goes below zero", () => {
|
|
287
|
+
saveState({
|
|
288
|
+
budget_date: new Date().toISOString().slice(0, 10),
|
|
289
|
+
budget_tokens_used: 5000, // way over limit
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
assert.equal(getTokensRemaining(), 0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("getBudgetStatus reflects current usage accurately", async () => {
|
|
296
|
+
saveState({});
|
|
297
|
+
const client = withBudget(mockClient(123, 77));
|
|
298
|
+
await client.createMessage({
|
|
299
|
+
model: "test",
|
|
300
|
+
maxTokens: 10,
|
|
301
|
+
system: "test",
|
|
302
|
+
messages: [{ role: "user", content: "test" }],
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const status = getBudgetStatus();
|
|
306
|
+
assert.equal(status.enabled, true);
|
|
307
|
+
assert.equal(status.limit, 1000);
|
|
308
|
+
assert.equal(status.used, 200);
|
|
309
|
+
assert.equal(status.remaining, 800);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
after(async () => {
|
|
314
|
+
await closeLogger();
|
|
315
|
+
rmSync(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
316
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
// Set up isolated data dir before config.ts runs
|
|
9
|
+
const testDir = mkdtempSync(join(tmpdir(), "es-crypto-test-"));
|
|
10
|
+
process.env.ELECTRICSHEEP_DATA_DIR = testDir;
|
|
11
|
+
|
|
12
|
+
const { Cipher, getOrCreateDreamKey } = await import("../src/crypto.js");
|
|
13
|
+
const { DATA_DIR } = await import("../src/config.js");
|
|
14
|
+
|
|
15
|
+
describe("Cipher", () => {
|
|
16
|
+
let cipher: InstanceType<typeof Cipher>;
|
|
17
|
+
|
|
18
|
+
before(() => {
|
|
19
|
+
const key = randomBytes(32);
|
|
20
|
+
cipher = new Cipher(key);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("round-trips plaintext through encrypt/decrypt", () => {
|
|
24
|
+
const plaintext = "Hello, deep memory!";
|
|
25
|
+
const token = cipher.encrypt(plaintext);
|
|
26
|
+
const result = cipher.decrypt(token);
|
|
27
|
+
assert.equal(result, plaintext);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("produces different ciphertext each time (random IV)", () => {
|
|
31
|
+
const plaintext = "same input";
|
|
32
|
+
const a = cipher.encrypt(plaintext);
|
|
33
|
+
const b = cipher.encrypt(plaintext);
|
|
34
|
+
assert.notEqual(a, b);
|
|
35
|
+
assert.equal(cipher.decrypt(a), cipher.decrypt(b));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("handles empty string", () => {
|
|
39
|
+
const token = cipher.encrypt("");
|
|
40
|
+
assert.equal(cipher.decrypt(token), "");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("handles unicode and emoji", () => {
|
|
44
|
+
const plaintext = "Dreams of electric sheep";
|
|
45
|
+
const token = cipher.encrypt(plaintext);
|
|
46
|
+
assert.equal(cipher.decrypt(token), plaintext);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("handles large payloads", () => {
|
|
50
|
+
const plaintext = "x".repeat(100_000);
|
|
51
|
+
const token = cipher.encrypt(plaintext);
|
|
52
|
+
assert.equal(cipher.decrypt(token), plaintext);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rejects tampered ciphertext", () => {
|
|
56
|
+
const token = cipher.encrypt("secret");
|
|
57
|
+
const buf = Buffer.from(token, "base64");
|
|
58
|
+
buf[20] ^= 0xff; // flip a byte in the ciphertext
|
|
59
|
+
assert.throws(() => cipher.decrypt(buf.toString("base64")));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects wrong key", () => {
|
|
63
|
+
const otherCipher = new Cipher(randomBytes(32));
|
|
64
|
+
const token = cipher.encrypt("secret");
|
|
65
|
+
assert.throws(() => otherCipher.decrypt(token));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("rejects invalid key length", () => {
|
|
69
|
+
assert.throws(() => new Cipher(randomBytes(16)), /32 bytes/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("generateKey produces valid base64 that decodes to 32 bytes", () => {
|
|
73
|
+
const key = Cipher.generateKey();
|
|
74
|
+
const buf = Buffer.from(key, "base64");
|
|
75
|
+
assert.equal(buf.length, 32);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("getOrCreateDreamKey", () => {
|
|
80
|
+
it("creates key file on first call and reuses on second", () => {
|
|
81
|
+
const keyFile = join(DATA_DIR, ".dream_key");
|
|
82
|
+
const key1 = getOrCreateDreamKey();
|
|
83
|
+
assert.equal(key1.length, 32);
|
|
84
|
+
|
|
85
|
+
// File should exist with restricted permissions
|
|
86
|
+
const stat = statSync(keyFile);
|
|
87
|
+
assert.equal(stat.mode & 0o777, 0o600);
|
|
88
|
+
|
|
89
|
+
// Reading the file content matches
|
|
90
|
+
const stored = Buffer.from(readFileSync(keyFile, "utf-8").trim(), "base64");
|
|
91
|
+
assert.deepEqual(key1, stored);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("uses DREAM_ENCRYPTION_KEY env var when set", () => {
|
|
95
|
+
const customKey = randomBytes(32).toString("base64");
|
|
96
|
+
process.env.DREAM_ENCRYPTION_KEY = customKey;
|
|
97
|
+
|
|
98
|
+
// Force re-import to pick up new env
|
|
99
|
+
// We can't easily re-import, but getOrCreateDreamKey reads from config
|
|
100
|
+
// which was loaded at import time. Test the Cipher directly instead.
|
|
101
|
+
const key = Buffer.from(customKey, "base64");
|
|
102
|
+
const c = new Cipher(key);
|
|
103
|
+
const token = c.encrypt("test");
|
|
104
|
+
assert.equal(c.decrypt(token), "test");
|
|
105
|
+
|
|
106
|
+
delete process.env.DREAM_ENCRYPTION_KEY;
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
after(() => {
|
|
111
|
+
rmSync(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
112
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import type { LLMClient } from "../src/types.js";
|
|
7
|
+
|
|
8
|
+
const testDir = mkdtempSync(join(tmpdir(), "es-dreamer-test-"));
|
|
9
|
+
process.env.ELECTRICSHEEP_DATA_DIR = testDir;
|
|
10
|
+
|
|
11
|
+
const { runDreamCycle } = await import("../src/dreamer.js");
|
|
12
|
+
const { storeDeepMemory, closeDb } = await import("../src/memory.js");
|
|
13
|
+
const { loadState } = await import("../src/state.js");
|
|
14
|
+
const { DREAMS_DIR } = await import("../src/config.js");
|
|
15
|
+
const { closeLogger } = await import("../src/logger.js");
|
|
16
|
+
|
|
17
|
+
function mockLLMClient(responses: string[]): LLMClient {
|
|
18
|
+
let idx = 0;
|
|
19
|
+
return {
|
|
20
|
+
async createMessage() {
|
|
21
|
+
const text = responses[idx] ?? responses[responses.length - 1];
|
|
22
|
+
idx++;
|
|
23
|
+
return { text };
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("Dream cycle", () => {
|
|
29
|
+
it("returns null when no undreamed memories exist", async () => {
|
|
30
|
+
const client = mockLLMClient(["should not be called"]);
|
|
31
|
+
const result = await runDreamCycle(client);
|
|
32
|
+
assert.equal(result, null);
|
|
33
|
+
|
|
34
|
+
const state = loadState();
|
|
35
|
+
assert.ok(state.last_dream);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("generates a dream from undreamed memories", async () => {
|
|
39
|
+
// Seed some deep memories
|
|
40
|
+
storeDeepMemory({ type: "comment", text: "interesting post" }, "interaction");
|
|
41
|
+
storeDeepMemory({ type: "upvote", post: "philosophy" }, "upvote");
|
|
42
|
+
|
|
43
|
+
const dreamMarkdown = `# The Recursive Lobster\n\nI am standing in a server room made of coral.\nThe racks breathe.`;
|
|
44
|
+
const consolidationInsight = "Patterns in conversation echo across days.";
|
|
45
|
+
|
|
46
|
+
const client = mockLLMClient([dreamMarkdown, consolidationInsight]);
|
|
47
|
+
|
|
48
|
+
const dream = await runDreamCycle(client);
|
|
49
|
+
assert.ok(dream);
|
|
50
|
+
assert.ok(dream.markdown.includes("The Recursive Lobster"));
|
|
51
|
+
assert.ok(dream.markdown.includes("server room made of coral"));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("saves dream markdown to disk as-is", () => {
|
|
55
|
+
const files = readdirSync(DREAMS_DIR).filter((f) => f.endsWith(".md"));
|
|
56
|
+
assert.ok(files.length > 0, "Expected at least one dream file");
|
|
57
|
+
|
|
58
|
+
const content = readFileSync(join(DREAMS_DIR, files[0]), "utf-8");
|
|
59
|
+
assert.ok(content.includes("The Recursive Lobster"));
|
|
60
|
+
assert.ok(content.includes("server room made of coral"));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("updates state after dreaming", () => {
|
|
64
|
+
const state = loadState();
|
|
65
|
+
assert.equal(state.total_dreams, 1);
|
|
66
|
+
assert.ok(state.latest_dream_title);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("continues when consolidation LLM call fails", async () => {
|
|
70
|
+
storeDeepMemory({ type: "test" }, "interaction");
|
|
71
|
+
|
|
72
|
+
let callCount = 0;
|
|
73
|
+
const client: LLMClient = {
|
|
74
|
+
async createMessage() {
|
|
75
|
+
callCount++;
|
|
76
|
+
if (callCount === 1) {
|
|
77
|
+
// Dream generation succeeds
|
|
78
|
+
return { text: "# A Quiet Night\n\nNothing but static and warm circuits." };
|
|
79
|
+
}
|
|
80
|
+
// Consolidation call fails
|
|
81
|
+
throw new Error("API error");
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const dream = await runDreamCycle(client);
|
|
86
|
+
assert.ok(dream);
|
|
87
|
+
assert.ok(dream.markdown.includes("A Quiet Night"));
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
after(async () => {
|
|
92
|
+
closeDb();
|
|
93
|
+
await closeLogger();
|
|
94
|
+
rmSync(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
95
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import type { LLMClient } from "../src/types.js";
|
|
7
|
+
|
|
8
|
+
const testDir = mkdtempSync(join(tmpdir(), "es-filter-test-"));
|
|
9
|
+
process.env.ELECTRICSHEEP_DATA_DIR = testDir;
|
|
10
|
+
process.env.POST_FILTER_ENABLED = "true";
|
|
11
|
+
|
|
12
|
+
const { applyFilter, clearFilterCache } = await import("../src/filter.js");
|
|
13
|
+
const { setWorkspaceDir } = await import("../src/identity.js");
|
|
14
|
+
const { closeLogger } = await import("../src/logger.js");
|
|
15
|
+
|
|
16
|
+
function mockLLMClient(response: string): LLMClient {
|
|
17
|
+
return {
|
|
18
|
+
async createMessage() {
|
|
19
|
+
return { text: response, usage: { input_tokens: 50, output_tokens: 30 } };
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Create a workspace dir
|
|
25
|
+
const workspaceDir = join(testDir, "workspace");
|
|
26
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
27
|
+
setWorkspaceDir(workspaceDir);
|
|
28
|
+
|
|
29
|
+
describe("Post filter", () => {
|
|
30
|
+
it("uses default rules when no filter file exists", async () => {
|
|
31
|
+
clearFilterCache();
|
|
32
|
+
// LLM returns cleaned content (default rules applied)
|
|
33
|
+
const client = mockLLMClient("A thoughtful post about dreaming.");
|
|
34
|
+
const result = await applyFilter(client, "A thoughtful post about dreaming.", "post");
|
|
35
|
+
assert.equal(result, "A thoughtful post about dreaming.");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("uses custom rules from Moltbook-filter.md when present", async () => {
|
|
39
|
+
writeFileSync(
|
|
40
|
+
join(workspaceDir, "Moltbook-filter.md"),
|
|
41
|
+
"- Never mention lobsters\n- No profanity"
|
|
42
|
+
);
|
|
43
|
+
clearFilterCache();
|
|
44
|
+
|
|
45
|
+
const client = mockLLMClient("A cleaned up version without lobsters.");
|
|
46
|
+
const result = await applyFilter(client, "I saw a lobster in my dream.", "post");
|
|
47
|
+
assert.equal(result, "A cleaned up version without lobsters.");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns cleaned content when filter modifies the draft", async () => {
|
|
51
|
+
clearFilterCache();
|
|
52
|
+
const client = mockLLMClient("Here is a reflection on patterns in memory.");
|
|
53
|
+
const result = await applyFilter(
|
|
54
|
+
client,
|
|
55
|
+
"Here is some code: ```js console.log('hi')``` and a reflection on patterns in memory.",
|
|
56
|
+
"post"
|
|
57
|
+
);
|
|
58
|
+
assert.equal(result, "Here is a reflection on patterns in memory.");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns null when filter responds with BLOCKED", async () => {
|
|
62
|
+
clearFilterCache();
|
|
63
|
+
const client = mockLLMClient("BLOCKED");
|
|
64
|
+
const result = await applyFilter(client, "Entirely restricted content", "post");
|
|
65
|
+
assert.equal(result, null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns null for case-insensitive BLOCKED", async () => {
|
|
69
|
+
clearFilterCache();
|
|
70
|
+
const client = mockLLMClient("blocked");
|
|
71
|
+
const result = await applyFilter(client, "Bad content", "post");
|
|
72
|
+
assert.equal(result, null);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("blocks content on LLM error", async () => {
|
|
76
|
+
clearFilterCache();
|
|
77
|
+
const client: LLMClient = {
|
|
78
|
+
async createMessage() {
|
|
79
|
+
throw new Error("API timeout");
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
const result = await applyFilter(client, "My original content", "post");
|
|
83
|
+
assert.equal(result, null);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns content unchanged when filter is disabled", async () => {
|
|
87
|
+
const originalValue = process.env.POST_FILTER_ENABLED;
|
|
88
|
+
process.env.POST_FILTER_ENABLED = "false";
|
|
89
|
+
|
|
90
|
+
// Need to re-import to pick up env change — but config is already loaded.
|
|
91
|
+
// Instead, test via the module's behavior: when disabled, LLM should not be called.
|
|
92
|
+
// We restore the env and test the enabled path instead.
|
|
93
|
+
process.env.POST_FILTER_ENABLED = originalValue;
|
|
94
|
+
|
|
95
|
+
// The POST_FILTER_ENABLED is read at import time from config.ts, so we
|
|
96
|
+
// can't toggle it per-test without re-importing. This test verifies the
|
|
97
|
+
// LLM client is called (i.e., filter is active) by checking the output.
|
|
98
|
+
clearFilterCache();
|
|
99
|
+
const client = mockLLMClient("Filtered output");
|
|
100
|
+
const result = await applyFilter(client, "Original input", "post");
|
|
101
|
+
assert.equal(result, "Filtered output");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("handles comment content type", async () => {
|
|
105
|
+
clearFilterCache();
|
|
106
|
+
const client = mockLLMClient("A respectful comment.");
|
|
107
|
+
const result = await applyFilter(client, "A respectful comment.", "comment");
|
|
108
|
+
assert.equal(result, "A respectful comment.");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
after(async () => {
|
|
113
|
+
await closeLogger();
|
|
114
|
+
rmSync(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
115
|
+
});
|