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.
@@ -0,0 +1,28 @@
1
+ // Soul — Local config example. Copy this to config.local.js and customize.
2
+ // config.local.js is gitignored and will override config.default.js values.
3
+ const path = require('path');
4
+
5
+ module.exports = {
6
+ // Language: 'en' or 'ko'
7
+ LANG: 'en',
8
+
9
+ // KV-Cache overrides
10
+ KV_CACHE: {
11
+ // Switch to SQLite backend for better performance with many snapshots
12
+ // backend: 'sqlite',
13
+
14
+ // Enable Ollama semantic search (requires: ollama pull nomic-embed-text)
15
+ // embedding: {
16
+ // enabled: true,
17
+ // model: 'nomic-embed-text',
18
+ // endpoint: 'http://127.0.0.1:11434',
19
+ // },
20
+
21
+ // Enable automatic backups
22
+ // backup: {
23
+ // enabled: true,
24
+ // dir: path.resolve(__dirname, '..', 'backups'),
25
+ // keepCount: 7,
26
+ // },
27
+ },
28
+ };
package/lib/config.js ADDED
@@ -0,0 +1,28 @@
1
+ // Soul MCP v4.1 — Config loader. Deep-merges config.default.js with config.local.js overrides.
2
+ const defaults = require('./config.default.js');
3
+
4
+ let local = {};
5
+ try {
6
+ local = require('./config.local.js');
7
+ } catch (e) {
8
+ // config.local.js is optional — only silence MODULE_NOT_FOUND
9
+ if (e.code !== 'MODULE_NOT_FOUND') throw e;
10
+ }
11
+
12
+ // Deep merge: local overrides default, nested objects are merged (not replaced)
13
+ function deepMerge(base, override) {
14
+ const result = { ...base };
15
+ for (const key of Object.keys(override)) {
16
+ if (
17
+ override[key] && typeof override[key] === 'object' && !Array.isArray(override[key]) &&
18
+ base[key] && typeof base[key] === 'object' && !Array.isArray(base[key])
19
+ ) {
20
+ result[key] = deepMerge(base[key], override[key]);
21
+ } else {
22
+ result[key] = override[key];
23
+ }
24
+ }
25
+ return result;
26
+ }
27
+
28
+ module.exports = deepMerge(defaults, local);
package/lib/context.js ADDED
@@ -0,0 +1,34 @@
1
+ // Soul — Session context manager. Replaces global state anti-pattern.
2
+ const _ctx = {
3
+ agentName: null,
4
+ kvChain: {}, // { projectName: parentSessionId }
5
+ };
6
+
7
+ /** Get current session context */
8
+ function getContext() {
9
+ return _ctx;
10
+ }
11
+
12
+ /** Set agent name (called during n2_boot) */
13
+ function setAgentName(name) {
14
+ _ctx.agentName = name;
15
+ }
16
+
17
+ /** Get agent name with fallback */
18
+ function getAgentName() {
19
+ return _ctx.agentName || process.env.N2_AGENT_NAME || 'default';
20
+ }
21
+
22
+ /** Set KV chain parent (for session linking) */
23
+ function setKvChainParent(project, sessionId) {
24
+ _ctx.kvChain[project] = sessionId;
25
+ }
26
+
27
+ /** Get and consume KV chain parent */
28
+ function popKvChainParent(project) {
29
+ const parent = _ctx.kvChain[project] || null;
30
+ delete _ctx.kvChain[project];
31
+ return parent;
32
+ }
33
+
34
+ module.exports = { getContext, setAgentName, getAgentName, setKvChainParent, popKvChainParent };
@@ -0,0 +1,187 @@
1
+ // Soul — Centralized conversation log writer for inter-agent communication.
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { detectAgentsDir } = require('./agent-registry');
5
+ const { writeJson, readJson, nowISO, logError } = require('./utils');
6
+
7
+ // ── Agent name whitelist (lazy-loaded from agent configs) ──
8
+
9
+ let _validNames = null;
10
+
11
+ /**
12
+ * Load valid agent names from agent config files.
13
+ * Only these names (+ defaults) can have conversation folders.
14
+ * @returns {Set<string>}
15
+ */
16
+ function getValidAgentNames() {
17
+ if (_validNames) return _validNames;
18
+ _validNames = new Set(['master', 'owner']);
19
+ try {
20
+ const agentsDir = detectAgentsDir();
21
+ if (agentsDir && fs.existsSync(agentsDir)) {
22
+ const files = fs.readdirSync(agentsDir)
23
+ .filter(f => f.endsWith('.json') && f !== 'global.json');
24
+ for (const f of files) {
25
+ try {
26
+ const cfg = JSON.parse(fs.readFileSync(path.join(agentsDir, f), 'utf-8'));
27
+ if (cfg.name && cfg.enabled !== false) _validNames.add(cfg.name);
28
+ } catch (e) { logError('intercom:parse-config', `${f}: ${e.message}`); }
29
+ }
30
+ }
31
+ } catch (e) { logError('intercom:agents-dir', e); }
32
+ return _validNames;
33
+ }
34
+
35
+ /**
36
+ * Normalize a sender/caller name to a valid agent name.
37
+ * Falls back to 'master' if the name is not in the whitelist.
38
+ * @param {string} name
39
+ * @returns {string}
40
+ */
41
+ function normalizeName(name) {
42
+ if (!name || typeof name !== 'string') return 'master';
43
+ const valid = getValidAgentNames();
44
+ if (valid.has(name)) return name;
45
+ for (const v of valid) {
46
+ if (v.toLowerCase() === name.toLowerCase()) return v;
47
+ }
48
+ return 'master';
49
+ }
50
+
51
+ // ── Path helpers ──
52
+
53
+ function getConversationsDir(config) {
54
+ const dataDir = config?.dataDir || path.join(__dirname, '..', 'data');
55
+ return path.join(dataDir, 'conversations');
56
+ }
57
+
58
+ function getAgentLogDir(config, agentName, date) {
59
+ const [y, m, d] = (date || nowISO().split('T')[0]).split('-');
60
+ return path.join(getConversationsDir(config), agentName, y, m, d);
61
+ }
62
+
63
+ /**
64
+ * Get next sequential log ID (max existing + 1).
65
+ * @param {string} dir
66
+ * @returns {string} Zero-padded 3-digit ID (e.g. '001')
67
+ */
68
+ function getNextLogId(dir) {
69
+ if (!fs.existsSync(dir)) return '001';
70
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
71
+ const nums = files.map(f => parseInt(f.split('.')[0]) || 0);
72
+ const max = nums.length > 0 ? Math.max(...nums) : 0;
73
+ return String(max + 1).padStart(3, '0');
74
+ }
75
+
76
+ // ── Core write function ──
77
+
78
+ /**
79
+ * Write a conversation log entry for both caller and target.
80
+ * Enforces folder isolation: only registered agent names can create directories.
81
+ *
82
+ * @param {object|null} config - Soul config (for dataDir)
83
+ * @param {string} caller - Sender name (will be normalized)
84
+ * @param {string|{name:string}} target - Target agent name or config object
85
+ * @param {string} message - User message
86
+ * @param {{content:string, usage?:object}} response - LLM response
87
+ * @param {{provider:string, model:string}} meta - Provider metadata
88
+ * @returns {{callerLog:string, targetLog:string}|null}
89
+ */
90
+ function writeConversationLog(config, caller, target, message, response, meta) {
91
+ const safeCaller = normalizeName(caller);
92
+ const targetName = typeof target === 'object' ? (target.name || String(target)) : String(target);
93
+ const safeTarget = normalizeName(targetName);
94
+
95
+ const date = nowISO().split('T')[0];
96
+ const entry = {
97
+ timestamp: nowISO(),
98
+ type: 'call',
99
+ caller: safeCaller,
100
+ target: safeTarget,
101
+ provider: meta?.provider || 'unknown',
102
+ model: meta?.model || 'unknown',
103
+ message: typeof message === 'string' ? message : '',
104
+ response: response?.content || '',
105
+ usage: response?.usage || null,
106
+ };
107
+
108
+ const callerDir = getAgentLogDir(config, safeCaller, date);
109
+ const callerId = getNextLogId(callerDir);
110
+ writeJson(path.join(callerDir, `${callerId}.json`), entry);
111
+
112
+ const targetDir = getAgentLogDir(config, safeTarget, date);
113
+ const targetId = getNextLogId(targetDir);
114
+ writeJson(path.join(targetDir, `${targetId}.json`), { ...entry, type: 'called' });
115
+
116
+ // Signal file for live detection (best-effort)
117
+ try {
118
+ const signalPath = path.join(getConversationsDir(config), '..', 'intercom-signal.json');
119
+ const tmpPath = signalPath + '.tmp';
120
+ fs.writeFileSync(tmpPath, JSON.stringify(entry, null, 2), 'utf-8');
121
+ fs.renameSync(tmpPath, signalPath);
122
+ } catch (e) { logError('intercom:signal', e); }
123
+
124
+ return { callerLog: `${safeCaller}/${callerId}`, targetLog: `${safeTarget}/${targetId}` };
125
+ }
126
+
127
+ // ── Read functions ──
128
+
129
+ /**
130
+ * Read conversation logs for an agent on a specific date.
131
+ * @param {object|null} config
132
+ * @param {string} agentName
133
+ * @param {string} date - YYYY-MM-DD
134
+ * @param {number} lastN - Max entries to return
135
+ * @returns {object[]}
136
+ */
137
+ function readConversationLogs(config, agentName, date, lastN) {
138
+ const dir = getAgentLogDir(config, agentName, date);
139
+ if (!fs.existsSync(dir)) return [];
140
+ const files = fs.readdirSync(dir)
141
+ .filter(f => f.endsWith('.json'))
142
+ .sort()
143
+ .slice(-(lastN || 50));
144
+ return files.map(f => readJson(path.join(dir, f))).filter(Boolean);
145
+ }
146
+
147
+ /**
148
+ * Get recent conversation dates for an agent.
149
+ * @param {object|null} config
150
+ * @param {string} agentName
151
+ * @param {number} limit
152
+ * @returns {string[]}
153
+ */
154
+ function getConversationDates(config, agentName, limit) {
155
+ const baseDir = path.join(getConversationsDir(config), agentName);
156
+ if (!fs.existsSync(baseDir)) return [];
157
+ const dates = [];
158
+ try {
159
+ const years = fs.readdirSync(baseDir).filter(f => /^\d{4}$/.test(f)).sort().reverse();
160
+ for (const y of years) {
161
+ const months = fs.readdirSync(path.join(baseDir, y)).filter(f => /^\d{2}$/.test(f)).sort().reverse();
162
+ for (const m of months) {
163
+ const days = fs.readdirSync(path.join(baseDir, y, m)).filter(f => /^\d{2}$/.test(f)).sort().reverse();
164
+ for (const d of days) {
165
+ dates.push(`${y}-${m}-${d}`);
166
+ if (dates.length >= (limit || 7)) return dates;
167
+ }
168
+ }
169
+ }
170
+ } catch (e) { logError('intercom:dates', e); }
171
+ return dates;
172
+ }
173
+
174
+ /** Invalidate cached agent names (call after agent config changes) */
175
+ function resetNameCache() {
176
+ _validNames = null;
177
+ }
178
+
179
+ module.exports = {
180
+ writeConversationLog,
181
+ readConversationLogs,
182
+ getConversationDates,
183
+ getNextLogId,
184
+ normalizeName,
185
+ getValidAgentNames,
186
+ resetNameCache,
187
+ };
@@ -0,0 +1,192 @@
1
+ // Soul KV-Cache — Agent adapter. Converts browser/MCP sessions to unified KV schema.
2
+ const { createSession } = require('./schema');
3
+
4
+ /**
5
+ * Converts CheckpointManager's CheckpointData to KV-Cache session schema.
6
+ * Source: src/core/executor/checkpoint-resumption.ts
7
+ *
8
+ * @param {object} checkpoint - CheckpointData from CheckpointManager
9
+ * @param {string} projectName
10
+ * @returns {object} Normalized session
11
+ */
12
+ function fromCheckpoint(checkpoint, projectName = 'default') {
13
+ const actions = (checkpoint.recentActions || []);
14
+ const decisions = actions
15
+ .filter(a => a.success)
16
+ .map(a => `Turn ${a.turn}: ${a.summary}`);
17
+
18
+ return createSession({
19
+ id: checkpoint.id,
20
+ agentName: 'browser-executor',
21
+ agentType: 'browser',
22
+ startedAt: new Date(checkpoint.timestamp).toISOString(),
23
+ turnCount: checkpoint.turnNumber || 0,
24
+ keys: extractKeywords(checkpoint.progressSummary || ''),
25
+ context: {
26
+ summary: checkpoint.progressSummary || '',
27
+ decisions,
28
+ filesChanged: [],
29
+ todo: checkpoint.remainingGoals || [],
30
+ },
31
+ projectName,
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Converts MemoryBridge MemoryNode array to KV-Cache entries.
37
+ * Source: src/core/executor/memory-bridge.ts
38
+ *
39
+ * @param {object[]} nodes - MemoryNode array
40
+ * @param {string} owner - Agent name
41
+ * @param {string} projectName
42
+ * @returns {object} Normalized session
43
+ */
44
+ function fromMemoryBridge(nodes, owner = 'browser', projectName = 'default') {
45
+ const allTags = [];
46
+ const summaries = [];
47
+
48
+ for (const node of nodes) {
49
+ if (node.tags) allTags.push(...node.tags);
50
+ if (node.content) {
51
+ summaries.push(node.content.slice(0, 200));
52
+ }
53
+ }
54
+
55
+ const keySet = new Set(allTags);
56
+
57
+ return createSession({
58
+ agentName: owner,
59
+ agentType: 'browser',
60
+ keys: Array.from(keySet),
61
+ context: {
62
+ summary: summaries.join('\n---\n'),
63
+ decisions: [],
64
+ filesChanged: [],
65
+ todo: [],
66
+ },
67
+ projectName,
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Converts MCP n2_work_end session data to KV-Cache schema.
73
+ * Source: soul/sequences/end.js
74
+ *
75
+ * @param {object} workEndData - Data from n2_work_end call
76
+ * @returns {object} Normalized session
77
+ */
78
+ function fromMcpSession(workEndData) {
79
+ const d = workEndData || {};
80
+ const filesChanged = [
81
+ ...(d.filesCreated || []).map(f => f.path || f),
82
+ ...(d.filesModified || []).map(f => f.path || f),
83
+ ...(d.filesDeleted || []).map(f => f.path || f),
84
+ ];
85
+
86
+ return createSession({
87
+ agentName: d.agent || 'unknown',
88
+ agentType: 'mcp',
89
+ startedAt: d.startedAt,
90
+ keys: extractKeywords(d.summary || ''),
91
+ context: {
92
+ summary: d.summary || '',
93
+ decisions: d.decisions || [],
94
+ filesChanged,
95
+ todo: d.todo || [],
96
+ },
97
+ parentSessionId: d.parentSessionId || null,
98
+ projectName: d.project || 'default',
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Generates a resume prompt from a snapshot within a token budget.
104
+ * Model-agnostic: produces plain text usable by any LLM.
105
+ *
106
+ * @param {object} snapshot - KV-Cache session object
107
+ * @param {number} budgetTokens - Max tokens (estimated)
108
+ * @returns {string} Resume prompt text
109
+ */
110
+ function toResumePrompt(snapshot, budgetTokens = 2000) {
111
+ if (!snapshot) return '';
112
+
113
+ const lines = [];
114
+ lines.push(`[Previous Session: ${snapshot.agentName} | ${snapshot.startedAt}]`);
115
+
116
+ if (snapshot.keys.length > 0) {
117
+ lines.push(`Topics: ${snapshot.keys.join(', ')}`);
118
+ }
119
+
120
+ if (snapshot.context.summary) {
121
+ lines.push(`Summary: ${snapshot.context.summary}`);
122
+ }
123
+
124
+ if (snapshot.context.decisions.length > 0) {
125
+ lines.push(`Decisions:`);
126
+ for (const d of snapshot.context.decisions) {
127
+ lines.push(` - ${d}`);
128
+ }
129
+ }
130
+
131
+ if (snapshot.context.todo.length > 0) {
132
+ lines.push(`TODO:`);
133
+ for (const t of snapshot.context.todo) {
134
+ lines.push(` - ${t}`);
135
+ }
136
+ }
137
+
138
+ let result = lines.join('\n');
139
+
140
+ // Rough trim to token budget (chars/3 as conservative estimate)
141
+ const maxChars = budgetTokens * 3;
142
+ if (result.length > maxChars) {
143
+ result = result.slice(0, maxChars) + '\n...(truncated)';
144
+ }
145
+
146
+ return result;
147
+ }
148
+
149
+ /**
150
+ * Simple keyword extraction from text.
151
+ * No LLM needed: uses frequency-based extraction.
152
+ *
153
+ * @param {string} text
154
+ * @param {number} maxKeywords
155
+ * @returns {string[]}
156
+ */
157
+ function extractKeywords(text, maxKeywords = 10) {
158
+ if (!text) return [];
159
+
160
+ // Common stop words (English + Korean particles)
161
+ const stopWords = new Set([
162
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'in', 'on', 'at',
163
+ 'to', 'for', 'of', 'with', 'by', 'from', 'and', 'or', 'not',
164
+ 'this', 'that', 'it', 'as', 'be', 'has', 'have', 'had', 'do',
165
+ 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'can',
166
+ ]);
167
+
168
+ const words = text
169
+ .toLowerCase()
170
+ .replace(/[^a-z0-9가-힣\s-]/g, ' ')
171
+ .split(/\s+/)
172
+ .filter(w => w.length >= 3 && !stopWords.has(w));
173
+
174
+ // Frequency count
175
+ const freq = {};
176
+ for (const w of words) {
177
+ freq[w] = (freq[w] || 0) + 1;
178
+ }
179
+
180
+ return Object.entries(freq)
181
+ .sort((a, b) => b[1] - a[1])
182
+ .slice(0, maxKeywords)
183
+ .map(([word]) => word);
184
+ }
185
+
186
+ module.exports = {
187
+ fromCheckpoint,
188
+ fromMemoryBridge,
189
+ fromMcpSession,
190
+ toResumePrompt,
191
+ extractKeywords,
192
+ };