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,104 @@
1
+ 'use strict';
2
+ const { describe, it } = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+
5
+ const ChannelBase = require('../channels/channel-base');
6
+ const IMessageChannel = require('../channels/imessage-channel');
7
+ const SlackChannel = require('../channels/slack-channel');
8
+
9
+ describe('ChannelBase', () => {
10
+ it('stores name and defaults running to false', () => {
11
+ const ch = new ChannelBase('test');
12
+ assert.equal(ch.name, 'test');
13
+ assert.equal(ch.running, false);
14
+ });
15
+
16
+ it('start() throws not-implemented error', async () => {
17
+ const ch = new ChannelBase('test');
18
+ await assert.rejects(() => ch.start(), /test\.start\(\) not implemented/);
19
+ });
20
+
21
+ it('send() throws not-implemented error', async () => {
22
+ const ch = new ChannelBase('test');
23
+ await assert.rejects(() => ch.send('target', 'msg'), /test\.send\(\) not implemented/);
24
+ });
25
+
26
+ it('stop() sets running to false', async () => {
27
+ const ch = new ChannelBase('test');
28
+ ch.running = true;
29
+ await ch.stop();
30
+ assert.equal(ch.running, false);
31
+ });
32
+ });
33
+
34
+ describe('IMessageChannel', () => {
35
+ it('constructor sets buddyId from opts', () => {
36
+ const ch = new IMessageChannel({ buddyId: '+15551234567' });
37
+ assert.equal(ch.buddyId, '+15551234567');
38
+ assert.equal(ch.name, 'imessage');
39
+ assert.equal(ch.running, false);
40
+ });
41
+
42
+ it('constructor defaults buddyId to null', () => {
43
+ const ch = new IMessageChannel();
44
+ assert.equal(ch.buddyId, null);
45
+ });
46
+
47
+ it('constructor accepts custom pollInterval', () => {
48
+ const ch = new IMessageChannel({ pollInterval: 5000 });
49
+ assert.equal(ch.pollInterval, 5000);
50
+ });
51
+
52
+ it('start() with no buddyId returns without error and does not set running', async () => {
53
+ const ch = new IMessageChannel();
54
+ await ch.start(); // should not throw
55
+ assert.equal(ch.running, false);
56
+ });
57
+
58
+ it('stop() clears poll timer', async () => {
59
+ const ch = new IMessageChannel({ buddyId: '+15551234567' });
60
+ ch.running = true;
61
+ ch._pollTimer = setTimeout(() => {}, 100000);
62
+ await ch.stop();
63
+ assert.equal(ch.running, false);
64
+ });
65
+ });
66
+
67
+ describe('SlackChannel', () => {
68
+ it('constructor sets botToken from opts', () => {
69
+ const ch = new SlackChannel({ botToken: 'xoxb-test-token' });
70
+ assert.equal(ch.botToken, 'xoxb-test-token');
71
+ assert.equal(ch.name, 'slack_dm');
72
+ assert.equal(ch.running, false);
73
+ });
74
+
75
+ it('constructor defaults botToken to null when env not set', () => {
76
+ const orig = process.env.SLACK_BOT_TOKEN;
77
+ delete process.env.SLACK_BOT_TOKEN;
78
+ const ch = new SlackChannel();
79
+ assert.equal(ch.botToken, null);
80
+ if (orig !== undefined) process.env.SLACK_BOT_TOKEN = orig;
81
+ });
82
+
83
+ it('constructor accepts custom pollInterval', () => {
84
+ const ch = new SlackChannel({ pollInterval: 30000 });
85
+ assert.equal(ch.pollInterval, 30000);
86
+ });
87
+
88
+ it('start() with no botToken returns without error and does not set running', async () => {
89
+ const orig = process.env.SLACK_BOT_TOKEN;
90
+ delete process.env.SLACK_BOT_TOKEN;
91
+ const ch = new SlackChannel();
92
+ await ch.start(); // should not throw
93
+ assert.equal(ch.running, false);
94
+ if (orig !== undefined) process.env.SLACK_BOT_TOKEN = orig;
95
+ });
96
+
97
+ it('stop() clears poll timer', async () => {
98
+ const ch = new SlackChannel({ botToken: 'xoxb-test' });
99
+ ch.running = true;
100
+ ch._pollTimer = setTimeout(() => {}, 100000);
101
+ await ch.stop();
102
+ assert.equal(ch.running, false);
103
+ });
104
+ });
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+ const { describe, it, before, after } = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+
8
+ const brain = require('../brain.js');
9
+
10
+ let testDir;
11
+
12
+ function setupTestDb() {
13
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wall-e-chat-test-'));
14
+ const testDbPath = path.join(testDir, 'test-brain.db');
15
+ brain.initDb(testDbPath);
16
+ brain.setOwner('name', 'TestUser');
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
+ describe('Chat Module', () => {
27
+ before(() => setupTestDb());
28
+ after(() => teardownTestDb());
29
+
30
+ it('returns a reply from mock client', async () => {
31
+ const chatModule = require('../chat.js');
32
+
33
+ // Inject a mock client
34
+ chatModule._setClient({
35
+ messages: {
36
+ async create() {
37
+ return {
38
+ content: [{ type: 'text', text: 'Hello from mock WALL-E!' }],
39
+ };
40
+ }
41
+ }
42
+ });
43
+
44
+ const result = await chatModule.chat('Hi there');
45
+ assert.ok(result.reply);
46
+ assert.equal(result.reply, 'Hello from mock WALL-E!');
47
+
48
+ // Clean up
49
+ chatModule._setClient(null);
50
+ });
51
+
52
+ it('stores inbound and outbound memories', async () => {
53
+ const chatModule = require('../chat.js');
54
+
55
+ chatModule._setClient({
56
+ messages: {
57
+ async create() {
58
+ return {
59
+ content: [{ type: 'text', text: 'Mock reply for memory test' }],
60
+ };
61
+ }
62
+ }
63
+ });
64
+
65
+ const beforeCount = brain.listMemories({ source: 'wall-e-chat' }).length;
66
+ await chatModule.chat('Remember this test message');
67
+ const afterMemories = brain.listMemories({ source: 'wall-e-chat' });
68
+
69
+ // Should have 2 new memories (inbound + outbound)
70
+ assert.equal(afterMemories.length - beforeCount, 2);
71
+
72
+ const inbound = afterMemories.find(m => m.memory_type === 'message_received' && m.content === 'Remember this test message');
73
+ const outbound = afterMemories.find(m => m.memory_type === 'message_sent' && m.content === 'Mock reply for memory test');
74
+ assert.ok(inbound, 'should have inbound memory');
75
+ assert.ok(outbound, 'should have outbound memory');
76
+ assert.equal(inbound.direction, 'inbound');
77
+ assert.equal(outbound.direction, 'outbound');
78
+
79
+ chatModule._setClient(null);
80
+ });
81
+
82
+ it('includes owner name in system prompt context', async () => {
83
+ const chatModule = require('../chat.js');
84
+ let capturedParams = null;
85
+
86
+ chatModule._setClient({
87
+ messages: {
88
+ async create(params) {
89
+ capturedParams = params;
90
+ return {
91
+ content: [{ type: 'text', text: 'ok' }],
92
+ };
93
+ }
94
+ }
95
+ });
96
+
97
+ await chatModule.chat('test');
98
+ assert.ok(capturedParams);
99
+ assert.ok(capturedParams.system.includes('TestUser'), 'system prompt should include owner name');
100
+
101
+ chatModule._setClient(null);
102
+ });
103
+ });
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+ const { describe, it, before, after, beforeEach } = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+
8
+ const brain = require('../brain.js');
9
+
10
+ let testDir;
11
+
12
+ function setupTestDb() {
13
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wall-e-conf-test-'));
14
+ const testDbPath = path.join(testDir, 'test-brain.db');
15
+ brain.initDb(testDbPath);
16
+ brain.setOwner('name', 'TestUser');
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
+ describe('Confidence Module', () => {
27
+ before(() => setupTestDb());
28
+ after(() => teardownTestDb());
29
+
30
+ it('getDomainConfidence auto-creates domain entry', () => {
31
+ const confidence = require('../decision/confidence.js');
32
+ const dc = confidence.getDomainConfidence('slack');
33
+ assert.ok(dc);
34
+ assert.equal(dc.domain, 'slack');
35
+ assert.equal(dc.current_tier, 1);
36
+ assert.equal(dc.total_actions, 0);
37
+ });
38
+
39
+ it('recordAction increments counts', () => {
40
+ const confidence = require('../decision/confidence.js');
41
+ confidence.recordAction('email', true);
42
+ const dc = confidence.getDomainConfidence('email');
43
+ assert.equal(dc.total_actions, 1);
44
+ assert.equal(dc.approved_actions, 1);
45
+ assert.equal(dc.rejected_actions, 0);
46
+
47
+ confidence.recordAction('email', false);
48
+ const dc2 = confidence.getDomainConfidence('email');
49
+ assert.equal(dc2.total_actions, 2);
50
+ assert.equal(dc2.approved_actions, 1);
51
+ assert.equal(dc2.rejected_actions, 1);
52
+ });
53
+
54
+ it('checkGraduation promotes from tier 1 to 2 when memories > 50', () => {
55
+ const confidence = require('../decision/confidence.js');
56
+ const domain = 'calendar';
57
+
58
+ // Insert 51 memories with source = 'calendar'
59
+ for (let i = 0; i < 51; i++) {
60
+ brain.insertMemory({
61
+ source: domain,
62
+ source_id: `cal-${i}`,
63
+ memory_type: 'event',
64
+ content: `Calendar event ${i}`,
65
+ timestamp: new Date().toISOString(),
66
+ });
67
+ }
68
+
69
+ const result = confidence.checkGraduation(domain);
70
+ assert.equal(result.promoted, true);
71
+ assert.equal(result.from, 1);
72
+ assert.equal(result.to, 2);
73
+
74
+ const dc = confidence.getDomainConfidence(domain);
75
+ assert.equal(dc.current_tier, 2);
76
+ });
77
+
78
+ it('canActAutonomously returns false for tier 2, true for tier 4', () => {
79
+ const confidence = require('../decision/confidence.js');
80
+
81
+ // 'slack' is at tier 1
82
+ assert.equal(confidence.canActAutonomously('slack', false), false);
83
+
84
+ // 'calendar' is at tier 2 after previous test
85
+ assert.equal(confidence.canActAutonomously('calendar', false), false);
86
+
87
+ // Manually set a domain to tier 3
88
+ brain.getDb().prepare("UPDATE domain_confidence SET current_tier = 3 WHERE domain = 'calendar'").run();
89
+ assert.equal(confidence.canActAutonomously('calendar', false), true);
90
+ assert.equal(confidence.canActAutonomously('calendar', true), false); // high-risk blocked at tier 3
91
+
92
+ // Manually set to tier 4
93
+ brain.getDb().prepare("UPDATE domain_confidence SET current_tier = 4 WHERE domain = 'calendar'").run();
94
+ assert.equal(confidence.canActAutonomously('calendar', true), true); // even high-risk OK at tier 4
95
+ });
96
+
97
+ it('demotion: 3 rejections in a row drops tier', () => {
98
+ const confidence = require('../decision/confidence.js');
99
+ const domain = 'deploy';
100
+
101
+ // Set up at tier 3
102
+ confidence.getDomainConfidence(domain);
103
+ brain.getDb().prepare("UPDATE domain_confidence SET current_tier = 3 WHERE domain = ?").run(domain);
104
+
105
+ // Insert 3 rejected actions
106
+ for (let i = 0; i < 3; i++) {
107
+ brain.insertAction({
108
+ action_type: 'deploy',
109
+ tier: 3,
110
+ domain,
111
+ description: `Deploy action ${i}`,
112
+ confidence: 0.8,
113
+ });
114
+ }
115
+ // Mark all 3 as rejected
116
+ const actions = brain.listActions({ domain });
117
+ for (const a of actions.slice(0, 3)) {
118
+ brain.updateActionStatus(a.id, 'rejected', 'bad deploy');
119
+ }
120
+
121
+ // Now record another rejection -- should trigger demotion
122
+ confidence.recordAction(domain, false);
123
+
124
+ const dc = confidence.getDomainConfidence(domain);
125
+ assert.equal(dc.current_tier, 2, 'should have been demoted from tier 3 to 2');
126
+ });
127
+
128
+ it('listDomainConfidences returns all domains', () => {
129
+ const confidence = require('../decision/confidence.js');
130
+ const list = confidence.listDomainConfidences();
131
+ assert.ok(Array.isArray(list));
132
+ assert.ok(list.length >= 3); // slack, email, calendar, deploy
133
+ });
134
+ });
@@ -0,0 +1,217 @@
1
+ const { describe, it, before, after } = 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
+ const { buildContradictionPrompt, parseContradictionResponse, detectContradictions } = require('../extraction/contradiction.js');
9
+
10
+ let testDir;
11
+ let testDbPath;
12
+
13
+ function setupTestDb() {
14
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wall-e-contradiction-test-'));
15
+ testDbPath = path.join(testDir, 'test-brain.db');
16
+ brain.initDb(testDbPath);
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
+ // ── buildContradictionPrompt ──────────────────────────────────────────────
27
+
28
+ describe('buildContradictionPrompt', () => {
29
+ it('includes existing knowledge entries in XML blocks', () => {
30
+ const existing = [
31
+ { id: 'k1', status: 'active', subject: 'TestUser', predicate: 'prefers_editor', object: 'VSCode', confidence: 0.9 },
32
+ ];
33
+ const newEntries = [
34
+ { subject: 'TestUser', predicate: 'prefers_editor', object: 'Cursor' },
35
+ ];
36
+ const prompt = buildContradictionPrompt(existing, newEntries, 'TestUser');
37
+
38
+ assert.ok(prompt.includes('<existing_knowledge id="k1"'));
39
+ assert.ok(prompt.includes('VSCode'));
40
+ assert.ok(prompt.includes('<new_entry index="0"'));
41
+ assert.ok(prompt.includes('Cursor'));
42
+ assert.ok(prompt.includes('TestUser'));
43
+ });
44
+
45
+ it('includes owner name', () => {
46
+ const prompt = buildContradictionPrompt([], [], 'TestOwner');
47
+ assert.ok(prompt.includes('TestOwner'));
48
+ });
49
+
50
+ it('handles multiple existing and new entries', () => {
51
+ const existing = [
52
+ { id: 'k1', status: 'active', subject: 'A', predicate: 'p', object: 'o1', confidence: 0.5 },
53
+ { id: 'k2', status: 'active', subject: 'A', predicate: 'q', object: 'o2', confidence: 0.7 },
54
+ ];
55
+ const newEntries = [
56
+ { subject: 'A', predicate: 'p', object: 'o3' },
57
+ { subject: 'A', predicate: 'q', object: 'o4' },
58
+ ];
59
+ const prompt = buildContradictionPrompt(existing, newEntries, 'Owner');
60
+ assert.ok(prompt.includes('id="k1"'));
61
+ assert.ok(prompt.includes('id="k2"'));
62
+ assert.ok(prompt.includes('index="0"'));
63
+ assert.ok(prompt.includes('index="1"'));
64
+ });
65
+
66
+ it('escapes backtick fences in content', () => {
67
+ const existing = [
68
+ { id: 'k1', status: 'active', subject: 'test```inject', predicate: 'p', object: 'o', confidence: 0.5 },
69
+ ];
70
+ const prompt = buildContradictionPrompt(existing, [], 'Owner');
71
+ assert.ok(!prompt.includes('test```inject'));
72
+ assert.ok(prompt.includes('test` ` `inject'));
73
+ });
74
+ });
75
+
76
+ // ── parseContradictionResponse ────────────────────────────────────────────
77
+
78
+ describe('parseContradictionResponse', () => {
79
+ it('parses valid JSON array', () => {
80
+ const input = JSON.stringify([
81
+ { old_id: 'k1', new_subject: 'A', new_predicate: 'p', new_object: 'o', explanation: 'contradicts' }
82
+ ]);
83
+ const result = parseContradictionResponse(input);
84
+ assert.equal(result.length, 1);
85
+ assert.equal(result[0].old_id, 'k1');
86
+ assert.equal(result[0].explanation, 'contradicts');
87
+ });
88
+
89
+ it('strips markdown fences', () => {
90
+ const input = '```json\n[{"old_id":"k1","new_subject":"A","new_predicate":"p","new_object":"o","explanation":"x"}]\n```';
91
+ const result = parseContradictionResponse(input);
92
+ assert.equal(result.length, 1);
93
+ assert.equal(result[0].old_id, 'k1');
94
+ });
95
+
96
+ it('returns [] for empty array', () => {
97
+ const result = parseContradictionResponse('[]');
98
+ assert.deepEqual(result, []);
99
+ });
100
+
101
+ it('returns [] for invalid JSON', () => {
102
+ const result = parseContradictionResponse('not json at all');
103
+ assert.deepEqual(result, []);
104
+ });
105
+
106
+ it('returns [] for non-array JSON', () => {
107
+ const result = parseContradictionResponse('{"key": "value"}');
108
+ assert.deepEqual(result, []);
109
+ });
110
+
111
+ it('filters out entries missing required fields', () => {
112
+ const input = JSON.stringify([
113
+ { old_id: 'k1', new_subject: 'A', new_predicate: 'p', new_object: 'o', explanation: 'ok' },
114
+ { old_id: 'k2', new_subject: 'B' }, // missing fields
115
+ ]);
116
+ const result = parseContradictionResponse(input);
117
+ assert.equal(result.length, 1);
118
+ assert.equal(result[0].old_id, 'k1');
119
+ });
120
+
121
+ it('returns [] for empty string', () => {
122
+ assert.deepEqual(parseContradictionResponse(''), []);
123
+ });
124
+ });
125
+
126
+ // ── detectContradictions ──────────────────────────────────────────────────
127
+
128
+ describe('detectContradictions', () => {
129
+ before(() => setupTestDb());
130
+ after(() => teardownTestDb());
131
+
132
+ it('returns empty array when no new entries', async () => {
133
+ const result = await detectContradictions([], 'Owner');
134
+ assert.deepEqual(result, []);
135
+ });
136
+
137
+ it('returns empty array when no existing knowledge matches', async () => {
138
+ const mockDetectFn = () => {
139
+ throw new Error('Should not be called');
140
+ };
141
+ const result = await detectContradictions(
142
+ [{ subject: 'NonexistentSubject', predicate: 'p', object: 'o' }],
143
+ 'Owner',
144
+ { detectFn: mockDetectFn }
145
+ );
146
+ assert.deepEqual(result, []);
147
+ });
148
+
149
+ it('detects contradictions using mockDetectFn', async () => {
150
+ // Insert existing knowledge
151
+ const existing = brain.insertKnowledge({
152
+ category: 'preference',
153
+ subject: 'TestUser',
154
+ predicate: 'prefers_language',
155
+ object: 'Python',
156
+ });
157
+
158
+ const mockDetectFn = (existingKnowledge, newEntries, ownerName) => {
159
+ assert.ok(existingKnowledge.length >= 1);
160
+ assert.equal(ownerName, 'TestUser');
161
+ return [{
162
+ old_id: existing.id,
163
+ new_subject: 'TestUser',
164
+ new_predicate: 'prefers_language',
165
+ new_object: 'Rust',
166
+ explanation: 'Changed preference from Python to Rust',
167
+ }];
168
+ };
169
+
170
+ const result = await detectContradictions(
171
+ [{ subject: 'TestUser', predicate: 'prefers_language', object: 'Rust' }],
172
+ 'TestUser',
173
+ { detectFn: mockDetectFn }
174
+ );
175
+
176
+ assert.equal(result.length, 1);
177
+ assert.equal(result[0].old_id, existing.id);
178
+ assert.equal(result[0].new_entry.subject, 'TestUser');
179
+ assert.equal(result[0].new_entry.object, 'Rust');
180
+ assert.equal(result[0].explanation, 'Changed preference from Python to Rust');
181
+ });
182
+
183
+ it('handles multiple subjects with mockDetectFn', async () => {
184
+ brain.insertKnowledge({ category: 'preference', subject: 'SubA', predicate: 'likes', object: 'X' });
185
+ brain.insertKnowledge({ category: 'preference', subject: 'SubB', predicate: 'likes', object: 'Y' });
186
+
187
+ const mockDetectFn = (existingKnowledge, newEntries, ownerName) => {
188
+ // Return contradiction for SubA only
189
+ if (existingKnowledge.some(k => k.subject === 'SubA')) {
190
+ return [{ old_id: existingKnowledge[0].id, new_subject: 'SubA', new_predicate: 'likes', new_object: 'Z', explanation: 'changed' }];
191
+ }
192
+ return [];
193
+ };
194
+
195
+ const result = await detectContradictions(
196
+ [
197
+ { subject: 'SubA', predicate: 'likes', object: 'Z' },
198
+ { subject: 'SubB', predicate: 'likes', object: 'Y' }, // same, no contradiction
199
+ ],
200
+ 'Owner',
201
+ { detectFn: mockDetectFn }
202
+ );
203
+
204
+ assert.equal(result.length, 1);
205
+ assert.equal(result[0].new_entry.subject, 'SubA');
206
+ });
207
+
208
+ it('skips entries with no subject', async () => {
209
+ const mockDetectFn = () => { throw new Error('Should not be called'); };
210
+ const result = await detectContradictions(
211
+ [{ predicate: 'p', object: 'o' }], // no subject
212
+ 'Owner',
213
+ { detectFn: mockDetectFn }
214
+ );
215
+ assert.deepEqual(result, []);
216
+ });
217
+ });
@@ -0,0 +1,113 @@
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 brain = require('../brain');
7
+ const CtmAdapter = require('../adapters/ctm');
8
+ const ingest = require('../loops/ingest');
9
+
10
+ describe('Ingest Loop', () => {
11
+ let tmpDir;
12
+ let dbPath;
13
+ let adapter;
14
+
15
+ const userMsg = {
16
+ type: 'user',
17
+ message: { role: 'user', content: 'Implement the login feature' },
18
+ cwd: '/home/user/project',
19
+ timestamp: '2026-03-22T10:00:00Z',
20
+ uuid: 'ingest-msg-001',
21
+ };
22
+
23
+ const assistantMsg = {
24
+ type: 'assistant',
25
+ message: {
26
+ role: 'assistant',
27
+ content: [{ type: 'text', text: 'Sure, creating login feature now.' }],
28
+ },
29
+ timestamp: '2026-03-22T10:01:00Z',
30
+ uuid: 'ingest-msg-002',
31
+ };
32
+
33
+ before(() => {
34
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ingest-test-'));
35
+
36
+ // Init brain DB in temp dir
37
+ dbPath = path.join(tmpDir, 'wall-e-brain.db');
38
+ brain.initDb(dbPath);
39
+
40
+ // Create test session files
41
+ const sessionsDir = path.join(tmpDir, 'sessions');
42
+ const projectDir = path.join(sessionsDir, '-home-user-project');
43
+ fs.mkdirSync(projectDir, { recursive: true });
44
+
45
+ const sessionFile = path.join(projectDir, 'session-001.jsonl');
46
+ const lines = [JSON.stringify(userMsg), JSON.stringify(assistantMsg)].join('\n');
47
+ fs.writeFileSync(sessionFile, lines);
48
+
49
+ adapter = new CtmAdapter({ projectsDir: sessionsDir });
50
+ });
51
+
52
+ after(() => {
53
+ brain.closeDb();
54
+ fs.rmSync(tmpDir, { recursive: true, force: true });
55
+ });
56
+
57
+ it('runOnce([adapter]) returns {memoriesIngested: N} where N >= 1', async () => {
58
+ const result = await ingest.runOnce([adapter]);
59
+ assert.ok(result.memoriesIngested >= 1, `expected at least 1, got ${result.memoriesIngested}`);
60
+ });
61
+
62
+ it('after ingest, brain.listMemories({source:"ctm"}) contains ingested memories', () => {
63
+ const memories = brain.listMemories({ source: 'ctm' });
64
+ assert.ok(memories.length >= 1, `expected at least 1 memory, got ${memories.length}`);
65
+ assert.ok(memories.every(m => m.source === 'ctm'));
66
+ });
67
+
68
+ it('brain.getCheckpoint("ingest") has been updated', () => {
69
+ const checkpoint = brain.getCheckpoint('ingest');
70
+ assert.ok(checkpoint, 'checkpoint should exist');
71
+ assert.ok(checkpoint.last_run_at, 'checkpoint should have last_run_at');
72
+ assert.ok(checkpoint.last_memory_at, 'checkpoint should have last_memory_at');
73
+ });
74
+
75
+ it('running runOnce again returns {memoriesIngested: 0} (dedup)', async () => {
76
+ const result = await ingest.runOnce([adapter]);
77
+ assert.strictEqual(result.memoriesIngested, 0, 'second run should ingest 0 due to dedup');
78
+ });
79
+
80
+ it('if adapter throws during poll(), ingest continues with other adapters', async () => {
81
+ // Create a mock adapter that throws
82
+ const failAdapter = {
83
+ name: 'fail-adapter',
84
+ async poll() { throw new Error('Simulated adapter failure'); },
85
+ };
86
+
87
+ // Create a working adapter with fresh session data
88
+ const freshSessionsDir = path.join(tmpDir, 'sessions-fresh');
89
+ const freshProjectDir = path.join(freshSessionsDir, '-home-user-fresh');
90
+ fs.mkdirSync(freshProjectDir, { recursive: true });
91
+
92
+ const freshMsg = {
93
+ type: 'user',
94
+ message: { role: 'user', content: 'Fresh message for error test' },
95
+ timestamp: '2026-03-22T11:00:00Z',
96
+ uuid: 'ingest-fresh-001',
97
+ };
98
+ fs.writeFileSync(
99
+ path.join(freshProjectDir, 'session-fresh.jsonl'),
100
+ JSON.stringify(freshMsg)
101
+ );
102
+
103
+ const freshAdapter = new CtmAdapter({ projectsDir: freshSessionsDir });
104
+
105
+ const result = await ingest.runOnce([failAdapter, freshAdapter]);
106
+ assert.ok(result.memoriesIngested >= 1, `expected at least 1 from working adapter, got ${result.memoriesIngested}`);
107
+ });
108
+
109
+ it('runOnce([]) with empty adapter list returns {memoriesIngested: 0}', async () => {
110
+ const result = await ingest.runOnce([]);
111
+ assert.strictEqual(result.memoriesIngested, 0);
112
+ });
113
+ });