n2-soul 6.1.4 โ†’ 6.1.6

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.
@@ -0,0 +1,3 @@
1
+ # Soul MCP โ€” Funding configuration
2
+ # This enables the ๐Ÿ’– Sponsor button on the GitHub repository
3
+ github: [choihyunsus]
package/README.ko.md CHANGED
@@ -31,24 +31,90 @@ Cursor, VS Code Copilot ๋“ฑ MCP ํ˜ธํ™˜ AI ์—์ด์ „ํŠธ์™€ ์ƒˆ ์ฑ„ํŒ…์„ ์‹œ์ž‘
31
31
 
32
32
  ### 1. ์„ค์น˜
33
33
 
34
+ **๋ฐฉ๋ฒ• A: npm (๊ถŒ์žฅ)**
34
35
  ```bash
35
- git clone https://github.com/user/soul.git
36
+ npm install n2-soul
37
+ ```
38
+
39
+ **๋ฐฉ๋ฒ• B: ์†Œ์Šค์—์„œ ์„ค์น˜**
40
+ ```bash
41
+ git clone https://github.com/choihyunsus/soul.git
36
42
  cd soul
37
43
  npm install
38
44
  ```
39
45
 
40
46
  ### 2. MCP ์„ค์ •์— Soul ์ถ”๊ฐ€
41
47
 
48
+ Soul์€ ํ‘œ์ค€ MCP ์„œ๋ฒ„(stdio)์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ ์ค‘์ธ ํ˜ธ์ŠคํŠธ์˜ ์„ค์ •์— ์ถ”๊ฐ€ํ•˜์„ธ์š”:
49
+
50
+ <details>
51
+ <summary><strong>Cursor / VS Code Copilot / Claude Desktop</strong></summary>
52
+
53
+ `mcp.json`, `settings.json`, ๋˜๋Š” `claude_desktop_config.json`์— ์ถ”๊ฐ€:
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "soul": {
58
+ "command": "node",
59
+ "args": ["/path/to/node_modules/n2-soul/index.js"]
60
+ }
61
+ }
62
+ }
63
+ ```
64
+ </details>
65
+
66
+ <details>
67
+ <summary><strong>๐Ÿฆ™ Ollama + Open WebUI</strong></summary>
68
+
69
+ Open WebUI๋Š” MCP ๋„๊ตฌ๋ฅผ ๋„ค์ดํ‹ฐ๋ธŒ๋กœ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.
70
+
71
+ ```bash
72
+ # 1. Ollama ์‹คํ–‰ ํ™•์ธ
73
+ ollama serve
74
+
75
+ # 2. Soul ์„ค์น˜
76
+ npm install n2-soul
77
+
78
+ # 3. Soul ๊ฒฝ๋กœ ํ™•์ธ
79
+ # Windows:
80
+ echo %cd%\node_modules\n2-soul\index.js
81
+ # Mac/Linux:
82
+ echo $(pwd)/node_modules/n2-soul/index.js
83
+ ```
84
+
85
+ **Open WebUI**์—์„œ: **โš™๏ธ ์„ค์ • โ†’ Tools โ†’ MCP Servers** โ†’ ์ƒˆ ์„œ๋ฒ„ ์ถ”๊ฐ€:
86
+ ```
87
+ Name: soul
88
+ Command: node
89
+ Args: /your/path/to/node_modules/n2-soul/index.js
90
+ ```
91
+
92
+ ์ด์ œ Open WebUI์—์„œ ์ฑ„ํŒ…ํ•˜๋Š” ๋ชจ๋“  ๋ชจ๋ธ์ด Soul์˜ 20๊ฐœ ์ด์ƒ์˜ ๋ฉ”๋ชจ๋ฆฌ ๋„๊ตฌ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
93
+ </details>
94
+
95
+ <details>
96
+ <summary><strong>๐Ÿ–ฅ๏ธ LM Studio</strong></summary>
97
+
98
+ LM Studio๋Š” MCP๋ฅผ ๋„ค์ดํ‹ฐ๋ธŒ๋กœ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. `~/.lmstudio/mcp.json`์— ์ถ”๊ฐ€:
42
99
  ```json
43
100
  {
44
101
  "mcpServers": {
45
102
  "soul": {
46
103
  "command": "node",
47
- "args": ["/path/to/soul/index.js"]
104
+ "args": ["/path/to/node_modules/n2-soul/index.js"]
48
105
  }
49
106
  }
50
107
  }
