create-walle 0.1.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 (136) hide show
  1. package/bin/create-walle.js +134 -0
  2. package/package.json +18 -0
  3. package/template/.env.example +40 -0
  4. package/template/CLAUDE.md +12 -0
  5. package/template/LICENSE +21 -0
  6. package/template/README.md +167 -0
  7. package/template/bin/setup.js +100 -0
  8. package/template/claude-code-skill.md +60 -0
  9. package/template/claude-task-manager/api-prompts.js +1841 -0
  10. package/template/claude-task-manager/api-reviews.js +275 -0
  11. package/template/claude-task-manager/approval-agent.js +454 -0
  12. package/template/claude-task-manager/bin/restart-ctm.sh +16 -0
  13. package/template/claude-task-manager/db.js +1721 -0
  14. package/template/claude-task-manager/docs/PROMPT-MANAGEMENT-DESIGN.md +631 -0
  15. package/template/claude-task-manager/git-utils.js +214 -0
  16. package/template/claude-task-manager/package-lock.json +1607 -0
  17. package/template/claude-task-manager/package.json +31 -0
  18. package/template/claude-task-manager/prompt-harvest.js +1148 -0
  19. package/template/claude-task-manager/public/css/prompts.css +880 -0
  20. package/template/claude-task-manager/public/css/reviews.css +430 -0
  21. package/template/claude-task-manager/public/css/walle.css +732 -0
  22. package/template/claude-task-manager/public/favicon.ico +0 -0
  23. package/template/claude-task-manager/public/icon.svg +37 -0
  24. package/template/claude-task-manager/public/index.html +8346 -0
  25. package/template/claude-task-manager/public/js/prompts.js +3159 -0
  26. package/template/claude-task-manager/public/js/reviews.js +1292 -0
  27. package/template/claude-task-manager/public/js/walle.js +3081 -0
  28. package/template/claude-task-manager/public/manifest.json +13 -0
  29. package/template/claude-task-manager/public/prompts.html +4353 -0
  30. package/template/claude-task-manager/public/setup.html +216 -0
  31. package/template/claude-task-manager/queue-engine.js +404 -0
  32. package/template/claude-task-manager/server-state.js +5 -0
  33. package/template/claude-task-manager/server.js +2254 -0
  34. package/template/claude-task-manager/session-utils.js +124 -0
  35. package/template/claude-task-manager/start.sh +17 -0
  36. package/template/claude-task-manager/tests/test-ai-search.js +61 -0
  37. package/template/claude-task-manager/tests/test-editor-ux.js +76 -0
  38. package/template/claude-task-manager/tests/test-editor-ux2.js +51 -0
  39. package/template/claude-task-manager/tests/test-features-v2.js +127 -0
  40. package/template/claude-task-manager/tests/test-insights-cached.js +78 -0
  41. package/template/claude-task-manager/tests/test-insights.js +124 -0
  42. package/template/claude-task-manager/tests/test-permissions-v2.js +127 -0
  43. package/template/claude-task-manager/tests/test-permissions.js +122 -0
  44. package/template/claude-task-manager/tests/test-pin.js +51 -0
  45. package/template/claude-task-manager/tests/test-prompts.js +164 -0
  46. package/template/claude-task-manager/tests/test-recent-sessions.js +96 -0
  47. package/template/claude-task-manager/tests/test-review.js +104 -0
  48. package/template/claude-task-manager/tests/test-send-dropdown.js +76 -0
  49. package/template/claude-task-manager/tests/test-send-final.js +30 -0
  50. package/template/claude-task-manager/tests/test-send-fixes.js +76 -0
  51. package/template/claude-task-manager/tests/test-send-integration.js +107 -0
  52. package/template/claude-task-manager/tests/test-send-visual.js +34 -0
  53. package/template/claude-task-manager/tests/test-session-create.js +147 -0
  54. package/template/claude-task-manager/tests/test-sidebar-ux.js +83 -0
  55. package/template/claude-task-manager/tests/test-url-hash.js +68 -0
  56. package/template/claude-task-manager/tests/test-ux-crop.js +34 -0
  57. package/template/claude-task-manager/tests/test-ux-review.js +130 -0
  58. package/template/claude-task-manager/tests/test-zoom-card.js +76 -0
  59. package/template/claude-task-manager/tests/test-zoom.js +92 -0
  60. package/template/claude-task-manager/tests/test-zoom2.js +67 -0
  61. package/template/docs/site/api/README.md +187 -0
  62. package/template/docs/site/guides/claude-code.md +58 -0
  63. package/template/docs/site/guides/configuration.md +96 -0
  64. package/template/docs/site/guides/quickstart.md +158 -0
  65. package/template/docs/site/index.md +14 -0
  66. package/template/docs/site/skills/README.md +135 -0
  67. package/template/wall-e/.dockerignore +11 -0
  68. package/template/wall-e/Dockerfile +25 -0
  69. package/template/wall-e/adapters/adapter-base.js +37 -0
  70. package/template/wall-e/adapters/ctm.js +193 -0
  71. package/template/wall-e/adapters/slack.js +56 -0
  72. package/template/wall-e/agent.js +319 -0
  73. package/template/wall-e/api-walle.js +1073 -0
  74. package/template/wall-e/brain.js +1235 -0
  75. package/template/wall-e/channels/agent-api.js +172 -0
  76. package/template/wall-e/channels/channel-base.js +14 -0
  77. package/template/wall-e/channels/imessage-channel.js +113 -0
  78. package/template/wall-e/channels/slack-channel.js +118 -0
  79. package/template/wall-e/chat.js +778 -0
  80. package/template/wall-e/decision/confidence.js +93 -0
  81. package/template/wall-e/deploy.sh +35 -0
  82. package/template/wall-e/docs/specs/2026-04-01-publish-plan.md +112 -0
  83. package/template/wall-e/docs/specs/SKILL-FORMAT.md +326 -0
  84. package/template/wall-e/extraction/contradiction.js +168 -0
  85. package/template/wall-e/extraction/knowledge-extractor.js +190 -0
  86. package/template/wall-e/fly.toml +24 -0
  87. package/template/wall-e/loops/ingest.js +34 -0
  88. package/template/wall-e/loops/reflect.js +63 -0
  89. package/template/wall-e/loops/tasks.js +487 -0
  90. package/template/wall-e/loops/think.js +125 -0
  91. package/template/wall-e/package-lock.json +533 -0
  92. package/template/wall-e/package.json +18 -0
  93. package/template/wall-e/scripts/ingest-slack-search.js +85 -0
  94. package/template/wall-e/scripts/pull-slack-via-claude.js +98 -0
  95. package/template/wall-e/scripts/slack-backfill.js +295 -0
  96. package/template/wall-e/scripts/slack-channel-history.js +454 -0
  97. package/template/wall-e/server.js +93 -0
  98. package/template/wall-e/skills/_bundled/email-digest/SKILL.md +95 -0
  99. package/template/wall-e/skills/_bundled/email-sync/SKILL.md +65 -0
  100. package/template/wall-e/skills/_bundled/email-sync/mail-reader.jxa +104 -0
  101. package/template/wall-e/skills/_bundled/email-sync/run.js +213 -0
  102. package/template/wall-e/skills/_bundled/google-calendar/SKILL.md +73 -0
  103. package/template/wall-e/skills/_bundled/google-calendar/cal-reader.swift +81 -0
  104. package/template/wall-e/skills/_bundled/google-calendar/run.js +181 -0
  105. package/template/wall-e/skills/_bundled/memory-search/SKILL.md +92 -0
  106. package/template/wall-e/skills/_bundled/morning-briefing/SKILL.md +131 -0
  107. package/template/wall-e/skills/_bundled/morning-briefing/run.js +264 -0
  108. package/template/wall-e/skills/_bundled/slack-backfill/SKILL.md +60 -0
  109. package/template/wall-e/skills/_bundled/slack-sync/SKILL.md +55 -0
  110. package/template/wall-e/skills/claude-code-reader.js +144 -0
  111. package/template/wall-e/skills/mcp-client.js +407 -0
  112. package/template/wall-e/skills/skill-executor.js +163 -0
  113. package/template/wall-e/skills/skill-loader.js +410 -0
  114. package/template/wall-e/skills/skill-planner.js +88 -0
  115. package/template/wall-e/skills/slack-ingest.js +329 -0
  116. package/template/wall-e/skills/slack-pull-live.js +270 -0
  117. package/template/wall-e/skills/tool-executor.js +188 -0
  118. package/template/wall-e/tests/adapter-base.test.js +20 -0
  119. package/template/wall-e/tests/adapter-ctm.test.js +122 -0
  120. package/template/wall-e/tests/adapter-slack.test.js +98 -0
  121. package/template/wall-e/tests/agent-api.test.js +256 -0
  122. package/template/wall-e/tests/api-walle.test.js +222 -0
  123. package/template/wall-e/tests/brain.test.js +602 -0
  124. package/template/wall-e/tests/channels.test.js +104 -0
  125. package/template/wall-e/tests/chat.test.js +103 -0
  126. package/template/wall-e/tests/confidence.test.js +134 -0
  127. package/template/wall-e/tests/contradiction.test.js +217 -0
  128. package/template/wall-e/tests/ingest.test.js +113 -0
  129. package/template/wall-e/tests/mcp-client.test.js +71 -0
  130. package/template/wall-e/tests/reflect.test.js +103 -0
  131. package/template/wall-e/tests/server.test.js +111 -0
  132. package/template/wall-e/tests/skills.test.js +198 -0
  133. package/template/wall-e/tests/slack-ingest.test.js +103 -0
  134. package/template/wall-e/tests/think.test.js +435 -0
  135. package/template/wall-e/tools/local-tools.js +697 -0
  136. package/template/wall-e/tools/slack-mcp.js +290 -0
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { execFile } = require('child_process');
5
+ const { promisify } = require('util');
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * Execute a tool call from Claude's tool_use response.
10
+ * Uses execFile (not exec) to prevent shell injection.
11
+ * @param {string} toolName - e.g., 'http_fetch', 'read_file', 'shell_exec'
12
+ * @param {object} input - tool parameters from Claude
13
+ * @returns {Promise<{success: boolean, result: any, error?: string}>}
14
+ */
15
+ async function executeTool(toolName, input) {
16
+ const executor = TOOL_EXECUTORS[toolName];
17
+ if (!executor) {
18
+ return { success: false, result: null, error: `Unknown tool: ${toolName}` };
19
+ }
20
+ try {
21
+ const result = await executor(input);
22
+ return { success: true, result };
23
+ } catch (err) {
24
+ return { success: false, result: null, error: err.message };
25
+ }
26
+ }
27
+
28
+ const TOOL_EXECUTORS = {
29
+ // HTTP fetch — make API requests
30
+ async http_fetch({ url, method, headers, body, timeout_ms }) {
31
+ if (!url) throw new Error('url is required');
32
+ // Validate URL to prevent SSRF — only allow http/https
33
+ const parsed = new URL(url);
34
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
35
+ throw new Error('Only http/https URLs allowed');
36
+ }
37
+
38
+ const opts = {
39
+ method: method || 'GET',
40
+ headers: headers || {},
41
+ signal: AbortSignal.timeout(timeout_ms || 15000),
42
+ };
43
+ if (body) opts.body = typeof body === 'string' ? body : JSON.stringify(body);
44
+
45
+ const res = await fetch(url, opts);
46
+ const text = await res.text();
47
+ // Truncate large responses
48
+ const truncated = text.length > 50000 ? text.slice(0, 50000) + '...[truncated]' : text;
49
+ return { status: res.status, body: truncated, ok: res.ok };
50
+ },
51
+
52
+ // Read local file
53
+ async read_file({ file_path, max_bytes }) {
54
+ if (!file_path) throw new Error('file_path is required');
55
+ // Security: resolve path, prevent reading outside home or data dirs
56
+ const resolved = path.resolve(file_path);
57
+ const home = process.env.HOME;
58
+ if (!resolved.startsWith(home)) {
59
+ throw new Error('Can only read files under home directory');
60
+ }
61
+
62
+ const stat = fs.statSync(resolved);
63
+ const limit = max_bytes || 500000; // 500KB default
64
+ if (stat.size > limit) {
65
+ // Read only the first chunk
66
+ const fd = fs.openSync(resolved, 'r');
67
+ const buf = Buffer.alloc(limit);
68
+ fs.readSync(fd, buf, 0, limit, 0);
69
+ fs.closeSync(fd);
70
+ return buf.toString('utf8') + '...[truncated]';
71
+ }
72
+ return fs.readFileSync(resolved, 'utf8');
73
+ },
74
+
75
+ // Shell command — restricted via allowlist, uses execFile (no shell injection)
76
+ async shell_exec({ command, args, timeout_ms }) {
77
+ if (!command) throw new Error('command is required');
78
+ // Allowlist of safe commands
79
+ const ALLOWED = ['ls', 'cat', 'head', 'tail', 'grep', 'find', 'wc', 'date', 'echo', 'curl', 'jq'];
80
+ const cmd = path.basename(command);
81
+ if (!ALLOWED.includes(cmd)) {
82
+ throw new Error(`Command not allowed: ${cmd}. Allowed: ${ALLOWED.join(', ')}`);
83
+ }
84
+ // execFile is used intentionally here (not exec) to prevent shell injection
85
+ const { stdout, stderr } = await execFileAsync(command, args || [], {
86
+ timeout: timeout_ms || 10000,
87
+ maxBuffer: 500000,
88
+ });
89
+ return { stdout: stdout.slice(0, 50000), stderr: stderr.slice(0, 5000) };
90
+ },
91
+
92
+ // MCP tool call — call a tool on a connected MCP server
93
+ async mcp_call({ server, tool, arguments: args }) {
94
+ if (!server) throw new Error('server is required');
95
+ if (!tool) throw new Error('tool is required');
96
+ const { callMcpTool } = require('./mcp-client');
97
+ const result = await callMcpTool(server, tool, args || {});
98
+ // Extract text content from MCP response
99
+ if (result && result.content) {
100
+ const texts = result.content
101
+ .filter(c => c.type === 'text')
102
+ .map(c => c.text);
103
+ const combined = texts.join('\n');
104
+ return combined.length > 50000 ? combined.slice(0, 50000) + '...[truncated]' : combined;
105
+ }
106
+ return result;
107
+ },
108
+
109
+ // List all available MCP tools across configured servers
110
+ async list_mcp_tools() {
111
+ const { listAllTools } = require('./mcp-client');
112
+ const tools = await listAllTools();
113
+ return tools.map(t => ({
114
+ server: t.server,
115
+ name: t.name,
116
+ description: (t.description || '').slice(0, 200),
117
+ }));
118
+ },
119
+ };
120
+
121
+ /**
122
+ * Get tool definitions to pass to Claude API.
123
+ * These tell Claude what tools are available.
124
+ */
125
+ function getToolDefinitions() {
126
+ return [
127
+ {
128
+ name: 'http_fetch',
129
+ description: 'Make an HTTP request to fetch data from an API or web service.',
130
+ input_schema: {
131
+ type: 'object',
132
+ properties: {
133
+ url: { type: 'string', description: 'The URL to fetch' },
134
+ method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'], default: 'GET' },
135
+ headers: { type: 'object', description: 'Request headers' },
136
+ body: { type: 'string', description: 'Request body (for POST/PUT)' },
137
+ timeout_ms: { type: 'number', description: 'Timeout in milliseconds', default: 15000 },
138
+ },
139
+ required: ['url'],
140
+ },
141
+ },
142
+ {
143
+ name: 'read_file',
144
+ description: 'Read a local file. Use for importing data exports (ChatGPT, etc.).',
145
+ input_schema: {
146
+ type: 'object',
147
+ properties: {
148
+ file_path: { type: 'string', description: 'Absolute path to the file' },
149
+ max_bytes: { type: 'number', description: 'Max bytes to read', default: 500000 },
150
+ },
151
+ required: ['file_path'],
152
+ },
153
+ },
154
+ {
155
+ name: 'shell_exec',
156
+ description: 'Run a shell command. Only safe commands allowed (ls, cat, grep, curl, etc.).',
157
+ input_schema: {
158
+ type: 'object',
159
+ properties: {
160
+ command: { type: 'string', description: 'Command to run (e.g., curl, grep)' },
161
+ args: { type: 'array', items: { type: 'string' }, description: 'Command arguments' },
162
+ timeout_ms: { type: 'number', default: 10000 },
163
+ },
164
+ required: ['command'],
165
+ },
166
+ },
167
+ {
168
+ name: 'mcp_call',
169
+ description: 'Call a tool on an MCP server. Available servers and tools can be discovered via list_mcp_tools.',
170
+ input_schema: {
171
+ type: 'object',
172
+ properties: {
173
+ server: { type: 'string', description: 'MCP server name (e.g., "slack", "sendgrid")' },
174
+ tool: { type: 'string', description: 'Tool name on the server' },
175
+ arguments: { type: 'object', description: 'Tool arguments' },
176
+ },
177
+ required: ['server', 'tool'],
178
+ },
179
+ },
180
+ {
181
+ name: 'list_mcp_tools',
182
+ description: 'List all available MCP tools across all configured servers.',
183
+ input_schema: { type: 'object', properties: {} },
184
+ },
185
+ ];
186
+ }
187
+
188
+ module.exports = { executeTool, getToolDefinitions, TOOL_EXECUTORS };
@@ -0,0 +1,20 @@
1
+ const { describe, it } = require('node:test');
2
+ const assert = require('node:assert');
3
+ const AdapterBase = require('../adapters/adapter-base');
4
+
5
+ describe('AdapterBase', () => {
6
+ it('should store name', () => {
7
+ const adapter = new AdapterBase('test');
8
+ assert.strictEqual(adapter.name, 'test');
9
+ });
10
+
11
+ it('poll() should throw not implemented', async () => {
12
+ const adapter = new AdapterBase('test');
13
+ await assert.rejects(() => adapter.poll(null), { message: 'test.poll() not implemented' });
14
+ });
15
+
16
+ it('normalize() should throw not implemented', () => {
17
+ const adapter = new AdapterBase('test');
18
+ assert.throws(() => adapter.normalize({}), { message: 'test.normalize() not implemented' });
19
+ });
20
+ });
@@ -0,0 +1,122 @@
1
+ const { describe, it, before, after } = require('node:test');
2
+ const assert = require('node:assert');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const CtmAdapter = require('../adapters/ctm');
7
+
8
+ describe('CtmAdapter', () => {
9
+ let tmpDir;
10
+ let projectDir;
11
+ let sessionFile;
12
+ let adapter;
13
+
14
+ const userMsg1 = {
15
+ type: 'user',
16
+ message: { role: 'user', content: 'Add a login page' },
17
+ cwd: '/home/user/project',
18
+ timestamp: '2026-03-22T10:00:00Z',
19
+ uuid: 'msg-001',
20
+ };
21
+
22
+ const assistantMsg = {
23
+ type: 'assistant',
24
+ message: {
25
+ role: 'assistant',
26
+ content: [
27
+ { type: 'text', text: 'Creating login page.' },
28
+ { type: 'tool_use', name: 'Write', input: { file_path: '/login.js' } },
29
+ ],
30
+ },
31
+ };
32
+
33
+ const userMsg2 = {
34
+ type: 'user',
35
+ message: {
36
+ role: 'user',
37
+ content: [{ type: 'text', text: 'Add validation too' }],
38
+ },
39
+ timestamp: '2026-03-22T10:05:00Z',
40
+ uuid: 'msg-002',
41
+ };
42
+
43
+ before(() => {
44
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctm-test-'));
45
+ projectDir = path.join(tmpDir, '-home-user-project');
46
+ fs.mkdirSync(projectDir, { recursive: true });
47
+
48
+ sessionFile = path.join(projectDir, 'session-001.jsonl');
49
+ const lines = [
50
+ JSON.stringify(userMsg1),
51
+ JSON.stringify(assistantMsg),
52
+ 'not json{',
53
+ JSON.stringify(userMsg2),
54
+ ].join('\n');
55
+ fs.writeFileSync(sessionFile, lines);
56
+
57
+ adapter = new CtmAdapter({ projectsDir: tmpDir });
58
+ });
59
+
60
+ after(() => {
61
+ fs.rmSync(tmpDir, { recursive: true, force: true });
62
+ });
63
+
64
+ it('normalize() on user message with string content', () => {
65
+ const raw = { ...userMsg1 };
66
+ const result = adapter.normalize(raw);
67
+ assert.strictEqual(result.source, 'ctm');
68
+ assert.strictEqual(result.direction, 'outbound');
69
+ assert.strictEqual(result.memory_type, 'message_sent');
70
+ assert.ok(result.content.toLowerCase().includes('login page'), 'content should mention login page');
71
+ });
72
+
73
+ it('normalize() on assistant message with array content and tool_uses', () => {
74
+ const raw = { ...assistantMsg };
75
+ const result = adapter.normalize(raw);
76
+ assert.strictEqual(result.direction, 'observed');
77
+ assert.strictEqual(result.memory_type, 'assistant_response');
78
+ assert.ok(result.content.includes('Write'), 'content should include tool summary');
79
+ assert.ok(result.metadata, 'metadata should be present');
80
+ const meta = JSON.parse(result.metadata);
81
+ assert.ok(Array.isArray(meta.tool_uses), 'metadata should have tool_uses array');
82
+ assert.strictEqual(meta.tool_uses[0].name, 'Write');
83
+ });
84
+
85
+ it('poll(null) returns normalized memories from session files', async () => {
86
+ const memories = await adapter.poll(null);
87
+ assert.ok(memories.length >= 2, `expected at least 2 memories, got ${memories.length}`);
88
+ const sources = memories.map(m => m.source);
89
+ assert.ok(sources.every(s => s === 'ctm'));
90
+ });
91
+
92
+ it('poll(lastTimestamp) on second call returns 0 results (file already processed)', async () => {
93
+ // adapter already processed the file in the previous poll(null) call
94
+ const memories = await adapter.poll(null);
95
+ assert.strictEqual(memories.length, 0, 'second poll should return 0 since file mtime unchanged');
96
+ });
97
+
98
+ it('malformed JSONL lines are skipped', () => {
99
+ // The session file contains "not json{" which should be skipped
100
+ const entries = adapter._parseSessionFile(sessionFile);
101
+ // We wrote 3 valid JSON lines and 1 malformed
102
+ assert.strictEqual(entries.length, 3, 'should have 3 valid entries, skipping malformed line');
103
+ });
104
+
105
+ it('non-existent projectsDir returns empty from poll()', async () => {
106
+ const badAdapter = new CtmAdapter({ projectsDir: '/tmp/nonexistent-ctm-dir-xyz' });
107
+ const result = await badAdapter.poll(null);
108
+ assert.deepStrictEqual(result, []);
109
+ });
110
+
111
+ it('session with only assistant messages still returns observed memories', async () => {
112
+ const assistantOnlyDir = path.join(tmpDir, '-tmp-assistant-only');
113
+ fs.mkdirSync(assistantOnlyDir, { recursive: true });
114
+ const assistantOnlyFile = path.join(assistantOnlyDir, 'session-assist.jsonl');
115
+ fs.writeFileSync(assistantOnlyFile, JSON.stringify(assistantMsg) + '\n');
116
+
117
+ const freshAdapter = new CtmAdapter({ projectsDir: tmpDir });
118
+ const memories = await freshAdapter.poll(null);
119
+ const observed = memories.filter(m => m.direction === 'observed');
120
+ assert.ok(observed.length >= 1, 'should have at least one observed memory from assistant-only session');
121
+ });
122
+ });
@@ -0,0 +1,98 @@
1
+ const { describe, it } = require('node:test');
2
+ const assert = require('node:assert');
3
+ const SlackAdapter = require('../adapters/slack');
4
+
5
+ describe('SlackAdapter', () => {
6
+ it('normalize() inbound message from non-owner', () => {
7
+ const adapter = new SlackAdapter({ ownerNames: ['Bot User'] });
8
+ const result = adapter.normalize({
9
+ sender: 'Alice Chen',
10
+ text: 'Hello team',
11
+ channel: '#engineering',
12
+ ts: '1234567890.123456',
13
+ permalink: 'https://slack.com/msg/1',
14
+ thread_ts: null,
15
+ timestamp: '2026-03-22T10:00:00.000Z',
16
+ });
17
+
18
+ assert.strictEqual(result.source, 'slack');
19
+ assert.strictEqual(result.direction, 'inbound');
20
+ assert.strictEqual(result.memory_type, 'message_received');
21
+ assert.strictEqual(result.source_channel, '#engineering');
22
+ });
23
+
24
+ it('normalize() outbound message when sender matches ownerNames', () => {
25
+ const adapter = new SlackAdapter({ ownerNames: ['Alice Chen'] });
26
+ const result = adapter.normalize({
27
+ sender: 'Alice Chen',
28
+ text: 'Hello team',
29
+ channel: '#engineering',
30
+ ts: '1234567890.123456',
31
+ permalink: 'https://slack.com/msg/1',
32
+ thread_ts: null,
33
+ timestamp: '2026-03-22T10:00:00.000Z',
34
+ });
35
+
36
+ assert.strictEqual(result.direction, 'outbound');
37
+ assert.strictEqual(result.memory_type, 'message_sent');
38
+ });
39
+
40
+ it('normalize() returns null on empty text', () => {
41
+ const adapter = new SlackAdapter({ ownerNames: [] });
42
+ const result = adapter.normalize({
43
+ sender: 'Alice Chen',
44
+ text: '',
45
+ channel: '#engineering',
46
+ ts: '123',
47
+ });
48
+
49
+ assert.strictEqual(result, null);
50
+ });
51
+
52
+ it('normalize() handles missing sender (participants is null)', () => {
53
+ const adapter = new SlackAdapter({ ownerNames: [] });
54
+ const result = adapter.normalize({
55
+ text: 'System notification',
56
+ channel: '#alerts',
57
+ ts: '999',
58
+ });
59
+
60
+ assert.strictEqual(result.participants, null);
61
+ assert.strictEqual(result.content, 'System notification');
62
+ });
63
+
64
+ it('normalize() generates source_id when ts is missing', () => {
65
+ const adapter = new SlackAdapter({ ownerNames: [] });
66
+ const result = adapter.normalize({
67
+ sender: 'Alice',
68
+ text: 'No timestamp id',
69
+ channel: '#general',
70
+ });
71
+
72
+ assert.ok(result.source_id, 'source_id should not be null');
73
+ assert.ok(result.source_id.startsWith('generated-'), 'source_id should be a generated fallback');
74
+ });
75
+
76
+ it('feed([]) then poll() returns empty array', async () => {
77
+ const adapter = new SlackAdapter({ ownerNames: [] });
78
+ adapter.feed([]);
79
+ const results = await adapter.poll(null);
80
+ assert.deepStrictEqual(results, []);
81
+ });
82
+
83
+ it('feed(messages) then poll(null) returns normalized memories, second poll returns empty', async () => {
84
+ const adapter = new SlackAdapter({ ownerNames: ['Me'] });
85
+ adapter.feed([
86
+ { sender: 'Alice', text: 'Hi', channel: '#general', ts: '1', timestamp: '2026-03-22T10:00:00.000Z' },
87
+ { sender: 'Me', text: 'Hello', channel: '#general', ts: '2', timestamp: '2026-03-22T10:01:00.000Z' },
88
+ ]);
89
+
90
+ const first = await adapter.poll(null);
91
+ assert.strictEqual(first.length, 2);
92
+ assert.strictEqual(first[0].direction, 'inbound');
93
+ assert.strictEqual(first[1].direction, 'outbound');
94
+
95
+ const second = await adapter.poll(null);
96
+ assert.deepStrictEqual(second, []);
97
+ });
98
+ });
@@ -0,0 +1,256 @@
1
+ const { describe, it, before, after, beforeEach } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+
7
+ const brain = require('../brain.js');
8
+
9
+ let testDir;
10
+ let testDbPath;
11
+
12
+ function setupTestDb() {
13
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wall-e-agent-test-'));
14
+ testDbPath = path.join(testDir, 'test-brain.db');
15
+ brain.initDb(testDbPath);
16
+ brain.setOwner('name', 'Test Owner');
17
+ }
18
+
19
+ function teardownTestDb() {
20
+ try { brain.closeDb(); } catch (_) {}
21
+ if (testDir && fs.existsSync(testDir)) {
22
+ fs.rmSync(testDir, { recursive: true, force: true });
23
+ }
24
+ }
25
+
26
+ // We need to clear require cache between tests to reset module state
27
+ function freshAgentApi() {
28
+ // Clear cached module so paired agents map is fresh
29
+ const modPath = require.resolve('../channels/agent-api');
30
+ delete require.cache[modPath];
31
+ const agentApi = require('../channels/agent-api');
32
+ agentApi._resetAgentId();
33
+ return agentApi;
34
+ }
35
+
36
+ describe('Agent API - Identity', () => {
37
+ before(() => setupTestDb());
38
+ after(() => teardownTestDb());
39
+
40
+ it('getAgentId() returns a string based on owner name', () => {
41
+ const agentApi = freshAgentApi();
42
+ const id = agentApi.getAgentId();
43
+ assert.equal(typeof id, 'string');
44
+ assert.ok(id.startsWith('wall-e-'));
45
+ assert.ok(id.includes('test-owner'), `Expected id to contain "test-owner", got "${id}"`);
46
+ });
47
+ });
48
+
49
+ describe('Agent API - Pairing', () => {
50
+ before(() => setupTestDb());
51
+ after(() => teardownTestDb());
52
+
53
+ it('pairAgent/listPairedAgents/unpairAgent work correctly', () => {
54
+ const agentApi = freshAgentApi();
55
+
56
+ // Initially empty
57
+ assert.deepEqual(agentApi.listPairedAgents(), []);
58
+
59
+ // Pair an agent
60
+ agentApi.pairAgent('wall-e-alice', 'http://localhost:4000', 'Alice');
61
+ const paired = agentApi.listPairedAgents();
62
+ assert.equal(paired.length, 1);
63
+ assert.equal(paired[0].id, 'wall-e-alice');
64
+ assert.equal(paired[0].endpoint, 'http://localhost:4000');
65
+ assert.equal(paired[0].owner, 'Alice');
66
+
67
+ // Pair another
68
+ agentApi.pairAgent('wall-e-bob', 'http://localhost:5000', 'Bob');
69
+ assert.equal(agentApi.listPairedAgents().length, 2);
70
+
71
+ // Unpair
72
+ agentApi.unpairAgent('wall-e-alice');
73
+ const remaining = agentApi.listPairedAgents();
74
+ assert.equal(remaining.length, 1);
75
+ assert.equal(remaining[0].id, 'wall-e-bob');
76
+
77
+ // Unpair last
78
+ agentApi.unpairAgent('wall-e-bob');
79
+ assert.deepEqual(agentApi.listPairedAgents(), []);
80
+ });
81
+ });
82
+
83
+ describe('Agent API - Inbound Exchange', () => {
84
+ before(() => setupTestDb());
85
+ after(() => teardownTestDb());
86
+
87
+ it('returns error for unknown agent', async () => {
88
+ const agentApi = freshAgentApi();
89
+ const result = await agentApi.handleInboundExchange({
90
+ from: { agent_id: 'wall-e-stranger', owner: 'Stranger' },
91
+ intent: 'query',
92
+ topic: 'test',
93
+ message: 'Hello?',
94
+ });
95
+ assert.ok(result.error);
96
+ assert.ok(result.error.includes('Not paired'));
97
+ });
98
+
99
+ it('returns error for missing from.agent_id', async () => {
100
+ const agentApi = freshAgentApi();
101
+ const result = await agentApi.handleInboundExchange({
102
+ from: {},
103
+ message: 'Hello?',
104
+ });
105
+ assert.ok(result.error);
106
+ assert.ok(result.error.includes('Missing from.agent_id'));
107
+ });
108
+
109
+ it('handles inbound exchange from paired agent with mock chat', async () => {
110
+ const agentApi = freshAgentApi();
111
+
112
+ // Insert a person matching the agent owner
113
+ const personResult = brain.insertPerson({
114
+ name: 'Alice',
115
+ relationship: 'colleague',
116
+ trust_level: 0.8,
117
+ });
118
+
119
+ // Insert some knowledge
120
+ brain.insertKnowledge({
121
+ category: 'work_projects',
122
+ subject: 'Test Owner',
123
+ predicate: 'works on',
124
+ object: 'Project Alpha',
125
+ confidence: 0.9,
126
+ });
127
+ brain.insertKnowledge({
128
+ category: 'personal_health',
129
+ subject: 'Test Owner',
130
+ predicate: 'has',
131
+ object: 'back pain',
132
+ confidence: 0.8,
133
+ });
134
+
135
+ // Pair the agent
136
+ agentApi.pairAgent('wall-e-alice', 'http://localhost:4000', 'Alice');
137
+
138
+ // Mock chat module to avoid calling real API
139
+ const chatMod = require('../chat');
140
+ const originalSetClient = chatMod._setClient;
141
+ chatMod._setClient({
142
+ messages: {
143
+ async create() {
144
+ return {
145
+ content: [{ type: 'text', text: 'I am working on Project Alpha currently.' }],
146
+ };
147
+ },
148
+ },
149
+ });
150
+
151
+ try {
152
+ const result = await agentApi.handleInboundExchange({
153
+ from: { agent_id: 'wall-e-alice', owner: 'Alice' },
154
+ intent: 'query',
155
+ topic: 'work',
156
+ message: 'What are you working on?',
157
+ });
158
+
159
+ // Should have a valid response
160
+ assert.ok(!result.error, 'Should not have error: ' + (result.error || ''));
161
+ assert.ok(result.from);
162
+ assert.equal(result.from.owner, 'Test Owner');
163
+ assert.equal(result.intent, 'respond');
164
+ assert.equal(result.topic, 'work');
165
+ assert.ok(typeof result.message === 'string');
166
+ assert.ok(result.message.length > 0);
167
+ assert.ok(Array.isArray(result.shared_knowledge));
168
+ assert.equal(typeof result.withheld, 'boolean');
169
+
170
+ // Check that the exchange was logged in the DB
171
+ const exchanges = brain.listExchanges({ limit: 10 });
172
+ assert.ok(exchanges.length > 0, 'Exchange should be logged in DB');
173
+ const logged = exchanges.find(e => e.remote_agent_id === 'wall-e-alice' && e.direction === 'inbound');
174
+ assert.ok(logged, 'Should find an inbound exchange from wall-e-alice');
175
+ assert.equal(logged.topic, 'work');
176
+ assert.ok(logged.message_in.includes('What are you working on?'));
177
+ } finally {
178
+ chatMod._setClient(null);
179
+ }
180
+ });
181
+ });
182
+
183
+ describe('Brain - Persona CRUD', () => {
184
+ before(() => setupTestDb());
185
+ after(() => teardownTestDb());
186
+
187
+ it('insertPersona and getPersonaForPerson work', () => {
188
+ const person = brain.insertPerson({ name: 'Bob', relationship: 'friend' });
189
+ const persona = brain.insertPersona({
190
+ person_id: person.id,
191
+ persona_type: 'friend',
192
+ sharing_rules: JSON.stringify({ shareable: ['all'], never_share: ['salary'] }),
193
+ tone: 'casual',
194
+ boundaries: JSON.stringify({ never_share: ['salary'] }),
195
+ });
196
+ assert.ok(persona.id);
197
+
198
+ const fetched = brain.getPersonaForPerson(person.id);
199
+ assert.ok(fetched);
200
+ assert.equal(fetched.person_id, person.id);
201
+ assert.equal(fetched.tone, 'casual');
202
+ assert.equal(fetched.persona_type, 'friend');
203
+ });
204
+
205
+ it('listPersonas returns personas with person info', () => {
206
+ const list = brain.listPersonas();
207
+ assert.ok(Array.isArray(list));
208
+ assert.ok(list.length > 0);
209
+ assert.ok(list[0].person_name);
210
+ });
211
+
212
+ it('updatePersona changes fields', () => {
213
+ const people = brain.listPeople({});
214
+ const bob = people.find(p => p.name === 'Bob');
215
+ assert.ok(bob);
216
+ const persona = brain.getPersonaForPerson(bob.id);
217
+ assert.ok(persona);
218
+
219
+ brain.updatePersona(persona.id, { tone: 'formal' });
220
+ const updated = brain.getPersonaForPerson(bob.id);
221
+ assert.equal(updated.tone, 'formal');
222
+ });
223
+
224
+ it('updatePersona rejects invalid fields', () => {
225
+ const people = brain.listPeople({});
226
+ const bob = people.find(p => p.name === 'Bob');
227
+ const persona = brain.getPersonaForPerson(bob.id);
228
+ assert.throws(() => {
229
+ brain.updatePersona(persona.id, { invalid_field: 'test' });
230
+ }, /Invalid update fields/);
231
+ });
232
+ });
233
+
234
+ describe('Brain - listExchanges', () => {
235
+ before(() => setupTestDb());
236
+ after(() => teardownTestDb());
237
+
238
+ it('returns empty array when no exchanges', () => {
239
+ const list = brain.listExchanges({});
240
+ assert.ok(Array.isArray(list));
241
+ assert.equal(list.length, 0);
242
+ });
243
+
244
+ it('returns exchanges after insert', () => {
245
+ const { v4: uuidv4 } = require('uuid');
246
+ brain.getDb().prepare(`
247
+ INSERT INTO agent_exchanges (id, direction, remote_agent_id, topic, message_in, message_out, created_at)
248
+ VALUES (?, 'outbound', 'wall-e-test', 'greeting', 'hi', 'hello', datetime('now'))
249
+ `).run(uuidv4());
250
+
251
+ const list = brain.listExchanges({ limit: 10 });
252
+ assert.equal(list.length, 1);
253
+ assert.equal(list[0].direction, 'outbound');
254
+ assert.equal(list[0].topic, 'greeting');
255
+ });
256
+ });