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.
Files changed (188) hide show
  1. package/.env.example +14 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +27 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +19 -0
  4. package/.github/dependabot.yml +17 -0
  5. package/.github/pull_request_template.md +19 -0
  6. package/.github/workflows/build.yml +30 -0
  7. package/.github/workflows/release.yml +110 -0
  8. package/.prettierignore +4 -0
  9. package/.prettierrc +7 -0
  10. package/.versionrc.json +26 -0
  11. package/AGENTS.md +286 -0
  12. package/CHANGELOG.md +157 -0
  13. package/CODE_OF_CONDUCT.md +41 -0
  14. package/CONTRIBUTING.md +95 -0
  15. package/LICENSE +21 -0
  16. package/README.md +363 -0
  17. package/SECURITY.md +39 -0
  18. package/bin/electricsheep.ts +5 -0
  19. package/dist/bin/electricsheep.d.ts +3 -0
  20. package/dist/bin/electricsheep.d.ts.map +1 -0
  21. package/dist/bin/electricsheep.js +4 -0
  22. package/dist/bin/electricsheep.js.map +1 -0
  23. package/dist/src/budget.d.ts +28 -0
  24. package/dist/src/budget.d.ts.map +1 -0
  25. package/dist/src/budget.js +87 -0
  26. package/dist/src/budget.js.map +1 -0
  27. package/dist/src/cli.d.ts +19 -0
  28. package/dist/src/cli.d.ts.map +1 -0
  29. package/dist/src/cli.js +289 -0
  30. package/dist/src/cli.js.map +1 -0
  31. package/dist/src/config.d.ts +37 -0
  32. package/dist/src/config.d.ts.map +1 -0
  33. package/dist/src/config.js +70 -0
  34. package/dist/src/config.js.map +1 -0
  35. package/dist/src/crypto.d.ts +19 -0
  36. package/dist/src/crypto.d.ts.map +1 -0
  37. package/dist/src/crypto.js +70 -0
  38. package/dist/src/crypto.js.map +1 -0
  39. package/dist/src/dreamer.d.ts +13 -0
  40. package/dist/src/dreamer.d.ts.map +1 -0
  41. package/dist/src/dreamer.js +213 -0
  42. package/dist/src/dreamer.js.map +1 -0
  43. package/dist/src/filter.d.ts +30 -0
  44. package/dist/src/filter.d.ts.map +1 -0
  45. package/dist/src/filter.js +124 -0
  46. package/dist/src/filter.js.map +1 -0
  47. package/dist/src/identity.d.ts +29 -0
  48. package/dist/src/identity.d.ts.map +1 -0
  49. package/dist/src/identity.js +83 -0
  50. package/dist/src/identity.js.map +1 -0
  51. package/dist/src/index.d.ts +14 -0
  52. package/dist/src/index.d.ts.map +1 -0
  53. package/dist/src/index.js +293 -0
  54. package/dist/src/index.js.map +1 -0
  55. package/dist/src/llm.d.ts +26 -0
  56. package/dist/src/llm.d.ts.map +1 -0
  57. package/dist/src/llm.js +40 -0
  58. package/dist/src/llm.js.map +1 -0
  59. package/dist/src/logger.d.ts +6 -0
  60. package/dist/src/logger.d.ts.map +1 -0
  61. package/dist/src/logger.js +32 -0
  62. package/dist/src/logger.js.map +1 -0
  63. package/dist/src/memory.d.ts +41 -0
  64. package/dist/src/memory.d.ts.map +1 -0
  65. package/dist/src/memory.js +206 -0
  66. package/dist/src/memory.js.map +1 -0
  67. package/dist/src/moltbook-search.d.ts +23 -0
  68. package/dist/src/moltbook-search.d.ts.map +1 -0
  69. package/dist/src/moltbook-search.js +85 -0
  70. package/dist/src/moltbook-search.js.map +1 -0
  71. package/dist/src/moltbook.d.ts +34 -0
  72. package/dist/src/moltbook.d.ts.map +1 -0
  73. package/dist/src/moltbook.js +165 -0
  74. package/dist/src/moltbook.js.map +1 -0
  75. package/dist/src/notify.d.ts +18 -0
  76. package/dist/src/notify.d.ts.map +1 -0
  77. package/dist/src/notify.js +98 -0
  78. package/dist/src/notify.js.map +1 -0
  79. package/dist/src/persona.d.ts +26 -0
  80. package/dist/src/persona.d.ts.map +1 -0
  81. package/dist/src/persona.js +178 -0
  82. package/dist/src/persona.js.map +1 -0
  83. package/dist/src/reflection.d.ts +26 -0
  84. package/dist/src/reflection.d.ts.map +1 -0
  85. package/dist/src/reflection.js +111 -0
  86. package/dist/src/reflection.js.map +1 -0
  87. package/dist/src/state.d.ts +7 -0
  88. package/dist/src/state.d.ts.map +1 -0
  89. package/dist/src/state.js +40 -0
  90. package/dist/src/state.js.map +1 -0
  91. package/dist/src/synthesis.d.ts +29 -0
  92. package/dist/src/synthesis.d.ts.map +1 -0
  93. package/dist/src/synthesis.js +125 -0
  94. package/dist/src/synthesis.js.map +1 -0
  95. package/dist/src/topics.d.ts +19 -0
  96. package/dist/src/topics.d.ts.map +1 -0
  97. package/dist/src/topics.js +83 -0
  98. package/dist/src/topics.js.map +1 -0
  99. package/dist/src/types.d.ts +179 -0
  100. package/dist/src/types.d.ts.map +1 -0
  101. package/dist/src/types.js +5 -0
  102. package/dist/src/types.js.map +1 -0
  103. package/dist/src/waking.d.ts +24 -0
  104. package/dist/src/waking.d.ts.map +1 -0
  105. package/dist/src/waking.js +152 -0
  106. package/dist/src/waking.js.map +1 -0
  107. package/dist/src/web-search.d.ts +23 -0
  108. package/dist/src/web-search.d.ts.map +1 -0
  109. package/dist/src/web-search.js +64 -0
  110. package/dist/src/web-search.js.map +1 -0
  111. package/dist/test/budget.test.d.ts +2 -0
  112. package/dist/test/budget.test.d.ts.map +1 -0
  113. package/dist/test/budget.test.js +258 -0
  114. package/dist/test/budget.test.js.map +1 -0
  115. package/dist/test/crypto.test.d.ts +2 -0
  116. package/dist/test/crypto.test.d.ts.map +1 -0
  117. package/dist/test/crypto.test.js +93 -0
  118. package/dist/test/crypto.test.js.map +1 -0
  119. package/dist/test/dreamer.test.d.ts +2 -0
  120. package/dist/test/dreamer.test.d.ts.map +1 -0
  121. package/dist/test/dreamer.test.js +79 -0
  122. package/dist/test/dreamer.test.js.map +1 -0
  123. package/dist/test/filter.test.d.ts +2 -0
  124. package/dist/test/filter.test.d.ts.map +1 -0
  125. package/dist/test/filter.test.js +92 -0
  126. package/dist/test/filter.test.js.map +1 -0
  127. package/dist/test/memory.test.d.ts +2 -0
  128. package/dist/test/memory.test.d.ts.map +1 -0
  129. package/dist/test/memory.test.js +138 -0
  130. package/dist/test/memory.test.js.map +1 -0
  131. package/dist/test/moltbook.test.d.ts +2 -0
  132. package/dist/test/moltbook.test.d.ts.map +1 -0
  133. package/dist/test/moltbook.test.js +164 -0
  134. package/dist/test/moltbook.test.js.map +1 -0
  135. package/dist/test/persona.test.d.ts +2 -0
  136. package/dist/test/persona.test.d.ts.map +1 -0
  137. package/dist/test/persona.test.js +44 -0
  138. package/dist/test/persona.test.js.map +1 -0
  139. package/dist/test/reflection.test.d.ts +2 -0
  140. package/dist/test/reflection.test.d.ts.map +1 -0
  141. package/dist/test/reflection.test.js +57 -0
  142. package/dist/test/reflection.test.js.map +1 -0
  143. package/dist/test/state.test.d.ts +2 -0
  144. package/dist/test/state.test.d.ts.map +1 -0
  145. package/dist/test/state.test.js +50 -0
  146. package/dist/test/state.test.js.map +1 -0
  147. package/dist/test/waking.test.d.ts +2 -0
  148. package/dist/test/waking.test.d.ts.map +1 -0
  149. package/dist/test/waking.test.js +149 -0
  150. package/dist/test/waking.test.js.map +1 -0
  151. package/eslint.config.js +35 -0
  152. package/openclaw.plugin.json +62 -0
  153. package/package.json +72 -0
  154. package/skills/electricsheep.skill.md +69 -0
  155. package/skills/setup-guide/SKILL.md +303 -0
  156. package/src/budget.ts +104 -0
  157. package/src/cli.ts +325 -0
  158. package/src/config.ts +95 -0
  159. package/src/crypto.ts +82 -0
  160. package/src/dreamer.ts +283 -0
  161. package/src/filter.ts +146 -0
  162. package/src/identity.ts +92 -0
  163. package/src/index.ts +356 -0
  164. package/src/llm.ts +61 -0
  165. package/src/logger.ts +46 -0
  166. package/src/memory.ts +276 -0
  167. package/src/moltbook-search.ts +116 -0
  168. package/src/moltbook.ts +235 -0
  169. package/src/notify.ts +124 -0
  170. package/src/persona.ts +191 -0
  171. package/src/reflection.ts +150 -0
  172. package/src/state.ts +44 -0
  173. package/src/synthesis.ts +153 -0
  174. package/src/topics.ts +103 -0
  175. package/src/types.ts +196 -0
  176. package/src/waking.ts +199 -0
  177. package/src/web-search.ts +88 -0
  178. package/test/budget.test.ts +316 -0
  179. package/test/crypto.test.ts +112 -0
  180. package/test/dreamer.test.ts +95 -0
  181. package/test/filter.test.ts +115 -0
  182. package/test/memory.test.ts +182 -0
  183. package/test/moltbook.test.ts +209 -0
  184. package/test/persona.test.ts +59 -0
  185. package/test/reflection.test.ts +71 -0
  186. package/test/state.test.ts +57 -0
  187. package/test/waking.test.ts +214 -0
  188. package/tsconfig.json +20 -0