51
108
  ```
109
+ </details>
110
+
111
+ <details>
112
+ <summary><strong>๐Ÿ”ง ๊ธฐํƒ€ MCP ํ˜ธํ™˜ ํ˜ธ์ŠคํŠธ</strong></summary>
113
+
114
+ Soul์€ **stdio** ์œ„์˜ ํ‘œ์ค€ MCP ํ”„๋กœํ† ์ฝœ์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. MCP๋ฅผ ์ง€์›ํ•˜๋Š” ๋„๊ตฌ๋ผ๋ฉด Soul์ด ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค. command๋ฅผ `node`๋กœ, args๋ฅผ `n2-soul/index.js` ๊ฒฝ๋กœ๋กœ ์ง€์ •ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.
115
+ </details>
116
+
117
+ > **๐Ÿ’ก ํŒ:** npm์œผ๋กœ ์„ค์น˜ํ•œ ๊ฒฝ์šฐ ๊ฒฝ๋กœ๋Š” `node_modules/n2-soul/index.js`์ž…๋‹ˆ๋‹ค. ์†Œ์Šค์—์„œ ์„ค์น˜ํ•œ ๊ฒฝ์šฐ ํด๋ก ํ•œ ๋””๋ ‰ํ† ๋ฆฌ์˜ ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”.
52
118
 
53
119
  ### 3. ์—์ด์ „ํŠธ์—๊ฒŒ Soul ์‚ฌ์šฉ๋ฒ• ์•Œ๋ ค์ฃผ๊ธฐ
54
120
 
package/README.md CHANGED
@@ -66,16 +66,74 @@ npm install
66
66
 
67
67
  ### 2. Add Soul to your MCP config
68
68
 
69
+ Soul is a standard MCP server (stdio). Add it to your host's config:
70
+
71
+ <details>
72
+ <summary><strong>Cursor / VS Code Copilot / Claude Desktop</strong></summary>
73
+
74
+ Add to `mcp.json`, `settings.json`, or `claude_desktop_config.json`:
69
75
  ```json
70
76
  {
71
77
  "mcpServers": {
72
78
  "soul": {
73
79
  "command": "node",
74
- "args": ["/path/to/soul/index.js"]
80
+ "args": ["/path/to/node_modules/n2-soul/index.js"]
75
81
  }
76
82
  }
77
83
  }
