@semalt-ai/code 1.8.5 → 1.20.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 (192) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/ARCHITECTURE.md +6 -95
  4. package/CLAUDE.md +196 -316
  5. package/README.md +148 -4
  6. package/docs/ARCHITECTURE.md +1321 -0
  7. package/docs/CONFIG.md +340 -0
  8. package/docs/HISTORY.md +245 -0
  9. package/examples/embed.js +74 -0
  10. package/index.js +251 -10
  11. package/lib/agent.js +856 -120
  12. package/lib/api.js +239 -50
  13. package/lib/args.js +74 -2
  14. package/lib/audit.js +23 -1
  15. package/lib/background.js +584 -0
  16. package/lib/checkpoints.js +757 -0
  17. package/lib/commands/auth.js +94 -0
  18. package/lib/commands/chat-session.js +489 -0
  19. package/lib/commands/chat-slash.js +415 -0
  20. package/lib/commands/chat-turn.js +669 -0
  21. package/lib/commands/chat.js +407 -0
  22. package/lib/commands/custom.js +157 -0
  23. package/lib/commands/history-utils.js +66 -0
  24. package/lib/commands/index.js +268 -0
  25. package/lib/commands/mcp.js +113 -0
  26. package/lib/commands/oneshot.js +193 -0
  27. package/lib/commands/registry.js +269 -0
  28. package/lib/commands/tasks.js +89 -0
  29. package/lib/compact.js +87 -0
  30. package/lib/config.js +360 -11
  31. package/lib/constants.js +401 -3
  32. package/lib/deny.js +199 -0
  33. package/lib/doctor.js +160 -0
  34. package/lib/headless.js +202 -0
  35. package/lib/hooks.js +286 -0
  36. package/lib/images.js +270 -0
  37. package/lib/internals.js +49 -0
  38. package/lib/mcp/boundary.js +131 -0
  39. package/lib/mcp/client.js +270 -0
  40. package/lib/mcp/oauth.js +134 -0
  41. package/lib/memory.js +209 -0
  42. package/lib/metrics.js +37 -2
  43. package/lib/payload.js +54 -0
  44. package/lib/permission-rules.js +401 -0
  45. package/lib/permissions.js +123 -26
  46. package/lib/pricing.js +67 -0
  47. package/lib/proc.js +62 -0
  48. package/lib/prompts.js +99 -8
  49. package/lib/sandbox.js +568 -0
  50. package/lib/sdk.js +328 -0
  51. package/lib/secrets.js +211 -0
  52. package/lib/skills.js +223 -0
  53. package/lib/subagents.js +516 -0
  54. package/lib/tool_registry.js +2862 -0
  55. package/lib/tool_specs.js +263 -9
  56. package/lib/tools.js +352 -1039
  57. package/lib/ui/anim.js +86 -0
  58. package/lib/ui/ansi.js +17 -27
  59. package/lib/ui/chat-history.js +253 -71
  60. package/lib/ui/create-ui.js +67 -24
  61. package/lib/ui/diff.js +90 -25
  62. package/lib/ui/file-activity.js +236 -0
  63. package/lib/ui/format.js +195 -29
  64. package/lib/ui/input-field.js +21 -11
  65. package/lib/ui/md-stream.js +234 -0
  66. package/lib/ui/render-operation.js +113 -0
  67. package/lib/ui/select.js +1 -4
  68. package/lib/ui/status-bar.js +146 -36
  69. package/lib/ui/stream.js +20 -13
  70. package/lib/ui/theme.js +190 -44
  71. package/lib/ui/tool-operation.js +190 -0
  72. package/lib/ui/utils.js +9 -5
  73. package/lib/ui/web-activity.js +270 -0
  74. package/lib/ui/writer.js +159 -45
  75. package/lib/ui.js +1 -1
  76. package/lib/verify.js +229 -0
  77. package/lib/web-extract.js +213 -0
  78. package/lib/web-summarize.js +68 -0
  79. package/package.json +19 -4
  80. package/scripts/lint.js +57 -0
  81. package/test/agent-loop.test.js +389 -0
  82. package/test/anim-driver.test.js +153 -0
  83. package/test/ask-user-display.test.js +226 -0
  84. package/test/ask-user-gate.test.js +231 -0
  85. package/test/background.test.js +414 -0
  86. package/test/chat-history-nocolor.test.js +155 -0
  87. package/test/chat-relogin.test.js +207 -0
  88. package/test/chat.test.js +114 -0
  89. package/test/checkpoints-agent.test.js +181 -0
  90. package/test/checkpoints.test.js +650 -0
  91. package/test/command-registry.test.js +160 -0
  92. package/test/compact.test.js +116 -0
  93. package/test/completion-lazy.test.js +52 -0
  94. package/test/config-merge.test.js +324 -0
  95. package/test/config-quarantine.test.js +128 -0
  96. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  97. package/test/config-write-guard-skip.test.js +46 -0
  98. package/test/config-write-guard.test.js +153 -0
  99. package/test/context-split.test.js +215 -0
  100. package/test/cost-doctor.test.js +142 -0
  101. package/test/custom-commands-chat.test.js +106 -0
  102. package/test/custom-commands.test.js +230 -0
  103. package/test/defer-detail-band.test.js +403 -0
  104. package/test/deny-windows.test.js +120 -0
  105. package/test/deny.test.js +83 -0
  106. package/test/detail-band-tab-flatten.test.js +242 -0
  107. package/test/download-allow-anywhere.test.js +66 -0
  108. package/test/download-confine.test.js +153 -0
  109. package/test/exec-diff.test.js +268 -0
  110. package/test/executors.test.js +599 -0
  111. package/test/extract-tool-calls.test.js +349 -0
  112. package/test/fetch-url-validation.test.js +219 -0
  113. package/test/file-activity.test.js +522 -0
  114. package/test/fixtures/tool-calls.js +57 -0
  115. package/test/fixtures/web-page.js +91 -0
  116. package/test/git-tools.test.js +384 -0
  117. package/test/grep-glob-serialize.test.js +242 -0
  118. package/test/grep-glob.test.js +268 -0
  119. package/test/grep-path-target.test.js +227 -0
  120. package/test/harness/README.md +57 -0
  121. package/test/harness/chat-harness.js +143 -0
  122. package/test/harness/memwarn-headless-child.js +65 -0
  123. package/test/harness/mock-llm.js +120 -0
  124. package/test/harness/mock-mcp-server.js +142 -0
  125. package/test/harness/sse-server.js +69 -0
  126. package/test/headless.test.js +348 -0
  127. package/test/history-utils.test.js +88 -0
  128. package/test/hooks-agent.test.js +238 -0
  129. package/test/hooks-verify-sandbox.test.js +232 -0
  130. package/test/hooks.test.js +216 -0
  131. package/test/http-get-user-agent.test.js +142 -0
  132. package/test/images-api.test.js +208 -0
  133. package/test/images.test.js +238 -0
  134. package/test/input-field-ctrl-o.test.js +37 -0
  135. package/test/live-height-physical.test.js +281 -0
  136. package/test/max-iterations.test.js +218 -0
  137. package/test/mcp-boundary.test.js +57 -0
  138. package/test/mcp-client.test.js +267 -0
  139. package/test/mcp-oauth.test.js +86 -0
  140. package/test/md-stream.test.js +183 -0
  141. package/test/memory-truncation-warning.test.js +222 -0
  142. package/test/memory.test.js +198 -0
  143. package/test/native-dispatch.test.js +409 -0
  144. package/test/native-live-narration.test.js +254 -0
  145. package/test/output-chokepoint.test.js +188 -0
  146. package/test/output-heredoc-leak.test.js +195 -0
  147. package/test/output-preview.test.js +245 -0
  148. package/test/path-guards.test.js +134 -0
  149. package/test/payload.test.js +99 -0
  150. package/test/permission-rules-agent.test.js +210 -0
  151. package/test/permission-rules.test.js +297 -0
  152. package/test/permissions.test.js +362 -0
  153. package/test/plan-mode.test.js +167 -0
  154. package/test/read-paginate.test.js +275 -0
  155. package/test/readonly-tools.test.js +177 -0
  156. package/test/render-operation.test.js +317 -0
  157. package/test/replay-descriptor-xml.test.js +216 -0
  158. package/test/replay-descriptor.test.js +189 -0
  159. package/test/replay-web-aggregate.test.js +291 -0
  160. package/test/replay-web-persist.test.js +241 -0
  161. package/test/result-cap.test.js +233 -0
  162. package/test/running-glyph-anim.test.js +111 -0
  163. package/test/sandbox-agent.test.js +147 -0
  164. package/test/sandbox-integration.test.js +216 -0
  165. package/test/sandbox.test.js +408 -0
  166. package/test/sdk.test.js +234 -0
  167. package/test/shell-output-cap.test.js +181 -0
  168. package/test/skills-chat.test.js +110 -0
  169. package/test/skills.test.js +295 -0
  170. package/test/smoke.test.js +68 -0
  171. package/test/status-bar-driver.test.js +93 -0
  172. package/test/status-bar-pause.test.js +164 -0
  173. package/test/status-bar-resync.test.js +188 -0
  174. package/test/stream-parser.test.js +171 -0
  175. package/test/subagents-agent.test.js +178 -0
  176. package/test/subagents.test.js +222 -0
  177. package/test/theme-palette.test.js +166 -0
  178. package/test/tool-registry.test.js +85 -0
  179. package/test/trim-budget.test.js +101 -0
  180. package/test/truncate-visible.test.js +78 -0
  181. package/test/verify-agent.test.js +317 -0
  182. package/test/verify.test.js +141 -0
  183. package/test/view-image.test.js +199 -0
  184. package/test/web-activity-ordering.test.js +203 -0
  185. package/test/web-activity.test.js +207 -0
  186. package/test/web-data-extraction-guidance.test.js +71 -0
  187. package/test/web-extract.test.js +185 -0
  188. package/test/web-fetch-agent.test.js +291 -0
  189. package/test/web-fetch-mode.test.js +193 -0
  190. package/test/web-search.test.js +380 -0
  191. package/lib/commands.js +0 -1438
  192. package/path +0 -1
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ // Focused tests for lib/ui/md-stream.js — the line-at-a-time Markdown → ANSI
4
+ // styler that drives both live agent narration and --resume / history replay.
5
+
6
+ const { test } = require('node:test');
7
+ const assert = require('node:assert');
8
+
9
+ // The styler gates colour through theme.colorEnabled(), which requires a TTY and
10
+ // no NO_COLOR. Force a colour-on baseline before requiring anything.
11
+ process.stdout.isTTY = true;
12
+ delete process.env.NO_COLOR;
13
+ process.stdout.columns = 80;
14
+
15
+ const { StreamMarkdown, renderBlock } = require('../lib/ui/md-stream');
16
+ const { ChatHistory } = require('../lib/ui/chat-history');
17
+
18
+ const stripAnsi = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
19
+ const hasAnsi = (s) => /\x1b\[/.test(String(s));
20
+
21
+ // Feed a multi-line string through a styler line-by-line, then flush. Returns
22
+ // the array of committed (non-null) styled chunks.
23
+ function feedAll(md, text) {
24
+ const out = [];
25
+ for (const line of text.split('\n')) {
26
+ const s = md.feedLine(line);
27
+ if (s !== null) out.push(s);
28
+ }
29
+ const tail = md.flush();
30
+ if (tail !== null) out.push(tail);
31
+ return out;
32
+ }
33
+
34
+ // ── inline spans within a complete line ──────────────────────────────────────
35
+
36
+ test('inline: bold, italic, and inline code are styled within a line', () => {
37
+ const md = new StreamMarkdown();
38
+ const styled = md.feedLine('a **bold** and *em* and `code` here');
39
+ assert.ok(hasAnsi(styled), 'emits SGR for inline spans');
40
+ assert.ok(styled.includes('\x1b[1m'), 'bold open');
41
+ assert.ok(styled.includes('\x1b[3m'), 'italic open');
42
+ assert.ok(styled.includes('\x1b[38;5;75m'), 'inline-code fg color');
43
+ // Markers are consumed; the words survive.
44
+ const plain = stripAnsi(styled);
45
+ assert.strictEqual(plain, ' a bold and em and code here');
46
+ });
47
+
48
+ test('inline: every styled line ends reset so no ANSI bleeds forward', () => {
49
+ const md = new StreamMarkdown();
50
+ const styled = md.feedLine('plain **bold**');
51
+ assert.ok(styled.endsWith('\x1b[0m'), 'line ends with RST');
52
+ });
53
+
54
+ // ── headings, lists, blockquotes, rules ──────────────────────────────────────
55
+
56
+ test('block: headings, list items, blockquote, and rule are recognised', () => {
57
+ const md = new StreamMarkdown();
58
+ assert.strictEqual(stripAnsi(md.feedLine('# Title')).split('\n')[0], ' Title');
59
+ assert.match(stripAnsi(md.feedLine('## Sub')), /^ {2}Sub/);
60
+ assert.match(stripAnsi(md.feedLine('### Small')), /^ {2}Small$/);
61
+ assert.match(stripAnsi(md.feedLine('- item')), /❯ item/);
62
+ assert.match(stripAnsi(md.feedLine('1. first')), /1\. first/);
63
+ assert.match(stripAnsi(md.feedLine('> quoted')), /│ quoted/);
64
+ assert.match(stripAnsi(md.feedLine('---')), /^ {2}─+$/);
65
+ });
66
+
67
+ // ── fenced code block with a language ────────────────────────────────────────
68
+
69
+ test('code: a fenced block with a language emits a box at fence close', () => {
70
+ const md = new StreamMarkdown();
71
+ assert.strictEqual(md.feedLine('```python'), null, 'open fence emits nothing');
72
+ assert.strictEqual(md.feedLine('def f():'), null, 'body buffered, nothing emitted');
73
+ assert.strictEqual(md.feedLine(' return 1'), null);
74
+ const box = md.feedLine('```');
75
+ assert.notStrictEqual(box, null, 'closing fence emits the whole box');
76
+ const lines = stripAnsi(box).split('\n');
77
+ assert.match(lines[0], /╭.*python/, 'top border carries the language label');
78
+ assert.match(box, /\x1b\[38;5;176m/, 'a keyword (def/return) is syntax-highlighted');
79
+ assert.match(box, /\x1b\[K/, 'code body fills background to edge via EL');
80
+ assert.match(lines[lines.length - 1], /╰─+$/, 'bottom border closes the box');
81
+ });
82
+
83
+ test('code: a block split across multiple feedLine calls renders both body lines', () => {
84
+ const md = new StreamMarkdown();
85
+ md.feedLine('```js'); // open
86
+ md.feedLine('const a = 1;');
87
+ md.feedLine('const b = 2;');
88
+ const box = md.feedLine('```');
89
+ const plain = stripAnsi(box);
90
+ assert.match(plain, /const a = 1;/);
91
+ assert.match(plain, /const b = 2;/);
92
+ });
93
+
94
+ // ── flush() with an unclosed fence ───────────────────────────────────────────
95
+
96
+ test('flush: an UNCLOSED fence is closed cleanly with no stranded SGR', () => {
97
+ const md = new StreamMarkdown();
98
+ md.feedLine('```python');
99
+ md.feedLine('x = 1');
100
+ const tail = md.flush();
101
+ assert.notStrictEqual(tail, null, 'flush emits the buffered box');
102
+ const plain = stripAnsi(tail);
103
+ assert.match(plain, /╭/, 'top border present');
104
+ assert.match(plain, /╰/, 'bottom border present (fence closed cleanly)');
105
+ assert.ok(tail.endsWith('\x1b[0m'), 'no SGR left open at the end');
106
+ // After flush the styler is back to a clean state.
107
+ assert.strictEqual(md.inCodeBlock, false);
108
+ assert.strictEqual(md.feedLine('plain again').includes('╭'), false);
109
+ });
110
+
111
+ // ── NO_COLOR → plain output, no SGR ──────────────────────────────────────────
112
+
113
+ test('NO_COLOR: output is plain text with no escape codes', () => {
114
+ process.env.NO_COLOR = '1';
115
+ try {
116
+ const md = new StreamMarkdown();
117
+ const prose = md.feedLine('a **bold** and `code`');
118
+ assert.strictEqual(hasAnsi(prose), false, 'no SGR under NO_COLOR');
119
+ assert.strictEqual(prose, ' a bold and code', 'markers stripped, plain prose');
120
+ // Code blocks also stay plain (box-drawing chars are not ANSI).
121
+ md.feedLine('```js');
122
+ md.feedLine('const a = 1;');
123
+ const box = md.feedLine('```');
124
+ assert.strictEqual(hasAnsi(box), false, 'no SGR in the code box under NO_COLOR');
125
+ assert.match(box, /const a = 1;/);
126
+ } finally {
127
+ delete process.env.NO_COLOR;
128
+ }
129
+ });
130
+
131
+ // ── live-vs-_buildAI parity (the strongest constraint) ───────────────────────
132
+
133
+ test('parity: live streaming and _buildAI render the SAME bytes for the same content', () => {
134
+ const content = [
135
+ 'Here is **bold** and `inline`.',
136
+ '',
137
+ '- one',
138
+ '- two',
139
+ '',
140
+ '```python',
141
+ 'def f():',
142
+ ' return 42',
143
+ '```',
144
+ 'Done.',
145
+ ].join('\n');
146
+
147
+ // (a) Live path: simulate streamToken receiving the content (plus the trailing
148
+ // newline the model emits) one chunk, then a finalize/flush. Capture commits.
149
+ const live = new ChatHistory();
150
+ const liveOut = [];
151
+ live._commit = (t) => liveOut.push(t);
152
+ live.streamToken(content + '\n');
153
+ live._flushStream();
154
+ // Drop the header (first commit) and the trailing blank-line commit so we
155
+ // compare only the styled body, which is what _buildAI's renderBlock produces.
156
+ const liveBody = liveOut.slice(1, -1).join('');
157
+
158
+ // (b) Replay path: _buildAI → renderBlock for the same content.
159
+ const replay = renderBlock(content);
160
+ // renderBlock returns the body with no trailing newline; the live path commits
161
+ // each styled chunk followed by '\n'. Normalise by appending the same '\n'.
162
+ assert.strictEqual(liveBody, replay + '\n', 'live body bytes == replay body bytes');
163
+ });
164
+
165
+ test('parity: renderBlock matches a manual feed-then-flush over the same lines', () => {
166
+ const content = 'alpha\n**beta**\n\n```\ncode\n```';
167
+ const md = new StreamMarkdown();
168
+ const manual = feedAll(md, content).join('\n');
169
+ assert.strictEqual(renderBlock(content), manual);
170
+ });
171
+
172
+ // ── reset() clears cross-turn state ──────────────────────────────────────────
173
+
174
+ test('reset: clears an in-progress fenced block between turns', () => {
175
+ const md = new StreamMarkdown();
176
+ md.feedLine('```python');
177
+ md.feedLine('leftover');
178
+ md.reset();
179
+ assert.strictEqual(md.inCodeBlock, false);
180
+ // Next turn's first line is treated as prose, not buffered code.
181
+ const styled = md.feedLine('fresh line');
182
+ assert.match(stripAnsi(styled), /^ {2}fresh line$/);
183
+ });
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ // Fail-loud project-memory truncation (memory-truncation warning task).
4
+ //
5
+ // Project memory (AGENTS.md/CLAUDE.md) is loaded and capped at
6
+ // DEFAULT_MEMORY_MAX_BYTES. Before this change the cut was SILENT — a user with
7
+ // a large memory file had most of it dropped with no notice. These tests prove
8
+ // the fail-loud behavior now matching the rest of the project (cf. config
9
+ // quarantine, sandbox-unavailable):
10
+ // * a file over the cap → a one-time user-facing warning naming the path and
11
+ // the loaded/original sizes + dropped %, while the (truncated) content still
12
+ // loads;
13
+ // * a file under the cap → NO warning (paired negative — "warned" can never be
14
+ // confused with "always warns");
15
+ // * the warning is user-facing only — never injected into the model/system
16
+ // prompt content;
17
+ // * headless json mode keeps stdout byte-pure: the warning goes to stderr;
18
+ // * the SDK surfaces it via its 'warning' event, once per agent;
19
+ // * no memory file present → no warning, prompt section absent (intact).
20
+
21
+ const os = require('node:os');
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+
25
+ // Redirect HOME before requiring lib modules so the global-memory level
26
+ // (~/.semalt-ai/AGENTS.md) resolves under an empty temp home and never picks up
27
+ // the developer's real memory.
28
+ const TMP_HOME = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-memwarn-home-')));
29
+ const PREV_HOME = process.env.HOME;
30
+ const PREV_USERPROFILE = process.env.USERPROFILE;
31
+ process.env.HOME = TMP_HOME;
32
+ process.env.USERPROFILE = TMP_HOME;
33
+
34
+ const { test, after } = require('node:test');
35
+ const assert = require('node:assert');
36
+
37
+ const {
38
+ loadProjectMemory,
39
+ memoryTruncationWarnings,
40
+ DEFAULT_MEMORY_MAX_BYTES,
41
+ } = require('../lib/memory');
42
+ const { getSystemPrompt } = require('../lib/prompts');
43
+
44
+ const { createAgent } = require('../lib/sdk');
45
+ const { startMockLLM } = require('./harness/mock-llm');
46
+
47
+ const PREV_CWD = process.cwd();
48
+ const PREV_KEY = process.env.SEMALT_API_KEY;
49
+ after(() => {
50
+ process.chdir(PREV_CWD);
51
+ if (PREV_HOME === undefined) delete process.env.HOME; else process.env.HOME = PREV_HOME;
52
+ if (PREV_USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = PREV_USERPROFILE;
53
+ if (PREV_KEY === undefined) delete process.env.SEMALT_API_KEY; else process.env.SEMALT_API_KEY = PREV_KEY;
54
+ });
55
+
56
+ function mkRepo(prefix) {
57
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
58
+ fs.mkdirSync(path.join(root, '.git'), { recursive: true });
59
+ return root;
60
+ }
61
+ function write(p, data) { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, data); }
62
+ const emptyHome = () => fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-memwarn-eh-')));
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Pure logic: warning content + the paired negative
66
+ // ---------------------------------------------------------------------------
67
+
68
+ test('a memory file larger than the cap → warning with path + loaded/original sizes; content still loads', () => {
69
+ const home = emptyHome();
70
+ const root = mkRepo('semalt-memwarn-big-');
71
+ const file = path.join(root, 'AGENTS.md');
72
+ write(file, 'X'.repeat(5000));
73
+
74
+ const r = loadProjectMemory({ cwd: root, home, maxBytes: 1000 });
75
+ assert.strictEqual(r.truncated, true);
76
+ assert.strictEqual(r.truncatedFiles.length, 1, 'one file recorded as truncated');
77
+ assert.strictEqual(r.truncatedFiles[0].path, file);
78
+ assert.strictEqual(r.truncatedFiles[0].originalBytes, 5000);
79
+ assert.ok(r.truncatedFiles[0].loadedBytes < 5000, 'loaded less than original');
80
+
81
+ const warnings = memoryTruncationWarnings(r);
82
+ assert.strictEqual(warnings.length, 1, 'exactly one warning');
83
+ const w = warnings[0];
84
+ assert.ok(w.includes(file), 'warning names the file path');
85
+ assert.ok(/loaded \d+ (?:B|KB) of \d+ (?:B|KB)/.test(w), 'warning shows loaded of original size');
86
+ assert.ok(/\d+% dropped/.test(w), 'warning shows the dropped fraction');
87
+
88
+ // The (truncated) memory content STILL loads — the warning is additive only.
89
+ assert.ok(r.block.includes('XXXX'), 'truncated content is still present in the block');
90
+ });
91
+
92
+ test('a memory file UNDER the cap → no warning (paired negative)', () => {
93
+ const home = emptyHome();
94
+ const root = mkRepo('semalt-memwarn-small-');
95
+ write(path.join(root, 'AGENTS.md'), 'concise project guidance');
96
+
97
+ const r = loadProjectMemory({ cwd: root, home });
98
+ assert.strictEqual(r.truncated, false);
99
+ assert.deepStrictEqual(r.truncatedFiles, []);
100
+ assert.deepStrictEqual(memoryTruncationWarnings(r), [], 'nothing dropped → no warning');
101
+ });
102
+
103
+ test('multiple files: only the truncated ones are warned about', () => {
104
+ const home = emptyHome();
105
+ const root = mkRepo('semalt-memwarn-multi-');
106
+ // global (home) small, project-root large → only the large one is cut.
107
+ write(path.join(home, '.semalt-ai', 'AGENTS.md'), 'tiny');
108
+ const big = path.join(root, 'AGENTS.md');
109
+ write(big, 'Q'.repeat(8000));
110
+
111
+ const r = loadProjectMemory({ cwd: root, home, maxBytes: 600 });
112
+ const warnings = memoryTruncationWarnings(r);
113
+ assert.ok(warnings.length >= 1, 'at least the large file is warned about');
114
+ assert.ok(warnings.some((w) => w.includes(big)), 'the oversized project file is named');
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // The warning is user-facing only — never in the model/system prompt content
119
+ // ---------------------------------------------------------------------------
120
+
121
+ test('the warning is NOT injected into the system prompt content', () => {
122
+ const home = emptyHome();
123
+ const root = mkRepo('semalt-memwarn-prompt-');
124
+ write(path.join(root, 'AGENTS.md'), 'Y'.repeat(DEFAULT_MEMORY_MAX_BYTES + 5000));
125
+ process.chdir(root);
126
+
127
+ const r = loadProjectMemory({ cwd: root, home });
128
+ assert.strictEqual(r.truncated, true);
129
+ const warnings = memoryTruncationWarnings(r);
130
+ assert.ok(warnings.length >= 1);
131
+
132
+ // The block keeps its existing inline notice, but NOT the user warning string.
133
+ assert.ok(!r.block.includes('Consider trimming it to the most relevant guidance'),
134
+ 'user-facing warning text is not in the memory block');
135
+ for (const w of warnings) {
136
+ assert.ok(!r.block.includes(w), 'the warning string is not embedded in the block');
137
+ }
138
+
139
+ // getSystemPrompt loads memory from cwd; the warning must not leak into it.
140
+ const prompt = getSystemPrompt(false);
141
+ for (const w of warnings) {
142
+ assert.ok(!prompt.includes(w), 'the warning string is not in the system prompt');
143
+ }
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Headless json mode: warning → stderr, stdout stays pure JSON
148
+ // ---------------------------------------------------------------------------
149
+
150
+ test('headless json mode: truncation warning goes to stderr, JSON envelope intact on stdout', () => {
151
+ // Run cmdCode in a CHILD process so its stdout/stderr are fully isolated —
152
+ // swapping the parent's global process.stdout would collide with the
153
+ // node:test TAP reporter (it would swallow other tests' result lines).
154
+ const { spawnSync } = require('node:child_process');
155
+ const child = path.join(__dirname, 'harness', 'memwarn-headless-child.js');
156
+ const res = spawnSync(process.execPath, [child], { encoding: 'utf8' });
157
+ assert.strictEqual(res.status, 0, `child exited cleanly (stderr: ${res.stderr})`);
158
+
159
+ const stdout = res.stdout || '';
160
+ const stderr = res.stderr || '';
161
+
162
+ // The warning is on stderr — the user channel machine modes already use
163
+ // (matching the config-quarantine / sandbox-unavailable startup warnings).
164
+ assert.ok(/⚠ Memory file .*AGENTS\.md truncated/.test(stderr), 'warning surfaced on stderr');
165
+
166
+ // The warning does NOT corrupt stdout: no warning glyph / text leaks there,
167
+ // and the JSON envelope on stdout is intact and parseable.
168
+ assert.ok(!stdout.includes('⚠'), 'no warning glyph pollutes stdout');
169
+ assert.ok(!/Memory file .*truncated/.test(stdout), 'no warning text pollutes stdout');
170
+ const jsonLine = stdout.split('\n').find((l) => l.trim().startsWith('{'));
171
+ assert.ok(jsonLine, 'the JSON envelope line is present on stdout');
172
+ assert.strictEqual(JSON.parse(jsonLine).result, 'All done.', 'the JSON envelope is intact');
173
+ });
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // SDK: surfaced via the 'warning' event, once per agent
177
+ // ---------------------------------------------------------------------------
178
+
179
+ test('SDK emits a one-time "warning" event for a truncated memory file', async () => {
180
+ const root = mkRepo('semalt-memwarn-sdk-');
181
+ write(path.join(root, 'AGENTS.md'), 'W'.repeat(DEFAULT_MEMORY_MAX_BYTES + 3000));
182
+ process.chdir(root);
183
+ process.env.SEMALT_API_KEY = 'test-key';
184
+
185
+ const mock = await startMockLLM();
186
+ mock.replyWith('ok one');
187
+ mock.replyWith('ok two');
188
+ const agent = createAgent({
189
+ apiBase: mock.base, apiKey: 'test-key', model: 'test-model', sandbox: { mode: 'off' },
190
+ });
191
+ const warnings = [];
192
+ agent.on('warning', (m) => warnings.push(m));
193
+ try {
194
+ await agent.run('first');
195
+ await agent.run('second'); // a second run must NOT re-warn
196
+ } finally {
197
+ await agent.close();
198
+ await mock.close();
199
+ }
200
+ const memWarnings = warnings.filter((w) => /Memory file .*AGENTS\.md truncated/.test(w));
201
+ assert.strictEqual(memWarnings.length, 1, 'warned exactly once across two runs');
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Absent memory → no warning, prompt section absent (existing behavior intact)
206
+ // ---------------------------------------------------------------------------
207
+
208
+ test('no memory file present → no warning and no memory section', () => {
209
+ const home = emptyHome();
210
+ const root = mkRepo('semalt-memwarn-none-');
211
+
212
+ const r = loadProjectMemory({ cwd: root, home });
213
+ assert.strictEqual(r.block, '');
214
+ assert.strictEqual(r.truncated, false);
215
+ assert.deepStrictEqual(r.truncatedFiles, []);
216
+ assert.deepStrictEqual(memoryTruncationWarnings(r), [], 'no memory → no warning');
217
+
218
+ // Empty memory leaves the prompt unchanged (no PROJECT_MEMORY section).
219
+ const base = getSystemPrompt(false, '', '');
220
+ assert.ok(!base.includes('PROJECT_MEMORY'), 'no memory section when memory is empty');
221
+ assert.strictEqual(getSystemPrompt(false, r.block, ''), base, 'empty block appends nothing');
222
+ });
@@ -0,0 +1,198 @@
1
+ 'use strict';
2
+
3
+ // Characterization tests for project memory (Task 2.3): the AGENTS.md/CLAUDE.md
4
+ // hierarchy loader and its injection into the system prompt. Filesystem work is
5
+ // isolated under temp dirs; HOME is redirected before requiring lib modules so
6
+ // the global-memory level resolves under the temp home.
7
+
8
+ const os = require('node:os');
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+
12
+ const TMP_HOME = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-memhome-')));
13
+ const PREV_HOME = process.env.HOME;
14
+ const PREV_USERPROFILE = process.env.USERPROFILE;
15
+ process.env.HOME = TMP_HOME;
16
+ process.env.USERPROFILE = TMP_HOME;
17
+
18
+ const { test, after } = require('node:test');
19
+ const assert = require('node:assert');
20
+
21
+ const {
22
+ loadProjectMemory,
23
+ discoverMemoryFiles,
24
+ findRepoRoot,
25
+ memoryStatusLines,
26
+ } = require('../lib/memory');
27
+ const { getSystemPrompt } = require('../lib/prompts');
28
+
29
+ const PREV_CWD = process.cwd();
30
+ after(() => {
31
+ process.chdir(PREV_CWD);
32
+ if (PREV_HOME === undefined) delete process.env.HOME; else process.env.HOME = PREV_HOME;
33
+ if (PREV_USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = PREV_USERPROFILE;
34
+ });
35
+
36
+ function mkRepo(prefix) {
37
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
38
+ fs.mkdirSync(path.join(root, '.git'), { recursive: true });
39
+ return root;
40
+ }
41
+ function write(p, data) { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, data); }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Absent files → no memory, base prompt unchanged
45
+ // ---------------------------------------------------------------------------
46
+
47
+ test('no memory files present → empty block and no files', () => {
48
+ const emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-emptyhome-')));
49
+ const root = mkRepo('semalt-mem-none-');
50
+ const r = loadProjectMemory({ cwd: root, home: emptyHome });
51
+ assert.strictEqual(r.block, '');
52
+ assert.deepStrictEqual(r.files, []);
53
+ assert.strictEqual(r.truncated, false);
54
+ });
55
+
56
+ test('getSystemPrompt is byte-for-byte the base prompt when memory is empty', () => {
57
+ const base = getSystemPrompt(false, '');
58
+ assert.ok(!base.includes('PROJECT_MEMORY'), 'no memory section in the base prompt');
59
+ // Memory is appended verbatim to the end — proves append-only + empty == base.
60
+ assert.strictEqual(getSystemPrompt(false, '\n\nMEMBLOCK'), base + '\n\nMEMBLOCK');
61
+ // Same for the native template.
62
+ const nbase = getSystemPrompt(true, '');
63
+ assert.strictEqual(getSystemPrompt(true, '\n\nX'), nbase + '\n\nX');
64
+ });
65
+
66
+ test('getSystemPrompt with no project memory in CWD/home equals the explicit-empty base', () => {
67
+ const emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-eh2-')));
68
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-nomem-cwd-')));
69
+ const prevHome = process.env.HOME;
70
+ process.env.HOME = emptyHome; process.env.USERPROFILE = emptyHome;
71
+ process.chdir(dir);
72
+ try {
73
+ assert.strictEqual(getSystemPrompt(false), getSystemPrompt(false, ''));
74
+ } finally {
75
+ process.env.HOME = prevHome; process.env.USERPROFILE = prevHome;
76
+ process.chdir(PREV_CWD);
77
+ }
78
+ });
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Hierarchy: global → project root → nested CWD, in order
82
+ // ---------------------------------------------------------------------------
83
+
84
+ test('hierarchy loads global, repo root, and nested CWD in order', () => {
85
+ write(path.join(TMP_HOME, '.semalt-ai', 'AGENTS.md'), 'GLOBAL_MEM');
86
+ const root = mkRepo('semalt-mem-hier-');
87
+ write(path.join(root, 'AGENTS.md'), 'ROOT_MEM');
88
+ const nested = path.join(root, 'pkg', 'sub');
89
+ write(path.join(nested, 'AGENTS.md'), 'CWD_MEM');
90
+
91
+ const r = loadProjectMemory({ cwd: nested, home: TMP_HOME });
92
+ assert.deepStrictEqual(r.files.map((f) => f.source), ['global', 'project-root', 'cwd']);
93
+ const gi = r.block.indexOf('GLOBAL_MEM');
94
+ const ri = r.block.indexOf('ROOT_MEM');
95
+ const ci = r.block.indexOf('CWD_MEM');
96
+ assert.ok(gi !== -1 && ri !== -1 && ci !== -1, 'all three present');
97
+ assert.ok(gi < ri && ri < ci, 'concatenated in hierarchy order');
98
+ assert.ok(r.block.includes('<<<PROJECT_MEMORY>>>') && r.block.includes('<<<END_PROJECT_MEMORY>>>'));
99
+
100
+ // cleanup global so it does not leak into other tests
101
+ fs.rmSync(path.join(TMP_HOME, '.semalt-ai'), { recursive: true, force: true });
102
+ });
103
+
104
+ test('CWD at the repo root is not double-loaded', () => {
105
+ const emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-eh3-')));
106
+ const root = mkRepo('semalt-mem-rootcwd-');
107
+ write(path.join(root, 'AGENTS.md'), 'ONLY_ROOT');
108
+ const r = loadProjectMemory({ cwd: root, home: emptyHome });
109
+ assert.deepStrictEqual(r.files.map((f) => f.source), ['project-root']);
110
+ });
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // AGENTS.md vs CLAUDE.md alias
114
+ // ---------------------------------------------------------------------------
115
+
116
+ test('AGENTS.md is preferred over CLAUDE.md and the choice is reported', () => {
117
+ const emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-eh4-')));
118
+ const root = mkRepo('semalt-mem-alias-');
119
+ write(path.join(root, 'AGENTS.md'), 'A_WINS');
120
+ write(path.join(root, 'CLAUDE.md'), 'C_IGNORED');
121
+ const r = loadProjectMemory({ cwd: root, home: emptyHome });
122
+ assert.strictEqual(r.files.length, 1);
123
+ assert.strictEqual(r.files[0].name, 'AGENTS.md');
124
+ assert.strictEqual(r.files[0].alsoPresent, true);
125
+ assert.ok(r.block.includes('A_WINS'));
126
+ assert.ok(!r.block.includes('C_IGNORED'), 'CLAUDE.md content is not loaded when AGENTS.md exists');
127
+ });
128
+
129
+ test('CLAUDE.md is used as the alias when AGENTS.md is absent', () => {
130
+ const emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-eh5-')));
131
+ const root = mkRepo('semalt-mem-claude-');
132
+ write(path.join(root, 'CLAUDE.md'), 'CLAUDE_ONLY');
133
+ const r = loadProjectMemory({ cwd: root, home: emptyHome });
134
+ assert.strictEqual(r.files[0].name, 'CLAUDE.md');
135
+ assert.strictEqual(r.files[0].alsoPresent, false);
136
+ assert.ok(r.block.includes('CLAUDE_ONLY'));
137
+ });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Truncation
141
+ // ---------------------------------------------------------------------------
142
+
143
+ test('oversized memory is truncated with a visible notice', () => {
144
+ const emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-eh6-')));
145
+ const root = mkRepo('semalt-mem-big-');
146
+ write(path.join(root, 'AGENTS.md'), 'X'.repeat(5000));
147
+ const r = loadProjectMemory({ cwd: root, home: emptyHome, maxBytes: 200 });
148
+ assert.strictEqual(r.truncated, true);
149
+ assert.ok(/truncated/i.test(r.block), 'block carries a truncation notice');
150
+ });
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // findRepoRoot + status lines
154
+ // ---------------------------------------------------------------------------
155
+
156
+ test('findRepoRoot locates the nearest .git ancestor and returns null without one', () => {
157
+ const root = mkRepo('semalt-mem-root-');
158
+ const nested = path.join(root, 'a', 'b');
159
+ fs.mkdirSync(nested, { recursive: true });
160
+ assert.strictEqual(findRepoRoot(nested), root);
161
+ const bare = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-bare-')));
162
+ // a temp dir under the OS tmpdir is not inside a git repo
163
+ assert.strictEqual(findRepoRoot(bare), null);
164
+ });
165
+
166
+ test('memoryStatusLines reports loaded files, alias note, and edit target', () => {
167
+ const result = {
168
+ files: [
169
+ { path: '/home/u/.semalt-ai/AGENTS.md', source: 'global', name: 'AGENTS.md', alsoPresent: false },
170
+ { path: '/repo/AGENTS.md', source: 'project-root', name: 'AGENTS.md', alsoPresent: true },
171
+ ],
172
+ truncated: false,
173
+ };
174
+ const lines = memoryStatusLines(result).join('\n');
175
+ assert.ok(lines.includes('/home/u/.semalt-ai/AGENTS.md'));
176
+ assert.ok(lines.includes('[global]') && lines.includes('[project-root]'));
177
+ assert.ok(/CLAUDE\.md also present/.test(lines), 'alias note shown');
178
+ assert.ok(lines.includes('Edit project memory: /repo/AGENTS.md'), 'points at the nearest project file');
179
+ });
180
+
181
+ test('memoryStatusLines handles the no-memory case', () => {
182
+ const lines = memoryStatusLines({ files: [], truncated: false }).join('\n');
183
+ assert.ok(/No project memory files found/.test(lines));
184
+ });
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // discoverMemoryFiles direct
188
+ // ---------------------------------------------------------------------------
189
+
190
+ test('discoverMemoryFiles returns ordered entries with resolved paths', () => {
191
+ const emptyHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'semalt-eh7-')));
192
+ const root = mkRepo('semalt-disc-');
193
+ write(path.join(root, 'CLAUDE.md'), 'x');
194
+ const found = discoverMemoryFiles(root, emptyHome);
195
+ assert.strictEqual(found.length, 1);
196
+ assert.strictEqual(found[0].source, 'project-root');
197
+ assert.strictEqual(path.isAbsolute(found[0].path), true);
198
+ });