@digitalforgestudios/openclaw-sulcus 3.5.5 → 3.8.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/README.md +40 -0
- package/bin/configure.mjs +502 -72
- package/hooks.defaults.json +8 -4
- package/index.ts +244 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,6 +51,46 @@ Then restart: `openclaw gateway restart`
|
|
|
51
51
|
| `memory_store` | Store with auto-detected type |
|
|
52
52
|
| `memory_forget` | Delete by ID |
|
|
53
53
|
|
|
54
|
+
## Hooks & Extended Tools
|
|
55
|
+
|
|
56
|
+
All hooks and tools are **enabled by default**. Override in your `openclaw.json` plugin config:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
"openclaw-sulcus": {
|
|
60
|
+
"enabled": true,
|
|
61
|
+
"config": {
|
|
62
|
+
"apiKey": "sk-YOUR_KEY_HERE",
|
|
63
|
+
"hooks": {
|
|
64
|
+
"before_prompt_build": { "action": "inject_awareness", "enabled": true },
|
|
65
|
+
"before_agent_start": { "action": "auto_recall", "enabled": true, "limit": 5, "minScore": 0.3 },
|
|
66
|
+
"agent_end": { "action": "none", "enabled": true }
|
|
67
|
+
},
|
|
68
|
+
"tools": {
|
|
69
|
+
"memory_recall": { "enabled": true },
|
|
70
|
+
"memory_store": { "enabled": true },
|
|
71
|
+
"memory_status": { "enabled": true },
|
|
72
|
+
"consolidate": { "enabled": true },
|
|
73
|
+
"export_markdown": { "enabled": true },
|
|
74
|
+
"import_markdown": { "enabled": true },
|
|
75
|
+
"evaluate_triggers": { "enabled": true }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Hook | Default | Description |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `before_prompt_build` | ON | Inject memory awareness context into prompts |
|
|
84
|
+
| `before_agent_start` | ON | Auto-recall relevant memories each turn |
|
|
85
|
+
| `agent_end` | ON | Post-turn processing |
|
|
86
|
+
|
|
87
|
+
| Extended Tool | Default | Description |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `consolidate` | ON | Cluster and merge similar memories |
|
|
90
|
+
| `export_markdown` | ON | Export memories as markdown |
|
|
91
|
+
| `import_markdown` | ON | Import memories from markdown |
|
|
92
|
+
| `evaluate_triggers` | ON | Run reactive trigger evaluations |
|
|
93
|
+
|
|
54
94
|
## Features
|
|
55
95
|
|
|
56
96
|
- **Auto-recall** — relevant memories injected before each agent turn
|
package/bin/configure.mjs
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Sulcus Configuration Wizard
|
|
4
|
-
* Interactive CLI to configure
|
|
4
|
+
* Interactive CLI to configure Sulcus for multiple AI tools.
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
|
-
* npx @digitalforgestudios/openclaw-sulcus configure
|
|
7
|
+
* npx @digitalforgestudios/openclaw-sulcus configure # OpenClaw
|
|
8
|
+
* npx @digitalforgestudios/openclaw-sulcus configure --claude # Claude CLI / Claude Code
|
|
9
|
+
* npx @digitalforgestudios/openclaw-sulcus configure --openai # OpenAI Codex CLI
|
|
10
|
+
* npx @digitalforgestudios/openclaw-sulcus configure --gemini # Google Gemini CLI
|
|
8
11
|
* node bin/configure.mjs [--no-color] [--help]
|
|
9
12
|
*/
|
|
10
13
|
|
|
@@ -43,31 +46,60 @@ const red = (s) => `${c.red}${s}${c.reset}`;
|
|
|
43
46
|
const cyan = (s) => `${c.cyan}${s}${c.reset}`;
|
|
44
47
|
const magenta = (s) => `${c.magenta}${s}${c.reset}`;
|
|
45
48
|
|
|
49
|
+
// ─── Mode detection ───────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const MODE_OPENCLAW = 'openclaw';
|
|
52
|
+
const MODE_CLAUDE = 'claude';
|
|
53
|
+
const MODE_OPENAI = 'openai';
|
|
54
|
+
const MODE_GEMINI = 'gemini';
|
|
55
|
+
|
|
56
|
+
function detectMode() {
|
|
57
|
+
if (process.argv.includes('--claude')) return MODE_CLAUDE;
|
|
58
|
+
if (process.argv.includes('--openai')) return MODE_OPENAI;
|
|
59
|
+
if (process.argv.includes('--gemini')) return MODE_GEMINI;
|
|
60
|
+
return MODE_OPENCLAW;
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
// ─── Help ─────────────────────────────────────────────────────────────────────
|
|
47
64
|
|
|
48
65
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
49
66
|
console.log(`
|
|
50
67
|
${bold('Sulcus Configuration Wizard')}
|
|
51
68
|
|
|
52
|
-
Interactively configure
|
|
69
|
+
Interactively configure Sulcus as an MCP server for your AI tools.
|
|
53
70
|
|
|
54
71
|
${bold('Usage:')}
|
|
55
|
-
npx @digitalforgestudios/openclaw-sulcus configure [options]
|
|
56
|
-
node bin/configure.mjs [options]
|
|
72
|
+
npx @digitalforgestudios/openclaw-sulcus configure [mode] [options]
|
|
73
|
+
node bin/configure.mjs [mode] [options]
|
|
74
|
+
|
|
75
|
+
${bold('Modes:')}
|
|
76
|
+
${dim('(no flag)')} Configure for ${cyan('OpenClaw')} (openclaw.json)
|
|
77
|
+
${cyan('--claude')} Configure for ${cyan('Claude CLI / Claude Code')} (~/.claude/claude_desktop_config.json)
|
|
78
|
+
${cyan('--openai')} Configure for ${cyan('OpenAI Codex CLI')} (~/.codex/config.toml)
|
|
79
|
+
${cyan('--gemini')} Configure for ${cyan('Google Gemini CLI')} (~/.gemini/settings.json)
|
|
57
80
|
|
|
58
81
|
${bold('Options:')}
|
|
59
82
|
--help, -h Show this help message
|
|
60
83
|
--no-color Disable coloured output
|
|
61
84
|
|
|
62
|
-
${bold('
|
|
63
|
-
1. Locates your openclaw.json (checks
|
|
85
|
+
${bold('OpenClaw mode (default):')}
|
|
86
|
+
1. Locates your openclaw.json (checks $OPENCLAW_CONFIG_PATH, ~/.openclaw/, ./)
|
|
64
87
|
2. Walks you through backend mode, dylib path, namespace, hooks, and tools
|
|
65
88
|
3. Deep-merges settings under plugins.entries.openclaw-sulcus.config
|
|
66
89
|
4. Validates that your native dylibs exist and warns if they are missing
|
|
67
90
|
5. Reminds you to restart the OpenClaw gateway
|
|
68
91
|
|
|
69
|
-
${bold('
|
|
92
|
+
${bold('MCP server modes (--claude / --openai / --gemini):')}
|
|
93
|
+
1. Detects if the target CLI is installed
|
|
94
|
+
2. Asks: Cloud mode (Sulcus API) or Local mode (binary path)?
|
|
95
|
+
3. Merges the sulcus MCP server entry into the target config
|
|
96
|
+
4. Preserves all existing config settings
|
|
97
|
+
|
|
98
|
+
${bold('Examples:')}
|
|
70
99
|
npx @digitalforgestudios/openclaw-sulcus configure
|
|
100
|
+
npx @digitalforgestudios/openclaw-sulcus configure --claude
|
|
101
|
+
npx @digitalforgestudios/openclaw-sulcus configure --openai
|
|
102
|
+
npx @digitalforgestudios/openclaw-sulcus configure --gemini
|
|
71
103
|
`);
|
|
72
104
|
process.exit(0);
|
|
73
105
|
}
|
|
@@ -101,8 +133,6 @@ function ask(question, defaultValue = '') {
|
|
|
101
133
|
|
|
102
134
|
/**
|
|
103
135
|
* Ask a yes/no question. Returns boolean.
|
|
104
|
-
* @param {string} question
|
|
105
|
-
* @param {boolean} defaultVal
|
|
106
136
|
*/
|
|
107
137
|
function askYN(question, defaultVal = false) {
|
|
108
138
|
return new Promise((resolve) => {
|
|
@@ -115,27 +145,13 @@ function askYN(question, defaultVal = false) {
|
|
|
115
145
|
});
|
|
116
146
|
}
|
|
117
147
|
|
|
118
|
-
// ───
|
|
148
|
+
// ─── Utility helpers ──────────────────────────────────────────────────────────
|
|
119
149
|
|
|
120
150
|
function expandHome(p) {
|
|
121
151
|
if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1));
|
|
122
152
|
return p;
|
|
123
153
|
}
|
|
124
154
|
|
|
125
|
-
function findOpenclawJson() {
|
|
126
|
-
const candidates = [
|
|
127
|
-
process.env.OPENCLAW_CONFIG_PATH,
|
|
128
|
-
path.join(os.homedir(), '.openclaw', 'openclaw.json'),
|
|
129
|
-
path.join(process.cwd(), 'openclaw.json'),
|
|
130
|
-
].filter(Boolean);
|
|
131
|
-
|
|
132
|
-
for (const candidate of candidates) {
|
|
133
|
-
const resolved = expandHome(candidate);
|
|
134
|
-
if (fs.existsSync(resolved)) return resolved;
|
|
135
|
-
}
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
155
|
// Deep-merge two plain objects (target mutated).
|
|
140
156
|
function deepMerge(target, source) {
|
|
141
157
|
for (const key of Object.keys(source)) {
|
|
@@ -155,12 +171,448 @@ function deepMerge(target, source) {
|
|
|
155
171
|
return target;
|
|
156
172
|
}
|
|
157
173
|
|
|
158
|
-
// ───
|
|
174
|
+
// ─── Binary detection (sulcus executable) ────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
const SULCUS_BINARY_SEARCH_PATHS = [
|
|
177
|
+
path.join(os.homedir(), '.sulcus', 'bin', 'sulcus'),
|
|
178
|
+
path.join(os.homedir(), '.local', 'bin', 'sulcus'),
|
|
179
|
+
'/usr/local/bin/sulcus',
|
|
180
|
+
];
|
|
159
181
|
|
|
160
182
|
/**
|
|
161
|
-
*
|
|
162
|
-
* Returns
|
|
183
|
+
* Try to locate the sulcus binary.
|
|
184
|
+
* Returns the absolute path if found, or null.
|
|
163
185
|
*/
|
|
186
|
+
function findSulcusBinary() {
|
|
187
|
+
for (const p of SULCUS_BINARY_SEARCH_PATHS) {
|
|
188
|
+
if (fs.existsSync(p)) return p;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const result = execSync('which sulcus', { stdio: 'pipe' }).toString().trim();
|
|
192
|
+
if (result && fs.existsSync(result)) return result;
|
|
193
|
+
} catch (_) {
|
|
194
|
+
// not on PATH
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Check if a CLI tool is installed by running `which <name>`.
|
|
201
|
+
*/
|
|
202
|
+
function isCliInstalled(name) {
|
|
203
|
+
try {
|
|
204
|
+
execSync(`which ${name}`, { stdio: 'pipe' });
|
|
205
|
+
return true;
|
|
206
|
+
} catch (_) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── JSON config helpers ──────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
function readJsonConfig(filePath) {
|
|
214
|
+
try {
|
|
215
|
+
if (!fs.existsSync(filePath)) return {};
|
|
216
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
217
|
+
} catch (_) {
|
|
218
|
+
return {};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function writeJsonConfig(filePath, data) {
|
|
223
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
224
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Minimal hand-rolled TOML helpers ────────────────────────────────────────
|
|
228
|
+
// Supports the subset needed for ~/.codex/config.toml:
|
|
229
|
+
// - Bare/quoted string values, integers, booleans
|
|
230
|
+
// - Arrays of quoted strings: key = ["a", "b"]
|
|
231
|
+
// - [table] and [table.subtable] headers
|
|
232
|
+
// - # line comments
|
|
233
|
+
|
|
234
|
+
function parseToml(src) {
|
|
235
|
+
const lines = src.split('\n');
|
|
236
|
+
const root = {};
|
|
237
|
+
let current = root;
|
|
238
|
+
|
|
239
|
+
for (let rawLine of lines) {
|
|
240
|
+
const line = rawLine.trim();
|
|
241
|
+
if (!line || line.startsWith('#')) continue;
|
|
242
|
+
|
|
243
|
+
// Table header: [a.b.c]
|
|
244
|
+
const tableMatch = line.match(/^\[([^\]]+)\]$/);
|
|
245
|
+
if (tableMatch) {
|
|
246
|
+
const parts = tableMatch[1].trim().split('.').map(s => s.trim());
|
|
247
|
+
current = root;
|
|
248
|
+
for (const part of parts) {
|
|
249
|
+
if (current[part] === undefined) current[part] = {};
|
|
250
|
+
else if (typeof current[part] !== 'object' || Array.isArray(current[part])) {
|
|
251
|
+
current[part] = {};
|
|
252
|
+
}
|
|
253
|
+
current = current[part];
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const eqIdx = line.indexOf('=');
|
|
259
|
+
if (eqIdx === -1) continue;
|
|
260
|
+
|
|
261
|
+
const key = line.slice(0, eqIdx).trim();
|
|
262
|
+
let valStr = line.slice(eqIdx + 1).trim();
|
|
263
|
+
let value;
|
|
264
|
+
|
|
265
|
+
if (valStr.startsWith('[')) {
|
|
266
|
+
// Array of strings
|
|
267
|
+
const inner = valStr.slice(1, valStr.lastIndexOf(']'));
|
|
268
|
+
value = inner
|
|
269
|
+
.split(',')
|
|
270
|
+
.map(s => s.trim().replace(/^["']|["']$/g, ''))
|
|
271
|
+
.filter(s => s.length > 0);
|
|
272
|
+
} else if (valStr.startsWith('"') || valStr.startsWith("'")) {
|
|
273
|
+
const q = valStr[0];
|
|
274
|
+
const end = valStr.indexOf(q, 1);
|
|
275
|
+
value = end === -1 ? valStr.slice(1) : valStr.slice(1, end);
|
|
276
|
+
} else if (valStr === 'true') {
|
|
277
|
+
value = true;
|
|
278
|
+
} else if (valStr === 'false') {
|
|
279
|
+
value = false;
|
|
280
|
+
} else {
|
|
281
|
+
const commentIdx = valStr.indexOf('#');
|
|
282
|
+
if (commentIdx !== -1) valStr = valStr.slice(0, commentIdx).trim();
|
|
283
|
+
const num = Number(valStr);
|
|
284
|
+
value = isNaN(num) || valStr === '' ? valStr : num;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
current[key] = value;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return root;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function serializeToml(obj, prefix = []) {
|
|
294
|
+
const scalarLines = [];
|
|
295
|
+
const subTables = [];
|
|
296
|
+
|
|
297
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
298
|
+
if (val === null || val === undefined) continue;
|
|
299
|
+
|
|
300
|
+
if (Array.isArray(val)) {
|
|
301
|
+
const items = val.map(v => JSON.stringify(String(v))).join(', ');
|
|
302
|
+
scalarLines.push(`${key} = [${items}]`);
|
|
303
|
+
} else if (typeof val === 'object') {
|
|
304
|
+
subTables.push([key, val]);
|
|
305
|
+
} else if (typeof val === 'string') {
|
|
306
|
+
scalarLines.push(`${key} = ${JSON.stringify(val)}`);
|
|
307
|
+
} else {
|
|
308
|
+
scalarLines.push(`${key} = ${val}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const parts = [...scalarLines];
|
|
313
|
+
|
|
314
|
+
for (const [key, val] of subTables) {
|
|
315
|
+
const tablePath = [...prefix, key];
|
|
316
|
+
parts.push('');
|
|
317
|
+
parts.push(`[${tablePath.join('.')}]`);
|
|
318
|
+
const inner = serializeToml(val, tablePath);
|
|
319
|
+
if (inner) parts.push(inner);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return parts.join('\n');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function readTomlConfig(filePath) {
|
|
326
|
+
try {
|
|
327
|
+
if (!fs.existsSync(filePath)) return {};
|
|
328
|
+
return parseToml(fs.readFileSync(filePath, 'utf8'));
|
|
329
|
+
} catch (_) {
|
|
330
|
+
return {};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function writeTomlConfig(filePath, data) {
|
|
335
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
336
|
+
const content = serializeToml(data).trimStart() + '\n';
|
|
337
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── Shared MCP server config prompts ────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Ask cloud vs local, collect info, return an MCP server config object:
|
|
344
|
+
* { command, args, env? }
|
|
345
|
+
*/
|
|
346
|
+
async function askMcpServerConfig() {
|
|
347
|
+
console.log();
|
|
348
|
+
console.log(` ${bold('Mode:')}`);
|
|
349
|
+
console.log(` ${cyan('[1]')} Cloud ${dim('(Sulcus API — requires server URL + API key)')}`);
|
|
350
|
+
console.log(` ${cyan('[2]')} Local ${dim('(sulcus binary installed on this machine)')}`);
|
|
351
|
+
const modeRaw = await ask(` >`, '1');
|
|
352
|
+
const isCloud = modeRaw !== '2';
|
|
353
|
+
console.log();
|
|
354
|
+
|
|
355
|
+
if (isCloud) {
|
|
356
|
+
const serverUrl = await ask(
|
|
357
|
+
` ${bold('Sulcus server URL:')}`,
|
|
358
|
+
'https://api.sulcus.ca',
|
|
359
|
+
);
|
|
360
|
+
const apiKey = await ask(` ${bold('Sulcus API key')} ${dim('(sk-...)')}:`, '');
|
|
361
|
+
console.log();
|
|
362
|
+
|
|
363
|
+
const env = {};
|
|
364
|
+
if (serverUrl) env.SULCUS_SERVER_URL = serverUrl;
|
|
365
|
+
if (apiKey) env.SULCUS_API_KEY = apiKey;
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
command: expandHome('~/.sulcus/bin/sulcus'),
|
|
369
|
+
args: ['stdio'],
|
|
370
|
+
...(Object.keys(env).length > 0 ? { env } : {}),
|
|
371
|
+
};
|
|
372
|
+
} else {
|
|
373
|
+
// Local mode — detect binary
|
|
374
|
+
const detected = findSulcusBinary();
|
|
375
|
+
if (detected) {
|
|
376
|
+
console.log(` ${green('✓')} Found sulcus binary: ${cyan(detected)}`);
|
|
377
|
+
} else {
|
|
378
|
+
console.log(` ${yellow('⚠')} sulcus binary not found in common locations.`);
|
|
379
|
+
console.log(` ${dim('Search paths checked:')}`);
|
|
380
|
+
for (const p of SULCUS_BINARY_SEARCH_PATHS) {
|
|
381
|
+
console.log(` ${dim('•')} ${dim(p)}`);
|
|
382
|
+
}
|
|
383
|
+
console.log(` ${dim('Download from:')} ${cyan('https://github.com/digitalforgeca/sulcus/releases/latest')}`);
|
|
384
|
+
console.log();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const binaryPath = await ask(
|
|
388
|
+
` ${bold('Path to sulcus binary:')}`,
|
|
389
|
+
detected || expandHome('~/.sulcus/bin/sulcus'),
|
|
390
|
+
);
|
|
391
|
+
console.log();
|
|
392
|
+
|
|
393
|
+
const wantCloudEnv = await askYN(
|
|
394
|
+
'Also add SULCUS_SERVER_URL / SULCUS_API_KEY? (for cloud sync)',
|
|
395
|
+
false,
|
|
396
|
+
);
|
|
397
|
+
console.log();
|
|
398
|
+
|
|
399
|
+
let env;
|
|
400
|
+
if (wantCloudEnv) {
|
|
401
|
+
const serverUrl = await ask(
|
|
402
|
+
` ${bold('Sulcus server URL:')}`,
|
|
403
|
+
'https://api.sulcus.ca',
|
|
404
|
+
);
|
|
405
|
+
const apiKey = await ask(` ${bold('Sulcus API key')} ${dim('(sk-...)')}:`, '');
|
|
406
|
+
console.log();
|
|
407
|
+
const e = {};
|
|
408
|
+
if (serverUrl) e.SULCUS_SERVER_URL = serverUrl;
|
|
409
|
+
if (apiKey) e.SULCUS_API_KEY = apiKey;
|
|
410
|
+
if (Object.keys(e).length > 0) env = e;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
command: binaryPath,
|
|
415
|
+
args: ['stdio'],
|
|
416
|
+
...(env ? { env } : {}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Print an MCP server summary block.
|
|
423
|
+
*/
|
|
424
|
+
function printMcpSummary(toolName, configFile, serverCfg) {
|
|
425
|
+
const displayFile = configFile.replace(os.homedir(), '~');
|
|
426
|
+
console.log(` ${dim('──── Summary ────────────────────────────────────────')}`);
|
|
427
|
+
console.log(` Tool: ${cyan(toolName)}`);
|
|
428
|
+
console.log(` Config: ${cyan(displayFile)}`);
|
|
429
|
+
console.log(` MCP server: ${cyan('sulcus')}`);
|
|
430
|
+
console.log(` Command: ${cyan(serverCfg.command)}`);
|
|
431
|
+
console.log(` Args: ${cyan(serverCfg.args.join(', '))}`);
|
|
432
|
+
if (serverCfg.env && Object.keys(serverCfg.env).length > 0) {
|
|
433
|
+
for (const [k, v] of Object.entries(serverCfg.env)) {
|
|
434
|
+
const display = k === 'SULCUS_API_KEY' && v && v.length > 8
|
|
435
|
+
? v.slice(0, 8) + '...'
|
|
436
|
+
: v;
|
|
437
|
+
console.log(` ${k}: ${cyan(display)}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
console.log(` ${dim('─────────────────────────────────────────────────────')}`);
|
|
441
|
+
console.log();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ─── Claude wizard ────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
async function runClaudeWizard() {
|
|
447
|
+
const configFile = expandHome('~/.claude/claude_desktop_config.json');
|
|
448
|
+
|
|
449
|
+
console.log(`
|
|
450
|
+
${bold(magenta('🧠 Sulcus → Claude CLI Configuration Wizard'))}
|
|
451
|
+
${dim('──────────────────────────────────────────────────────')}
|
|
452
|
+
Configures Sulcus as an MCP server for ${cyan('Claude CLI / Claude Code')}.
|
|
453
|
+
Config file: ${cyan('~/.claude/claude_desktop_config.json')}
|
|
454
|
+
Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any time.
|
|
455
|
+
`);
|
|
456
|
+
|
|
457
|
+
// Detect claude CLI
|
|
458
|
+
const claudeInstalled = isCliInstalled('claude');
|
|
459
|
+
if (claudeInstalled) {
|
|
460
|
+
console.log(` ${green('✓')} claude CLI detected on PATH`);
|
|
461
|
+
} else {
|
|
462
|
+
console.log(` ${yellow('⚠')} claude CLI not found on PATH`);
|
|
463
|
+
console.log(` ${dim('Install Claude Code from:')} ${cyan('https://claude.ai/download')}`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const serverCfg = await askMcpServerConfig();
|
|
467
|
+
|
|
468
|
+
console.log(`${bold('Writing config...')}`);
|
|
469
|
+
|
|
470
|
+
let existing = readJsonConfig(configFile);
|
|
471
|
+
if (!existing.mcpServers) existing.mcpServers = {};
|
|
472
|
+
existing.mcpServers.sulcus = serverCfg;
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
writeJsonConfig(configFile, existing);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
console.log(` ${red('✗')} Failed to write ${cyan(configFile)}: ${err.message}`);
|
|
478
|
+
rl.close();
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
console.log(` ${green('✓')} Written to ${cyan(configFile.replace(os.homedir(), '~'))}`);
|
|
483
|
+
console.log();
|
|
484
|
+
|
|
485
|
+
printMcpSummary('Claude CLI / Claude Code', configFile, serverCfg);
|
|
486
|
+
|
|
487
|
+
console.log(` ${bold(green('✅ Configuration complete!'))} Restart Claude to pick up changes.`);
|
|
488
|
+
console.log();
|
|
489
|
+
|
|
490
|
+
rl.close();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ─── OpenAI Codex wizard ──────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
async function runOpenAIWizard() {
|
|
496
|
+
const configFile = expandHome('~/.codex/config.toml');
|
|
497
|
+
|
|
498
|
+
console.log(`
|
|
499
|
+
${bold(magenta('🧠 Sulcus → OpenAI Codex CLI Configuration Wizard'))}
|
|
500
|
+
${dim('────────────────────────────────────────────────────────')}
|
|
501
|
+
Configures Sulcus as an MCP server for ${cyan('OpenAI Codex CLI')}.
|
|
502
|
+
Config file: ${cyan('~/.codex/config.toml')}
|
|
503
|
+
Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any time.
|
|
504
|
+
`);
|
|
505
|
+
|
|
506
|
+
// Detect codex CLI
|
|
507
|
+
const codexInstalled = isCliInstalled('codex');
|
|
508
|
+
if (codexInstalled) {
|
|
509
|
+
console.log(` ${green('✓')} codex CLI detected on PATH`);
|
|
510
|
+
} else {
|
|
511
|
+
console.log(` ${yellow('⚠')} codex CLI not found on PATH`);
|
|
512
|
+
console.log(` ${dim('Install from:')} ${cyan('https://github.com/openai/codex')}`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const serverCfg = await askMcpServerConfig();
|
|
516
|
+
|
|
517
|
+
console.log(`${bold('Writing config...')}`);
|
|
518
|
+
|
|
519
|
+
// Read existing TOML and merge in the sulcus entry
|
|
520
|
+
let existing = readTomlConfig(configFile);
|
|
521
|
+
if (!existing.mcp_servers) existing.mcp_servers = {};
|
|
522
|
+
existing.mcp_servers.sulcus = {
|
|
523
|
+
command: serverCfg.command,
|
|
524
|
+
args: serverCfg.args,
|
|
525
|
+
...(serverCfg.env && Object.keys(serverCfg.env).length > 0
|
|
526
|
+
? { env: serverCfg.env }
|
|
527
|
+
: {}),
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
writeTomlConfig(configFile, existing);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
console.log(` ${red('✗')} Failed to write ${cyan(configFile)}: ${err.message}`);
|
|
534
|
+
rl.close();
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
console.log(` ${green('✓')} Written to ${cyan(configFile.replace(os.homedir(), '~'))}`);
|
|
539
|
+
console.log();
|
|
540
|
+
|
|
541
|
+
printMcpSummary('OpenAI Codex CLI', configFile, serverCfg);
|
|
542
|
+
|
|
543
|
+
console.log(` ${bold(green('✅ Configuration complete!'))} Restart Codex to pick up changes.`);
|
|
544
|
+
console.log();
|
|
545
|
+
|
|
546
|
+
rl.close();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ─── Gemini wizard ────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
async function runGeminiWizard() {
|
|
552
|
+
const configFile = expandHome('~/.gemini/settings.json');
|
|
553
|
+
|
|
554
|
+
console.log(`
|
|
555
|
+
${bold(magenta('🧠 Sulcus → Google Gemini CLI Configuration Wizard'))}
|
|
556
|
+
${dim('──────────────────────────────────────────────────────────')}
|
|
557
|
+
Configures Sulcus as an MCP server for ${cyan('Google Gemini CLI')}.
|
|
558
|
+
Config file: ${cyan('~/.gemini/settings.json')}
|
|
559
|
+
Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any time.
|
|
560
|
+
`);
|
|
561
|
+
|
|
562
|
+
// Detect gemini CLI
|
|
563
|
+
const geminiInstalled = isCliInstalled('gemini');
|
|
564
|
+
if (geminiInstalled) {
|
|
565
|
+
console.log(` ${green('✓')} gemini CLI detected on PATH`);
|
|
566
|
+
} else {
|
|
567
|
+
console.log(` ${yellow('⚠')} gemini CLI not found on PATH`);
|
|
568
|
+
console.log(` ${dim('Install from:')} ${cyan('https://github.com/google-gemini/gemini-cli')}`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const serverCfg = await askMcpServerConfig();
|
|
572
|
+
|
|
573
|
+
console.log(`${bold('Writing config...')}`);
|
|
574
|
+
|
|
575
|
+
let existing = readJsonConfig(configFile);
|
|
576
|
+
if (!existing.mcpServers) existing.mcpServers = {};
|
|
577
|
+
existing.mcpServers.sulcus = serverCfg;
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
writeJsonConfig(configFile, existing);
|
|
581
|
+
} catch (err) {
|
|
582
|
+
console.log(` ${red('✗')} Failed to write ${cyan(configFile)}: ${err.message}`);
|
|
583
|
+
rl.close();
|
|
584
|
+
process.exit(1);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
console.log(` ${green('✓')} Written to ${cyan(configFile.replace(os.homedir(), '~'))}`);
|
|
588
|
+
console.log();
|
|
589
|
+
|
|
590
|
+
printMcpSummary('Google Gemini CLI', configFile, serverCfg);
|
|
591
|
+
|
|
592
|
+
console.log(` ${bold(green('✅ Configuration complete!'))} Restart Gemini CLI to pick up changes.`);
|
|
593
|
+
console.log();
|
|
594
|
+
|
|
595
|
+
rl.close();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ─── openclaw.json discovery ──────────────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
function findOpenclawJson() {
|
|
601
|
+
const candidates = [
|
|
602
|
+
process.env.OPENCLAW_CONFIG_PATH,
|
|
603
|
+
path.join(os.homedir(), '.openclaw', 'openclaw.json'),
|
|
604
|
+
path.join(process.cwd(), 'openclaw.json'),
|
|
605
|
+
].filter(Boolean);
|
|
606
|
+
|
|
607
|
+
for (const candidate of candidates) {
|
|
608
|
+
const resolved = expandHome(candidate);
|
|
609
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
610
|
+
}
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ─── Prebuilt binary download (dylibs for OpenClaw mode) ─────────────────────
|
|
615
|
+
|
|
164
616
|
function detectPlatform() {
|
|
165
617
|
const plat = process.platform;
|
|
166
618
|
const arch = process.arch;
|
|
@@ -178,11 +630,6 @@ function detectPlatform() {
|
|
|
178
630
|
);
|
|
179
631
|
}
|
|
180
632
|
|
|
181
|
-
/**
|
|
182
|
-
* Follow redirects and download `url` into `destFile`.
|
|
183
|
-
* Shows a simple percentage progress bar (or dots when content-length is unknown).
|
|
184
|
-
* Follows up to maxRedirects hops.
|
|
185
|
-
*/
|
|
186
633
|
function downloadFile(url, destFile, maxRedirects = 5) {
|
|
187
634
|
return new Promise((resolve, reject) => {
|
|
188
635
|
let hops = 0;
|
|
@@ -204,13 +651,12 @@ function downloadFile(url, destFile, maxRedirects = 5) {
|
|
|
204
651
|
const req = https.request(opts, (res) => {
|
|
205
652
|
const { statusCode, headers: resHeaders } = res;
|
|
206
653
|
|
|
207
|
-
// Follow 301/302/307/308 redirects
|
|
208
654
|
if (
|
|
209
655
|
(statusCode === 301 || statusCode === 302 ||
|
|
210
656
|
statusCode === 307 || statusCode === 308) &&
|
|
211
657
|
resHeaders.location
|
|
212
658
|
) {
|
|
213
|
-
res.resume();
|
|
659
|
+
res.resume();
|
|
214
660
|
return attempt(resHeaders.location);
|
|
215
661
|
}
|
|
216
662
|
|
|
@@ -236,7 +682,6 @@ function downloadFile(url, destFile, maxRedirects = 5) {
|
|
|
236
682
|
process.stdout.write(`\r Downloading... ${pct}% `);
|
|
237
683
|
}
|
|
238
684
|
} else {
|
|
239
|
-
// No content-length — show dots
|
|
240
685
|
if (received % (64 * 1024) === 0) process.stdout.write('.');
|
|
241
686
|
}
|
|
242
687
|
});
|
|
@@ -262,13 +707,6 @@ function downloadFile(url, destFile, maxRedirects = 5) {
|
|
|
262
707
|
});
|
|
263
708
|
}
|
|
264
709
|
|
|
265
|
-
/**
|
|
266
|
-
* Download and install prebuilt dylibs for the current platform.
|
|
267
|
-
* Returns true on success, false if the user skips or something goes wrong.
|
|
268
|
-
*
|
|
269
|
-
* @param {string} resolvedLibDir Absolute path where dylibs should be placed
|
|
270
|
-
* @param {string[]} dylibNames Base names without extension, e.g. ['libsulcus_store', ...]
|
|
271
|
-
*/
|
|
272
710
|
async function downloadAndInstallBinaries(resolvedLibDir, dylibNames) {
|
|
273
711
|
let platformInfo;
|
|
274
712
|
try {
|
|
@@ -292,7 +730,6 @@ async function downloadAndInstallBinaries(resolvedLibDir, dylibNames) {
|
|
|
292
730
|
return false;
|
|
293
731
|
}
|
|
294
732
|
|
|
295
|
-
// Create libDir if needed
|
|
296
733
|
try {
|
|
297
734
|
fs.mkdirSync(resolvedLibDir, { recursive: true });
|
|
298
735
|
} catch (err) {
|
|
@@ -301,6 +738,7 @@ async function downloadAndInstallBinaries(resolvedLibDir, dylibNames) {
|
|
|
301
738
|
return false;
|
|
302
739
|
}
|
|
303
740
|
|
|
741
|
+
|
|
304
742
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sulcus-'));
|
|
305
743
|
const tarPath = path.join(tmpDir, `sulcus-${platform}.tar.gz`);
|
|
306
744
|
|
|
@@ -317,7 +755,6 @@ async function downloadAndInstallBinaries(resolvedLibDir, dylibNames) {
|
|
|
317
755
|
return false;
|
|
318
756
|
}
|
|
319
757
|
|
|
320
|
-
// Extract
|
|
321
758
|
console.log(` Extracting...`);
|
|
322
759
|
try {
|
|
323
760
|
execSync(`tar xzf ${JSON.stringify(tarPath)} -C ${JSON.stringify(tmpDir)}`, { stdio: 'pipe' });
|
|
@@ -327,7 +764,6 @@ async function downloadAndInstallBinaries(resolvedLibDir, dylibNames) {
|
|
|
327
764
|
return false;
|
|
328
765
|
}
|
|
329
766
|
|
|
330
|
-
// Move each dylib into libDir
|
|
331
767
|
let allInstalled = true;
|
|
332
768
|
for (const lib of dylibNames) {
|
|
333
769
|
const srcFile = path.join(tmpDir, lib + ext);
|
|
@@ -351,15 +787,14 @@ async function downloadAndInstallBinaries(resolvedLibDir, dylibNames) {
|
|
|
351
787
|
}
|
|
352
788
|
}
|
|
353
789
|
|
|
354
|
-
// Cleanup temp dir
|
|
355
790
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
|
356
791
|
|
|
357
792
|
return allInstalled;
|
|
358
793
|
}
|
|
359
794
|
|
|
360
|
-
// ───
|
|
795
|
+
// ─── OpenClaw wizard (original, unchanged) ───────────────────────────────────
|
|
361
796
|
|
|
362
|
-
async function
|
|
797
|
+
async function runOpenclawWizard() {
|
|
363
798
|
console.log(`
|
|
364
799
|
${bold(magenta('🧠 Sulcus Configuration Wizard'))}
|
|
365
800
|
${dim('────────────────────────────────────────────')}
|
|
@@ -367,7 +802,7 @@ Configures the ${cyan('openclaw-sulcus')} plugin inside your ${cyan('openclaw.js
|
|
|
367
802
|
Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any time.
|
|
368
803
|
`);
|
|
369
804
|
|
|
370
|
-
// ── Step 1: Locate openclaw.json
|
|
805
|
+
// ── Step 1: Locate openclaw.json ─────────────────────────────────────────
|
|
371
806
|
|
|
372
807
|
console.log(`${bold('Step 1 · Locate openclaw.json')}`);
|
|
373
808
|
|
|
@@ -379,7 +814,7 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
379
814
|
console.log(` ${yellow('⚠')} Could not find openclaw.json in the usual locations.`);
|
|
380
815
|
console.log(` Checked:`);
|
|
381
816
|
if (process.env.OPENCLAW_CONFIG_PATH)
|
|
382
|
-
console.log(` •
|
|
817
|
+
console.log(` • $OPENCLAW_CONFIG_PATH → ${process.env.OPENCLAW_CONFIG_PATH}`);
|
|
383
818
|
console.log(` • ~/.openclaw/openclaw.json`);
|
|
384
819
|
console.log(` • ./openclaw.json\n`);
|
|
385
820
|
|
|
@@ -399,7 +834,6 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
399
834
|
}
|
|
400
835
|
}
|
|
401
836
|
|
|
402
|
-
// Read existing config
|
|
403
837
|
let existingConfig = {};
|
|
404
838
|
try {
|
|
405
839
|
existingConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
@@ -410,12 +844,11 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
410
844
|
process.exit(1);
|
|
411
845
|
}
|
|
412
846
|
|
|
413
|
-
// ── Step 2: Wizard questions
|
|
847
|
+
// ── Step 2: Wizard questions ─────────────────────────────────────────────
|
|
414
848
|
|
|
415
849
|
console.log(`${bold('Step 2 · Configure Sulcus')}`);
|
|
416
850
|
console.log();
|
|
417
851
|
|
|
418
|
-
// Backend mode
|
|
419
852
|
console.log(` ${bold('Backend mode:')}`);
|
|
420
853
|
console.log(` ${cyan('[1]')} Local only ${dim('(WASM + native dylibs, no network)')}`);
|
|
421
854
|
console.log(` ${cyan('[2]')} Cloud sync ${dim('(local + server replication)')}`);
|
|
@@ -423,7 +856,6 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
423
856
|
const cloudSync = modeRaw === '2';
|
|
424
857
|
console.log();
|
|
425
858
|
|
|
426
|
-
// Native dylib path
|
|
427
859
|
const libDirDefault = '~/.sulcus/lib';
|
|
428
860
|
const libDirRaw = await ask(
|
|
429
861
|
` ${bold('Where are your native dylibs?')}`,
|
|
@@ -432,11 +864,9 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
432
864
|
const libDir = libDirRaw;
|
|
433
865
|
console.log();
|
|
434
866
|
|
|
435
|
-
// Agent namespace
|
|
436
867
|
const namespace = await ask(` ${bold('Agent namespace:')}`, 'default');
|
|
437
868
|
console.log();
|
|
438
869
|
|
|
439
|
-
// Hooks
|
|
440
870
|
console.log(` ${bold('Enable hooks:')}`);
|
|
441
871
|
const injectAwareness = await askYN(
|
|
442
872
|
'Inject memory awareness into prompts? (before_prompt_build)',
|
|
@@ -448,7 +878,6 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
448
878
|
);
|
|
449
879
|
console.log();
|
|
450
880
|
|
|
451
|
-
// Tools
|
|
452
881
|
console.log(` ${bold('Enable tools:')}`);
|
|
453
882
|
const toolMemoryRecall = await askYN('memory_recall — search memories', true);
|
|
454
883
|
const toolMemoryStore = await askYN('memory_store — save memories', true);
|
|
@@ -459,7 +888,6 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
459
888
|
const toolEvalTriggers = await askYN('evaluate_triggers — reactive trigger engine', false);
|
|
460
889
|
console.log();
|
|
461
890
|
|
|
462
|
-
// Cloud sync extras
|
|
463
891
|
let serverUrl = '';
|
|
464
892
|
let apiKey = '';
|
|
465
893
|
if (cloudSync) {
|
|
@@ -469,7 +897,7 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
469
897
|
console.log();
|
|
470
898
|
}
|
|
471
899
|
|
|
472
|
-
// ── Step 3: Build and write config
|
|
900
|
+
// ── Step 3: Build and write config ──────────────────────────────────────
|
|
473
901
|
|
|
474
902
|
console.log(`${bold('Step 3 · Write openclaw.json')}`);
|
|
475
903
|
|
|
@@ -493,12 +921,10 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
493
921
|
},
|
|
494
922
|
};
|
|
495
923
|
|
|
496
|
-
// Remove undefined keys
|
|
497
924
|
Object.keys(sulcusConfig).forEach(
|
|
498
925
|
(k) => sulcusConfig[k] === undefined && delete sulcusConfig[k],
|
|
499
926
|
);
|
|
500
927
|
|
|
501
|
-
// Deep-merge into existing config
|
|
502
928
|
const merged = deepMerge(existingConfig, {
|
|
503
929
|
plugins: {
|
|
504
930
|
entries: {
|
|
@@ -524,7 +950,6 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
524
950
|
console.log(` ${green('✓')} Written to ${cyan(configPath)}`);
|
|
525
951
|
console.log();
|
|
526
952
|
|
|
527
|
-
// Summary
|
|
528
953
|
console.log(` ${dim('──── Summary ────────────────────────────────────')}`);
|
|
529
954
|
console.log(` Plugin: ${cyan('openclaw-sulcus')} ${green('enabled')}`);
|
|
530
955
|
console.log(` Backend: ${cyan(cloudSync ? 'cloud sync' : 'local only')}`);
|
|
@@ -551,7 +976,7 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
551
976
|
console.log();
|
|
552
977
|
}
|
|
553
978
|
|
|
554
|
-
// ── Step 4: Validate dylib path (+ auto-download if missing)
|
|
979
|
+
// ── Step 4: Validate dylib path (+ auto-download if missing) ────────────
|
|
555
980
|
|
|
556
981
|
console.log(`${bold('Step 4 · Validate')}`);
|
|
557
982
|
|
|
@@ -561,9 +986,6 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
561
986
|
: process.platform === 'win32' ? '.dll'
|
|
562
987
|
: '.so';
|
|
563
988
|
|
|
564
|
-
/**
|
|
565
|
-
* Check which dylibs are present. Returns true when all are found.
|
|
566
|
-
*/
|
|
567
989
|
function checkDylibs() {
|
|
568
990
|
if (!fs.existsSync(resolvedLibDir)) return false;
|
|
569
991
|
return dylibNames.every((lib) => fs.existsSync(path.join(resolvedLibDir, lib + ext)));
|
|
@@ -572,24 +994,19 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
572
994
|
let dylibsOk = checkDylibs();
|
|
573
995
|
|
|
574
996
|
if (dylibsOk) {
|
|
575
|
-
// All present — just print them
|
|
576
997
|
for (const lib of dylibNames) {
|
|
577
998
|
console.log(` ${green('✓')} Found: ${dim(path.join(resolvedLibDir, lib + ext))}`);
|
|
578
999
|
}
|
|
579
1000
|
} else {
|
|
580
|
-
// Some or all missing — try auto-download
|
|
581
1001
|
const downloaded = await downloadAndInstallBinaries(resolvedLibDir, dylibNames);
|
|
582
1002
|
|
|
583
1003
|
if (downloaded) {
|
|
584
|
-
// Re-validate after successful download
|
|
585
1004
|
dylibsOk = checkDylibs();
|
|
586
1005
|
if (!dylibsOk) {
|
|
587
1006
|
console.log(` ${yellow('⚠')} Some dylibs still missing after installation.`);
|
|
588
1007
|
}
|
|
589
1008
|
} else if (!downloaded) {
|
|
590
|
-
// Download skipped or failed — show manual instructions
|
|
591
1009
|
if (fs.existsSync(resolvedLibDir)) {
|
|
592
|
-
// Directory exists but files missing — list what we found / didn't find
|
|
593
1010
|
for (const lib of dylibNames) {
|
|
594
1011
|
const full = path.join(resolvedLibDir, lib + ext);
|
|
595
1012
|
if (fs.existsSync(full)) {
|
|
@@ -619,6 +1036,19 @@ Press ${bold('Enter')} to accept defaults. ${bold('Ctrl+C')} to cancel at any ti
|
|
|
619
1036
|
rl.close();
|
|
620
1037
|
}
|
|
621
1038
|
|
|
1039
|
+
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
1040
|
+
|
|
1041
|
+
async function run() {
|
|
1042
|
+
const mode = detectMode();
|
|
1043
|
+
|
|
1044
|
+
switch (mode) {
|
|
1045
|
+
case MODE_CLAUDE: return runClaudeWizard();
|
|
1046
|
+
case MODE_OPENAI: return runOpenAIWizard();
|
|
1047
|
+
case MODE_GEMINI: return runGeminiWizard();
|
|
1048
|
+
default: return runOpenclawWizard();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
622
1052
|
run().catch((err) => {
|
|
623
1053
|
console.error(`\n${red('Fatal error:')} ${err.message}\n`);
|
|
624
1054
|
rl.close();
|
package/hooks.defaults.json
CHANGED
|
@@ -18,12 +18,16 @@
|
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
20
|
"tools": {
|
|
21
|
-
"memory_recall": { "enabled":
|
|
22
|
-
"memory_store": { "enabled":
|
|
23
|
-
"memory_status": { "enabled":
|
|
21
|
+
"memory_recall": { "enabled": true },
|
|
22
|
+
"memory_store": { "enabled": true },
|
|
23
|
+
"memory_status": { "enabled": true },
|
|
24
24
|
"consolidate": { "enabled": false },
|
|
25
25
|
"export_markdown": { "enabled": false },
|
|
26
26
|
"import_markdown": { "enabled": false },
|
|
27
|
-
"evaluate_triggers": { "enabled": false }
|
|
27
|
+
"evaluate_triggers": { "enabled": false },
|
|
28
|
+
"siu_label": { "enabled": false },
|
|
29
|
+
"siu_status": { "enabled": false },
|
|
30
|
+
"siu_retrain": { "enabled": false },
|
|
31
|
+
"trigger_feedback": { "enabled": false }
|
|
28
32
|
}
|
|
29
33
|
}
|
package/index.ts
CHANGED
|
@@ -98,12 +98,14 @@ const hookHandlers: Record<string, HookHandler> = {
|
|
|
98
98
|
auto_recall: async (event: any, config: HookConfig, ctx: HookHandlerCtx) => {
|
|
99
99
|
const { sulcusMem, namespace, logger } = ctx;
|
|
100
100
|
if (!sulcusMem) return;
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
const agentLabel = event?.agentId ?? "(unknown)";
|
|
102
|
+
logger.info(`sulcus: before_agent_start hook triggered for agent ${agentLabel}`);
|
|
103
|
+
const prompt = typeof event?.prompt === "string" ? event.prompt : "";
|
|
104
|
+
if (!prompt) return;
|
|
103
105
|
try {
|
|
104
106
|
const limit = config.limit ?? 5;
|
|
105
|
-
logger.debug(`sulcus: searching context for prompt: ${
|
|
106
|
-
const res = await sulcusMem.search_memory(
|
|
107
|
+
logger.debug(`sulcus: searching context for prompt: ${prompt.substring(0, 50)}...`);
|
|
108
|
+
const res = await sulcusMem.search_memory(prompt, limit);
|
|
107
109
|
const results = res?.results ?? [];
|
|
108
110
|
if (!results || results.length === 0) {
|
|
109
111
|
return { prependSystemContext: FALLBACK_AWARENESS };
|
|
@@ -129,6 +131,74 @@ const hookHandlers: Record<string, HookHandler> = {
|
|
|
129
131
|
none: async (event: any, _config: HookConfig, ctx: HookHandlerCtx) => {
|
|
130
132
|
ctx.logger.debug(`sulcus: hook fired (action=none) for agent ${event.agentId ?? "(unknown)"} (no-op)`);
|
|
131
133
|
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* sivu_auto_capture — SIU v2 quality-gated auto-capture on agent_end.
|
|
137
|
+
*
|
|
138
|
+
* When fired after each turn, extracts the user message from the event,
|
|
139
|
+
* runs it through SIVU (store/reject gate) and SICU (type classifier),
|
|
140
|
+
* and stores the memory only if SIVU approves. Falls back to basic
|
|
141
|
+
* junk-filtering + episodic capture if SIU v2 endpoint is unavailable.
|
|
142
|
+
*
|
|
143
|
+
* Config options:
|
|
144
|
+
* min_store_confidence: number (default 0.5) — minimum SIVU confidence to store
|
|
145
|
+
* fallback_on_error: boolean (default true) — store as episodic if SIU unavailable
|
|
146
|
+
*/
|
|
147
|
+
sivu_auto_capture: async (event: any, config: HookConfig, ctx: HookHandlerCtx) => {
|
|
148
|
+
const { sulcusMem, logger } = ctx;
|
|
149
|
+
if (!sulcusMem) return;
|
|
150
|
+
|
|
151
|
+
// Extract user message from the event
|
|
152
|
+
const userMessage = event?.userMessage ?? event?.prompt ?? event?.text ?? "";
|
|
153
|
+
if (!userMessage || typeof userMessage !== "string") {
|
|
154
|
+
logger.debug("sulcus: sivu_auto_capture — no user message in event, skipping");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Pre-filter obvious junk before hitting the API
|
|
159
|
+
if (isJunkMemory(userMessage)) {
|
|
160
|
+
logger.debug(`sulcus: sivu_auto_capture — pre-filtered junk: "${userMessage.substring(0, 50)}..."`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const minConfidence = config.min_store_confidence ?? 0.5;
|
|
165
|
+
const fallbackOnError = config.fallback_on_error !== false; // default true
|
|
166
|
+
|
|
167
|
+
// Try SIU v2 endpoint for quality-gated classification
|
|
168
|
+
if (sulcusMem instanceof SulcusCloudClient) {
|
|
169
|
+
try {
|
|
170
|
+
const siuResult = await (sulcusMem as SulcusCloudClient).request("POST", "/api/v2/siu/label", {
|
|
171
|
+
text: userMessage,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const shouldStore = siuResult?.store === true && (siuResult?.store_confidence ?? 0) >= minConfidence;
|
|
175
|
+
const memoryType = siuResult?.memory_type ?? "episodic";
|
|
176
|
+
const modelVersion = siuResult?.model_version ?? "unknown";
|
|
177
|
+
|
|
178
|
+
if (!shouldStore) {
|
|
179
|
+
logger.info(`sulcus: sivu_auto_capture — SIVU rejected (confidence: ${siuResult?.store_confidence?.toFixed(3) ?? "?"}, model: ${modelVersion}): "${userMessage.substring(0, 60)}..."`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// SIVU approved — store with SICU-predicted type
|
|
184
|
+
const res = await sulcusMem.add_memory(userMessage, memoryType);
|
|
185
|
+
logger.info(`sulcus: sivu_auto_capture — stored [${memoryType}] (id: ${res?.id ?? "?"}, sivu_conf: ${siuResult?.store_confidence?.toFixed(3)}, sicu_conf: ${siuResult?.type_confidence?.toFixed(3)}, model: ${modelVersion}): "${userMessage.substring(0, 60)}..."`);
|
|
186
|
+
return;
|
|
187
|
+
} catch (e: any) {
|
|
188
|
+
logger.warn(`sulcus: sivu_auto_capture — SIU v2 endpoint error: ${e.message}`);
|
|
189
|
+
if (!fallbackOnError) return;
|
|
190
|
+
// Fall through to basic capture
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Fallback: store as episodic (no SIU gating available)
|
|
195
|
+
try {
|
|
196
|
+
const res = await sulcusMem.add_memory(userMessage, "episodic");
|
|
197
|
+
logger.info(`sulcus: sivu_auto_capture — fallback stored [episodic] (id: ${res?.id ?? "?"}): "${userMessage.substring(0, 60)}..."`);
|
|
198
|
+
} catch (e: any) {
|
|
199
|
+
logger.warn(`sulcus: sivu_auto_capture — fallback store failed: ${e.message}`);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
132
202
|
};
|
|
133
203
|
|
|
134
204
|
// ─── CLOUD HTTP CLIENT ───────────────────────────────────────────────────────
|
|
@@ -147,7 +217,7 @@ class SulcusCloudClient {
|
|
|
147
217
|
}
|
|
148
218
|
|
|
149
219
|
/** Low-level HTTP helper. Returns parsed JSON response body. */
|
|
150
|
-
|
|
220
|
+
request(method: string, path: string, body?: any): Promise<any> {
|
|
151
221
|
return new Promise((resolve, reject) => {
|
|
152
222
|
let parsedUrl: URL;
|
|
153
223
|
try {
|
|
@@ -519,6 +589,8 @@ interface ToolDeps {
|
|
|
519
589
|
wasmDir: string;
|
|
520
590
|
logger: any;
|
|
521
591
|
isAvailable: boolean;
|
|
592
|
+
/** HTTP request helper for SIU v2 endpoints — null when cloud backend is not configured. */
|
|
593
|
+
siuRequest: ((method: string, path: string, body?: any) => Promise<any>) | null;
|
|
522
594
|
}
|
|
523
595
|
|
|
524
596
|
const toolDefinitions: Record<string, ToolDefinition> = {
|
|
@@ -729,6 +801,155 @@ const toolDefinitions: Record<string, ToolDefinition> = {
|
|
|
729
801
|
};
|
|
730
802
|
},
|
|
731
803
|
},
|
|
804
|
+
|
|
805
|
+
// ── SIU v2 Tools ───────────────────────────────────────────────────────────
|
|
806
|
+
// These tools call the SIU v2 server endpoints for text classification.
|
|
807
|
+
// Requires cloud backend (serverUrl + apiKey). Uses /api/v2/siu/* endpoints.
|
|
808
|
+
|
|
809
|
+
siu_label: {
|
|
810
|
+
schema: {
|
|
811
|
+
name: "siu_label",
|
|
812
|
+
label: "SIU Label",
|
|
813
|
+
description: "Classify text using SIU v2 — returns SIVU store/reject decision and SICU memory type classification with confidence scores.",
|
|
814
|
+
parameters: Type.Object({
|
|
815
|
+
text: Type.String({ description: "Text to classify." }),
|
|
816
|
+
classify_only: Type.Optional(Type.Boolean({ description: "Skip SIVU quality gate, only run SICU type classification." })),
|
|
817
|
+
}),
|
|
818
|
+
},
|
|
819
|
+
options: { name: "siu_label" },
|
|
820
|
+
makeExecute: ({ backendMode, siuRequest, logger }) =>
|
|
821
|
+
async (_id: string, params: any) => {
|
|
822
|
+
if (!siuRequest) {
|
|
823
|
+
return {
|
|
824
|
+
content: [{ type: "text", text: "SIU label requires cloud backend (serverUrl + apiKey)." }],
|
|
825
|
+
details: { error: "cloud_required" },
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
try {
|
|
829
|
+
const res = await siuRequest("POST", "/api/v2/siu/label", {
|
|
830
|
+
text: params.text,
|
|
831
|
+
classify_only: params.classify_only ?? false,
|
|
832
|
+
});
|
|
833
|
+
return {
|
|
834
|
+
content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
|
|
835
|
+
details: res,
|
|
836
|
+
};
|
|
837
|
+
} catch (e: any) {
|
|
838
|
+
logger.warn(`sulcus: siu_label failed: ${e.message}`);
|
|
839
|
+
return {
|
|
840
|
+
content: [{ type: "text", text: `SIU label failed: ${e.message}` }],
|
|
841
|
+
details: { error: e.message },
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
|
|
847
|
+
siu_status: {
|
|
848
|
+
schema: {
|
|
849
|
+
name: "siu_status",
|
|
850
|
+
label: "SIU Status",
|
|
851
|
+
description: "Check SIU v2 model availability, deployed versions, and training signal statistics.",
|
|
852
|
+
parameters: Type.Object({}),
|
|
853
|
+
},
|
|
854
|
+
options: { name: "siu_status" },
|
|
855
|
+
makeExecute: ({ siuRequest, logger }) =>
|
|
856
|
+
async (_id: string, _params: any) => {
|
|
857
|
+
if (!siuRequest) {
|
|
858
|
+
return {
|
|
859
|
+
content: [{ type: "text", text: "SIU status requires cloud backend (serverUrl + apiKey)." }],
|
|
860
|
+
details: { error: "cloud_required" },
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
const res = await siuRequest("GET", "/api/v2/siu/status");
|
|
865
|
+
return {
|
|
866
|
+
content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
|
|
867
|
+
details: res,
|
|
868
|
+
};
|
|
869
|
+
} catch (e: any) {
|
|
870
|
+
logger.warn(`sulcus: siu_status failed: ${e.message}`);
|
|
871
|
+
return {
|
|
872
|
+
content: [{ type: "text", text: `SIU status failed: ${e.message}` }],
|
|
873
|
+
details: { error: e.message },
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
|
|
879
|
+
siu_retrain: {
|
|
880
|
+
schema: {
|
|
881
|
+
name: "siu_retrain",
|
|
882
|
+
label: "SIU Retrain",
|
|
883
|
+
description: "Trigger an async retrain of SIU v2 models using accumulated training signals. Returns job status.",
|
|
884
|
+
parameters: Type.Object({}),
|
|
885
|
+
},
|
|
886
|
+
options: { name: "siu_retrain" },
|
|
887
|
+
makeExecute: ({ siuRequest, logger }) =>
|
|
888
|
+
async (_id: string, _params: any) => {
|
|
889
|
+
if (!siuRequest) {
|
|
890
|
+
return {
|
|
891
|
+
content: [{ type: "text", text: "SIU retrain requires cloud backend (serverUrl + apiKey)." }],
|
|
892
|
+
details: { error: "cloud_required" },
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
const res = await siuRequest("POST", "/api/v2/siu/retrain");
|
|
897
|
+
return {
|
|
898
|
+
content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
|
|
899
|
+
details: res,
|
|
900
|
+
};
|
|
901
|
+
} catch (e: any) {
|
|
902
|
+
logger.warn(`sulcus: siu_retrain failed: ${e.message}`);
|
|
903
|
+
return {
|
|
904
|
+
content: [{ type: "text", text: `SIU retrain failed: ${e.message}` }],
|
|
905
|
+
details: { error: e.message },
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
trigger_feedback: {
|
|
911
|
+
schema: {
|
|
912
|
+
name: "trigger_feedback",
|
|
913
|
+
label: "Trigger Feedback",
|
|
914
|
+
description:
|
|
915
|
+
"Record feedback on a trigger fire (for SITU training). Use to report false positives (fired but shouldn't have), false negatives (should have fired but didn't), or confirm correct fires.",
|
|
916
|
+
parameters: Type.Object({
|
|
917
|
+
feedback_type: Type.String({
|
|
918
|
+
description:
|
|
919
|
+
'One of: "false_positive" (fired wrongly), "false_negative" (missed fire), "correct" (good fire), "wrong_action" (fired but wrong action)',
|
|
920
|
+
}),
|
|
921
|
+
trigger_id: Type.Optional(Type.String({ description: "UUID of the trigger rule" })),
|
|
922
|
+
trigger_log_id: Type.Optional(Type.String({ description: "UUID of the trigger fire log entry" })),
|
|
923
|
+
event_type: Type.Optional(Type.String({ description: "Event type: memory_created, heat_threshold, recall, etc." })),
|
|
924
|
+
memory_id: Type.Optional(Type.String({ description: "UUID of the memory involved" })),
|
|
925
|
+
expected_action: Type.Optional(Type.String({ description: "What should have happened: fire, no_fire, different_action" })),
|
|
926
|
+
notes: Type.Optional(Type.String({ description: "Free-text explanation of the feedback" })),
|
|
927
|
+
}),
|
|
928
|
+
},
|
|
929
|
+
options: { name: "trigger_feedback" },
|
|
930
|
+
makeExecute: ({ siuRequest, logger }) =>
|
|
931
|
+
async (_id: string, params: any) => {
|
|
932
|
+
if (!siuRequest) {
|
|
933
|
+
return {
|
|
934
|
+
content: [{ type: "text", text: "Trigger feedback requires cloud backend (serverUrl + apiKey)." }],
|
|
935
|
+
details: { error: "cloud_required" },
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
try {
|
|
939
|
+
const res = await siuRequest("POST", "/api/v1/triggers/feedback", params);
|
|
940
|
+
return {
|
|
941
|
+
content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
|
|
942
|
+
details: res,
|
|
943
|
+
};
|
|
944
|
+
} catch (e: any) {
|
|
945
|
+
logger.warn(`sulcus: trigger_feedback failed: ${e.message}`);
|
|
946
|
+
return {
|
|
947
|
+
content: [{ type: "text", text: `Trigger feedback failed: ${e.message}` }],
|
|
948
|
+
details: { error: e.message },
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
},
|
|
952
|
+
},
|
|
732
953
|
};
|
|
733
954
|
|
|
734
955
|
// ─── PLUGIN ──────────────────────────────────────────────────────────────────
|
|
@@ -832,6 +1053,12 @@ const sulcusPlugin = {
|
|
|
832
1053
|
api.logger.warn(`sulcus: ✗ unavailable — ${hints.join("; ") || "unknown reason"}. Configure serverUrl+apiKey for cloud, or install native dylibs for local.`);
|
|
833
1054
|
}
|
|
834
1055
|
|
|
1056
|
+
// ── SIU v2 request helper (bound to cloud client if available) ──
|
|
1057
|
+
// SIU endpoints live on the same server as the Sulcus API.
|
|
1058
|
+
const siuRequestFn = (backendMode === "cloud" && sulcusMem instanceof SulcusCloudClient)
|
|
1059
|
+
? (method: string, path: string, body?: any) => (sulcusMem as SulcusCloudClient).request(method, path, body)
|
|
1060
|
+
: null;
|
|
1061
|
+
|
|
835
1062
|
// ── Shared deps for tool executors ──
|
|
836
1063
|
const toolDeps: ToolDeps = {
|
|
837
1064
|
sulcusMem,
|
|
@@ -843,6 +1070,7 @@ const sulcusPlugin = {
|
|
|
843
1070
|
wasmDir,
|
|
844
1071
|
logger: api.logger,
|
|
845
1072
|
isAvailable,
|
|
1073
|
+
siuRequest: siuRequestFn,
|
|
846
1074
|
};
|
|
847
1075
|
|
|
848
1076
|
// ── Shared context for hook handlers ──
|
|
@@ -858,11 +1086,21 @@ const sulcusPlugin = {
|
|
|
858
1086
|
};
|
|
859
1087
|
|
|
860
1088
|
// ── Config-driven hook registration ──
|
|
1089
|
+
// Each handler is wrapped in a defensive try-catch to prevent plugin errors
|
|
1090
|
+
// from crashing the host agent's startup pipeline (OpenClaw bug workaround:
|
|
1091
|
+
// normalizeResolvedModel() doesn't guard params.model being undefined).
|
|
861
1092
|
for (const [hookName, hookConfig] of Object.entries(hooksConfig.hooks)) {
|
|
862
1093
|
if (!hookConfig.enabled) continue;
|
|
863
1094
|
const handler = hookHandlers[hookConfig.action];
|
|
864
1095
|
if (handler) {
|
|
865
|
-
api.on(hookName, (event: any) =>
|
|
1096
|
+
api.on(hookName, async (event: any) => {
|
|
1097
|
+
try {
|
|
1098
|
+
return await handler(event, hookConfig, handlerCtx);
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
api.logger.warn(`sulcus: hook "${hookName}" (action=${hookConfig.action}) threw: ${err} — returning empty result`);
|
|
1101
|
+
return undefined;
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
866
1104
|
} else {
|
|
867
1105
|
api.logger.warn(`sulcus: unknown hook action "${hookConfig.action}" for hook "${hookName}"`);
|
|
868
1106
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@digitalforgestudios/openclaw-sulcus",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "Sulcus — reactive, thermodynamic memory plugin for OpenClaw. Opt-in persistent memory with heat-based decay, semantic search, and cross-agent sync. Auto-recall and auto-capture disabled by default.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openclaw",
|