78
84
  ```
85
+ </details>
86
+
87
+ <details>
88
+ <summary><strong>๐Ÿฆ™ Ollama + Open WebUI</strong></summary>
89
+
90
+ Open WebUI supports MCP tools natively.
91
+
92
+ ```bash
93
+ # 1. Make sure Ollama is running
94
+ ollama serve
95
+
96
+ # 2. Install Soul
97
+ npm install n2-soul
98
+
99
+ # 3. Find your Soul path
100
+ # Windows:
101
+ echo %cd%\node_modules\n2-soul\index.js
102
+ # Mac/Linux:
103
+ echo $(pwd)/node_modules/n2-soul/index.js
104
+ ```
105
+
106
+ In **Open WebUI**: Go to **โš™๏ธ Settings โ†’ Tools โ†’ MCP Servers** โ†’ Add new server:
107
+ ```
108
+ Name: soul
109
+ Command: node
110
+ Args: /your/path/to/node_modules/n2-soul/index.js
111
+ ```
112
+
113
+ Now any model you chat with in Open WebUI can use Soul's 20+ memory tools.
114
+ </details>
115
+
116
+ <details>
117
+ <summary><strong>๐Ÿ–ฅ๏ธ LM Studio</strong></summary>
118
+
119
+ LM Studio supports MCP natively. Add to `~/.lmstudio/mcp.json`:
120
+ ```json
121
+ {
122
+ "mcpServers": {
123
+ "soul": {
124
+ "command": "node",
125
+ "args": ["/path/to/node_modules/n2-soul/index.js"]
126
+ }
127
+ }
128
+ }
129
+ ```
130
+ </details>
131
+
132
+ <details>
133
+ <summary><strong>๐Ÿ”ง Any other MCP-compatible host</strong></summary>
134
+
135
+ Soul speaks standard MCP protocol over **stdio**. If your tool supports MCP, Soul works. Just point the command to `node` and the args to `n2-soul/index.js`.
136
+ </details>
79
137
 
80
138
  > **๐Ÿ’ก Tip:** If you installed via npm, the path is `node_modules/n2-soul/index.js`. If from source, use the absolute path to your cloned directory.
81
139
 
package/lib/utils.js CHANGED
@@ -1,120 +1,120 @@
1
- // Soul MCP v6.0 โ€” Shared utility functions (file I/O, time, security, logging)
2
- const fs = require('fs');
3
- const path = require('path');
4
-
5
- // Timezone: prioritizes env > config.TIMEZONE > fallback 'Asia/Seoul'
6
- let _tz = null;
7
- function _getTimezone() {
8
- if (!_tz) {
9
- try {
10
- const config = require('./config');
11
- _tz = process.env.N2_TIMEZONE || config.TIMEZONE || 'Asia/Seoul';
12
- } catch (e) {
13
- _tz = process.env.N2_TIMEZONE || 'Asia/Seoul';
14
- }
15
- }
16
- return _tz;
17
- }
18
-
19
- // -- Logging --
20
-
21
- function logError(context, err) {
22
- const msg = err instanceof Error ? err.message : String(err);
23
- console.error(`[soul:${context}]`, msg);
24
- }
25
-
26
- // -- File I/O --
27
-
28
- function readFile(filePath) {
29
- try {
30
- return fs.readFileSync(filePath, 'utf8');
31
- } catch (e) {
32
- logError('readFile', e);
33
- return null;
34
- }
35
- }
36
-
37
- function readJson(filePath) {
38
- const content = readFile(filePath);
39
- if (!content) return null;
40
- try {
41
- return JSON.parse(content);
42
- } catch (e) {
43
- logError('readJson', e);
44
- return null;
45
- }
46
- }
47
-
48
- function writeJson(filePath, data) {
49
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
50
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
51
- }
52
-
53
- function writeFile(filePath, content) {
54
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
55
- fs.writeFileSync(filePath, content, 'utf8');
56
- }
57
-
58
- // -- Time --
59
-
60
- function today() {
61
- return new Date().toLocaleDateString('sv-SE', { timeZone: _getTimezone() });
62
- }
63
-
64
- function nowISO() {
65
- const formatter = new Intl.DateTimeFormat('sv-SE', {
66
- timeZone: _getTimezone(),
67
- year: 'numeric', month: '2-digit', day: '2-digit',
68
- hour: '2-digit', minute: '2-digit', second: '2-digit',
69
- hour12: false,
70
- });
71
- const parts = formatter.formatToParts(new Date());
72
- const get = (type) => parts.find(p => p.type === type)?.value || '00';
73
- // Compute UTC offset dynamically for the configured timezone
74
- const now = new Date();
75
- const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' });
76
- const tzStr = now.toLocaleString('en-US', { timeZone: _getTimezone() });
77
- const diffMs = new Date(tzStr) - new Date(utcStr);
78
- const diffH = Math.floor(Math.abs(diffMs) / 3600000);
79
- const diffM = Math.floor((Math.abs(diffMs) % 3600000) / 60000);
80
- const sign = diffMs >= 0 ? '+' : '-';
81
- const offset = `${sign}${String(diffH).padStart(2, '0')}:${String(diffM).padStart(2, '0')}`;
82
- return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}${offset}`;
83
- }
84
-
85
- // -- Security --
86
-
87
- function safePath(filePath, baseDir) {
88
- const resolved = path.resolve(baseDir, filePath);
89
- const normalizedBase = path.resolve(baseDir);
90
- if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) {
91
- logError('safePath', `Path traversal blocked: ${filePath}`);
92
- return null;
93
- }
94
- return resolved;
95
- }
96
-
97
- // -- First-line comment validation --
98
-
99
- function validateFirstLineComment(filePath) {
100
- try {
101
- const content = fs.readFileSync(filePath, 'utf8');
102
- const firstLine = content.split('\n')[0].trim();
103
- const patterns = [
104
- /^\/\/\s*.+/, // JS/TS
105
- /^#\s*.+/, // Python/Shell/YAML
106
- /^<!--\s*.+/, // HTML/MD
107
- /^\/\*\s*.+/, // CSS/Java
108
- /^\{.*"_desc"/, // JSON with _desc field
109
- ];
110
- return patterns.some(p => p.test(firstLine));
111
- } catch (e) {
112
- logError('validateFirstLineComment', e);
113
- return false;
114
- }
115
- }
116
-
117
- module.exports = {
118
- logError, readFile, readJson, writeJson, writeFile,
119
- today, nowISO, safePath, validateFirstLineComment,
120
- };
1
+ // Soul MCP v6.0 โ€” Shared utility functions (file I/O, time, security, logging)
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ // Timezone: prioritizes env > config.TIMEZONE > fallback 'Asia/Seoul'
6
+ let _tz = null;
7
+ function _getTimezone() {
8
+ if (!_tz) {
9
+ try {
10
+ const config = require('./config');
11
+ _tz = process.env.N2_TIMEZONE || config.TIMEZONE || 'Asia/Seoul';
12
+ } catch (e) {
13
+ _tz = process.env.N2_TIMEZONE || 'Asia/Seoul';
14
+ }
15
+ }
16
+ return _tz;
17
+ }
18
+
19
+ // -- Logging --
20
+
21
+ function logError(context, err) {
22
+ const msg = err instanceof Error ? err.message : String(err);
23
+ console.error(`[soul:${context}]`, msg);
24
+ }
25
+
26
+ // -- File I/O --
27
+
28
+ function readFile(filePath) {
29
+ try {
30
+ return fs.readFileSync(filePath, 'utf8');
31
+ } catch (e) {
32
+ logError('readFile', e);
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function readJson(filePath) {
38
+ const content = readFile(filePath);
39
+ if (!content) return null;
40
+ try {
41
+ return JSON.parse(content);
42
+ } catch (e) {
43
+ logError('readJson', e);
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function writeJson(filePath, data) {
49
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
50
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
51
+ }
52
+
53
+ function writeFile(filePath, content) {
54
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
55
+ fs.writeFileSync(filePath, content, 'utf8');
56
+ }
57
+
58
+ // -- Time --
59
+
60
+ function today() {
61
+ return new Date().toLocaleDateString('sv-SE', { timeZone: _getTimezone() });
62
+ }
63
+
64
+ function nowISO() {
65
+ const formatter = new Intl.DateTimeFormat('sv-SE', {
66
+ timeZone: _getTimezone(),
67
+ year: 'numeric', month: '2-digit', day: '2-digit',
68
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
69
+ hour12: false,
70
+ });
71
+ const parts = formatter.formatToParts(new Date());
72
+ const get = (type) => parts.find(p => p.type === type)?.value || '00';
73
+ // Compute UTC offset dynamically for the configured timezone
74
+ const now = new Date();
75
+ const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' });
76
+ const tzStr = now.toLocaleString('en-US', { timeZone: _getTimezone() });
77
+ const diffMs = new Date(tzStr) - new Date(utcStr);
78
+ const diffH = Math.floor(Math.abs(diffMs) / 3600000);
79
+ const diffM = Math.floor((Math.abs(diffMs) % 3600000) / 60000);
80
+ const sign = diffMs >= 0 ? '+' : '-';
81
+ const offset = `${sign}${String(diffH).padStart(2, '0')}:${String(diffM).padStart(2, '0')}`;
82
+ return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}${offset}`;
83
+ }
84
+
85
+ // -- Security --
86
+
87
+ function safePath(filePath, baseDir) {
88
+ const resolved = path.resolve(baseDir, filePath);
89
+ const normalizedBase = path.resolve(baseDir);
90
+ if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) {
91
+ logError('safePath', `Path traversal blocked: ${filePath}`);
92
+ return null;
93
+ }
94
+ return resolved;
95
+ }
96
+
97
+ // -- First-line comment validation --
98
+
99
+ function validateFirstLineComment(filePath) {
100
+ try {
101
+ const content = fs.readFileSync(filePath, 'utf8');
102
+ const firstLine = content.split('\n')[0].trim();
103
+ const patterns = [
104
+ /^\/\/\s*.+/, // JS/TS
105
+ /^#\s*.+/, // Python/Shell/YAML
106
+ /^<!--\s*.+/, // HTML/MD
107
+ /^\/\*\s*.+/, // CSS/Java
108
+ /^\{.*"_desc"/, // JSON with _desc field
109
+ ];
110
+ return patterns.some(p => p.test(firstLine));
111
+ } catch (e) {
112
+ logError('validateFirstLineComment', e);
113
+ return false;
114
+ }
115
+ }
116
+
117
+ module.exports = {
118
+ logError, readFile, readJson, writeJson, writeFile,
119
+ today, nowISO, safePath, validateFirstLineComment,
120
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n2-soul",
3
- "version": "6.1.4",
3
+ "version": "6.1.6",
4
4
  "description": "Multi-agent session orchestrator with KV-Cache and Ark for MCP (Model Context Protocol)",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/sequences/work.js CHANGED
@@ -1,271 +1,271 @@
1
- // Soul MCP v6.0 โ€” Work sequence. Real-time change tracking, file ownership, context search.
2
- const fs = require('fs');
3
- const path = require('path');
4
- const { nowISO, readJson, readFile, validateFirstLineComment, logError } = require('../lib/utils');
5
- const { SoulEngine } = require('../lib/soul-engine');
6
-
7
- // In-memory work session state per project
8
- const activeSessions = {};
9
-
10
- // TTL: auto-expire stale sessions (checked every hour)
11
- const SESSION_TTL_MS = (require('../lib/config').WORK?.sessionTtlHours ?? 24) * 60 * 60 * 1000;
12
- const _sessionGcTimer = setInterval(() => {
13
- const now = Date.now();
14
- for (const [project, session] of Object.entries(activeSessions)) {
15
- if (session._createdMs && (now - session._createdMs) > SESSION_TTL_MS) {
16
- delete activeSessions[project];
17
- logError('work:ttl', `Expired stale session: ${project} (agent: ${session.agent})`);
18
- }
19
- }
20
- }, 60 * 60 * 1000);
21
- _sessionGcTimer.unref(); // Don't prevent Node.js from exiting
22
-
23
- // โ”€โ”€ Helper: recursively walk files in a directory โ”€โ”€
24
- function walkFiles(dir, callback, maxDepth, depth = 0) {
25
- if (depth > maxDepth) return;
26
- try {
27
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
28
- const fullPath = path.join(dir, entry.name);
29
- if (entry.isDirectory()) {
30
- walkFiles(fullPath, callback, maxDepth, depth + 1);
31
- } else {
32
- callback(fullPath);
33
- }
34
- }
35
- } catch (e) {
36
- logError("walkFiles", `${dir}: ${e.message}`);
37
- }
38
- }
39
-
40
- function registerWorkSequence(server, z, config) {
41
- const engine = new SoulEngine(config.DATA_DIR);
42
-
43
- // Start a work sequence
44
- server.registerTool(
45
- 'n2_work_start',
46
- {
47
- title: 'N2 Work Start',
48
- description: 'Start a work sequence. Registers agent in activeWork on soul-board.',
49
- inputSchema: {
50
- agent: z.string().describe('Agent name'),
51
- project: z.string().describe('Project name'),
52
- task: z.string().describe('Task description'),
53
- },
54
- },
55
- async ({ agent, project, task }) => {
56
- engine.setActiveWork(project, agent, task, []);
57
- activeSessions[project] = {
58
- agent,
59
- task,
60
- startedAt: nowISO(),
61
- _createdMs: Date.now(),
62
- filesCreated: [],
63
- filesModified: [],
64
- filesDeleted: [],
65
- decisions: [],
66
- };
67
- return { content: [{ type: 'text', text: `Work started: ${agent} on ${project} โ€” ${task}` }] };
68
- }
69
- );
70
-
71
- // Claim file ownership before editing
72
- server.registerTool(
73
- 'n2_work_claim',
74
- {
75
- title: 'N2 Work Claim',
76
- description: 'Claim file ownership before modifying. Prevents collision with other agents.',
77
- inputSchema: {
78
- project: z.string().describe('Project name'),
79
- agent: z.string().describe('Agent name'),
80
- filePath: z.string().describe('File path relative to project root'),
81
- intent: z.string().describe('Why you are modifying this file'),
82
- },
83
- },
84
- async ({ project, agent, filePath, intent }) => {
85
- const result = engine.claimFile(project, filePath, agent, intent);
86
- if (!result.ok) {
87
- return { content: [{ type: 'text', text: `COLLISION: ${filePath} is owned by ${result.owner} (${result.intent}). Choose a different file.` }] };
88
- }
89
- return { content: [{ type: 'text', text: `Claimed: ${filePath} -> ${agent} (${intent})` }] };
90
- }
91
- );
92
-
93
- // Log file changes during work
94
- server.registerTool(
95
- 'n2_work_log',
96
- {
97
- title: 'N2 Work Log',
98
- description: 'Log file changes during work. Reports created/modified/deleted files with descriptions.',
99
- inputSchema: {
100
- project: z.string().describe('Project name'),
101
- filesCreated: z.array(z.object({
102
- path: z.string(),
103
- desc: z.string(),
104
- })).optional().describe('Files created'),
105
- filesModified: z.array(z.object({
106
- path: z.string(),
107
- desc: z.string(),
108
- })).optional().describe('Files modified'),
109
- filesDeleted: z.array(z.object({
110
- path: z.string(),
111
- desc: z.string(),
112
- })).optional().describe('Files deleted'),
113
- decisions: z.array(z.string()).optional().describe('Decisions made'),
114
- },
115
- },
116
- async ({ project, filesCreated, filesModified, filesDeleted, decisions }) => {
117
- const session = activeSessions[project];
118
- if (!session) {
119
- return { content: [{ type: 'text', text: 'ERROR: No active work session. Call n2_work_start first.' }] };
120
- }
121
-
122
- if (filesCreated) session.filesCreated.push(...filesCreated);
123
- if (filesModified) session.filesModified.push(...filesModified);
124
- if (filesDeleted) session.filesDeleted.push(...filesDeleted);
125
- if (decisions) session.decisions.push(...decisions);
126
-
127
- // Validate first-line comments on created files
128
- const warnings = [];
129
- for (const f of (filesCreated || [])) {
130
- try {
131
- const fullPath = path.resolve(f.path);
132
- if (!validateFirstLineComment(fullPath)) {
133
- warnings.push(`MISSING first-line comment: ${f.path}`);
134
- }
135
- } catch (e) { logError("work:validate", e); }
136
- }
137
-
138
- const total = (session.filesCreated.length + session.filesModified.length + session.filesDeleted.length);
139
- let msg = `Logged: ${total} file changes, ${session.decisions.length} decisions.`;
140
- if (warnings.length > 0) {
141
- msg += `\nWARNINGS:\n ${warnings.join('\n ')}`;
142
- }
143
- if ((filesCreated && filesCreated.length > 0) || (filesDeleted && filesDeleted.length > 0)) {
144
- msg += `\nFile tree will auto-update at n2_work_end. Use n2_project_scan for immediate refresh.`;
145
- }
146
- msg += `\nTODO RULE: All TODO files go in _data/ ONLY. Always mark completed items as [x]. Never use brain memory for TODOs.`;
147
- return { content: [{ type: 'text', text: msg }] };
148
- }
149
- );
150
-
151
- // โ”€โ”€ n2_context_search: Search across Brain memory and Ledger entries โ”€โ”€
152
- server.registerTool(
153
- 'n2_context_search',
154
- {
155
- title: 'N2 Context Search',
156
- description: 'Search across Brain memory and Ledger entries for relevant past context. Uses keyword matching with recency weighting. Great for finding related past work or decisions.',
157
- inputSchema: {
158
- query: z.string().describe('Search query (keywords, space-separated)'),
159
- sources: z.array(z.string()).optional().describe('Sources to search: "brain", "ledger". Default: all.'),
160
- maxResults: z.number().optional().describe('Max results (default: 10)'),
161
- },
162
- },
163
- async ({ query, sources, maxResults }) => {
164
- try {
165
- const dataDir = config.DATA_DIR;
166
- const searchCfg = config.SEARCH || {};
167
- const minKwLen = searchCfg.minKeywordLength || 2;
168
- const previewLen = searchCfg.previewLength || 200;
169
- const recencyBonus = searchCfg.recencyBonus || 10;
170
- const maxDepth = searchCfg.maxDepth || 6;
171
- const keywords = query.toLowerCase().split(/\s+/).filter(k => k.length >= minKwLen);
172
- const max = maxResults || searchCfg.defaultMaxResults || 10;
173
- const searchSources = sources || ['brain', 'ledger'];
174
- const results = [];
175
-
176
- function scoreText(text, filePath, source, meta = {}) {
177
- if (!text) return;
178
- const lower = text.toLowerCase();
179
- let score = 0;
180
- const matchedKeywords = [];
181
- for (const kw of keywords) {
182
- const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
183
- const count = (lower.match(new RegExp(escaped, 'g')) || []).length;
184
- if (count > 0) { score += count; matchedKeywords.push(kw); }
185
- }
186
- if (score > 0) {
187
- if (meta.timestamp) {
188
- const age = (Date.now() - new Date(meta.timestamp).getTime()) / (1000 * 60 * 60 * 24);
189
- score += Math.max(0, recencyBonus - age);
190
- }
191
- results.push({
192
- source, path: filePath,
193
- score: Math.round(score * 100) / 100,
194
- matchedKeywords,
195
- preview: text.slice(0, previewLen).replace(/\n/g, ' '),
196
- ...meta,
197
- });
198
- }
199
- }
200
-
201
- // Search Brain memory
202
- if (searchSources.includes('brain')) {
203
- const memoryDir = path.join(dataDir, 'memory');
204
- if (fs.existsSync(memoryDir)) {
205
- walkFiles(memoryDir, (fp) => {
206
- const content = readFile(fp);
207
- if (content) {
208
- const relPath = path.relative(memoryDir, fp);
209
- scoreText(content, `memory/${relPath}`, 'brain', {
210
- timestamp: fs.statSync(fp).mtime.toISOString(),
211
- });
212
- }
213
- }, maxDepth);
214
- }
215
- }
216
-
217
- // Search Ledger entries
218
- if (searchSources.includes('ledger')) {
219
- const projectsDir = path.join(dataDir, 'projects');
220
- if (fs.existsSync(projectsDir)) {
221
- for (const proj of fs.readdirSync(projectsDir)) {
222
- const ledgerBase = path.join(projectsDir, proj, 'ledger');
223
- if (!fs.existsSync(ledgerBase)) continue;
224
- walkFiles(ledgerBase, (fp) => {
225
- if (!fp.endsWith('.json')) return;
226
- const data = readJson(fp);
227
- if (!data) return;
228
- const text = [data.title, data.summary, ...(data.decisions || [])].filter(Boolean).join(' ');
229
- const relPath = path.relative(projectsDir, fp);
230
- scoreText(text, `projects/${relPath}`, 'ledger', {
231
- timestamp: data.completedAt || data.startedAt,
232
- agent: data.agent,
233
- title: data.title,
234
- });
235
- }, maxDepth);
236
- }
237
- }
238
- }
239
-
240
- results.sort((a, b) => b.score - a.score);
241
- const top = results.slice(0, max);
242
-
243
- if (top.length === 0) {
244
- return { content: [{ type: 'text', text: `๐Ÿ” No results for "${query}".` }] };
245
- }
246
-
247
- const lines = top.map((r, i) => {
248
- const icon = r.source === 'brain' ? '๐Ÿง ' : '๐Ÿ“–';
249
- const meta = [
250
- r.title ? `"${r.title}"` : '',
251
- r.agent ? `by ${r.agent}` : '',
252
- `score: ${r.score}`,
253
- ].filter(Boolean).join(' | ');
254
- return `${i + 1}. ${icon} ${r.path}\n ${meta}\n Keywords: [${r.matchedKeywords.join(', ')}]\n ${r.preview}`;
255
- });
256
-
257
- return {
258
- content: [{
259
- type: 'text', text:
260
- `๐Ÿ” Context search: "${query}" (${top.length} results)\n\n${lines.join('\n\n')}`
261
- }],
262
- };
263
- } catch (err) {
264
- return { content: [{ type: 'text', text: `โŒ Search error: ${err.message}` }] };
265
- }
266
- }
267
- );
268
- }
269
-
270
- // Export activeSessions for end.js to access
271
- module.exports = { registerWorkSequence, activeSessions };
1
+ // Soul MCP v6.0 โ€” Work sequence. Real-time change tracking, file ownership, context search.
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { nowISO, readJson, readFile, validateFirstLineComment, logError } = require('../lib/utils');
5
+ const { SoulEngine } = require('../lib/soul-engine');
6
+
7
+ // In-memory work session state per project
8
+ const activeSessions = {};
9
+
10
+ // TTL: auto-expire stale sessions (checked every hour)
11
+ const SESSION_TTL_MS = (require('../lib/config').WORK?.sessionTtlHours ?? 24) * 60 * 60 * 1000;
12
+ const _sessionGcTimer = setInterval(() => {
13
+ const now = Date.now();
14
+ for (const [project, session] of Object.entries(activeSessions)) {
15
+ if (session._createdMs && (now - session._createdMs) > SESSION_TTL_MS) {
16
+ delete activeSessions[project];
17
+ logError('work:ttl', `Expired stale session: ${project} (agent: ${session.agent})`);
18
+ }
19
+ }
20
+ }, 60 * 60 * 1000);
21
+ _sessionGcTimer.unref(); // Don't prevent Node.js from exiting
22
+
23
+ // โ”€โ”€ Helper: recursively walk files in a directory โ”€โ”€
24
+ function walkFiles(dir, callback, maxDepth, depth = 0) {
25
+ if (depth > maxDepth) return;
26
+ try {
27
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
28
+ const fullPath = path.join(dir, entry.name);
29
+ if (entry.isDirectory()) {
30
+ walkFiles(fullPath, callback, maxDepth, depth + 1);
31
+ } else {
32
+ callback(fullPath);
33
+ }
34
+ }
35
+ } catch (e) {
36
+ logError("walkFiles", `${dir}: ${e.message}`);
37
+ }
38
+ }
39
+
40
+ function registerWorkSequence(server, z, config) {
41
+ const engine = new SoulEngine(config.DATA_DIR);
42
+
43
+ // Start a work sequence
44
+ server.registerTool(
45
+ 'n2_work_start',
46
+ {
47
+ title: 'N2 Work Start',
48
+ description: 'Start a work sequence. Registers agent in activeWork on soul-board.',
49
+ inputSchema: {
50
+ agent: z.string().describe('Agent name'),
51
+ project: z.string().describe('Project name'),
52
+ task: z.string().describe('Task description'),
53
+ },
54
+ },
55
+ async ({ agent, project, task }) => {
56
+ engine.setActiveWork(project, agent, task, []);
57
+ activeSessions[project] = {
58
+ agent,
59
+ task,
60
+ startedAt: nowISO(),
61
+ _createdMs: Date.now(),
62
+ filesCreated: [],
63
+ filesModified: [],
64
+ filesDeleted: [],
65
+ decisions: [],
66
+ };
67
+ return { content: [{ type: 'text', text: `Work started: ${agent} on ${project} โ€” ${task}` }] };
68
+ }
69
+ );
70
+
71
+ // Claim file ownership before editing
72
+ server.registerTool(
73
+ 'n2_work_claim',
74
+ {
75
+ title: 'N2 Work Claim',
76
+ description: 'Claim file ownership before modifying. Prevents collision with other agents.',
77
+ inputSchema: {
78
+ project: z.string().describe('Project name'),
79
+ agent: z.string().describe('Agent name'),
80
+ filePath: z.string().describe('File path relative to project root'),
81
+ intent: z.string().describe('Why you are modifying this file'),
82
+ },
83
+ },
84
+ async ({ project, agent, filePath, intent }) => {
85
+ const result = engine.claimFile(project, filePath, agent, intent);
86
+ if (!result.ok) {
87
+ return { content: [{ type: 'text', text: `COLLISION: ${filePath} is owned by ${result.owner} (${result.intent}). Choose a different file.` }] };
88
+ }
89
+ return { content: [{ type: 'text', text: `Claimed: ${filePath} -> ${agent} (${intent})` }] };
90
+ }
91
+ );
92
+
93
+ // Log file changes during work
94
+ server.registerTool(
95
+ 'n2_work_log',
96
+ {
97
+ title: 'N2 Work Log',
98
+ description: 'Log file changes during work. Reports created/modified/deleted files with descriptions.',
99
+ inputSchema: {
100
+ project: z.string().describe('Project name'),
101
+ filesCreated: z.array(z.object({
102
+ path: z.string(),
103
+ desc: z.string(),
104
+ })).optional().describe('Files created'),
105
+ filesModified: z.array(z.object({
106
+ path: z.string(),
107
+ desc: z.string(),
108
+ })).optional().describe('Files modified'),
109
+ filesDeleted: z.array(z.object({
110
+ path: z.string(),
111
+ desc: z.string(),
112
+ })).optional().describe('Files deleted'),
113
+ decisions: z.array(z.string()).optional().describe('Decisions made'),
114
+ },
115
+ },
116
+ async ({ project, filesCreated, filesModified, filesDeleted, decisions }) => {
117
+ const session = activeSessions[project];
118
+ if (!session) {
119
+ return { content: [{ type: 'text', text: 'ERROR: No active work session. Call n2_work_start first.' }] };
120
+ }
121
+
122
+ if (filesCreated) session.filesCreated.push(...filesCreated);
123
+ if (filesModified) session.filesModified.push(...filesModified);
124
+ if (filesDeleted) session.filesDeleted.push(...filesDeleted);
125
+ if (decisions) session.decisions.push(...decisions);
126
+
127
+ // Validate first-line comments on created files
128
+ const warnings = [];
129
+ for (const f of (filesCreated || [])) {
130
+ try {
131
+ const fullPath = path.resolve(f.path);
132
+ if (!validateFirstLineComment(fullPath)) {
133
+ warnings.push(`MISSING first-line comment: ${f.path}`);
134
+ }
135
+ } catch (e) { logError("work:validate", e); }
136
+ }
137
+
138
+ const total = (session.filesCreated.length + session.filesModified.length + session.filesDeleted.length);
139
+ let msg = `Logged: ${total} file changes, ${session.decisions.length} decisions.`;
140
+ if (warnings.length > 0) {
141
+ msg += `\nWARNINGS:\n ${warnings.join('\n ')}`;
142
+ }
143
+ if ((filesCreated && filesCreated.length > 0) || (filesDeleted && filesDeleted.length > 0)) {
144
+ msg += `\nFile tree will auto-update at n2_work_end. Use n2_project_scan for immediate refresh.`;
145
+ }
146
+ msg += `\nTODO RULE: All TODO files go in _data/ ONLY. Always mark completed items as [x]. Never use brain memory for TODOs.`;
147
+ return { content: [{ type: 'text', text: msg }] };
148
+ }
149
+ );
150
+
151
+ // โ”€โ”€ n2_context_search: Search across Brain memory and Ledger entries โ”€โ”€
152
+ server.registerTool(
153
+ 'n2_context_search',
154
+ {
155
+ title: 'N2 Context Search',
156
+ description: 'Search across Brain memory and Ledger entries for relevant past context. Uses keyword matching with recency weighting. Great for finding related past work or decisions.',
157
+ inputSchema: {
158
+ query: z.string().describe('Search query (keywords, space-separated)'),
159
+ sources: z.array(z.string()).optional().describe('Sources to search: "brain", "ledger". Default: all.'),
160
+ maxResults: z.number().optional().describe('Max results (default: 10)'),
161
+ },
162
+ },
163
+ async ({ query, sources, maxResults }) => {
164
+ try {
165
+ const dataDir = config.DATA_DIR;
166
+ const searchCfg = config.SEARCH || {};
167
+ const minKwLen = searchCfg.minKeywordLength || 2;
168
+ const previewLen = searchCfg.previewLength || 200;
169
+ const recencyBonus = searchCfg.recencyBonus || 10;
170
+ const maxDepth = searchCfg.maxDepth || 6;
171
+ const keywords = query.toLowerCase().split(/\s+/).filter(k => k.length >= minKwLen);
172
+ const max = maxResults || searchCfg.defaultMaxResults || 10;
173
+ const searchSources = sources || ['brain', 'ledger'];
174
+ const results = [];
175
+
176
+ function scoreText(text, filePath, source, meta = {}) {
177
+ if (!text) return;
178
+ const lower = text.toLowerCase();
179
+ let score = 0;
180
+ const matchedKeywords = [];
181
+ for (const kw of keywords) {
182
+ const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
183
+ const count = (lower.match(new RegExp(escaped, 'g')) || []).length;
184
+ if (count > 0) { score += count; matchedKeywords.push(kw); }
185
+ }
186
+ if (score > 0) {
187
+ if (meta.timestamp) {
188
+ const age = (Date.now() - new Date(meta.timestamp).getTime()) / (1000 * 60 * 60 * 24);
189
+ score += Math.max(0, recencyBonus - age);
190
+ }
191
+ results.push({
192
+ source, path: filePath,
193
+ score: Math.round(score * 100) / 100,
194
+ matchedKeywords,
195
+ preview: text.slice(0, previewLen).replace(/\n/g, ' '),
196
+ ...meta,
197
+ });
198
+ }
199
+ }
200
+
201
+ // Search Brain memory
202
+ if (searchSources.includes('brain')) {
203
+ const memoryDir = path.join(dataDir, 'memory');
204
+ if (fs.existsSync(memoryDir)) {
205
+ walkFiles(memoryDir, (fp) => {
206
+ const content = readFile(fp);
207
+ if (content) {
208
+ const relPath = path.relative(memoryDir, fp);
209
+ scoreText(content, `memory/${relPath}`, 'brain', {
210
+ timestamp: fs.statSync(fp).mtime.toISOString(),
211
+ });
212
+ }
213
+ }, maxDepth);
214
+ }
215
+ }
216
+
217
+ // Search Ledger entries
218
+ if (searchSources.includes('ledger')) {
219
+ const projectsDir = path.join(dataDir, 'projects');
220
+ if (fs.existsSync(projectsDir)) {
221
+ for (const proj of fs.readdirSync(projectsDir)) {
222
+ const ledgerBase = path.join(projectsDir, proj, 'ledger');
223
+ if (!fs.existsSync(ledgerBase)) continue;
224
+ walkFiles(ledgerBase, (fp) => {
225
+ if (!fp.endsWith('.json')) return;
226
+ const data = readJson(fp);
227
+ if (!data) return;
228
+ const text = [data.title, data.summary, ...(data.decisions || [])].filter(Boolean).join(' ');
229
+ const relPath = path.relative(projectsDir, fp);
230
+ scoreText(text, `projects/${relPath}`, 'ledger', {
231
+ timestamp: data.completedAt || data.startedAt,
232
+ agent: data.agent,
233
+ title: data.title,
234
+ });
235
+ }, maxDepth);
236
+ }
237
+ }
238
+ }
239
+
240
+ results.sort((a, b) => b.score - a.score);
241
+ const top = results.slice(0, max);
242
+
243
+ if (top.length === 0) {
244
+ return { content: [{ type: 'text', text: `๐Ÿ” No results for "${query}".` }] };
245
+ }
246
+
247
+ const lines = top.map((r, i) => {
248
+ const icon = r.source === 'brain' ? '๐Ÿง ' : '๐Ÿ“–';
249
+ const meta = [
250
+ r.title ? `"${r.title}"` : '',
251
+ r.agent ? `by ${r.agent}` : '',
252
+ `score: ${r.score}`,
253
+ ].filter(Boolean).join(' | ');
254
+ return `${i + 1}. ${icon} ${r.path}\n ${meta}\n Keywords: [${r.matchedKeywords.join(', ')}]\n ${r.preview}`;
255
+ });
256
+
257
+ return {
258
+ content: [{
259
+ type: 'text', text:
260
+ `๐Ÿ” Context search: "${query}" (${top.length} results)\n\n${lines.join('\n\n')}`
261
+ }],
262
+ };
263
+ } catch (err) {
264
+ return { content: [{ type: 'text', text: `โŒ Search error: ${err.message}` }] };
265
+ }
266
+ }
267
+ );
268
+ }
269
+
270
+ // Export activeSessions for end.js to access
271
+ module.exports = { registerWorkSequence, activeSessions };