n2-soul 4.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.
- package/LICENSE +121 -0
- package/README.ko.md +197 -0
- package/README.md +197 -0
- package/index.js +30 -0
- package/lib/agent-registry.js +60 -0
- package/lib/config.default.js +68 -0
- package/lib/config.example.js +28 -0
- package/lib/config.js +28 -0
- package/lib/context.js +34 -0
- package/lib/intercom-log.js +187 -0
- package/lib/kv-cache/agent-adapter.js +192 -0
- package/lib/kv-cache/backup.js +357 -0
- package/lib/kv-cache/compressor.js +130 -0
- package/lib/kv-cache/embedding.js +205 -0
- package/lib/kv-cache/index.js +446 -0
- package/lib/kv-cache/schema.js +108 -0
- package/lib/kv-cache/snapshot.js +213 -0
- package/lib/kv-cache/sqlite-store.js +402 -0
- package/lib/kv-cache/tier-manager.js +239 -0
- package/lib/kv-cache/token-saver.js +153 -0
- package/lib/paths.js +20 -0
- package/lib/soul-engine.js +189 -0
- package/lib/utils.js +97 -0
- package/package.json +31 -0
- package/sequences/boot.js +81 -0
- package/sequences/end.js +132 -0
- package/sequences/work.js +257 -0
- package/tools/brain.js +45 -0
- package/tools/kv-cache.js +246 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Soul KV-Cache — Tiered storage manager. Hot/Warm/Cold lifecycle for snapshots.
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tiered storage levels for KV-Cache snapshots.
|
|
7
|
+
*
|
|
8
|
+
* Hot (0-7 days): In-memory cache + disk. Fastest access.
|
|
9
|
+
* Warm (8-30 days): Disk only. Normal file/db access.
|
|
10
|
+
* Cold (30+ days): Archived (compressed). Lazy load on demand.
|
|
11
|
+
*/
|
|
12
|
+
const TIERS = {
|
|
13
|
+
HOT: { name: 'hot', maxAgeDays: 7 },
|
|
14
|
+
WARM: { name: 'warm', maxAgeDays: 30 },
|
|
15
|
+
COLD: { name: 'cold', maxAgeDays: Infinity },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* TierManager wraps a storage engine and adds tiered caching.
|
|
20
|
+
* Hot tier snapshots are kept in memory for fast access.
|
|
21
|
+
* Cold tier snapshots are moved to a compressed archive.
|
|
22
|
+
*/
|
|
23
|
+
class TierManager {
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} storageEngine - SnapshotEngine or SqliteStore
|
|
26
|
+
* @param {object} config - tier config { hotDays, warmDays }
|
|
27
|
+
*/
|
|
28
|
+
constructor(storageEngine, config = {}) {
|
|
29
|
+
this.engine = storageEngine;
|
|
30
|
+
this.hotDays = config.hotDays || TIERS.HOT.maxAgeDays;
|
|
31
|
+
this.warmDays = config.warmDays || TIERS.WARM.maxAgeDays;
|
|
32
|
+
this._hotCache = {}; // { projectName: { id: session } }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Classify a snapshot's tier based on age.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} snapshot
|
|
39
|
+
* @returns {'hot'|'warm'|'cold'}
|
|
40
|
+
*/
|
|
41
|
+
classify(snapshot) {
|
|
42
|
+
const timestamp = snapshot.endedAt || snapshot.startedAt;
|
|
43
|
+
if (!timestamp) return 'warm';
|
|
44
|
+
|
|
45
|
+
const ageMs = Date.now() - new Date(timestamp).getTime();
|
|
46
|
+
const ageDays = ageMs / (24 * 60 * 60 * 1000);
|
|
47
|
+
|
|
48
|
+
if (ageDays <= this.hotDays) return 'hot';
|
|
49
|
+
if (ageDays <= this.warmDays) return 'warm';
|
|
50
|
+
return 'cold';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Save with automatic hot-cache population.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} session
|
|
57
|
+
* @returns {string} Snapshot ID
|
|
58
|
+
*/
|
|
59
|
+
save(session) {
|
|
60
|
+
const id = this.engine.save(session);
|
|
61
|
+
|
|
62
|
+
// Add to hot cache
|
|
63
|
+
const project = session.projectName || session.project;
|
|
64
|
+
if (project) {
|
|
65
|
+
if (!this._hotCache[project]) this._hotCache[project] = {};
|
|
66
|
+
this._hotCache[project][id] = { ...session, id };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return id;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load latest with hot-cache check first.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} projectName
|
|
76
|
+
* @returns {object|null}
|
|
77
|
+
*/
|
|
78
|
+
loadLatest(projectName) {
|
|
79
|
+
// Check hot cache first
|
|
80
|
+
const cache = this._hotCache[projectName];
|
|
81
|
+
if (cache) {
|
|
82
|
+
const entries = Object.values(cache);
|
|
83
|
+
if (entries.length > 0) {
|
|
84
|
+
entries.sort((a, b) => {
|
|
85
|
+
const ta = new Date(b.endedAt || b.startedAt).getTime();
|
|
86
|
+
const tb = new Date(a.endedAt || a.startedAt).getTime();
|
|
87
|
+
return ta - tb;
|
|
88
|
+
});
|
|
89
|
+
return entries[0];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Fall through to storage engine
|
|
94
|
+
return this.engine.loadLatest(projectName);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* List snapshots with tier annotations.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} projectName
|
|
101
|
+
* @param {number} limit
|
|
102
|
+
* @returns {object[]}
|
|
103
|
+
*/
|
|
104
|
+
list(projectName, limit = 10) {
|
|
105
|
+
const snapshots = this.engine.list(projectName, limit);
|
|
106
|
+
return snapshots.map(snap => ({
|
|
107
|
+
...snap,
|
|
108
|
+
_tier: this.classify(snap),
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Search with tier annotations.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} query
|
|
116
|
+
* @param {string} projectName
|
|
117
|
+
* @param {number} limit
|
|
118
|
+
* @returns {object[]}
|
|
119
|
+
*/
|
|
120
|
+
search(query, projectName, limit = 10) {
|
|
121
|
+
const results = this.engine.search(query, projectName, limit);
|
|
122
|
+
return results.map(snap => ({
|
|
123
|
+
...snap,
|
|
124
|
+
_tier: this.classify(snap),
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Tier-aware garbage collection.
|
|
130
|
+
* - Hot: never deleted
|
|
131
|
+
* - Warm: normal retention
|
|
132
|
+
* - Cold: archived or deleted based on maxAge
|
|
133
|
+
*
|
|
134
|
+
* @param {string} projectName
|
|
135
|
+
* @param {number} maxAgeDays
|
|
136
|
+
* @param {number} maxCount
|
|
137
|
+
* @returns {{ deleted: number, hotCount: number, warmCount: number, coldCount: number }}
|
|
138
|
+
*/
|
|
139
|
+
gc(projectName, maxAgeDays = 30, maxCount = 50) {
|
|
140
|
+
const result = this.engine.gc(projectName, maxAgeDays, maxCount);
|
|
141
|
+
|
|
142
|
+
// Refresh hot cache
|
|
143
|
+
this._refreshHotCache(projectName);
|
|
144
|
+
|
|
145
|
+
// Count tiers after GC
|
|
146
|
+
const remaining = this.list(projectName, 9999);
|
|
147
|
+
const counts = { hot: 0, warm: 0, cold: 0 };
|
|
148
|
+
for (const snap of remaining) {
|
|
149
|
+
counts[snap._tier || 'warm']++;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
deleted: result.deleted,
|
|
154
|
+
hotCount: counts.hot,
|
|
155
|
+
warmCount: counts.warm,
|
|
156
|
+
coldCount: counts.cold,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Refresh hot cache for a project from storage.
|
|
162
|
+
* @param {string} projectName
|
|
163
|
+
*/
|
|
164
|
+
_refreshHotCache(projectName) {
|
|
165
|
+
const snaps = this.engine.list(projectName, 100);
|
|
166
|
+
const cache = {};
|
|
167
|
+
|
|
168
|
+
for (const snap of snaps) {
|
|
169
|
+
if (this.classify(snap) === 'hot') {
|
|
170
|
+
cache[snap.id] = snap;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
this._hotCache[projectName] = cache;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Get tier distribution summary for a project.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} projectName
|
|
181
|
+
* @returns {{ hot: number, warm: number, cold: number, total: number }}
|
|
182
|
+
*/
|
|
183
|
+
tierSummary(projectName) {
|
|
184
|
+
const snaps = this.list(projectName, 9999);
|
|
185
|
+
const counts = { hot: 0, warm: 0, cold: 0, total: snaps.length };
|
|
186
|
+
|
|
187
|
+
for (const snap of snaps) {
|
|
188
|
+
counts[snap._tier || 'warm']++;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return counts;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Warm up: preload hot tier snapshots into memory.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} projectName
|
|
198
|
+
* @returns {number} Number of snapshots cached
|
|
199
|
+
*/
|
|
200
|
+
warmUp(projectName) {
|
|
201
|
+
this._refreshHotCache(projectName);
|
|
202
|
+
return Object.keys(this._hotCache[projectName] || {}).length;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Clear hot cache for a project.
|
|
207
|
+
* @param {string} projectName
|
|
208
|
+
*/
|
|
209
|
+
evict(projectName) {
|
|
210
|
+
delete this._hotCache[projectName];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Proxy: loadById
|
|
215
|
+
*/
|
|
216
|
+
loadById(projectName, snapshotId) {
|
|
217
|
+
return this.engine.loadById(projectName, snapshotId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Proxy: migrateFromJson (if available)
|
|
222
|
+
*/
|
|
223
|
+
migrateFromJson(jsonBaseDir, projectName) {
|
|
224
|
+
if (this.engine.migrateFromJson) {
|
|
225
|
+
return this.engine.migrateFromJson(jsonBaseDir, projectName);
|
|
226
|
+
}
|
|
227
|
+
return { error: 'Migration not available for this backend' };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Proxy: dispose
|
|
232
|
+
*/
|
|
233
|
+
dispose() {
|
|
234
|
+
this._hotCache = {};
|
|
235
|
+
if (this.engine.dispose) this.engine.dispose();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = { TierManager, TIERS };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Soul KV-Cache — Progressive loading. Extracts context at L1/L2/L3 token budgets.
|
|
2
|
+
const { extractKeywords } = require('./agent-adapter');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Progressive loading levels for KV-Cache context restoration.
|
|
6
|
+
* Lower levels use fewer tokens but provide less context.
|
|
7
|
+
*
|
|
8
|
+
* L1: Minimal — keywords + TODO only (~500 tokens)
|
|
9
|
+
* L2: Standard — L1 + compressed summary + decisions (~2000 tokens)
|
|
10
|
+
* L3: Full — complete uncompressed context (no limit)
|
|
11
|
+
*/
|
|
12
|
+
const LEVELS = {
|
|
13
|
+
L1: { name: 'minimal', maxTokens: 500 },
|
|
14
|
+
L2: { name: 'standard', maxTokens: 2000 },
|
|
15
|
+
L3: { name: 'full', maxTokens: Infinity },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extracts context from a snapshot at the specified progressive level.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} snapshot - KV-Cache session snapshot
|
|
22
|
+
* @param {string} level - 'L1', 'L2', or 'L3'
|
|
23
|
+
* @returns {{ level: string, tokens: number, prompt: string }}
|
|
24
|
+
*/
|
|
25
|
+
function extractAtLevel(snapshot, level = 'L2') {
|
|
26
|
+
if (!snapshot) return { level, tokens: 0, prompt: '' };
|
|
27
|
+
|
|
28
|
+
const spec = LEVELS[level] || LEVELS.L2;
|
|
29
|
+
const lines = [];
|
|
30
|
+
|
|
31
|
+
// --- L1: Minimal (keywords + TODO) ---
|
|
32
|
+
lines.push(`[Session: ${snapshot.agentName} | ${(snapshot.endedAt || snapshot.startedAt || '').split('T')[0]}]`);
|
|
33
|
+
|
|
34
|
+
if (snapshot.parentSessionId) {
|
|
35
|
+
lines.push(`Chain: ${snapshot.parentSessionId.slice(0, 8)} -> ${snapshot.id.slice(0, 8)}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (snapshot.keys?.length > 0) {
|
|
39
|
+
lines.push(`Topics: ${snapshot.keys.slice(0, 10).join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (snapshot.context?.todo?.length > 0) {
|
|
43
|
+
lines.push('TODO:');
|
|
44
|
+
for (const t of snapshot.context.todo) {
|
|
45
|
+
lines.push(` - ${t}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let prompt = lines.join('\n');
|
|
50
|
+
let tokens = estimateTokenCount(prompt);
|
|
51
|
+
|
|
52
|
+
if (level === 'L1' || tokens >= spec.maxTokens) {
|
|
53
|
+
return trimToTokenBudget(prompt, tokens, spec.maxTokens, 'L1');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- L2: Standard (+ summary + decisions) ---
|
|
57
|
+
if (snapshot.context?.decisions?.length > 0) {
|
|
58
|
+
lines.push('Decisions:');
|
|
59
|
+
for (const d of snapshot.context.decisions) {
|
|
60
|
+
lines.push(` - ${d}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (snapshot.context?.summary) {
|
|
65
|
+
lines.push(`Summary: ${snapshot.context.summary}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
prompt = lines.join('\n');
|
|
69
|
+
tokens = estimateTokenCount(prompt);
|
|
70
|
+
|
|
71
|
+
if (level === 'L2' || tokens >= spec.maxTokens) {
|
|
72
|
+
return trimToTokenBudget(prompt, tokens, spec.maxTokens, 'L2');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- L3: Full (+ files changed) ---
|
|
76
|
+
if (snapshot.context?.filesChanged?.length > 0) {
|
|
77
|
+
lines.push('Files changed:');
|
|
78
|
+
for (const f of snapshot.context.filesChanged) {
|
|
79
|
+
const entry = typeof f === 'string' ? f : `${f.path} — ${f.desc || ''}`;
|
|
80
|
+
lines.push(` - ${entry}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Include raw metadata
|
|
85
|
+
lines.push(`Agent type: ${snapshot.agentType || 'unknown'}`);
|
|
86
|
+
if (snapshot.model) lines.push(`Model: ${snapshot.model}`);
|
|
87
|
+
if (snapshot.turnCount) lines.push(`Turns: ${snapshot.turnCount}`);
|
|
88
|
+
if (snapshot.tokenEstimate) lines.push(`Token estimate: ${snapshot.tokenEstimate}`);
|
|
89
|
+
|
|
90
|
+
prompt = lines.join('\n');
|
|
91
|
+
tokens = estimateTokenCount(prompt);
|
|
92
|
+
|
|
93
|
+
return { level: 'L3', tokens, prompt };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Auto-selects the best progressive level based on available token budget.
|
|
98
|
+
* Starts from L3 and downgrades until it fits.
|
|
99
|
+
*
|
|
100
|
+
* @param {object} snapshot - KV-Cache session snapshot
|
|
101
|
+
* @param {number} budgetTokens - Available token budget
|
|
102
|
+
* @returns {{ level: string, tokens: number, prompt: string }}
|
|
103
|
+
*/
|
|
104
|
+
function autoLevel(snapshot, budgetTokens = 2000) {
|
|
105
|
+
if (!snapshot) return { level: 'L1', tokens: 0, prompt: '' };
|
|
106
|
+
|
|
107
|
+
// Try from highest to lowest
|
|
108
|
+
for (const lvl of ['L3', 'L2', 'L1']) {
|
|
109
|
+
const result = extractAtLevel(snapshot, lvl);
|
|
110
|
+
if (result.tokens <= budgetTokens) {
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Even L1 is over budget — trim L1
|
|
116
|
+
const l1 = extractAtLevel(snapshot, 'L1');
|
|
117
|
+
return trimToTokenBudget(l1.prompt, l1.tokens, budgetTokens, 'L1');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Estimates token count for text (model-agnostic).
|
|
122
|
+
* CJK characters ~1 token each, ASCII ~4 chars per token.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} text
|
|
125
|
+
* @returns {number}
|
|
126
|
+
*/
|
|
127
|
+
function estimateTokenCount(text) {
|
|
128
|
+
if (!text) return 0;
|
|
129
|
+
const cjkCount = (text.match(/[\u3000-\u9fff\uac00-\ud7af]/g) || []).length;
|
|
130
|
+
const asciiCount = text.length - cjkCount;
|
|
131
|
+
return Math.ceil(asciiCount / 4 + cjkCount / 2);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Trims prompt text to fit within a token budget.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} prompt
|
|
138
|
+
* @param {number} currentTokens
|
|
139
|
+
* @param {number} maxTokens
|
|
140
|
+
* @param {string} level
|
|
141
|
+
* @returns {{ level: string, tokens: number, prompt: string }}
|
|
142
|
+
*/
|
|
143
|
+
function trimToTokenBudget(prompt, currentTokens, maxTokens, level) {
|
|
144
|
+
if (currentTokens <= maxTokens) {
|
|
145
|
+
return { level, tokens: currentTokens, prompt };
|
|
146
|
+
}
|
|
147
|
+
// Conservative: assume 3 chars per token for trimming
|
|
148
|
+
const maxChars = maxTokens * 3;
|
|
149
|
+
const trimmed = prompt.slice(0, maxChars) + '\n...(truncated)';
|
|
150
|
+
return { level, tokens: maxTokens, prompt: trimmed };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { extractAtLevel, autoLevel, estimateTokenCount, LEVELS };
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Soul — Central path manager. Cross-platform compatible.
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
// soul/lib/paths.js → 2 levels up = project root
|
|
6
|
+
const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
|
|
7
|
+
const DATA_ROOT = path.join(path.resolve(__dirname, '..'), 'data');
|
|
8
|
+
|
|
9
|
+
/** Agents directory path (auto-created if needed) */
|
|
10
|
+
function getAgentsDir() {
|
|
11
|
+
const dir = path.join(DATA_ROOT, 'agents');
|
|
12
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
PROJECT_ROOT,
|
|
18
|
+
DATA_ROOT,
|
|
19
|
+
getAgentsDir,
|
|
20
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Soul MCP v4.0 — Soul engine: Board, Ledger, and File Index management.
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { readJson, writeJson, nowISO, logError } = require('./utils');
|
|
5
|
+
|
|
6
|
+
class SoulEngine {
|
|
7
|
+
constructor(dataDir) {
|
|
8
|
+
this.dataDir = dataDir;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// -- Path helpers --
|
|
12
|
+
|
|
13
|
+
projectDir(projectName) {
|
|
14
|
+
return path.join(this.dataDir, 'projects', projectName);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
boardPath(projectName) {
|
|
18
|
+
return path.join(this.projectDir(projectName), 'soul-board.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fileIndexPath(projectName) {
|
|
22
|
+
return path.join(this.projectDir(projectName), 'file-index.json');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ledgerDir(projectName, date) {
|
|
26
|
+
const [y, m, d] = (date || nowISO().split('T')[0]).split('-');
|
|
27
|
+
return path.join(this.projectDir(projectName), 'ledger', y, m, d);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// -- Soul Board --
|
|
31
|
+
|
|
32
|
+
readBoard(projectName) {
|
|
33
|
+
return readJson(this.boardPath(projectName)) || this._defaultBoard(projectName);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
writeBoard(projectName, board) {
|
|
37
|
+
board.updatedAt = nowISO();
|
|
38
|
+
writeJson(this.boardPath(projectName), board);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_defaultBoard(projectName) {
|
|
42
|
+
return {
|
|
43
|
+
project: projectName,
|
|
44
|
+
updatedAt: nowISO(),
|
|
45
|
+
updatedBy: null,
|
|
46
|
+
state: { summary: '', version: '', health: 'unknown' },
|
|
47
|
+
activeWork: {},
|
|
48
|
+
fileOwnership: {},
|
|
49
|
+
decisions: [],
|
|
50
|
+
handoff: { from: null, summary: '', todo: [], blockers: [] },
|
|
51
|
+
lastLedger: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// -- File Ownership --
|
|
56
|
+
|
|
57
|
+
claimFile(projectName, filePath, agent, intent) {
|
|
58
|
+
const board = this.readBoard(projectName);
|
|
59
|
+
const existing = board.fileOwnership[filePath];
|
|
60
|
+
if (existing && existing.owner && existing.owner !== agent) {
|
|
61
|
+
return { ok: false, owner: existing.owner, intent: existing.intent };
|
|
62
|
+
}
|
|
63
|
+
board.fileOwnership[filePath] = { owner: agent, since: nowISO(), intent };
|
|
64
|
+
board.updatedBy = agent;
|
|
65
|
+
this.writeBoard(projectName, board);
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
releaseFiles(projectName, agent) {
|
|
70
|
+
const board = this.readBoard(projectName);
|
|
71
|
+
for (const [fp, info] of Object.entries(board.fileOwnership)) {
|
|
72
|
+
if (info.owner === agent) {
|
|
73
|
+
board.fileOwnership[fp] = { owner: null };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
board.updatedBy = agent;
|
|
77
|
+
this.writeBoard(projectName, board);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// -- Active Work --
|
|
81
|
+
|
|
82
|
+
setActiveWork(projectName, agent, task, files) {
|
|
83
|
+
const board = this.readBoard(projectName);
|
|
84
|
+
board.activeWork[agent] = { task, since: nowISO(), files: files || [] };
|
|
85
|
+
board.updatedBy = agent;
|
|
86
|
+
this.writeBoard(projectName, board);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
clearActiveWork(projectName, agent) {
|
|
90
|
+
const board = this.readBoard(projectName);
|
|
91
|
+
board.activeWork[agent] = null;
|
|
92
|
+
board.updatedBy = agent;
|
|
93
|
+
this.writeBoard(projectName, board);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// -- Ledger --
|
|
97
|
+
|
|
98
|
+
getNextLedgerId(projectName, date) {
|
|
99
|
+
const dir = this.ledgerDir(projectName, date);
|
|
100
|
+
if (!fs.existsSync(dir)) return '001';
|
|
101
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
102
|
+
const nums = files.map(f => parseInt(f.split('-')[0]) || 0);
|
|
103
|
+
const max = nums.length > 0 ? Math.max(...nums) : 0;
|
|
104
|
+
return String(max + 1).padStart(3, '0');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
writeLedger(projectName, agent, entry) {
|
|
108
|
+
const date = nowISO().split('T')[0];
|
|
109
|
+
const id = this.getNextLedgerId(projectName, date);
|
|
110
|
+
const ledgerEntry = {
|
|
111
|
+
id,
|
|
112
|
+
agent,
|
|
113
|
+
startedAt: entry.startedAt || nowISO(),
|
|
114
|
+
completedAt: nowISO(),
|
|
115
|
+
title: entry.title || 'Untitled work',
|
|
116
|
+
filesCreated: entry.filesCreated || [],
|
|
117
|
+
filesModified: entry.filesModified || [],
|
|
118
|
+
filesDeleted: entry.filesDeleted || [],
|
|
119
|
+
decisions: entry.decisions || [],
|
|
120
|
+
summary: entry.summary || '',
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const dir = this.ledgerDir(projectName, date);
|
|
124
|
+
const fileName = `${id}-${agent.toLowerCase().replace(/[^a-z0-9]/g, '')}.json`;
|
|
125
|
+
writeJson(path.join(dir, fileName), ledgerEntry);
|
|
126
|
+
|
|
127
|
+
// Update board's lastLedger reference
|
|
128
|
+
const [y, m, d] = date.split('-');
|
|
129
|
+
const board = this.readBoard(projectName);
|
|
130
|
+
board.lastLedger = `${y}/${m}/${d}/${id}-${agent}`;
|
|
131
|
+
board.updatedBy = agent;
|
|
132
|
+
this.writeBoard(projectName, board);
|
|
133
|
+
|
|
134
|
+
return { id, path: path.join(dir, fileName) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// -- File Index --
|
|
138
|
+
|
|
139
|
+
readFileIndex(projectName) {
|
|
140
|
+
return readJson(this.fileIndexPath(projectName)) || { updatedAt: nowISO(), tree: {}, directories: {} };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
writeFileIndex(projectName, index) {
|
|
144
|
+
index.updatedAt = nowISO();
|
|
145
|
+
writeJson(this.fileIndexPath(projectName), index);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Auto-scan a directory and generate file-index tree
|
|
149
|
+
scanDirectory(rootDir, options = {}) {
|
|
150
|
+
const maxDepth = options.maxDepth || 5;
|
|
151
|
+
const excludes = options.excludes || ['node_modules', '.git', 'dist', 'out', '.next'];
|
|
152
|
+
|
|
153
|
+
function walk(dir, depth) {
|
|
154
|
+
if (depth > maxDepth) return {};
|
|
155
|
+
const result = {};
|
|
156
|
+
try {
|
|
157
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
if (excludes.includes(entry.name)) continue;
|
|
160
|
+
if (entry.name.startsWith('.') && entry.name !== '.env') continue;
|
|
161
|
+
|
|
162
|
+
const fullPath = path.join(dir, entry.name);
|
|
163
|
+
if (entry.isDirectory()) {
|
|
164
|
+
const key = entry.name + '/';
|
|
165
|
+
result[key] = {
|
|
166
|
+
desc: '',
|
|
167
|
+
children: walk(fullPath, depth + 1),
|
|
168
|
+
};
|
|
169
|
+
} else {
|
|
170
|
+
const stat = fs.statSync(fullPath);
|
|
171
|
+
result[entry.name] = {
|
|
172
|
+
desc: '',
|
|
173
|
+
created: stat.birthtime.toISOString().split('T')[0],
|
|
174
|
+
modified: stat.mtime.toISOString().split('T')[0],
|
|
175
|
+
status: 'active',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch (e) {
|
|
180
|
+
logError('scanDirectory', e);
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return walk(rootDir, 0);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { SoulEngine };
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Soul MCP v4.0 — Shared utility functions (file I/O, time, security, logging)
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
// -- Logging --
|
|
6
|
+
|
|
7
|
+
function logError(context, err) {
|
|
8
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
9
|
+
console.error(`[soul:${context}]`, msg);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// -- File I/O --
|
|
13
|
+
|
|
14
|
+
function readFile(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
17
|
+
} catch (e) {
|
|
18
|
+
logError('readFile', e);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readJson(filePath) {
|
|
24
|
+
const content = readFile(filePath);
|
|
25
|
+
if (!content) return null;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(content);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
logError('readJson', e);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeJson(filePath, data) {
|
|
35
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
36
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeFile(filePath, content) {
|
|
40
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
41
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// -- Time --
|
|
45
|
+
|
|
46
|
+
function today() {
|
|
47
|
+
return new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Seoul' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function nowISO() {
|
|
51
|
+
const formatter = new Intl.DateTimeFormat('sv-SE', {
|
|
52
|
+
timeZone: 'Asia/Seoul',
|
|
53
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
54
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
55
|
+
hour12: false,
|
|
56
|
+
});
|
|
57
|
+
const parts = formatter.formatToParts(new Date());
|
|
58
|
+
const get = (type) => parts.find(p => p.type === type)?.value || '00';
|
|
59
|
+
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}+09:00`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// -- Security --
|
|
63
|
+
|
|
64
|
+
function safePath(filePath, baseDir) {
|
|
65
|
+
const resolved = path.resolve(baseDir, filePath);
|
|
66
|
+
const normalizedBase = path.resolve(baseDir);
|
|
67
|
+
if (!resolved.startsWith(normalizedBase + path.sep) && resolved !== normalizedBase) {
|
|
68
|
+
logError('safePath', `Path traversal blocked: ${filePath}`);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return resolved;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// -- First-line comment validation --
|
|
75
|
+
|
|
76
|
+
function validateFirstLineComment(filePath) {
|
|
77
|
+
try {
|
|
78
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
79
|
+
const firstLine = content.split('\n')[0].trim();
|
|
80
|
+
const patterns = [
|
|
81
|
+
/^\/\/\s*.+/, // JS/TS
|
|
82
|
+
/^#\s*.+/, // Python/Shell/YAML
|
|
83
|
+
/^<!--\s*.+/, // HTML/MD
|
|
84
|
+
/^\/\*\s*.+/, // CSS/Java
|
|
85
|
+
/^\{.*"_desc"/, // JSON with _desc field
|
|
86
|
+
];
|
|
87
|
+
return patterns.some(p => p.test(firstLine));
|
|
88
|
+
} catch (e) {
|
|
89
|
+
logError('validateFirstLineComment', e);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
logError, readFile, readJson, writeJson, writeFile,
|
|
96
|
+
today, nowISO, safePath, validateFirstLineComment,
|
|
97
|
+
};
|