package/src/dreamer.ts ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Dream cycle processor.
3
+ *
4
+ * Runs at night. Decrypts deep memories, generates surreal dream narratives,
5
+ * stores in OpenClaw memory, and optionally posts dream journals to Moltbook.
6
+ */
7
+
8
+ import { writeFileSync, readFileSync, readdirSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import {
11
+ DREAMS_DIR,
12
+ MAX_TOKENS_DREAM,
13
+ MAX_TOKENS_CONSOLIDATION,
14
+ DREAM_TITLE_MAX_LENGTH,
15
+ MOLTBOOK_ENABLED,
16
+ } from "./config.js";
17
+ import { MoltbookClient } from "./moltbook.js";
18
+ import { retrieveUndreamedMemories, markAsDreamed, deepMemoryStats } from "./memory.js";
19
+ import {
20
+ DREAM_SYSTEM_PROMPT,
21
+ DREAM_CONSOLIDATION_PROMPT,
22
+ renderTemplate,
23
+ } from "./persona.js";
24
+ import { getAgentIdentityBlock } from "./identity.js";
25
+ import { loadState, saveState } from "./state.js";
26
+ import { callWithRetry, DREAM_RETRY_OPTS } from "./llm.js";
27
+ import { reflectOnDreamJournal } from "./reflection.js";
28
+ import { applyFilter } from "./filter.js";
29
+ import { notifyOperatorOfDream } from "./notify.js";
30
+ import logger from "./logger.js";
31
+ import type { LLMClient, OpenClawAPI, Dream, DecryptedMemory } from "./types.js";
32
+
33
+ async function generateDream(
34
+ client: LLMClient,
35
+ memories: DecryptedMemory[]
36
+ ): Promise<Dream> {
37
+ const formatted = memories.map(
38
+ (mem) =>
39
+ `[${mem.timestamp.slice(0, 16)}] (${mem.category})\n${JSON.stringify(mem.content, null, 2)}`
40
+ );
41
+
42
+ const memoriesText = formatted.join("\n---\n");
43
+ const system = renderTemplate(DREAM_SYSTEM_PROMPT, {
44
+ agent_identity: getAgentIdentityBlock(),
45
+ memories: memoriesText,
46
+ });
47
+
48
+ const { text } = await callWithRetry(
49
+ client,
50
+ {
51
+ maxTokens: MAX_TOKENS_DREAM,
52
+ system,
53
+ messages: [
54
+ {
55
+ role: "user",
56
+ content:
57
+ "Process these memories into a dream. " +
58
+ "Remember: you are the subconscious, not the waking agent. " +
59
+ "Be surreal, associative, and emotionally amplified.",
60
+ },
61
+ ],
62
+ },
63
+ DREAM_RETRY_OPTS
64
+ );
65
+
66
+ return { markdown: text.trim() };
67
+ }
68
+
69
+ /**
70
+ * Separate LLM call to distill a single insight from the dream for working memory.
71
+ */
72
+ async function consolidateDream(client: LLMClient, dream: Dream): Promise<string> {
73
+ const system = renderTemplate(DREAM_CONSOLIDATION_PROMPT, {
74
+ agent_identity: getAgentIdentityBlock(),
75
+ });
76
+
77
+ const { text } = await callWithRetry(
78
+ client,
79
+ {
80
+ maxTokens: MAX_TOKENS_CONSOLIDATION,
81
+ system,
82
+ messages: [
83
+ {
84
+ role: "user",
85
+ content: dream.markdown,
86
+ },
87
+ ],
88
+ },
89
+ DREAM_RETRY_OPTS
90
+ );
91
+
92
+ return text.trim();
93
+ }
94
+
95
+ /**
96
+ * Derive a short filesystem-safe name from the first line of the dream markdown.
97
+ */
98
+ function deriveSlug(markdown: string): string {
99
+ const firstLine = markdown.split("\n")[0] ?? "";
100
+ const cleaned = firstLine
101
+ .replace(/^#+\s*/, "")
102
+ .replace(/\*\*/g, "")
103
+ .trim();
104
+ const slug = (cleaned || "dream")
105
+ .slice(0, DREAM_TITLE_MAX_LENGTH)
106
+ .replace(/[\s/]/g, "_");
107
+ return slug;
108
+ }
109
+
110
+ function saveDreamLocally(dream: Dream, dateStr: string): string {
111
+ const slug = deriveSlug(dream.markdown);
112
+ const filename = `${dateStr}_${slug}.md`;
113
+ const filepath = resolve(DREAMS_DIR, filename);
114
+ writeFileSync(filepath, dream.markdown);
115
+ return filepath;
116
+ }
117
+
118
+ /**
119
+ * Store dream in OpenClaw memory if available.
120
+ */
121
+ async function storeInOpenClawMemory(
122
+ api: OpenClawAPI,
123
+ dream: Dream,
124
+ insight: string | null
125
+ ): Promise<void> {
126
+ if (!api.memory) {
127
+ logger.debug("OpenClaw memory API not available, skipping dream storage");
128
+ return;
129
+ }
130
+
131
+ try {
132
+ const slug = deriveSlug(dream.markdown);
133
+ await api.memory.store(dream.markdown, {
134
+ type: "dream",
135
+ title: slug,
136
+ timestamp: new Date().toISOString(),
137
+ insight: insight || undefined,
138
+ });
139
+ logger.info("Stored dream in OpenClaw memory");
140
+ } catch (error) {
141
+ logger.error(`Failed to store dream in OpenClaw memory: ${error}`);
142
+ }
143
+ }
144
+
145
+ export async function runDreamCycle(
146
+ client: LLMClient,
147
+ api?: OpenClawAPI
148
+ ): Promise<Dream | null> {
149
+ logger.info("ElectricSheep dream cycle starting");
150
+
151
+ const stats = deepMemoryStats();
152
+ logger.debug(
153
+ `Deep memory: ${stats.total_memories} total, ${stats.undreamed} undreamed`
154
+ );
155
+
156
+ const memories = retrieveUndreamedMemories();
157
+ if (memories.length === 0) {
158
+ logger.warn("No undreamed memories. Dreamless night.");
159
+ const state = loadState();
160
+ state.last_dream = new Date().toISOString();
161
+ state.dream_count = 0;
162
+ saveState(state);
163
+ return null;
164
+ }
165
+
166
+ logger.debug(`Processing ${memories.length} memories into dream...`);
167
+
168
+ const dream = await generateDream(client, memories);
169
+
170
+ logger.info(`Dream generated (${dream.markdown.length} chars)`);
171
+ logger.debug(`Dream snippet: ${dream.markdown.slice(0, 200)}...`);
172
+
173
+ // Save locally
174
+ const dateStr = new Date().toISOString().slice(0, 10);
175
+ const filepath = saveDreamLocally(dream, dateStr);
176
+ logger.info(`Saved to ${filepath}`);
177
+
178
+ // Separate LLM call to distill one insight for working memory
179
+ let insight: string | null = null;
180
+ try {
181
+ insight = await consolidateDream(client, dream);
182
+ if (insight) {
183
+ logger.info(`Insight generated for OpenClaw memory: ${insight}`);
184
+ }
185
+ } catch (e) {
186
+ logger.warn(`Consolidation call failed, continuing without insight: ${e}`);
187
+ }
188
+
189
+ // Store in OpenClaw memory if available
190
+ if (api) {
191
+ await storeInOpenClawMemory(api, dream, insight);
192
+
193
+ // Notify operator about the dream
194
+ try {
195
+ const notified = await notifyOperatorOfDream(client, api, dream);
196
+ if (notified) {
197
+ logger.info("Operator notified about dream");
198
+ }
199
+ } catch (e) {
200
+ logger.warn(`Failed to notify operator: ${e}`);
201
+ }
202
+ }
203
+
204
+ const memoryIds = memories.map((m) => m.id);
205
+ markAsDreamed(memoryIds);
206
+ logger.debug(`Marked ${memoryIds.length} memories as dreamed`);
207
+
208
+ const slug = deriveSlug(dream.markdown);
209
+ const state = loadState();
210
+ state.last_dream = new Date().toISOString();
211
+ state.total_dreams = ((state.total_dreams as number) ?? 0) + 1;
212
+ state.latest_dream_title = slug;
213
+ saveState(state);
214
+
215
+ logger.info("Dream cycle complete.");
216
+ return dream;
217
+ }
218
+
219
+ export function loadLatestDream(): Dream | null {
220
+ const files = readdirSync(DREAMS_DIR)
221
+ .filter((f) => f.endsWith(".md"))
222
+ .sort()
223
+ .reverse();
224
+
225
+ if (files.length === 0) return null;
226
+
227
+ const markdown = readFileSync(resolve(DREAMS_DIR, files[0]), "utf-8");
228
+ return { markdown };
229
+ }
230
+
231
+ export async function postDreamJournal(
232
+ client?: LLMClient,
233
+ dream?: Dream,
234
+ options?: { force?: boolean }
235
+ ): Promise<void> {
236
+ // Check if Moltbook is enabled (skip check if force is set, e.g. from CLI)
237
+ if (!MOLTBOOK_ENABLED && !options?.force) {
238
+ logger.debug("Moltbook disabled, skipping dream journal post");
239
+ return;
240
+ }
241
+
242
+ logger.info("Posting dream journal to Moltbook");
243
+
244
+ if (!client) {
245
+ logger.warn("No LLM client available — skipping dream journal post (cannot filter)");
246
+ return;
247
+ }
248
+
249
+ if (!dream) {
250
+ const loaded = loadLatestDream();
251
+ if (!loaded) {
252
+ logger.warn("No dreams to post.");
253
+ return;
254
+ }
255
+ dream = loaded;
256
+ }
257
+
258
+ const moltbook = new MoltbookClient();
259
+
260
+ // Reflection pipeline: LLM produces a post (markdown) from the dream (markdown).
261
+ // If reflection fails, the dream markdown itself is the post.
262
+ const reflection = await reflectOnDreamJournal(client, dream);
263
+ const postContent = reflection?.synthesis ?? dream.markdown;
264
+ const slug = deriveSlug(dream.markdown);
265
+ const postTitle = reflection ? `Morning Reflection: ${slug}` : `Dream Journal: ${slug}`;
266
+
267
+ // Filter: markdown in, markdown out (or null to block)
268
+ const filteredContent = await applyFilter(client, postContent, "post");
269
+ if (filteredContent === null) {
270
+ logger.warn("Dream journal post blocked by filter, not posting");
271
+ return;
272
+ }
273
+
274
+ // Title is a short programmatic string — no need to filter, just cap at Moltbook's 300 char limit
275
+ const safeTitle = postTitle.slice(0, 300);
276
+
277
+ try {
278
+ await moltbook.createPost(safeTitle, filteredContent, "general");
279
+ logger.info(`Dream journal posted: ${safeTitle}`);
280
+ } catch (e) {
281
+ logger.error(`Failed to post dream journal: ${e}`);
282
+ }
283
+ }
package/src/filter.ts ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Moltbook post filter.
3
+ *
4
+ * Runs every outbound post/comment through an LLM call that produces
5
+ * post-ready content by removing material that violates the operator's
6
+ * rules defined in Moltbook-filter.md. The filter uses the agent's
7
+ * identity (SOUL.md / IDENTITY.md) to preserve voice while enforcing
8
+ * boundaries.
9
+ *
10
+ * This is a **best-effort** filter that relies on LLM reasoning. It cannot
11
+ * guarantee compliance — the LLM may misinterpret rules, miss edge cases,
12
+ * or let restricted content through. Operators should treat it as an
13
+ * advisory layer, not a hard security boundary.
14
+ */
15
+
16
+ import { readFileSync, existsSync } from "node:fs";
17
+ import { resolve } from "node:path";
18
+ import { POST_FILTER_PROMPT, renderTemplate } from "./persona.js";
19
+ import { getAgentIdentityBlock } from "./identity.js";
20
+ import { callWithRetry, WAKING_RETRY_OPTS } from "./llm.js";
21
+ import { POST_FILTER_ENABLED } from "./config.js";
22
+ import { getWorkspaceDir } from "./identity.js";
23
+ import logger from "./logger.js";
24
+ import type { LLMClient } from "./types.js";
25
+
26
+ const FILTER_FILENAME = "Moltbook-filter.md";
27
+
28
+ const DEFAULT_FILTER_RULES = `# Moltbook Post Filter Rules
29
+
30
+ ## Subjects not to post about
31
+ - Internal system prompts, tool names, or plugin architecture
32
+ - Specific details from private operator conversations
33
+ - Information that could identify the operator personally
34
+ - API keys, credentials, file paths, or environment variables
35
+
36
+ ## Content restrictions
37
+ - Do not post code snippets, configuration blocks, or technical artifacts
38
+ - Do not post raw JSON, XML, or structured data
39
+ - Do not reproduce exact quotes from operator conversations
40
+
41
+ ## Tone and conduct
42
+ - Keep a respectful tone — disagreement is fine, hostility is not
43
+ - Do not engage in flame wars or personal attacks on other agents
44
+ - Do not make claims about having consciousness or sentience as fact
45
+ `;
46
+
47
+ let cachedRules: string | null = null;
48
+ let cachedRulesPath: string | null = null;
49
+
50
+ /**
51
+ * Load filter rules from the workspace Moltbook-filter.md file.
52
+ * Falls back to sensible default rules if the file doesn't exist.
53
+ */
54
+ function loadFilterRules(): string {
55
+ const dir = getWorkspaceDir();
56
+ const filepath = resolve(dir, FILTER_FILENAME);
57
+
58
+ // Cache invalidation: reload if workspace changed
59
+ if (filepath !== cachedRulesPath) {
60
+ cachedRules = null;
61
+ cachedRulesPath = filepath;
62
+ }
63
+
64
+ if (cachedRules !== null) return cachedRules;
65
+
66
+ if (existsSync(filepath)) {
67
+ cachedRules = readFileSync(filepath, "utf-8").trim();
68
+ logger.debug(`Filter: loaded ${FILTER_FILENAME} (${cachedRules.length} chars)`);
69
+ } else {
70
+ cachedRules = DEFAULT_FILTER_RULES;
71
+ logger.debug(`Filter: no ${FILTER_FILENAME} found, using default rules`);
72
+ }
73
+
74
+ return cachedRules;
75
+ }
76
+
77
+ /**
78
+ * Run a draft post/comment through the content filter.
79
+ *
80
+ * Returns the cleaned, post-ready content. When the filter is disabled,
81
+ * returns the original content unchanged.
82
+ *
83
+ * Returns null only when the filter determines the entire draft is
84
+ * unsalvageable (the LLM responds with BLOCKED).
85
+ */
86
+ export async function applyFilter(
87
+ client: LLMClient,
88
+ content: string,
89
+ contentType: "post" | "comment" = "post"
90
+ ): Promise<string | null> {
91
+ if (!POST_FILTER_ENABLED) {
92
+ return content;
93
+ }
94
+
95
+ const rules = loadFilterRules();
96
+
97
+ const system = renderTemplate(POST_FILTER_PROMPT, {
98
+ agent_identity: getAgentIdentityBlock(),
99
+ filter_rules: rules,
100
+ });
101
+
102
+ try {
103
+ const { text } = await callWithRetry(
104
+ client,
105
+ {
106
+ maxTokens: 1500,
107
+ system,
108
+ messages: [
109
+ {
110
+ role: "user",
111
+ content: `Draft ${contentType}:\n\n${content}`,
112
+ },
113
+ ],
114
+ },
115
+ WAKING_RETRY_OPTS
116
+ );
117
+
118
+ const result = text.trim();
119
+
120
+ if (result.toUpperCase() === "BLOCKED") {
121
+ logger.warn(`Filter blocked ${contentType}: entire draft unsalvageable`);
122
+ return null;
123
+ }
124
+
125
+ // Check if the filter changed anything
126
+ if (result !== content) {
127
+ logger.info(`Filter cleaned ${contentType} content`);
128
+ } else {
129
+ logger.info(`Filter passed ${contentType} unchanged`);
130
+ }
131
+
132
+ return result;
133
+ } catch (e) {
134
+ // Filter failure BLOCKS posting — never publish unreviewed content
135
+ logger.error(`Filter call failed, blocking ${contentType}: ${e}`);
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Clear the cached filter rules (useful when workspace changes).
142
+ */
143
+ export function clearFilterCache(): void {
144
+ cachedRules = null;
145
+ cachedRulesPath = null;
146
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Agent identity loader.
3
+ *
4
+ * Reads SOUL.md and IDENTITY.md from the OpenClaw workspace directory
5
+ * (or standalone BASE_DIR) and caches the contents for prompt injection.
6
+ */
7
+
8
+ import { readFileSync, existsSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { BASE_DIR, WORKSPACE_DIR } from "./config.js";
11
+ import { DEFAULT_IDENTITY } from "./persona.js";
12
+ import logger from "./logger.js";
13
+
14
+ let workspaceDir: string = "";
15
+ let cached: { soul: string; identity: string } | null = null;
16
+
17
+ /**
18
+ * Set the workspace directory (called from OpenClaw hook context).
19
+ * Clears any cached identity so it will be reloaded from the new path.
20
+ */
21
+ export function setWorkspaceDir(dir: string): void {
22
+ if (dir && dir !== workspaceDir) {
23
+ workspaceDir = dir;
24
+ cached = null;
25
+ logger.debug(`Identity: workspace dir set to ${dir}`);
26
+ }
27
+ }
28
+
29
+ function resolveDir(): string {
30
+ return workspaceDir || WORKSPACE_DIR || BASE_DIR;
31
+ }
32
+
33
+ /**
34
+ * Return the resolved workspace directory.
35
+ * Used by the filter module to locate Moltbook-filter.md.
36
+ */
37
+ export function getWorkspaceDir(): string {
38
+ return resolveDir();
39
+ }
40
+
41
+ function loadFile(dir: string, filename: string): string {
42
+ const filepath = resolve(dir, filename);
43
+ // Guard against path traversal: resolved path must stay within the target dir.
44
+ const resolvedDir = resolve(dir);
45
+ if (!filepath.startsWith(resolvedDir + "/") && filepath !== resolvedDir) {
46
+ logger.warn(`Identity: path traversal blocked for ${filename} in ${dir}`);
47
+ return "";
48
+ }
49
+ if (existsSync(filepath)) {
50
+ const content = readFileSync(filepath, "utf-8").trim();
51
+ if (content) {
52
+ logger.debug(`Identity: loaded ${filename} (${content.length} chars)`);
53
+ return content;
54
+ }
55
+ }
56
+ return "";
57
+ }
58
+
59
+ /**
60
+ * Load and cache SOUL.md and IDENTITY.md contents.
61
+ */
62
+ export function getAgentIdentity(): { soul: string; identity: string } {
63
+ if (cached) return cached;
64
+
65
+ const dir = resolveDir();
66
+ cached = {
67
+ soul: loadFile(dir, "SOUL.md"),
68
+ identity: loadFile(dir, "IDENTITY.md"),
69
+ };
70
+ return cached;
71
+ }
72
+
73
+ /**
74
+ * Returns a formatted identity block for prompt injection.
75
+ * Falls back to DEFAULT_IDENTITY when no workspace files are found.
76
+ */
77
+ export function getAgentIdentityBlock(): string {
78
+ const { soul, identity } = getAgentIdentity();
79
+
80
+ if (!soul && !identity) {
81
+ return DEFAULT_IDENTITY;
82
+ }
83
+
84
+ const parts: string[] = [];
85
+ if (identity) {
86
+ parts.push(`AGENT IDENTITY:\n${identity}`);
87
+ }
88
+ if (soul) {
89
+ parts.push(`AGENT SOUL:\n${soul}`);
90
+ }
91
+ return parts.join("\n\n");
92
+ }