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.
- package/.github/FUNDING.yml +3 -0
- package/README.ko.md +68 -2
- package/README.md +59 -1
- package/lib/utils.js +120 -120
- package/package.json +1 -1
- package/sequences/work.js +271 -271
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
|
-
|
|
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
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 };
|