@semalt-ai/code 1.8.5 → 1.19.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.
Files changed (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
@@ -0,0 +1,269 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Slash-command registry — single source of truth for the in-chat commands.
5
+ // ---------------------------------------------------------------------------
6
+ //
7
+ // One list drives all three things that used to be maintained separately:
8
+ // * dispatch (lib/commands.js resolves text → command via this list)
9
+ // * the /help text (helpText() is generated here, not hand-written)
10
+ // * tab-completion (lib/ui/input-field.js pulls completionNames() here)
11
+ //
12
+ // Each entry:
13
+ // name canonical command token (e.g. '/model', or 'exit')
14
+ // aliases? additional tokens that resolve to the same command
15
+ // takesArg? true → requires an argument: only `name <arg>` matches,
16
+ // bare `name` falls through (e.g. /file, /shell)
17
+ // 'optional'→ `name` and `name <arg>` both match (/model, /debug)
18
+ // (absent) → exact-match only (/new, /clear, …)
19
+ // bang? true → also matches a leading '!' (the /shell shorthand)
20
+ // caseInsensitive? true → match ignoring case (exit/quit)
21
+ // complete? true → offered in tab-completion
22
+ // help? string | string[] → line(s) shown in /help, in list order.
23
+ // Absence means "not advertised in /help" (e.g. /help, /prompt).
24
+ //
25
+ // Handlers live in lib/commands.js bound to the chat-session closure; this
26
+ // module owns identity, arg shape, help, and completion. A load-time parity
27
+ // check in commands.js asserts every entry here has a handler and vice-versa,
28
+ // so adding a command is a single registry entry + its handler. (Full handler
29
+ // co-location lands in Task 1.5 when commands.js is split and a session-context
30
+ // object is introduced.)
31
+
32
+ const SLASH_COMMANDS = [
33
+ { name: '/help', complete: true },
34
+ { name: '/file', takesArg: true, complete: true, help: '/file <path> Load file or dir into context' },
35
+ { name: '/image', takesArg: true, complete: true, help: '/image <path> Attach an image (PNG/JPEG/WebP/GIF) to your next message' },
36
+ { name: '/history', complete: true, help: '/history Browse local sessions' },
37
+ { name: '/chats', complete: true, help: '/chats Browse saved dashboard chats' },
38
+ { name: '/new', complete: true, help: '/new Start fresh conversation' },
39
+ { name: '/login', complete: true, help: '/login Authorize via browser' },
40
+ { name: '/whoami', complete: true, help: '/whoami Show current user' },
41
+ { name: '/logout', complete: true, help: '/logout Clear CLI login' },
42
+ { name: '/model', takesArg: 'optional', complete: true, help: ['/model Show current model', '/model <name> Switch model manually'] },
43
+ { name: '/models', complete: true, help: '/models Choose from dashboard models' },
44
+ { name: '/clear', complete: true, help: '/clear Clear conversation' },
45
+ { name: '/compact', aliases: ['/cost'], complete: true, help: '/compact Show token usage' },
46
+ { name: '/shell', takesArg: true, bang: true, complete: true, help: ['/shell <cmd> Run shell command', '!<cmd> Run shell command'] },
47
+ { name: '/approve', complete: true, help: '/approve Toggle auto-approve' },
48
+ { name: '/debug', takesArg: 'optional', complete: true, help: '/debug [off] Enable debug output + show last 5 audit entries' },
49
+ { name: '/config', complete: true, help: '/config Show config' },
50
+ { name: '/memory', complete: true, help: '/memory Show loaded project memory files' },
51
+ { name: '/mcp', complete: true, help: '/mcp Show MCP server connection status and tools' },
52
+ { name: '/skills', complete: true, help: '/skills List available skills (bodies load on invocation)' },
53
+ { name: '/plan', complete: true, help: '/plan Toggle plan mode (withhold changes until approved)' },
54
+ { name: '/rewind', takesArg: 'optional', complete: true, help: ['/rewind List file checkpoints (file changes only — shell not reversible)', '/rewind <seq> [code|conversation|both] Restore files and/or conversation (default both; add "force" to override out-of-band edits)'] },
55
+ { name: '/doctor', complete: true, help: '/doctor Run self-diagnostics (config, dashboard, model, audit, key, memory)' },
56
+ { name: '/sandbox', complete: true, help: '/sandbox Show OS sandbox status (mode, tool, availability, network)' },
57
+ { name: '/prompt' },
58
+ { name: 'exit', aliases: ['quit', '/exit', '/quit'], caseInsensitive: true, help: 'exit Quit' },
59
+ ];
60
+
61
+ // Custom (Markdown-defined) commands, registered at session start from
62
+ // ~/.semalt-ai/commands and .semalt/commands (Task 3.1). Held in module state so
63
+ // the registry stays the single source of truth: resolveCommand/completion/help
64
+ // all see custom commands alongside the built-ins. Built-ins are always listed
65
+ // first, so a custom command can never shadow one even if registration somehow
66
+ // let a colliding name through.
67
+ let CUSTOM_COMMANDS = [];
68
+
69
+ // Skills (Task 3.5), registered at session start from ~/.semalt-ai/skills and
70
+ // .semalt/skills. Like custom commands they live in module state so the registry
71
+ // stays the single source of truth, but they carry only METADATA + the path to
72
+ // their SKILL.md — never the body. Invoking `/<skill>` loads the body on demand
73
+ // (progressive disclosure), handled inline by the turn handler.
74
+ let SKILL_COMMANDS = [];
75
+
76
+ // Every command spec, built-ins first then customs then skills, so neither a
77
+ // custom command nor a skill can ever shadow a built-in even if a colliding name
78
+ // slipped past registration.
79
+ function allCommands() {
80
+ return SLASH_COMMANDS.concat(CUSTOM_COMMANDS).concat(SKILL_COMMANDS);
81
+ }
82
+
83
+ // Register discovered custom command specs (from lib/commands/custom.js),
84
+ // replacing any previously-registered set. Built-ins win on collision: any
85
+ // custom whose name collides with a built-in name OR alias is dropped with a
86
+ // warning. The input list is assumed already de-duplicated by name with
87
+ // project-over-global precedence. Each registered spec behaves like an
88
+ // optional-arg, completable command carrying its prompt `template`. Returns
89
+ // { registered, warnings }.
90
+ function registerCustomCommands(specs) {
91
+ const reserved = new Set();
92
+ for (const s of SLASH_COMMANDS) {
93
+ reserved.add(s.name);
94
+ for (const a of (s.aliases || [])) reserved.add(a);
95
+ }
96
+ const warnings = [];
97
+ const registered = [];
98
+ const taken = new Set();
99
+ for (const spec of (specs || [])) {
100
+ if (reserved.has(spec.name)) {
101
+ warnings.push(`Custom command ${spec.name} (${spec.source || 'custom'}) ignored: a built-in command already uses that name.`);
102
+ continue;
103
+ }
104
+ if (taken.has(spec.name)) continue;
105
+ taken.add(spec.name);
106
+ const aliases = (spec.aliases || []).filter((a) => !reserved.has(a) && !taken.has(a));
107
+ for (const a of aliases) taken.add(a);
108
+ registered.push({
109
+ name: spec.name,
110
+ aliases,
111
+ takesArg: 'optional',
112
+ complete: true,
113
+ custom: true,
114
+ template: spec.template || '',
115
+ description: spec.description || '',
116
+ argumentHint: spec.argumentHint || '',
117
+ source: spec.source || 'custom',
118
+ filePath: spec.filePath || '',
119
+ });
120
+ }
121
+ CUSTOM_COMMANDS = registered;
122
+ return { registered, warnings };
123
+ }
124
+
125
+ // Drop all registered custom commands (test isolation / session reset).
126
+ function clearCustomCommands() {
127
+ CUSTOM_COMMANDS = [];
128
+ }
129
+
130
+ // The currently-registered custom command specs (copy).
131
+ function customCommands() {
132
+ return CUSTOM_COMMANDS.slice();
133
+ }
134
+
135
+ // Register discovered skill metadata specs (from lib/skills.js), replacing any
136
+ // previously-registered set. Built-ins win on collision, and skills also defer
137
+ // to already-registered custom commands (customs register first in cmdChat) — a
138
+ // colliding skill is dropped with a warning. Each registered spec behaves like
139
+ // an optional-arg, completable command flagged `skill: true` and carrying the
140
+ // metadata + `skillPath` needed to load its body on invocation (NOT the body
141
+ // itself). Returns { registered, warnings }.
142
+ function registerSkills(specs) {
143
+ const reserved = new Set();
144
+ for (const s of SLASH_COMMANDS) {
145
+ reserved.add(s.name);
146
+ for (const a of (s.aliases || [])) reserved.add(a);
147
+ }
148
+ for (const c of CUSTOM_COMMANDS) {
149
+ reserved.add(c.name);
150
+ for (const a of (c.aliases || [])) reserved.add(a);
151
+ }
152
+ const warnings = [];
153
+ const registered = [];
154
+ const taken = new Set();
155
+ for (const spec of (specs || [])) {
156
+ if (reserved.has(spec.name)) {
157
+ warnings.push(`Skill ${spec.name} (${spec.source || 'skill'}) ignored: that command name is already in use.`);
158
+ continue;
159
+ }
160
+ if (taken.has(spec.name)) continue;
161
+ taken.add(spec.name);
162
+ registered.push({
163
+ name: spec.name,
164
+ aliases: [],
165
+ takesArg: 'optional',
166
+ complete: true,
167
+ skill: true,
168
+ slug: spec.slug || spec.name.replace(/^\//, ''),
169
+ displayName: spec.displayName || '',
170
+ description: spec.description || '',
171
+ skillPath: spec.skillPath || '',
172
+ dir: spec.dir || '',
173
+ source: spec.source || 'skill',
174
+ });
175
+ }
176
+ SKILL_COMMANDS = registered;
177
+ return { registered, warnings };
178
+ }
179
+
180
+ // Drop all registered skills (test isolation / session reset).
181
+ function clearSkills() {
182
+ SKILL_COMMANDS = [];
183
+ }
184
+
185
+ // The currently-registered skill specs (copy).
186
+ function skillCommands() {
187
+ return SKILL_COMMANDS.slice();
188
+ }
189
+
190
+ // Resolve raw input text to { name, arg, spec } or null when it is not a command.
191
+ // `name` is the canonical command name; `arg` is the trimmed remainder (or '').
192
+ function resolveCommand(text) {
193
+ if (typeof text !== 'string' || !text) return null;
194
+ for (const spec of allCommands()) {
195
+ const names = [spec.name, ...(spec.aliases || [])];
196
+ const exactOk = !spec.takesArg || spec.takesArg === 'optional';
197
+ if (exactOk) {
198
+ for (const n of names) {
199
+ if (spec.caseInsensitive ? text.toLowerCase() === n.toLowerCase() : text === n) {
200
+ return { name: spec.name, arg: '', spec };
201
+ }
202
+ }
203
+ }
204
+ if (spec.takesArg) {
205
+ for (const n of names) {
206
+ if (text.startsWith(n + ' ')) {
207
+ return { name: spec.name, arg: text.slice(n.length + 1).trim(), spec };
208
+ }
209
+ }
210
+ }
211
+ if (spec.bang && text.startsWith('!')) {
212
+ return { name: spec.name, arg: text.slice(1).trim(), spec };
213
+ }
214
+ }
215
+ return null;
216
+ }
217
+
218
+ // Command names offered in tab-completion (built-ins + registered customs).
219
+ function completionNames() {
220
+ return allCommands().filter((c) => c.complete).map((c) => c.name);
221
+ }
222
+
223
+ // The /help body, generated in list order from the `help` fields. Registered
224
+ // custom commands are appended under their own heading; with none registered
225
+ // the output is byte-for-byte the built-in-only help.
226
+ function helpText() {
227
+ const lines = ['Commands:'];
228
+ for (const s of SLASH_COMMANDS) {
229
+ if (!s.help) continue;
230
+ for (const h of (Array.isArray(s.help) ? s.help : [s.help])) lines.push(' ' + h);
231
+ }
232
+ if (CUSTOM_COMMANDS.length) {
233
+ lines.push('Custom commands:');
234
+ for (const c of CUSTOM_COMMANDS) {
235
+ const hint = c.argumentHint ? ' ' + c.argumentHint : '';
236
+ const desc = c.description ? ' ' + c.description : '';
237
+ lines.push(` ${c.name}${hint}${desc}`);
238
+ }
239
+ }
240
+ if (SKILL_COMMANDS.length) {
241
+ lines.push('Skills:');
242
+ for (const s of SKILL_COMMANDS) {
243
+ const desc = s.description ? ' ' + s.description : '';
244
+ lines.push(` ${s.name}${desc}`);
245
+ }
246
+ }
247
+ return lines.join('\n');
248
+ }
249
+
250
+ // Canonical built-in command names (for the handler parity check in chat.js).
251
+ // Custom commands are intentionally excluded: they are handled inline by the
252
+ // turn handler (rendered → submitted to the agent), not via slash handlers.
253
+ function commandNames() {
254
+ return SLASH_COMMANDS.map((c) => c.name);
255
+ }
256
+
257
+ module.exports = {
258
+ SLASH_COMMANDS,
259
+ resolveCommand,
260
+ completionNames,
261
+ helpText,
262
+ commandNames,
263
+ registerCustomCommands,
264
+ clearCustomCommands,
265
+ customCommands,
266
+ registerSkills,
267
+ clearSkills,
268
+ skillCommands,
269
+ };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ // Background-task CLI commands (Task 5.3): `run --background` to launch and
4
+ // `tasks <list|status|result|kill|prune>` to manage. Thin handlers over
5
+ // lib/background.js — the launcher validates synchronously before detaching, so
6
+ // any config/policy error surfaces HERE, in the terminal, with no orphan.
7
+
8
+ const bg = require('../background');
9
+ const { resolveApiKey } = require('../secrets');
10
+
11
+ function createTaskCommands(shared) {
12
+ const { getConfig, writer, FG_CYAN, FG_GRAY, FG_GREEN, FG_RED, FG_YELLOW, RST } = shared;
13
+ const store = bg.createTaskStore();
14
+
15
+ // Launch a detached background agent task. Policy is FIXED here from the CLI
16
+ // flags (refuse-by-default when none given) and cannot change after detach.
17
+ async function cmdRun(opts, promptArgs) {
18
+ const prompt = (promptArgs || []).join(' ');
19
+ if (!opts.background) {
20
+ writer.scrollback(` ${FG_RED}Usage: semalt-code run --background <prompt>${RST}`);
21
+ await writer.flush();
22
+ return;
23
+ }
24
+ const config = getConfig();
25
+ const policy = bg.buildPolicy({
26
+ allowedTiers: opts.allowedTiers || [],
27
+ readonly: !!opts.readonly,
28
+ dangerouslySkipPermissions: !!opts.dangerouslySkipPermissions,
29
+ });
30
+ // Binary network isolation (Task 4.4b): --no-network at launch is baked into
31
+ // the spec's sandbox config (config-driven), so the detached child enforces
32
+ // kernel-level no-network via its own config — no reliance on the child's argv.
33
+ const sandboxConfig = opts.noNetwork
34
+ ? { ...(config.sandbox || {}), network: 'off' }
35
+ : config.sandbox;
36
+ try {
37
+ const { id, pid, dir } = await bg.launchBackground({
38
+ prompt,
39
+ config,
40
+ policy,
41
+ sandboxConfig,
42
+ model: opts.model,
43
+ cwd: process.cwd(),
44
+ store,
45
+ resolveKey: resolveApiKey,
46
+ });
47
+ writer.scrollback(` ${FG_GREEN}✓${RST} Launched background task ${FG_CYAN}${id}${RST} (pid ${pid})`);
48
+ writer.scrollback(` ${FG_GRAY}policy: ${bg.policySummary(policy)} · dir: ${dir}${RST}`);
49
+ writer.scrollback(` ${FG_GRAY}Track with: semalt-code tasks status ${id}${RST}`);
50
+ } catch (err) {
51
+ writer.scrollback(` ${FG_RED}✗ ${err.message}${RST}`);
52
+ }
53
+ await writer.flush();
54
+ }
55
+
56
+ async function cmdTasks(sub, argv = []) {
57
+ const id = argv[0];
58
+ if (!sub || sub === 'list') {
59
+ writer.scrollback(bg.formatTaskList(store.list()));
60
+ } else if (sub === 'status') {
61
+ if (!id) { writer.scrollback(` ${FG_RED}Usage: semalt-code tasks status <id>${RST}`); }
62
+ else writer.scrollback(bg.formatTaskStatus(store.readMeta(id), store.readEvents(id)));
63
+ } else if (sub === 'result') {
64
+ if (!id) { writer.scrollback(` ${FG_RED}Usage: semalt-code tasks result <id>${RST}`); }
65
+ else {
66
+ const res = store.readResult(id);
67
+ if (!res) writer.scrollback(` ${FG_YELLOW}No result yet for ${id} (still running or failed before completion).${RST}`);
68
+ else writer.scrollback(JSON.stringify(res, null, 2));
69
+ }
70
+ } else if (sub === 'kill') {
71
+ if (!id) { writer.scrollback(` ${FG_RED}Usage: semalt-code tasks kill <id>${RST}`); }
72
+ else {
73
+ const r = await bg.killTask(store, id);
74
+ writer.scrollback(r.ok ? ` ${FG_GREEN}✓${RST} ${id}: ${r.reason}` : ` ${FG_RED}✗ ${id}: ${r.reason}${RST}`);
75
+ }
76
+ } else if (sub === 'prune') {
77
+ const ids = bg.prunableIds(store.list());
78
+ for (const tid of ids) store.remove(tid);
79
+ writer.scrollback(` ${FG_GREEN}✓${RST} Pruned ${ids.length} finished/stale task(s).`);
80
+ } else {
81
+ writer.scrollback('Usage: semalt-code tasks <list|status <id>|result <id>|kill <id>|prune>');
82
+ }
83
+ await writer.flush();
84
+ }
85
+
86
+ return { cmdRun, cmdTasks };
87
+ }
88
+
89
+ module.exports = { createTaskCommands };
package/lib/compact.js ADDED
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Conversation compaction (Task 2.7) — real /compact + auto-compaction.
5
+ // ---------------------------------------------------------------------------
6
+ //
7
+ // Splits the message history into a HEAD to summarize and a TAIL to keep
8
+ // verbatim, preserving the most recent turns and any pinned messages. The LLM
9
+ // summarization call itself is done by the caller; everything here is pure so
10
+ // the selection / replacement logic is unit-testable.
11
+
12
+ const DEFAULT_KEEP_RECENT = 6;
13
+
14
+ // Partition messages into { head, tail, pinned }:
15
+ // tail — the last `keepRecent` messages (kept verbatim)
16
+ // pinned — messages before the tail for which isPinned(msg, i) is true
17
+ // head — the remaining older messages (to be summarized)
18
+ // When there is nothing older than the tail, head is empty (nothing to do).
19
+ function selectForCompaction(messages, opts = {}) {
20
+ const list = Array.isArray(messages) ? messages : [];
21
+ const keepRecent = Number.isInteger(opts.keepRecent) ? opts.keepRecent : DEFAULT_KEEP_RECENT;
22
+ const isPinned = typeof opts.isPinned === 'function' ? opts.isPinned : () => false;
23
+ if (list.length <= keepRecent) return { head: [], tail: list.slice(), pinned: [] };
24
+ const cut = list.length - keepRecent;
25
+ const head = [];
26
+ const pinned = [];
27
+ for (let i = 0; i < cut; i++) {
28
+ if (isPinned(list[i], i)) pinned.push(list[i]);
29
+ else head.push(list[i]);
30
+ }
31
+ return { head, tail: list.slice(cut), pinned };
32
+ }
33
+
34
+ // Build the messages array for the summarization LLM call from the head.
35
+ function summarizationRequest(head) {
36
+ const transcript = (head || []).map((m) => {
37
+ const role = (m && m.role) || 'user';
38
+ const content = m && typeof m.content === 'string' ? m.content : JSON.stringify(m && m.content);
39
+ return `${role.toUpperCase()}: ${content}`;
40
+ }).join('\n\n');
41
+ return [
42
+ {
43
+ role: 'system',
44
+ content: 'You are compacting a coding-assistant conversation. Summarize the exchange below concisely but completely: preserve key decisions, file paths, code changes already made, commands run, and any open/unfinished tasks. Output ONLY the summary.',
45
+ },
46
+ { role: 'user', content: transcript },
47
+ ];
48
+ }
49
+
50
+ // Reassemble the history after summarization: pinned messages, then a single
51
+ // summary message, then the verbatim tail. The summary is a user-role message
52
+ // (broadly compatible) clearly labelled so the model treats it as context.
53
+ function buildCompactedMessages({ pinned = [], tail = [] }, summaryText) {
54
+ const summaryMsg = {
55
+ role: 'user',
56
+ content: `[Summary of earlier conversation]\n${(summaryText || '').trim()}`,
57
+ };
58
+ return [...pinned, summaryMsg, ...tail];
59
+ }
60
+
61
+ // Convenience: approximate token count of a message list using the same
62
+ // char/4 heuristic the rest of the CLI uses. Injected estimator keeps it pure.
63
+ function approxTokens(messages, estimate) {
64
+ const est = typeof estimate === 'function' ? estimate : (s) => Math.ceil((s || '').length / 4);
65
+ return (messages || []).reduce((sum, m) => sum + est(typeof m.content === 'string' ? m.content : JSON.stringify(m.content)), 0);
66
+ }
67
+
68
+ // Decide whether to auto-compact: only when a token limit is known, the
69
+ // history is bigger than what we'd keep, and usage has crossed `ratio` of the
70
+ // limit. Complements api.js trimToTokenBudget (which drops messages) by
71
+ // summarizing them instead.
72
+ function shouldAutoCompact(tokens, limit, messageCount, opts = {}) {
73
+ const keepRecent = Number.isInteger(opts.keepRecent) ? opts.keepRecent : DEFAULT_KEEP_RECENT;
74
+ const ratio = typeof opts.ratio === 'number' ? opts.ratio : 0.85;
75
+ if (!Number.isFinite(limit) || limit <= 0) return false;
76
+ if (messageCount <= keepRecent + 1) return false;
77
+ return tokens >= limit * ratio;
78
+ }
79
+
80
+ module.exports = {
81
+ DEFAULT_KEEP_RECENT,
82
+ selectForCompaction,
83
+ summarizationRequest,
84
+ buildCompactedMessages,
85
+ approxTokens,
86
+ shouldAutoCompact,
87
+ };