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,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
|
+
};
|