lazyclaw 6.3.1 → 6.4.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.ko.md +5 -1
- package/README.md +17 -13
- package/agents.mjs +54 -4
- package/channels-discord/index.mjs +5 -1
- package/channels-email/index.mjs +4 -3
- package/channels-whatsapp/index.mjs +3 -2
- package/chat_window.mjs +11 -2
- package/cli.mjs +103 -3
- package/commands/agents.mjs +6 -1
- package/commands/auth_nodes.mjs +19 -1
- package/commands/automation.mjs +20 -5
- package/commands/channels.mjs +46 -3
- package/commands/chat.mjs +90 -33
- package/commands/chat_hardening.mjs +23 -0
- package/commands/daemon.mjs +99 -1
- package/commands/gateway.mjs +123 -4
- package/commands/mcp.mjs +150 -0
- package/commands/misc.mjs +20 -0
- package/commands/sessions.mjs +68 -8
- package/commands/setup.mjs +44 -8
- package/commands/setup_channels.mjs +219 -47
- package/commands/skills.mjs +19 -1
- package/commands/workflow_named.mjs +137 -0
- package/config-validate.mjs +10 -1
- package/config_features.mjs +45 -0
- package/cron.mjs +19 -8
- package/daemon/lib/auth.mjs +25 -0
- package/daemon/lib/cost.mjs +36 -0
- package/daemon/lib/respond.mjs +20 -1
- package/daemon/lib/team_inbound.mjs +69 -0
- package/daemon/route_table.mjs +5 -1
- package/daemon/routes/_deps.mjs +2 -2
- package/daemon/routes/config.mjs +11 -6
- package/daemon/routes/conversation.mjs +103 -40
- package/daemon/routes/events.mjs +45 -0
- package/daemon/routes/meta.mjs +38 -7
- package/daemon/routes/providers.mjs +26 -6
- package/daemon/routes/workflows.mjs +30 -0
- package/daemon.mjs +26 -2
- package/dotenv_min.mjs +15 -3
- package/gateway/challenge_registry.mjs +90 -0
- package/gateway/device_auth.mjs +71 -97
- package/gateway/http_gateway.mjs +29 -3
- package/lib/args.mjs +69 -3
- package/lib/config.mjs +85 -3
- package/lib/render.mjs +43 -0
- package/lib/service_install.mjs +15 -1
- package/mas/agent_turn.mjs +61 -12
- package/mas/confidence.mjs +20 -1
- package/mas/embedder.mjs +100 -0
- package/mas/events.mjs +57 -0
- package/mas/index_db.mjs +103 -10
- package/mas/learning.mjs +96 -72
- package/mas/mention_router.mjs +48 -90
- package/mas/prompt_stack.mjs +60 -3
- package/mas/recall_blend.mjs +56 -0
- package/mas/redact.mjs +55 -0
- package/mas/router_posting.mjs +96 -0
- package/mas/router_termination.mjs +63 -0
- package/mas/scrub_env.mjs +21 -6
- package/mas/skill_synth.mjs +7 -33
- package/mas/tool_runner.mjs +3 -2
- package/mas/tools/bash.mjs +9 -2
- package/mas/tools/coding.mjs +28 -5
- package/mas/tools/delegation.mjs +34 -4
- package/mas/tools/git.mjs +19 -9
- package/mas/tools/ha.mjs +2 -0
- package/mas/tools/learning.mjs +55 -0
- package/mas/tools/media.mjs +21 -3
- package/mas/tools/os.mjs +70 -16
- package/mas/tools/recall.mjs +28 -2
- package/mcp/client.mjs +8 -1
- package/mcp/server_spawn.mjs +5 -1
- package/memory.mjs +24 -0
- package/package.json +3 -1
- package/providers/anthropic.mjs +101 -6
- package/providers/cache.mjs +9 -1
- package/providers/claude_cli.mjs +104 -22
- package/providers/claude_cli_session.mjs +166 -0
- package/providers/cli_error.mjs +38 -0
- package/providers/cli_login.mjs +179 -0
- package/providers/codex_cli.mjs +66 -11
- package/providers/gemini.mjs +101 -3
- package/providers/gemini_cli.mjs +62 -15
- package/providers/model_catalogue.mjs +33 -2
- package/providers/ollama.mjs +104 -3
- package/providers/openai.mjs +110 -8
- package/providers/openai_compat.mjs +97 -6
- package/providers/orchestrator.mjs +112 -12
- package/providers/registry.mjs +15 -9
- package/providers/retry.mjs +14 -2
- package/providers/tool_use/anthropic.mjs +17 -3
- package/providers/tool_use/claude_cli.mjs +72 -20
- package/providers/tool_use/gemini.mjs +18 -2
- package/providers/tool_use/openai.mjs +28 -5
- package/sandbox/confiners/seatbelt.mjs +37 -12
- package/sandbox/index.mjs +49 -0
- package/sandbox/local.mjs +7 -1
- package/sandbox/spawn.mjs +144 -0
- package/sandbox.mjs +5 -1
- package/scripts/loop-worker.mjs +25 -1
- package/sessions.mjs +0 -0
- package/skills/channel-style.md +20 -0
- package/skills/code-review.md +33 -0
- package/skills/commit-message.md +30 -0
- package/skills/concise.md +24 -0
- package/skills/debug-coach.md +25 -0
- package/skills/explain.md +24 -0
- package/skills/korean.md +25 -0
- package/skills/summarize.md +33 -0
- package/skills.mjs +24 -2
- package/skills_curator.mjs +6 -0
- package/skills_install.mjs +10 -2
- package/tasks.mjs +6 -1
- package/teams.mjs +78 -0
- package/tui/chat_mode_slash.mjs +59 -0
- package/tui/config_picker.mjs +1 -0
- package/tui/editor.mjs +178 -151
- package/tui/editor_keys.mjs +275 -0
- package/tui/hud.mjs +111 -0
- package/tui/login_flow.mjs +113 -0
- package/tui/modal_picker.mjs +10 -1
- package/tui/model_pick.mjs +287 -0
- package/tui/orchestrator_flow.mjs +164 -0
- package/tui/pickers.mjs +196 -80
- package/tui/repl.mjs +106 -44
- package/tui/repl_reset.mjs +37 -0
- package/tui/run_turn.mjs +228 -26
- package/tui/slash_args.mjs +159 -0
- package/tui/slash_commands.mjs +7 -5
- package/tui/slash_dispatcher.mjs +537 -344
- package/tui/slash_popup.mjs +5 -1
- package/tui/splash.mjs +15 -2
- package/tui/status_bar.mjs +26 -0
- package/tui/theme.mjs +28 -0
- package/web/avatars/01.png +0 -0
- package/web/avatars/02.png +0 -0
- package/web/avatars/03.png +0 -0
- package/web/avatars/04.png +0 -0
- package/web/avatars/05.png +0 -0
- package/web/avatars/06.png +0 -0
- package/web/avatars/07.png +0 -0
- package/web/avatars/08.png +0 -0
- package/web/avatars/09.png +0 -0
- package/web/avatars/10.png +0 -0
- package/web/avatars/11.png +0 -0
- package/web/avatars/12.png +0 -0
- package/web/avatars/13.png +0 -0
- package/web/avatars/14.png +0 -0
- package/web/avatars/15.png +0 -0
- package/web/avatars/16.png +0 -0
- package/web/avatars/17.png +0 -0
- package/web/avatars/18.png +0 -0
- package/web/avatars/19.png +0 -0
- package/web/avatars/20.png +0 -0
- package/web/dashboard.css +77 -0
- package/web/dashboard.html +29 -2
- package/web/dashboard.js +296 -33
- package/workflow/builtin_caps.mjs +94 -0
- package/workflow/declarative.mjs +101 -0
- package/workflow/named.mjs +71 -0
- package/workflow/named_cron.mjs +50 -0
- package/workflow/nodes.mjs +67 -0
- package/workflow/run_request.mjs +74 -0
- package/workflow/yaml_min.mjs +97 -0
package/mas/tools/delegation.mjs
CHANGED
|
@@ -2,9 +2,21 @@
|
|
|
2
2
|
// Both lazy-import providers/orchestrator.mjs / mas/agent_turn.mjs to
|
|
3
3
|
// avoid pulling those into every process that imports the registry.
|
|
4
4
|
|
|
5
|
+
// events.mjs is featherweight (zero-dep pub/sub), so a static import is fine —
|
|
6
|
+
// it does not pull agent_turn/orchestrator into the registry load path.
|
|
7
|
+
import { emit as emitEvent } from '../events.mjs';
|
|
8
|
+
|
|
5
9
|
let _dispatcher = null;
|
|
6
10
|
export function __setDispatcher(fn) { _dispatcher = fn; }
|
|
7
11
|
|
|
12
|
+
// Test seam for dispatchSpawn's turn runner, mirroring __setDispatcher.
|
|
13
|
+
// When set, dispatchSpawn calls it with the SAME args it would hand
|
|
14
|
+
// runAgentTurn ({ agent: <record>, userMessage, configDir }) so tests can
|
|
15
|
+
// assert a resolved agent RECORD (with .provider) reaches the runner —
|
|
16
|
+
// without spinning up a real provider.
|
|
17
|
+
let _turnRunner = null;
|
|
18
|
+
export function __setTurnRunner(fn) { _turnRunner = fn; }
|
|
19
|
+
|
|
8
20
|
async function dispatchDelegate(job) {
|
|
9
21
|
if (_dispatcher) return _dispatcher(job);
|
|
10
22
|
const orch = await import('../../providers/orchestrator.mjs').catch(() => null);
|
|
@@ -14,12 +26,27 @@ async function dispatchDelegate(job) {
|
|
|
14
26
|
return orch.dispatchWorker(job);
|
|
15
27
|
}
|
|
16
28
|
|
|
29
|
+
// job = { agent: '<name>', prompt, configDir }
|
|
30
|
+
// runAgentTurn expects an agent RECORD (reads .provider/.tools), not a name
|
|
31
|
+
// string. Resolve the name → record via getAgent first, then invoke the
|
|
32
|
+
// runner with the shape it actually consumes and surface the final text.
|
|
17
33
|
async function dispatchSpawn(job) {
|
|
34
|
+
const agentName = String(job?.agent || '');
|
|
18
35
|
const at = await import('../agent_turn.mjs').catch(() => null);
|
|
19
|
-
|
|
36
|
+
const runner = _turnRunner || (at && typeof at.runAgentTurn === 'function' ? at.runAgentTurn : null);
|
|
37
|
+
if (!runner) {
|
|
20
38
|
return { ok: false, error: 'task_spawn: agent_turn.runAgentTurn unavailable' };
|
|
21
39
|
}
|
|
22
|
-
|
|
40
|
+
const agents = await import('../../agents.mjs').catch(() => null);
|
|
41
|
+
if (!agents || typeof agents.getAgent !== 'function') {
|
|
42
|
+
return { ok: false, error: 'task_spawn: agents.getAgent unavailable' };
|
|
43
|
+
}
|
|
44
|
+
const record = agents.getAgent(agentName, job?.configDir);
|
|
45
|
+
if (!record) {
|
|
46
|
+
return { ok: false, error: `task_spawn: unknown agent ${agentName}` };
|
|
47
|
+
}
|
|
48
|
+
const result = await runner({ agent: record, userMessage: job?.prompt, configDir: job?.configDir, sandbox: job?.sandbox });
|
|
49
|
+
return { ok: true, text: result?.text || '', stoppedBy: result?.stoppedBy, iterations: result?.iterations };
|
|
23
50
|
}
|
|
24
51
|
|
|
25
52
|
const task_spawn = {
|
|
@@ -30,9 +57,12 @@ const task_spawn = {
|
|
|
30
57
|
properties: { agent: { type: 'string' }, prompt: { type: 'string' } },
|
|
31
58
|
required: ['agent', 'prompt'],
|
|
32
59
|
},
|
|
33
|
-
async exec(args) {
|
|
60
|
+
async exec(args, ctx = {}) {
|
|
34
61
|
if (!args?.agent || !args?.prompt) return { ok: false, error: 'task_spawn: agent + prompt required' };
|
|
35
|
-
|
|
62
|
+
// Live A→B delegation event for the dashboard (caller → spawned agent).
|
|
63
|
+
emitEvent('delegate', { taskId: ctx.taskId, from: ctx.agent?.name, to: args.agent });
|
|
64
|
+
// Propagate the outer turn's sandbox so a spawned sub-agent stays confined.
|
|
65
|
+
return dispatchSpawn({ agent: args.agent, prompt: args.prompt, configDir: ctx.configDir, sandbox: ctx.sandbox });
|
|
36
66
|
},
|
|
37
67
|
};
|
|
38
68
|
|
package/mas/tools/git.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// sensitive writes (commit/push). All shell out to git in ctx.cwd.
|
|
3
3
|
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { spawnSyncSandboxed } from '../../sandbox.mjs';
|
|
5
6
|
|
|
6
7
|
function git(cwd, args, opts = {}) {
|
|
7
8
|
// M11 / C12 — detect a missing git binary (Windows without
|
|
@@ -10,8 +11,17 @@ function git(cwd, args, opts = {}) {
|
|
|
10
11
|
// {ok:false} with a cryptic spawn error, which made the
|
|
11
12
|
// "lazyclaw doctor" path the only reliable signal. Now any agent
|
|
12
13
|
// touching this tool also gets a one-line diagnostic.
|
|
14
|
+
//
|
|
15
|
+
// CAPABILITY-ONLY sandbox seam: when opts.sandbox is truthy the git
|
|
16
|
+
// invocation is routed through the synchronous sandbox dispatcher
|
|
17
|
+
// (default-on isolation, step iii). No current caller threads a spec, so
|
|
18
|
+
// the bare spawnSync path below is byte-identical to the historical call.
|
|
13
19
|
const exe = process.env.GIT_EXECUTABLE || 'git';
|
|
14
|
-
const r =
|
|
20
|
+
const r = opts.sandbox
|
|
21
|
+
? (opts._spawnSyncSandboxed || spawnSyncSandboxed)(
|
|
22
|
+
opts.sandbox, exe, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 },
|
|
23
|
+
)
|
|
24
|
+
: spawnSync(exe, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
|
|
15
25
|
if (r.error && r.error.code === 'ENOENT') {
|
|
16
26
|
return {
|
|
17
27
|
ok: false,
|
|
@@ -29,7 +39,7 @@ const git_status = {
|
|
|
29
39
|
name: 'git_status', category: 'git', sensitive: false,
|
|
30
40
|
description: 'Run `git status` in the workspace.',
|
|
31
41
|
parameters: { type: 'object', properties: {} },
|
|
32
|
-
async exec(_args, ctx) { return git(ctx?.cwd, ['status']); },
|
|
42
|
+
async exec(_args, ctx) { return git(ctx?.cwd, ['status'], { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed }); },
|
|
33
43
|
};
|
|
34
44
|
|
|
35
45
|
const git_diff = {
|
|
@@ -40,7 +50,7 @@ const git_diff = {
|
|
|
40
50
|
const ar = ['diff'];
|
|
41
51
|
if (args?.staged) ar.push('--staged');
|
|
42
52
|
if (args?.path) ar.push('--', args.path);
|
|
43
|
-
return git(ctx?.cwd, ar);
|
|
53
|
+
return git(ctx?.cwd, ar, { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed });
|
|
44
54
|
},
|
|
45
55
|
};
|
|
46
56
|
|
|
@@ -50,7 +60,7 @@ const git_log = {
|
|
|
50
60
|
parameters: { type: 'object', properties: { limit: { type: 'number' } } },
|
|
51
61
|
async exec(args, ctx) {
|
|
52
62
|
const n = Math.max(1, Math.min(args?.limit || 10, 100));
|
|
53
|
-
const r = git(ctx?.cwd, ['log', `-n${n}`, '--pretty=format:%H%x09%an%x09%aI%x09%s']);
|
|
63
|
+
const r = git(ctx?.cwd, ['log', `-n${n}`, '--pretty=format:%H%x09%an%x09%aI%x09%s'], { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed });
|
|
54
64
|
if (!r.ok) return r;
|
|
55
65
|
const commits = r.stdout.trim().split('\n').filter(Boolean).map(line => {
|
|
56
66
|
const [hash, author, date, subject] = line.split('\t');
|
|
@@ -64,7 +74,7 @@ const git_blame = {
|
|
|
64
74
|
name: 'git_blame', category: 'git', sensitive: false,
|
|
65
75
|
description: 'Run `git blame <path>`.',
|
|
66
76
|
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
67
|
-
async exec(args, ctx) { return git(ctx?.cwd, ['blame', '--', args.path]); },
|
|
77
|
+
async exec(args, ctx) { return git(ctx?.cwd, ['blame', '--', args.path], { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed }); },
|
|
68
78
|
};
|
|
69
79
|
|
|
70
80
|
const git_branch = {
|
|
@@ -72,7 +82,7 @@ const git_branch = {
|
|
|
72
82
|
description: 'List branches.',
|
|
73
83
|
parameters: { type: 'object', properties: {} },
|
|
74
84
|
async exec(_args, ctx) {
|
|
75
|
-
const r = git(ctx?.cwd, ['branch', '--all', '--format=%(refname:short)%09%(upstream:short)%09%(HEAD)']);
|
|
85
|
+
const r = git(ctx?.cwd, ['branch', '--all', '--format=%(refname:short)%09%(upstream:short)%09%(HEAD)'], { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed });
|
|
76
86
|
if (!r.ok) return r;
|
|
77
87
|
const branches = r.stdout.trim().split('\n').filter(Boolean).map(line => {
|
|
78
88
|
const [name, upstream, head] = line.split('\t');
|
|
@@ -96,12 +106,12 @@ const git_commit = {
|
|
|
96
106
|
},
|
|
97
107
|
async exec(args, ctx) {
|
|
98
108
|
if (Array.isArray(args.paths) && args.paths.length) {
|
|
99
|
-
const a = git(ctx?.cwd, ['add', '--', ...args.paths]);
|
|
109
|
+
const a = git(ctx?.cwd, ['add', '--', ...args.paths], { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed });
|
|
100
110
|
if (!a.ok) return a;
|
|
101
111
|
}
|
|
102
112
|
const ar = ['commit', '-m', args.message];
|
|
103
113
|
if (args.amend) ar.splice(1, 0, '--amend');
|
|
104
|
-
return git(ctx?.cwd, ar);
|
|
114
|
+
return git(ctx?.cwd, ar, { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed });
|
|
105
115
|
},
|
|
106
116
|
};
|
|
107
117
|
|
|
@@ -120,7 +130,7 @@ const git_push = {
|
|
|
120
130
|
if (args?.force) ar.push('--force-with-lease');
|
|
121
131
|
if (args?.remote) ar.push(args.remote);
|
|
122
132
|
if (args?.branch) ar.push(args.branch);
|
|
123
|
-
return git(ctx?.cwd, ar);
|
|
133
|
+
return git(ctx?.cwd, ar, { sandbox: ctx?.sandbox, _spawnSyncSandboxed: ctx?._spawnSyncSandboxed });
|
|
124
134
|
},
|
|
125
135
|
};
|
|
126
136
|
|
package/mas/tools/ha.mjs
CHANGED
|
@@ -8,6 +8,7 @@ function deferred(name) {
|
|
|
8
8
|
|
|
9
9
|
const ha_call_service = {
|
|
10
10
|
name: 'ha_call_service', category: 'iot', sensitive: true,
|
|
11
|
+
unavailable: true, // not implemented (deferred to v5.1) — hidden from tool schemas until wired
|
|
11
12
|
description: 'STUB — Home Assistant service call deferred to v5.1.',
|
|
12
13
|
parameters: {
|
|
13
14
|
type: 'object',
|
|
@@ -22,6 +23,7 @@ const ha_call_service = {
|
|
|
22
23
|
|
|
23
24
|
const ha_get_state = {
|
|
24
25
|
name: 'ha_get_state', category: 'iot', sensitive: true,
|
|
26
|
+
unavailable: true, // not implemented (deferred to v5.1) — hidden from tool schemas until wired
|
|
25
27
|
description: 'STUB — Home Assistant state read deferred to v5.1.',
|
|
26
28
|
parameters: {
|
|
27
29
|
type: 'object',
|
package/mas/tools/learning.mjs
CHANGED
|
@@ -13,6 +13,38 @@ function resolveConfigDir(ctx) {
|
|
|
13
13
|
function memoryDir(ctx) { return path.join(resolveConfigDir(ctx), 'memory'); }
|
|
14
14
|
function userMdPath(ctx) { return path.join(memoryDir(ctx), 'USER.md'); }
|
|
15
15
|
|
|
16
|
+
// Best-effort FTS5 mirrors (spec §4.4) — same shape skill_synth /
|
|
17
|
+
// user_modeler use: dynamic import, openIndex, then index*. An index
|
|
18
|
+
// failure NEVER fails the tool; index_db's own write hooks already log
|
|
19
|
+
// to index-failures.jsonl, so we swallow here.
|
|
20
|
+
async function _indexSkillSafe(row, configDir) {
|
|
21
|
+
try {
|
|
22
|
+
const idx = await import('../index_db.mjs');
|
|
23
|
+
idx.openIndex(configDir);
|
|
24
|
+
idx.indexSkill(row, configDir);
|
|
25
|
+
} catch { /* non-fatal */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function _indexMemorySafe(row, ctx) {
|
|
29
|
+
try {
|
|
30
|
+
const configDir = resolveConfigDir(ctx);
|
|
31
|
+
const idx = await import('../index_db.mjs');
|
|
32
|
+
idx.openIndex(configDir);
|
|
33
|
+
idx.indexMemory(row, configDir);
|
|
34
|
+
} catch { /* non-fatal */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Minimal frontmatter key reader — skill_edit only needs trained_by/group
|
|
38
|
+
// off the existing frontmatter block to keep the FTS metadata stable.
|
|
39
|
+
function _parseFm(fm) {
|
|
40
|
+
const meta = {};
|
|
41
|
+
for (const line of String(fm || '').split('\n')) {
|
|
42
|
+
const i = line.indexOf(':');
|
|
43
|
+
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
44
|
+
}
|
|
45
|
+
return meta;
|
|
46
|
+
}
|
|
47
|
+
|
|
16
48
|
const skill_view = {
|
|
17
49
|
name: 'skill_view', category: 'learning', sensitive: false,
|
|
18
50
|
description: 'Return the body of an installed skill (by name).',
|
|
@@ -64,6 +96,15 @@ const skill_create = {
|
|
|
64
96
|
'',
|
|
65
97
|
].join('\n');
|
|
66
98
|
const file = skills.installSkill(args.name, fm + args.body + (args.body.endsWith('\n') ? '' : '\n'), configDir);
|
|
99
|
+
// FTS5 write-through (spec §4.4): mirror the new skill so recall finds
|
|
100
|
+
// it without waiting for a reindexAll. Best-effort — an index error must
|
|
101
|
+
// never fail the create. trained_by is always 'user' for this tool.
|
|
102
|
+
await _indexSkillSafe({
|
|
103
|
+
skill_name: args.name,
|
|
104
|
+
trained_by: 'user',
|
|
105
|
+
group_name: args.group || (args.name.includes('-') ? args.name.split('-')[0] : 'legacy'),
|
|
106
|
+
content: args.body,
|
|
107
|
+
}, configDir);
|
|
67
108
|
return { ok: true, name: args.name, file };
|
|
68
109
|
},
|
|
69
110
|
};
|
|
@@ -86,6 +127,16 @@ const skill_edit = {
|
|
|
86
127
|
let fm = m[1];
|
|
87
128
|
fm = fm.replace(/version:\s*(\d+)/, (_, v) => `version: ${Number(v) + 1}`);
|
|
88
129
|
skills.installSkill(args.name, `---\n${fm}\n---\n${args.body}${args.body.endsWith('\n') ? '' : '\n'}`, configDir);
|
|
130
|
+
// FTS5 write-through (spec §4.4): re-index the edited body so recall
|
|
131
|
+
// surfaces the new content. Preserve the skill's existing frontmatter
|
|
132
|
+
// trained_by / group; best-effort, never fails the edit.
|
|
133
|
+
const meta = _parseFm(fm);
|
|
134
|
+
await _indexSkillSafe({
|
|
135
|
+
skill_name: args.name,
|
|
136
|
+
trained_by: meta.trained_by || 'user',
|
|
137
|
+
group_name: meta.group || (args.name.includes('-') ? args.name.split('-')[0] : 'legacy'),
|
|
138
|
+
content: args.body,
|
|
139
|
+
}, configDir);
|
|
89
140
|
return { ok: true, name: args.name };
|
|
90
141
|
},
|
|
91
142
|
};
|
|
@@ -107,12 +158,16 @@ const memory_write = {
|
|
|
107
158
|
fs.mkdirSync(dir, { recursive: true });
|
|
108
159
|
if (args.kind === 'recent') {
|
|
109
160
|
fs.appendFileSync(path.join(dir, 'recent.jsonl'), JSON.stringify({ ts: Date.now(), content: args.content }) + '\n');
|
|
161
|
+
// recent.jsonl is not an FTS scope (no fts_recent table); skip indexing.
|
|
110
162
|
} else if (args.kind === 'core') {
|
|
111
163
|
fs.writeFileSync(path.join(dir, 'core.md'), args.content);
|
|
164
|
+
// FTS5 write-through (spec §4.4): mirror core memory so recall finds it.
|
|
165
|
+
await _indexMemorySafe({ topic: 'core', kind: 'core', content: args.content }, ctx);
|
|
112
166
|
} else if (args.kind === 'episodic') {
|
|
113
167
|
if (!args.topic) return { ok: false, error: 'memory_write: topic required for episodic' };
|
|
114
168
|
fs.mkdirSync(path.join(dir, 'episodic'), { recursive: true });
|
|
115
169
|
fs.writeFileSync(path.join(dir, 'episodic', `${args.topic}.md`), args.content);
|
|
170
|
+
await _indexMemorySafe({ topic: args.topic, kind: 'episodic', content: args.content }, ctx);
|
|
116
171
|
} else {
|
|
117
172
|
return { ok: false, error: `memory_write: unknown kind ${args.kind}` };
|
|
118
173
|
}
|
package/mas/tools/media.mjs
CHANGED
|
@@ -5,11 +5,23 @@
|
|
|
5
5
|
// "configure X" error otherwise.
|
|
6
6
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
8
9
|
import { fetch } from 'undici';
|
|
9
10
|
|
|
11
|
+
// Confine a model-supplied path to the working directory. image_describe
|
|
12
|
+
// UPLOADS the bytes to a third party (OpenAI), so — unlike `read` — it must
|
|
13
|
+
// not be coaxed into exfiltrating an arbitrary host file (~/.ssh/id_rsa,
|
|
14
|
+
// /etc/passwd). Returns the resolved path or null when it escapes cwd.
|
|
15
|
+
function confineToCwd(p, cwd) {
|
|
16
|
+
const root = path.resolve(cwd || process.cwd());
|
|
17
|
+
const resolved = path.resolve(root, String(p || ''));
|
|
18
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
|
|
19
|
+
return resolved;
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
const image_describe = {
|
|
11
23
|
name: 'image_describe', category: 'media', sensitive: true,
|
|
12
|
-
description: 'Describe an image. Requires OPENAI_API_KEY (gpt-4o vision)
|
|
24
|
+
description: 'Describe an image at a path inside the working directory. Requires OPENAI_API_KEY (gpt-4o vision).',
|
|
13
25
|
parameters: {
|
|
14
26
|
type: 'object',
|
|
15
27
|
properties: { path: { type: 'string' }, prompt: { type: 'string' } },
|
|
@@ -17,8 +29,10 @@ const image_describe = {
|
|
|
17
29
|
},
|
|
18
30
|
async exec(args, ctx) {
|
|
19
31
|
const env = ctx?.env || process.env;
|
|
20
|
-
|
|
21
|
-
|
|
32
|
+
const safePath = confineToCwd(args.path, ctx?.cwd);
|
|
33
|
+
if (!safePath) return { ok: false, error: `image_describe: path escapes the working directory: ${args.path}` };
|
|
34
|
+
if (!fs.existsSync(safePath)) return { ok: false, error: `image_describe: file not found ${args.path}` };
|
|
35
|
+
const b64 = fs.readFileSync(safePath).toString('base64');
|
|
22
36
|
const prompt = args.prompt || 'Describe this image briefly.';
|
|
23
37
|
if (env.OPENAI_API_KEY) {
|
|
24
38
|
try {
|
|
@@ -39,6 +53,9 @@ const image_describe = {
|
|
|
39
53
|
|
|
40
54
|
const image_generate = {
|
|
41
55
|
name: 'image_generate', category: 'media', sensitive: true,
|
|
56
|
+
// Working tool: the OPENAI_API_KEY path makes a real images/generations call
|
|
57
|
+
// (gpt-image-1); only the FAL_KEY path is unimplemented. NOT a stub — stays
|
|
58
|
+
// advertised so the 'media' toolset works on OpenAI-key deployments.
|
|
42
59
|
description: 'Generate an image. Requires OPENAI_API_KEY (DALL-E) or FAL_KEY.',
|
|
43
60
|
parameters: {
|
|
44
61
|
type: 'object',
|
|
@@ -67,6 +84,7 @@ const image_generate = {
|
|
|
67
84
|
|
|
68
85
|
const tts_speak = {
|
|
69
86
|
name: 'tts_speak', category: 'media', sensitive: true,
|
|
87
|
+
unavailable: true, // not implemented (deferred to v5.1) — hidden from tool schemas until wired
|
|
70
88
|
description: 'STUB — TTS reply deferred to v5.1 per spec §0.2.',
|
|
71
89
|
parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
|
|
72
90
|
async exec() {
|
package/mas/tools/os.mjs
CHANGED
|
@@ -6,12 +6,28 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
+
import { spawnSandboxed, spawnSyncSandboxed } from '../../sandbox.mjs';
|
|
9
10
|
|
|
10
11
|
function platformOf(ctx) { return ctx?.platform || process.platform; }
|
|
11
12
|
|
|
13
|
+
// Strip the sandbox-control keys (and our async-only `stdin` helper) from the
|
|
14
|
+
// options bag before forwarding to the (sandboxed) spawner, so only real
|
|
15
|
+
// child_process options reach it. The bare path keeps the historical `opts`
|
|
16
|
+
// object verbatim for byte-stability.
|
|
17
|
+
function spawnOptsOf(opts) {
|
|
18
|
+
const { sandbox, _spawnSandboxed, _spawnSyncSandboxed, stdin, ...rest } = opts;
|
|
19
|
+
return rest;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// CAPABILITY-ONLY sandbox seam (default-on isolation, step iv-b). When
|
|
23
|
+
// opts.sandbox is truthy the child is created through the sandbox dispatcher
|
|
24
|
+
// (containment ADDED); a null/absent spec keeps the byte-identical bare spawn
|
|
25
|
+
// path. _spawnSandboxed is a test injection seam — defaults to the real impl.
|
|
12
26
|
function runCmd(cmd, args, opts = {}) {
|
|
13
27
|
return new Promise((resolve) => {
|
|
14
|
-
const child =
|
|
28
|
+
const child = opts.sandbox
|
|
29
|
+
? (opts._spawnSandboxed || spawnSandboxed)(opts.sandbox, cmd, args, spawnOptsOf(opts))
|
|
30
|
+
: spawn(cmd, args, opts);
|
|
15
31
|
let out = '', err = '';
|
|
16
32
|
child.stdout?.on('data', d => out += d.toString());
|
|
17
33
|
child.stderr?.on('data', d => err += d.toString());
|
|
@@ -21,19 +37,38 @@ function runCmd(cmd, args, opts = {}) {
|
|
|
21
37
|
});
|
|
22
38
|
}
|
|
23
39
|
|
|
40
|
+
// Synchronous mirror of runCmd. When opts.sandbox is truthy the invocation is
|
|
41
|
+
// routed through spawnSyncSandboxed (injectable via opts._spawnSyncSandboxed);
|
|
42
|
+
// otherwise the bare spawnSync path is byte-identical to the historical call.
|
|
43
|
+
function runCmdSync(cmd, args, opts = {}) {
|
|
44
|
+
return opts.sandbox
|
|
45
|
+
? (opts._spawnSyncSandboxed || spawnSyncSandboxed)(opts.sandbox, cmd, args, spawnOptsOf(opts))
|
|
46
|
+
: spawnSync(cmd, args, spawnOptsOf(opts));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Pull the sandbox seam out of ctx so each tool can thread it uniformly.
|
|
50
|
+
function sandboxSeam(ctx) {
|
|
51
|
+
return {
|
|
52
|
+
sandbox: ctx?.sandbox,
|
|
53
|
+
_spawnSandboxed: ctx?._spawnSandboxed,
|
|
54
|
+
_spawnSyncSandboxed: ctx?._spawnSyncSandboxed,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
24
58
|
const clipboard_read = {
|
|
25
59
|
name: 'clipboard_read', category: 'os', sensitive: true,
|
|
26
60
|
description: 'Read the OS clipboard (text).',
|
|
27
61
|
parameters: { type: 'object', properties: {} },
|
|
28
62
|
async exec(_args, ctx) {
|
|
29
63
|
const p = platformOf(ctx);
|
|
64
|
+
const seam = sandboxSeam(ctx);
|
|
30
65
|
if (p === 'darwin') {
|
|
31
|
-
const r =
|
|
66
|
+
const r = runCmdSync('pbpaste', [], { encoding: 'utf8', ...seam });
|
|
32
67
|
return r.status === 0 ? { ok: true, text: r.stdout } : { ok: false, error: r.stderr || 'pbpaste failed' };
|
|
33
68
|
}
|
|
34
69
|
if (p === 'linux') {
|
|
35
70
|
for (const [bin, args] of [['wl-paste', []], ['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]]) {
|
|
36
|
-
const r =
|
|
71
|
+
const r = runCmdSync(bin, args, { encoding: 'utf8', ...seam });
|
|
37
72
|
if (r.status === 0) return { ok: true, text: r.stdout };
|
|
38
73
|
}
|
|
39
74
|
return { ok: false, error: 'clipboard_read: no clipboard helper (install wl-clipboard or xclip)' };
|
|
@@ -48,10 +83,11 @@ const clipboard_write = {
|
|
|
48
83
|
parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] },
|
|
49
84
|
async exec(args, ctx) {
|
|
50
85
|
const p = platformOf(ctx);
|
|
51
|
-
|
|
86
|
+
const seam = sandboxSeam(ctx);
|
|
87
|
+
if (p === 'darwin') return runCmd('pbcopy', [], { stdin: args.text, ...seam });
|
|
52
88
|
if (p === 'linux') {
|
|
53
89
|
for (const [bin, ar] of [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]]) {
|
|
54
|
-
const r = await runCmd(bin, ar, { stdin: args.text });
|
|
90
|
+
const r = await runCmd(bin, ar, { stdin: args.text, ...seam });
|
|
55
91
|
if (r.ok) return { ok: true };
|
|
56
92
|
}
|
|
57
93
|
return { ok: false, error: 'clipboard_write: no clipboard helper' };
|
|
@@ -67,13 +103,14 @@ const screenshot = {
|
|
|
67
103
|
async exec(args, ctx) {
|
|
68
104
|
const p = platformOf(ctx);
|
|
69
105
|
const out = args?.path || path.join(os.tmpdir(), `lzc-${Date.now()}.png`);
|
|
106
|
+
const seam = sandboxSeam(ctx);
|
|
70
107
|
if (p === 'darwin') {
|
|
71
|
-
const r =
|
|
108
|
+
const r = runCmdSync('screencapture', ['-x', out], { ...seam });
|
|
72
109
|
return r.status === 0 ? { ok: true, path: out } : { ok: false, error: 'screencapture failed' };
|
|
73
110
|
}
|
|
74
111
|
if (p === 'linux') {
|
|
75
112
|
for (const [bin, ar] of [['grim', [out]], ['gnome-screenshot', ['-f', out]], ['scrot', [out]]]) {
|
|
76
|
-
const r =
|
|
113
|
+
const r = runCmdSync(bin, ar, { ...seam });
|
|
77
114
|
if (r.status === 0) return { ok: true, path: out };
|
|
78
115
|
}
|
|
79
116
|
return { ok: false, error: 'screenshot: no helper found (grim/gnome-screenshot/scrot)' };
|
|
@@ -92,14 +129,15 @@ const notify = {
|
|
|
92
129
|
},
|
|
93
130
|
async exec(args, ctx) {
|
|
94
131
|
const p = platformOf(ctx);
|
|
132
|
+
const seam = sandboxSeam(ctx);
|
|
95
133
|
const title = args.title;
|
|
96
134
|
const body = args.body || '';
|
|
97
135
|
if (p === 'darwin') {
|
|
98
|
-
|
|
136
|
+
runCmdSync('osascript', ['-e', `display notification ${JSON.stringify(body)} with title ${JSON.stringify(title)}`], { ...seam });
|
|
99
137
|
return { ok: true };
|
|
100
138
|
}
|
|
101
139
|
if (p === 'linux') {
|
|
102
|
-
|
|
140
|
+
runCmdSync('notify-send', [title, body], { ...seam });
|
|
103
141
|
return { ok: true };
|
|
104
142
|
}
|
|
105
143
|
return { ok: false, error: `notify: unsupported platform ${p}` };
|
|
@@ -116,12 +154,29 @@ const open_url = {
|
|
|
116
154
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, error: 'open_url: http(s) only' };
|
|
117
155
|
} catch { return { ok: false, error: 'open_url: bad URL' }; }
|
|
118
156
|
const p = platformOf(ctx);
|
|
119
|
-
|
|
120
|
-
if (p === '
|
|
157
|
+
const seam = sandboxSeam(ctx);
|
|
158
|
+
if (p === 'darwin') return runCmd('open', [args.url], { ...seam });
|
|
159
|
+
if (p === 'linux') return runCmd('xdg-open', [args.url], { ...seam });
|
|
121
160
|
return { ok: false, error: `open_url: unsupported platform ${p}` };
|
|
122
161
|
},
|
|
123
162
|
};
|
|
124
163
|
|
|
164
|
+
// Build the osascript `choose file` expression. The prompt is model-supplied
|
|
165
|
+
// and gets interpolated into an AppleScript STRING literal, so it must escape
|
|
166
|
+
// backslashes (first) and double-quotes, and strip newlines/control chars — a
|
|
167
|
+
// raw `"`/`\`/newline would otherwise break out of the literal and run an
|
|
168
|
+
// injected AppleScript expression (e.g. `do shell script`). Exported for tests.
|
|
169
|
+
export function _buildChooseScript(kind, prompt) {
|
|
170
|
+
const fallback = kind === 'save' ? 'Save' : 'Choose';
|
|
171
|
+
const safe = String(prompt || fallback)
|
|
172
|
+
.replace(/[\r\n\t\v\f\0]+/g, ' ')
|
|
173
|
+
.replace(/\\/g, '\\\\')
|
|
174
|
+
.replace(/"/g, '\\"');
|
|
175
|
+
return kind === 'save'
|
|
176
|
+
? `POSIX path of (choose file name with prompt "${safe}")`
|
|
177
|
+
: `POSIX path of (choose file with prompt "${safe}")`;
|
|
178
|
+
}
|
|
179
|
+
|
|
125
180
|
const file_dialog = {
|
|
126
181
|
name: 'file_dialog', category: 'os', sensitive: true,
|
|
127
182
|
description: 'Show an OS file picker. Returns selected path (or null on cancel).',
|
|
@@ -131,17 +186,16 @@ const file_dialog = {
|
|
|
131
186
|
},
|
|
132
187
|
async exec(args, ctx) {
|
|
133
188
|
const p = platformOf(ctx);
|
|
189
|
+
const seam = sandboxSeam(ctx);
|
|
134
190
|
if (p === 'darwin') {
|
|
135
|
-
const script = (args.kind
|
|
136
|
-
|
|
137
|
-
: 'POSIX path of (choose file with prompt "' + (args.prompt || 'Choose').replace(/"/g, '\\"') + '")';
|
|
138
|
-
const r = spawnSync('osascript', ['-e', script], { encoding: 'utf8' });
|
|
191
|
+
const script = _buildChooseScript(args.kind, args.prompt);
|
|
192
|
+
const r = runCmdSync('osascript', ['-e', script], { encoding: 'utf8', ...seam });
|
|
139
193
|
if (r.status !== 0) return { ok: true, path: null };
|
|
140
194
|
return { ok: true, path: r.stdout.trim() };
|
|
141
195
|
}
|
|
142
196
|
if (p === 'linux') {
|
|
143
197
|
const ar = args.kind === 'save' ? ['--file-selection', '--save'] : ['--file-selection'];
|
|
144
|
-
const r =
|
|
198
|
+
const r = runCmdSync('zenity', ar, { encoding: 'utf8', ...seam });
|
|
145
199
|
if (r.status !== 0) return { ok: true, path: null };
|
|
146
200
|
return { ok: true, path: r.stdout.trim() };
|
|
147
201
|
}
|
package/mas/tools/recall.mjs
CHANGED
|
@@ -46,6 +46,13 @@ function _defaultConfigDir() {
|
|
|
46
46
|
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// Read config.json from the tool's own configDir (not the env), so hybrid
|
|
50
|
+
// recall reads the right config when the dir is passed explicitly. Best-effort.
|
|
51
|
+
function _readCfg(configDir) {
|
|
52
|
+
try { return JSON.parse(fs.readFileSync(path.join(configDir || _defaultConfigDir(), 'config.json'), 'utf8')); }
|
|
53
|
+
catch { return {}; }
|
|
54
|
+
}
|
|
55
|
+
|
|
49
56
|
// Minimal frontmatter parser — read just the cross_cli_tested provider
|
|
50
57
|
// list for a skill by name. Returns [] when the file is missing or
|
|
51
58
|
// parse fails (best-effort ranking helper, not a strict loader).
|
|
@@ -105,8 +112,21 @@ export async function exec(args, { configDir } = {}) {
|
|
|
105
112
|
} catch (err) {
|
|
106
113
|
return { ok: false, error: `recall: openIndex failed — ${err?.message || err}` };
|
|
107
114
|
}
|
|
115
|
+
// Hybrid recall: when cfg.recall.embeddings is enabled, embed the query and
|
|
116
|
+
// pass the vector so index_db re-ranks candidates by semantic similarity.
|
|
117
|
+
// Opt-in + best-effort — any embed failure falls back to pure FTS5.
|
|
118
|
+
let queryVector, weights;
|
|
119
|
+
try {
|
|
120
|
+
const cfg = _readCfg(configDir);
|
|
121
|
+
const { getEmbedder } = await import('../embedder.mjs');
|
|
122
|
+
const embedder = getEmbedder(cfg);
|
|
123
|
+
if (embedder) {
|
|
124
|
+
const [v] = await embedder.embed([query]);
|
|
125
|
+
if (v && v.length) { queryVector = v; weights = cfg.recall?.embeddings?.weights; }
|
|
126
|
+
}
|
|
127
|
+
} catch { /* fall back to pure FTS */ }
|
|
108
128
|
try {
|
|
109
|
-
out = indexRecall(query, { configDir, scope: scopes, k });
|
|
129
|
+
out = indexRecall(query, { configDir, scope: scopes, k, ...(queryVector ? { queryVector, weights } : {}) });
|
|
110
130
|
} catch (err) {
|
|
111
131
|
return { ok: false, error: `recall: query failed — ${err?.message || err}` };
|
|
112
132
|
}
|
|
@@ -120,7 +140,13 @@ export async function exec(args, { configDir } = {}) {
|
|
|
120
140
|
const meta = h.metadata || {};
|
|
121
141
|
for (const key of filterKeys) {
|
|
122
142
|
if (key === 'since') {
|
|
123
|
-
|
|
143
|
+
// Only fts_sessions carries a ts column; skills/trajectories/
|
|
144
|
+
// memories have no ts. A "since" bound must NOT silently drop
|
|
145
|
+
// those ts-less hits — only drop a hit that HAS a ts older than
|
|
146
|
+
// the bound. (meta.ts === undefined / '' means "no timestamp".)
|
|
147
|
+
if (meta.ts !== undefined && meta.ts !== null && meta.ts !== '') {
|
|
148
|
+
if (Number(meta.ts) < Number(filter.since)) return false;
|
|
149
|
+
}
|
|
124
150
|
} else if (String(meta[key] ?? '') !== String(filter[key])) {
|
|
125
151
|
return false;
|
|
126
152
|
}
|
package/mcp/client.mjs
CHANGED
|
@@ -59,7 +59,14 @@ export async function startServer({ name, command, args = [], env = {}, allowGlo
|
|
|
59
59
|
.filter(c => c.type === 'text')
|
|
60
60
|
.map(c => c.text)
|
|
61
61
|
.join('\n');
|
|
62
|
-
|
|
62
|
+
// MCP reports TOOL-level failures as { isError: true } WITHOUT
|
|
63
|
+
// throwing (only protocol/transport faults throw). Surface it as a
|
|
64
|
+
// failure so the agent doesn't reason forward on a failed call.
|
|
65
|
+
if (res?.isError) {
|
|
66
|
+
return { ok: false, error: `${toolName}: ${text || 'tool reported isError'}`, raw: res };
|
|
67
|
+
}
|
|
68
|
+
// Pass structuredContent through when the server provides it.
|
|
69
|
+
return { ok: true, text, structured: res?.structuredContent, raw: res };
|
|
63
70
|
} catch (e) {
|
|
64
71
|
return { ok: false, error: `${toolName}: ${e.message}` };
|
|
65
72
|
}
|
package/mcp/server_spawn.mjs
CHANGED
|
@@ -12,7 +12,11 @@ export async function startConfigured(cfg) {
|
|
|
12
12
|
const servers = cfg?.mcp?.servers || [];
|
|
13
13
|
const results = [];
|
|
14
14
|
for (const s of servers) {
|
|
15
|
-
|
|
15
|
+
// §8: MCP tools invoke external server code, so they MUST stay behind the
|
|
16
|
+
// fail-closed approval gate. Force sensitive:true at boot — config can
|
|
17
|
+
// narrow exposure (allowGlob) but can never downgrade an MCP tool to
|
|
18
|
+
// ungated. A config-supplied sensitive:false is ignored, not honored.
|
|
19
|
+
try { results.push(await startServer({ ...s, sensitive: true })); }
|
|
16
20
|
catch (e) { results.push({ ok: false, name: s.name, error: e.message }); }
|
|
17
21
|
}
|
|
18
22
|
return results;
|
package/memory.mjs
CHANGED
|
@@ -18,11 +18,22 @@
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
import os from 'node:os';
|
|
21
|
+
import { tightenIfLoose } from './secure_write.mjs';
|
|
21
22
|
|
|
22
23
|
const MEMORY_DIRNAME = 'memory';
|
|
23
24
|
const RECENT_CAP = 200;
|
|
24
25
|
const RECENT_KEEP_AFTER_DREAM = 50;
|
|
25
26
|
|
|
27
|
+
// Best-effort, lazy write-through into the durable FTS recall index. Lazy so
|
|
28
|
+
// better-sqlite3 stays off the chat hot path (this leaf module is touched on
|
|
29
|
+
// every turn via appendRecent) until something actually curates memory; the
|
|
30
|
+
// promise is swallowed so an index hiccup never breaks a memory write.
|
|
31
|
+
function _indexMemorySafe(row, configDir) {
|
|
32
|
+
import('./mas/index_db.mjs')
|
|
33
|
+
.then((idx) => { try { idx.indexMemory(row, configDir); } catch { /* best-effort */ } })
|
|
34
|
+
.catch(() => { /* index module unavailable — recall just stays stale */ });
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
export function defaultConfigDir() {
|
|
27
38
|
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
28
39
|
}
|
|
@@ -41,6 +52,10 @@ export function loadCore(configDir = defaultConfigDir()) {
|
|
|
41
52
|
export function setCore(text, configDir = defaultConfigDir()) {
|
|
42
53
|
fs.mkdirSync(memoryDir(configDir), { recursive: true });
|
|
43
54
|
fs.writeFileSync(corePath(configDir), String(text || ''));
|
|
55
|
+
tightenIfLoose(corePath(configDir)); // curated memory may hold pasted secrets → owner-only
|
|
56
|
+
// Mirror into the recall index so curated core memory is durably searchable
|
|
57
|
+
// (the README "durable recall over … memory" claim) — not just readable.
|
|
58
|
+
_indexMemorySafe({ topic: 'core', kind: 'core', content: String(text || '') }, configDir);
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
export function loadRecent(n = 20, configDir = defaultConfigDir()) {
|
|
@@ -78,6 +93,7 @@ export function appendRecent(sessionId, role, content, configDir = defaultConfig
|
|
|
78
93
|
ts: Date.now(),
|
|
79
94
|
}) + '\n';
|
|
80
95
|
fs.appendFileSync(recentPath(configDir), line);
|
|
96
|
+
tightenIfLoose(recentPath(configDir)); // recency log may hold pasted secrets → owner-only
|
|
81
97
|
// Cheap stat-based check to avoid read-rewrite on every append.
|
|
82
98
|
const st = fs.statSync(recentPath(configDir));
|
|
83
99
|
if (st.size > 1_000_000) {
|
|
@@ -125,11 +141,18 @@ export async function dream(sessionId, { provider, model, apiKey } = {}, configD
|
|
|
125
141
|
|
|
126
142
|
fs.mkdirSync(episodicDir(configDir), { recursive: true });
|
|
127
143
|
const written = [];
|
|
144
|
+
// Index the episodic topics in the same pass. dream() is async, so we await
|
|
145
|
+
// the (lazy) index import once for a deterministic write-through; a failure
|
|
146
|
+
// is swallowed so consolidation still succeeds even if recall can't index.
|
|
147
|
+
let indexDb = null;
|
|
148
|
+
try { indexDb = await import('./mas/index_db.mjs'); } catch { /* recall stays stale */ }
|
|
128
149
|
for (const t of topics) {
|
|
129
150
|
if (!t || !t.topic) continue;
|
|
130
151
|
const slug = String(t.topic).toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'untitled';
|
|
131
152
|
const p = path.join(episodicDir(configDir), `${slug}.md`);
|
|
132
153
|
fs.writeFileSync(p, `# ${slug}\n\n${t.summary || ''}\n`);
|
|
154
|
+
tightenIfLoose(p); // episodic summaries are conversation-derived → owner-only
|
|
155
|
+
if (indexDb) { try { indexDb.indexMemory({ topic: slug, kind: 'episodic', content: String(t.summary || '') }, configDir); } catch { /* best-effort */ } }
|
|
133
156
|
written.push(slug);
|
|
134
157
|
}
|
|
135
158
|
|
|
@@ -139,6 +162,7 @@ export async function dream(sessionId, { provider, model, apiKey } = {}, configD
|
|
|
139
162
|
const lines = fs.readFileSync(rp, 'utf8').split('\n').filter(Boolean);
|
|
140
163
|
if (lines.length > RECENT_KEEP_AFTER_DREAM) {
|
|
141
164
|
fs.writeFileSync(rp, lines.slice(-RECENT_KEEP_AFTER_DREAM).join('\n') + '\n');
|
|
165
|
+
tightenIfLoose(rp);
|
|
142
166
|
}
|
|
143
167
|
}
|
|
144
168
|
return { topics: written };
|