winter-super-cli 2026.5.22 → 2026.5.25

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 (63) hide show
  1. package/README.md +1 -1
  2. package/WINTER.md +6 -0
  3. package/bin/winter.js +77 -220
  4. package/package.json +1 -1
  5. package/resources/local/manifest.json +60 -57
  6. package/rules/default.md +1 -0
  7. package/src/ai/providers.js +38 -11
  8. package/src/cli/commands.js +24 -3
  9. package/src/cli/commands.test.js +116 -0
  10. package/src/cli/config.js +12 -0
  11. package/src/cli/repl.js +490 -141
  12. package/src/cli/repl.test.js +203 -2
  13. package/src/cli/snowflake-logo.js +15 -7
  14. package/src/cli/terminal-ui.js +125 -0
  15. package/src/cli/terminal-ui.test.js +33 -0
  16. package/src/plugins/manager.js +3 -1
  17. package/src/session/manager.js +44 -0
  18. package/src/session/manager.test.js +72 -0
  19. package/src/tools/executor.js +1 -1
  20. package/src/tools/executor.test.js +110 -0
  21. package/resources/local/claude/settings.json +0 -33
  22. package/resources/local/claude/todos/022bdc3c-e2c0-4a20-a74f-b348ed022c75-agent-022bdc3c-e2c0-4a20-a74f-b348ed022c75.json +0 -1
  23. package/resources/local/claude/todos/316f0e7d-5512-49fa-8c7f-edc75b777612-agent-316f0e7d-5512-49fa-8c7f-edc75b777612.json +0 -1
  24. package/resources/local/claude/todos/3676dc17-fca1-4692-934b-ce35e1965af6-agent-3676dc17-fca1-4692-934b-ce35e1965af6.json +0 -1
  25. package/resources/local/claude/todos/464493de-7f2a-45cf-93e8-ad73214afa10-agent-464493de-7f2a-45cf-93e8-ad73214afa10.json +0 -1
  26. package/resources/local/claude/todos/51f2e7a7-3f31-4692-a9b2-d3f3906aafea-agent-51f2e7a7-3f31-4692-a9b2-d3f3906aafea.json +0 -1
  27. package/resources/local/claude/todos/64a67dce-3d62-4a98-a548-b9c91a8e87e8-agent-64a67dce-3d62-4a98-a548-b9c91a8e87e8.json +0 -1
  28. package/resources/local/claude/todos/727a06e6-0ac2-41ca-8b81-2c14e4d40182-agent-727a06e6-0ac2-41ca-8b81-2c14e4d40182.json +0 -1
  29. package/resources/local/claude/todos/7d34d296-9b5a-4525-9b68-600d2ae20b59-agent-7d34d296-9b5a-4525-9b68-600d2ae20b59.json +0 -1
  30. package/resources/local/claude/todos/8c0606f1-5bcc-4176-8125-c5174fd69002-agent-8c0606f1-5bcc-4176-8125-c5174fd69002.json +0 -1
  31. package/resources/local/claude/todos/905aab16-5225-43f6-8ae4-c94491fd3a6f-agent-905aab16-5225-43f6-8ae4-c94491fd3a6f.json +0 -1
  32. package/resources/local/claude/todos/9dbe93f0-d62c-4c12-b4eb-0eecc437d625-agent-9dbe93f0-d62c-4c12-b4eb-0eecc437d625.json +0 -1
  33. package/resources/local/claude/todos/ad48500f-02a5-4f18-970b-82fb595d171f-agent-ad48500f-02a5-4f18-970b-82fb595d171f.json +0 -1
  34. package/resources/local/claude/todos/af86ea71-9907-4066-907c-68055e6c0081-agent-af86ea71-9907-4066-907c-68055e6c0081.json +0 -1
  35. package/resources/local/claude/todos/dbb0dc16-5d71-4f1d-a56c-db0741b3d485-agent-dbb0dc16-5d71-4f1d-a56c-db0741b3d485.json +0 -1
  36. package/resources/local/claude/todos/ff1ac487-eb0f-4c63-9360-fbb0a81bb5ae-agent-ff1ac487-eb0f-4c63-9360-fbb0a81bb5ae.json +0 -1
  37. package/resources/local/codex/config.toml +0 -84
  38. package/resources/local/codex/memories/MEMORY.md +0 -972
  39. package/resources/local/codex/memories/extensions/ad_hoc/instructions.md +0 -13
  40. package/resources/local/codex/memories/memory_summary.md +0 -188
  41. package/resources/local/codex/memories/raw_memories.md +0 -1488
  42. package/resources/local/codex/memories/rollout_summaries/2026-03-27T04-05-14-Iirb-nsis_full_installer_build_cpp_ocr_translator.md +0 -46
  43. package/resources/local/codex/memories/rollout_summaries/2026-03-28T06-18-17-Si3U-my_translator_overlay_lockfix_portable_nsis.md +0 -112
  44. package/resources/local/codex/memories/rollout_summaries/2026-04-15T06-42-11-2JMi-qelasy_timeout_and_watch_control_stability.md +0 -90
  45. package/resources/local/codex/memories/rollout_summaries/2026-04-16T03-12-59-z6Wi-request_all_row_click_detail_navigation.md +0 -42
  46. package/resources/local/codex/memories/rollout_summaries/2026-04-17T05-49-03-tNBk-my_translator_project_readability_audio_latency_clear_button.md +0 -75
  47. package/resources/local/codex/memories/rollout_summaries/2026-04-21T04-05-04-EXnh-nsis_packaging_harfbuzz_dll_qml_runtime_debug.md +0 -108
  48. package/resources/local/codex/memories/rollout_summaries/2026-04-22T03-48-40-VnNG-openclaw_opencode_sync_and_runtime_repair.md +0 -86
  49. package/resources/local/codex/memories/rollout_summaries/2026-04-22T06-49-49-R8yZ-web_book_user_portal_and_lint_fixes.md +0 -82
  50. package/resources/local/codex/memories/rollout_summaries/2026-04-22T06-50-35-ZaS1-smoke_admin_rbac_refund_connection_refused.md +0 -35
  51. package/resources/local/codex/memories/rollout_summaries/2026-04-22T11-05-04-aotT-nextjs_build_fix_statswidget_leaflet_ssr.md +0 -78
  52. package/resources/local/codex/memories/rollout_summaries/2026-04-23T03-22-24-a5q4-ui_still_looks_cloudflare_only.md +0 -41
  53. package/resources/local/codex/memories/rollout_summaries/2026-04-23T04-35-47-amlb-bayre247_hero_slide_above_search_form.md +0 -49
  54. package/resources/local/codex/memories/rollout_summaries/2026-04-23T04-59-21-lZWv-ocr_backend_parity_easyocr_tesseract_paddle_fallback.md +0 -92
  55. package/resources/local/codex/memories/rollout_summaries/2026-04-23T07-36-22-tPuo-request_workflow_editor_drag_edge_smaller_arrows_roadmap.md +0 -72
  56. package/resources/local/codex/memories/rollout_summaries/2026-04-24T08-01-05-Gb3B-checkin_shifts_workdays_assignments_and_checkout_overhaul.md +0 -90
  57. package/resources/local/codex/memories/rollout_summaries/2026-04-25T03-39-02-mbDr-web_book_refund_admin_popup_pagination_responsiveness.md +0 -151
  58. package/resources/local/codex/memories/rollout_summaries/2026-04-25T09-20-30-4usS-tool_scv_9router_custom_provider_and_paddle_ocr.md +0 -130
  59. package/resources/local/codex/memories/rollout_summaries/2026-05-06T10-19-38-mt2X-find_db_config_in_web_book_app_env.md +0 -40
  60. package/resources/local/codex/memories/rollout_summaries/2026-05-06T11-10-23-TkwP-goirong_backend_title_crash_and_client_audio_tcp_tunnel_debu.md +0 -85
  61. package/resources/local/codex/memories/rollout_summaries/2026-05-09T07-52-18-On1F-chakra_git_cleanup_readme_bilingual_publish_config.md +0 -88
  62. package/resources/local/codex/memories/rollout_summaries/2026-05-11T08-05-34-oMEl-check_crack_gui_logo_onefile_build.md +0 -68
  63. package/resources/local/codex/memories/skills/windows-packaged-app-smoke-check/SKILL.md +0 -72
@@ -1,5 +1,8 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { mkdtemp, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
3
6
 
4
7
  import { WinterREPL } from './repl.js';
5
8
 
@@ -23,6 +26,104 @@ test('resource paths point at bundled project resources', () => {
23
26
  assert.equal(paths.designs, 'E:\\dev\\app\\winter\\resources\\local\\awesome-design-md\\design-md');
24
27
  });
25
28
 
29
+ test('project context includes winter.md rules', async () => {
30
+ const root = await mkdtemp(path.join(tmpdir(), 'winter-context-'));
31
+ await writeFile(
32
+ path.join(root, 'winter.md'),
33
+ '# Winter Rules\n\n- Always use Vietnamese\n- Keep changes surgical\n'
34
+ );
35
+
36
+ const repl = new WinterREPL({ projectPath: root });
37
+ const context = await repl.getProjectContext();
38
+
39
+ assert.match(context, /\[winter\.md\]/);
40
+ assert.match(context, /Always use Vietnamese/);
41
+ assert.match(context, /Keep changes surgical/);
42
+ });
43
+
44
+ test('project context includes local resource manifest summary', async () => {
45
+ const repl = new WinterREPL({ projectPath: process.cwd() });
46
+ const context = await repl.getProjectContext();
47
+
48
+ assert.match(context, /\[Local Resources\]/);
49
+ assert.match(context, /agents\.md/);
50
+ assert.match(context, /awesome-design-md/);
51
+ assert.match(context, /codex/);
52
+ });
53
+
54
+ test('local resource context indexes Claude and Codex resource roots', async () => {
55
+ const repl = new WinterREPL({ projectPath: process.cwd() });
56
+ const context = await repl.getLocalResourceContext();
57
+
58
+ assert.match(context, /Claude skills/);
59
+ assert.match(context, /skill-creator/);
60
+ assert.match(context, /vercel-react-best-practices/);
61
+ assert.match(context, /Codex skills/);
62
+ assert.match(context, /vibefigma/);
63
+ assert.match(context, /Codex memories/);
64
+ });
65
+
66
+ test('inferStartupSkills promotes design skills for React-like projects', async () => {
67
+ const repl = new WinterREPL({ projectPath: process.cwd() });
68
+ repl.getStartupSkillCatalog = async () => new Set([
69
+ 'coding',
70
+ 'debug',
71
+ 'refactor',
72
+ 'test',
73
+ 'design',
74
+ 'web-design-guidelines',
75
+ 'vercel-react-best-practices',
76
+ ]);
77
+ repl.getProjectSignals = async () => ['react', 'next', 'tsx', 'ui'];
78
+
79
+ const snapshot = await repl.inferStartupSkills();
80
+
81
+ assert(snapshot.activeSkills.includes('coding'));
82
+ assert(snapshot.activeSkills.includes('web-design-guidelines'));
83
+ assert(snapshot.activeSkills.includes('vercel-react-best-practices'));
84
+ assert(snapshot.activeSkills.includes('design'));
85
+ });
86
+
87
+ test('bootstrapProjectCapabilities creates a startup plan and stores skills', async () => {
88
+ const repl = new WinterREPL({ projectPath: process.cwd() });
89
+ repl.getStartupSkillCatalog = async () => new Set(['coding', 'debug', 'refactor', 'test']);
90
+ repl.getProjectSignals = async () => ['node', 'cli'];
91
+
92
+ const contextStore = {};
93
+ const plans = [];
94
+ const memoryWrites = [];
95
+ repl.session = {
96
+ getContext: () => contextStore,
97
+ getPlans: () => plans,
98
+ createPlan: async (title, description) => {
99
+ const plan = { id: 'plan-1', title, description };
100
+ plans.push(plan);
101
+ return plan;
102
+ },
103
+ addPlanStep: async () => {},
104
+ updateContext: async (key, value) => {
105
+ contextStore[key] = value;
106
+ },
107
+ replaceMemory: async (prefix, content) => {
108
+ memoryWrites.push({ prefix, content });
109
+ },
110
+ };
111
+
112
+ await repl.bootstrapProjectCapabilities();
113
+
114
+ assert.equal(plans.length, 1);
115
+ assert.equal(contextStore.bootstrapPlan.title, 'Bootstrap project context');
116
+ assert.deepEqual(contextStore.activeSkills, ['coding', 'debug', 'refactor', 'test']);
117
+ assert.match(memoryWrites[0].content, /Auto-applied skills/);
118
+ });
119
+
120
+ test('shouldUseTools keeps agent mode enabled by default', () => {
121
+ const repl = new WinterREPL({ projectPath: process.cwd() });
122
+
123
+ assert.equal(repl.shouldUseTools('hello'), true);
124
+ assert.equal(repl.shouldUseTools('just chat'), true);
125
+ });
126
+
26
127
  test('extractModelIdsFromCache reads model slugs without service tier ids', () => {
27
128
  const repl = new WinterREPL({ projectPath: process.cwd() });
28
129
  const raw = JSON.stringify({
@@ -41,6 +142,25 @@ test('extractModelIdsFromCache reads model slugs without service tier ids', () =
41
142
  ]);
42
143
  });
43
144
 
145
+ test('system prompt compresses oversized memories and project context', () => {
146
+ const repl = new WinterREPL({ projectPath: process.cwd() });
147
+ repl.session = {
148
+ getSessionId: () => 'test-session',
149
+ getMemory: () => Array.from({ length: 24 }, (_, index) => ({
150
+ text: `Memory ${index + 1}: ${'x'.repeat(2500)}`,
151
+ })),
152
+ getPlans: () => [{ status: 'pending', title: 'Huge plan', description: 'y'.repeat(1200) }],
153
+ getContext: () => ({ activeSkills: ['coding', 'debug'], bootstrapPlan: { title: 'Bootstrap', description: 'Inspect everything' } }),
154
+ };
155
+
156
+ const prompt = repl.getSystemPrompt('Project context ' + 'z'.repeat(18000));
157
+
158
+ assert(prompt.length < 30000);
159
+ assert.match(prompt, /Memories \(Important Context\)/);
160
+ assert.match(prompt, /truncated/i);
161
+ assert.match(prompt, /project context truncated/i);
162
+ });
163
+
44
164
  test('readCachedModels returns bundled cache model ids', async () => {
45
165
  const repl = new WinterREPL({ projectPath: process.cwd() });
46
166
  const models = await repl.readCachedModels(repl.getResourcePaths().codex.models);
@@ -49,10 +169,10 @@ test('readCachedModels returns bundled cache model ids', async () => {
49
169
  assert(!models.includes('priority'));
50
170
  });
51
171
 
52
- test('shouldUseTools keeps simple chat on the fast path', () => {
172
+ test('shouldUseTools keeps agent mode enabled by default', () => {
53
173
  const repl = new WinterREPL({ projectPath: process.cwd() });
54
174
 
55
- assert.equal(repl.shouldUseTools('trả lời đúng một từ: ok'), false);
175
+ assert.equal(repl.shouldUseTools('trả lời đúng một từ: ok'), true);
56
176
  assert.equal(repl.shouldUseTools('sửa lỗi trong src/cli/repl.js rồi chạy test'), true);
57
177
  assert.equal(repl.shouldUseTools('git push lên github đi'), true);
58
178
  });
@@ -166,6 +286,87 @@ test('runConversation executes streamed tool calls then streams final answer', a
166
286
  assert.deepEqual(executed, [{ name: 'Read', args: { file_path: 'README.md' } }]);
167
287
  });
168
288
 
289
+ test('runConversation executes multiple tool calls across multiple turns before answering', async () => {
290
+ const repl = new WinterREPL({ projectPath: process.cwd() });
291
+ repl.simulateTyping = async (text) => {
292
+ process.stdout.write(text);
293
+ };
294
+
295
+ let streamCount = 0;
296
+ const executed = [];
297
+ repl.ai = {
298
+ tools: [],
299
+ providers: { custom: { model: 'test-model' } },
300
+ getActiveProvider: () => 'custom',
301
+ setTools(tools) {
302
+ this.tools = tools;
303
+ },
304
+ async *streamRequest() {
305
+ streamCount++;
306
+ if (streamCount === 1) {
307
+ yield {
308
+ raw: {
309
+ choices: [{
310
+ delta: {
311
+ tool_calls: [{
312
+ index: 0,
313
+ id: 'call-read',
314
+ type: 'function',
315
+ function: { name: 'Read', arguments: '{"file_path":"README.md"}' },
316
+ }],
317
+ },
318
+ finish_reason: 'tool_calls',
319
+ }],
320
+ },
321
+ };
322
+ return;
323
+ }
324
+
325
+ if (streamCount === 2) {
326
+ yield {
327
+ raw: {
328
+ choices: [{
329
+ delta: {
330
+ tool_calls: [{
331
+ index: 0,
332
+ id: 'call-grep',
333
+ type: 'function',
334
+ function: { name: 'Grep', arguments: '{"pattern":"Winter","path":"README.md"}' },
335
+ }],
336
+ },
337
+ finish_reason: 'tool_calls',
338
+ }],
339
+ },
340
+ };
341
+ return;
342
+ }
343
+
344
+ yield { content: 'Finished', usage: { prompt_tokens: 6, completion_tokens: 2, total_tokens: 8 } };
345
+ },
346
+ };
347
+ repl.tools = {
348
+ normalizeToolName: name => name,
349
+ async execute(name, args) {
350
+ executed.push({ name, args });
351
+ if (name === 'Read') {
352
+ return { success: true, path: args.file_path, content: 'Winter CLI\n', lines: 1, size: 10 };
353
+ }
354
+ if (name === 'Grep') {
355
+ return { success: true, pattern: args.pattern, path: args.path, matches: ['README.md:1:Winter CLI'], count: 1 };
356
+ }
357
+ return { success: true };
358
+ },
359
+ };
360
+
361
+ const answer = await repl.runConversation([{ role: 'user', content: 'analyze README and search it' }], 'Test', [{ name: 'Read' }, { name: 'Grep' }]);
362
+
363
+ assert.equal(answer, 'Finished');
364
+ assert.deepEqual(executed, [
365
+ { name: 'Read', args: { file_path: 'README.md' } },
366
+ { name: 'Grep', args: { pattern: 'Winter', path: 'README.md' } },
367
+ ]);
368
+ });
369
+
169
370
  test('runConversation reports malformed tool arguments instead of executing empty args', async () => {
170
371
  const repl = new WinterREPL({ projectPath: process.cwd() });
171
372
 
@@ -1,3 +1,5 @@
1
+ import { renderBox, terminalWidth } from './terminal-ui.js';
2
+
1
3
  export const colors = {
2
4
  reset: '\x1b[0m',
3
5
  bright: '\x1b[1m',
@@ -52,7 +54,6 @@ export function welcomeBanner(version, info = {}) {
52
54
  // Tính toán chiều rộng động (nhỏ hơn 5% cửa sổ, tối thiểu 60, tối đa 100 cho đẹp)
53
55
  const columns = process.stdout.columns || 80;
54
56
  const W = Math.max(60, Math.min(Math.floor(columns * 0.95), 100));
55
- const line = '═'.repeat(W);
56
57
  const dot = `${colors.green}●${colors.reset}`;
57
58
 
58
59
  // Căn giữa Snowflake Art
@@ -69,16 +70,23 @@ export function welcomeBanner(version, info = {}) {
69
70
  const subtitle = `Build by Atus | fb: iam.anhtu | github: anhtu1707`;
70
71
  const subPadding = Math.max(0, Math.floor((W - subtitle.length) / 2));
71
72
 
73
+ const infoWidth = Math.max(60, Math.min(terminalWidth(60, 100, 80), 100));
72
74
  const banner = `${colors.cyan}${centeredArt}${colors.reset}
73
75
 
74
76
  ${' '.repeat(titlePadding)}${colors.bright}${colors.magenta}W I N T E R${colors.reset} ${colors.dim}v${version}${colors.reset}
75
77
  ${' '.repeat(subPadding)}${colors.dim}${subtitle}${colors.reset}
76
- ${colors.blue}${line}${colors.reset}
77
- ${dot} ${colors.cyan}Project:${colors.reset} ${colors.green}${displayPath}${colors.reset}
78
- ${dot} ${colors.cyan}Model: ${colors.reset} ${model} ${colors.dim}(${provider})${colors.reset}
79
- ${dot} ${colors.cyan}Session:${colors.reset} ${colors.yellow}${pId}${colors.reset}
80
- ${colors.blue}${line}${colors.reset}
81
- ${colors.dim}Gõ ${colors.cyan}/help${colors.dim} để xem lệnh · ${colors.cyan}/auto${colors.dim} chế độ tự sửa · ${colors.cyan}ESC${colors.dim} để hủy${colors.reset}
78
+ ${renderBox({
79
+ title: '',
80
+ width: infoWidth,
81
+ borderColor: colors.blue,
82
+ titleColor: colors.blue,
83
+ body: [
84
+ `${dot} ${colors.cyan}Project:${colors.reset} ${colors.green}${displayPath}${colors.reset}`,
85
+ `${dot} ${colors.cyan}Model: ${colors.reset} ${model} ${colors.dim}(${provider})${colors.reset}`,
86
+ `${dot} ${colors.cyan}Session:${colors.reset} ${colors.yellow}${pId}${colors.reset}`,
87
+ `${colors.dim}Gõ ${colors.cyan}/help${colors.dim} để xem lệnh · ${colors.cyan}/auto${colors.dim} chế độ tự sửa · ${colors.cyan}ESC${colors.dim} để hủy${colors.reset}`,
88
+ ],
89
+ })}
82
90
  `;
83
91
  return banner;
84
92
  }
@@ -0,0 +1,125 @@
1
+ const ANSI_PATTERN = /\x1b\[[0-9;]*m/g;
2
+
3
+ export function stripAnsi(text) {
4
+ return String(text ?? '').replace(ANSI_PATTERN, '');
5
+ }
6
+
7
+ export function visibleWidth(text) {
8
+ return Array.from(stripAnsi(text)).length;
9
+ }
10
+
11
+ export function terminalWidth(min = 72, max = 120, fallback = 88) {
12
+ const columns = process.stdout.columns || fallback;
13
+ return Math.max(min, Math.min(columns - 2, max));
14
+ }
15
+
16
+ export function padVisible(text, width, fill = ' ') {
17
+ const visible = visibleWidth(text);
18
+ const padCount = Math.max(0, width - visible);
19
+ return `${text}${fill.repeat(padCount)}`;
20
+ }
21
+
22
+ export function wrapText(text, width) {
23
+ const output = [];
24
+ const lines = String(text ?? '').split(/\r?\n/);
25
+
26
+ for (const line of lines) {
27
+ const plain = stripAnsi(line);
28
+ if (visibleWidth(plain) <= width) {
29
+ output.push(line);
30
+ continue;
31
+ }
32
+
33
+ const words = plain.split(/\s+/).filter(Boolean);
34
+ if (words.length === 0) {
35
+ output.push(plain.slice(0, width));
36
+ continue;
37
+ }
38
+
39
+ let current = '';
40
+ for (const word of words) {
41
+ const candidate = current ? `${current} ${word}` : word;
42
+ if (visibleWidth(candidate) <= width) {
43
+ current = candidate;
44
+ } else {
45
+ if (current) output.push(current);
46
+ if (visibleWidth(word) > width) {
47
+ const chunks = chunkText(word, width);
48
+ output.push(...chunks.slice(0, -1));
49
+ current = chunks[chunks.length - 1] || '';
50
+ } else {
51
+ current = word;
52
+ }
53
+ }
54
+ }
55
+ if (current) output.push(current);
56
+ }
57
+
58
+ return output;
59
+ }
60
+
61
+ export function chunkText(text, width) {
62
+ const chars = Array.from(stripAnsi(text));
63
+ const chunks = [];
64
+ for (let i = 0; i < chars.length; i += width) {
65
+ chunks.push(chars.slice(i, i + width).join(''));
66
+ }
67
+ return chunks.length > 0 ? chunks : [''];
68
+ }
69
+
70
+ export function renderBox({ title = '', body = [], width, borderColor = '\x1b[35m', titleColor = '\x1b[36m', reset = '\x1b[0m' }) {
71
+ const innerWidth = Math.max(28, (width || terminalWidth()) - 4);
72
+ const top = `${borderColor}╭${'─'.repeat(innerWidth)}╮${reset}`;
73
+ const bottom = `${borderColor}╰${'─'.repeat(innerWidth)}╯${reset}`;
74
+ const lines = [];
75
+ const titleText = title ? ` ${title} ` : '';
76
+
77
+ if (titleText) {
78
+ const wrappedTitle = wrapText(titleText, innerWidth);
79
+ wrappedTitle.forEach((segment, index) => {
80
+ const plainSegment = stripAnsi(segment);
81
+ const visible = visibleWidth(plainSegment);
82
+ const padding = Math.max(0, innerWidth - visible);
83
+ const left = index === 0 ? Math.floor(padding / 2) : 0;
84
+ const right = index === 0 ? padding - left : padding;
85
+ lines.push(`${borderColor}│${reset}${' '.repeat(left)}${titleColor}${plainSegment}${reset}${' '.repeat(right)}${borderColor}│${reset}`);
86
+ });
87
+ lines.push(`${borderColor}├${'─'.repeat(innerWidth)}┤${reset}`);
88
+ }
89
+
90
+ for (const item of body) {
91
+ const rawText = String(item ?? '');
92
+ if (visibleWidth(rawText) <= innerWidth) {
93
+ const visible = visibleWidth(rawText);
94
+ const padding = Math.max(0, innerWidth - visible);
95
+ lines.push(`${borderColor}│${reset} ${rawText}${' '.repeat(Math.max(0, padding - 1))}${borderColor}│${reset}`);
96
+ continue;
97
+ }
98
+
99
+ const wrapped = wrapText(rawText, innerWidth);
100
+ if (wrapped.length === 0) {
101
+ lines.push(`${borderColor}│${reset} ${' '.repeat(Math.max(0, innerWidth - 1))}${borderColor}│${reset}`);
102
+ continue;
103
+ }
104
+
105
+ for (const segment of wrapped) {
106
+ const text = stripAnsi(segment);
107
+ const visible = visibleWidth(text);
108
+ const padding = Math.max(0, innerWidth - visible);
109
+ lines.push(`${borderColor}│${reset} ${text}${' '.repeat(Math.max(0, padding - 1))}${borderColor}│${reset}`);
110
+ }
111
+ }
112
+
113
+ return [top, ...lines, bottom].join('\n');
114
+ }
115
+
116
+ export function renderKeyValueRows(rows, width, colors) {
117
+ const innerWidth = Math.max(28, (width || terminalWidth()) - 4);
118
+ return rows.map(([left, right]) => {
119
+ const leftWidth = Math.floor(innerWidth * 0.5);
120
+ const rightWidth = innerWidth - leftWidth - 1;
121
+ const leftText = padVisible(left, leftWidth);
122
+ const rightText = padVisible(right, rightWidth);
123
+ return `${colors.border}│${colors.reset} ${leftText}${colors.spacer}${rightText} ${colors.border}│${colors.reset}`;
124
+ });
125
+ }
@@ -0,0 +1,33 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { renderBox, stripAnsi, visibleWidth, wrapText } from './terminal-ui.js';
4
+
5
+ test('visibleWidth ignores ANSI styling', () => {
6
+ assert.equal(visibleWidth('\u001b[36mWinter\u001b[0m'), 6);
7
+ });
8
+
9
+ test('wrapText splits long lines by visible width', () => {
10
+ assert.deepEqual(wrapText('winter cli is surprisingly compact', 10), [
11
+ 'winter cli',
12
+ 'is',
13
+ 'surprising',
14
+ 'ly compact',
15
+ ]);
16
+ });
17
+
18
+ test('renderBox keeps borders balanced', () => {
19
+ const box = renderBox({
20
+ title: 'Demo',
21
+ width: 40,
22
+ body: ['hello world'],
23
+ borderColor: '',
24
+ titleColor: '',
25
+ reset: '',
26
+ });
27
+
28
+ const lines = box.split('\n');
29
+ assert.equal(stripAnsi(lines[0]).startsWith('╭'), true);
30
+ assert.equal(stripAnsi(lines[lines.length - 1]).startsWith('╰'), true);
31
+ assert.equal(stripAnsi(lines[1]).includes('Demo'), true);
32
+ assert.equal(stripAnsi(lines[3]).includes('hello world'), true);
33
+ });
@@ -6,6 +6,7 @@
6
6
  import { promises as fs } from 'fs';
7
7
  import path from 'path';
8
8
  import { homedir } from 'os';
9
+ import { pathToFileURL } from 'url';
9
10
  import { colors, statusIcons } from '../cli/snowflake-logo.js';
10
11
 
11
12
  export class PluginManager {
@@ -61,7 +62,8 @@ export class PluginManager {
61
62
  for (const file of files) {
62
63
  if (file.endsWith('.js')) {
63
64
  try {
64
- const plugin = await import(path.join(this.pluginsDir, file));
65
+ const pluginPath = pathToFileURL(path.join(this.pluginsDir, file)).href;
66
+ const plugin = await import(pluginPath);
65
67
  plugins.push({
66
68
  name: plugin.default?.name || file.replace('.js', ''),
67
69
  version: plugin.default?.version || '1.0.0',
@@ -33,6 +33,7 @@ export class SessionManager {
33
33
  if (options.sessionId) {
34
34
  const success = await this.loadSession(options.sessionId);
35
35
  if (success) {
36
+ await this.rememberProject(this.currentSession?.project || options.project || process.cwd());
36
37
  this.initialized = true;
37
38
  return;
38
39
  }
@@ -41,6 +42,7 @@ export class SessionManager {
41
42
 
42
43
  // Luôn tạo session mới nếu không yêu cầu load hoặc load thất bại
43
44
  await this.newSession(options);
45
+ await this.rememberProject(options.project || this.currentSession?.project || process.cwd());
44
46
  this.initialized = true;
45
47
  }
46
48
 
@@ -86,6 +88,8 @@ export class SessionManager {
86
88
  this.plans = session.plans;
87
89
  this.memory = session.memory;
88
90
 
91
+ await this.rememberProject(session.project || options.project || process.cwd());
92
+
89
93
  // Update current session pointer
90
94
  const currentPath = path.join(this.sessionsDir, 'active', 'current.json');
91
95
  await fs.writeFile(currentPath, JSON.stringify({ id: sessionId }));
@@ -148,6 +152,27 @@ export class SessionManager {
148
152
  await this.saveSession();
149
153
  }
150
154
 
155
+ // Backwards-compatible alias used by other modules
156
+ async addMemory(text, type = 'info') {
157
+ return this.addToMemory(text, type);
158
+ }
159
+
160
+ // Replace previous memory entries that start with a given prefix
161
+ // Handles both legacy string entries and object entries with `text`.
162
+ async replaceMemory(prefix, content, type = 'info') {
163
+ const mem = this.memory || [];
164
+ const filtered = mem.filter(m => {
165
+ if (!m) return true;
166
+ if (typeof m === 'string') return !m.startsWith(prefix);
167
+ if (typeof m === 'object' && m.text) return !m.text.startsWith(prefix);
168
+ return true;
169
+ });
170
+
171
+ this.memory = filtered;
172
+ await this.addToMemory(`${prefix}:
173
+ ${content}`, type);
174
+ }
175
+
151
176
  async updateContext(key, value) {
152
177
  this.context[key] = {
153
178
  value,
@@ -269,10 +294,29 @@ export class SessionManager {
269
294
  this.plans = session.plans || [];
270
295
  this.memory = session.memory || [];
271
296
 
297
+ await this.rememberProject(session.project || process.cwd());
298
+
272
299
  await this.saveSession();
273
300
  return session;
274
301
  }
275
302
 
303
+ async rememberProject(projectPath) {
304
+ if (!projectPath) return;
305
+
306
+ if (this.config?.setProjectCurrent) {
307
+ await this.config.setProjectCurrent(projectPath);
308
+ return;
309
+ }
310
+
311
+ if (this.config?.load && this.config?.save) {
312
+ const config = await this.config.load();
313
+ config.project = config.project || {};
314
+ config.project.current = projectPath;
315
+ config.project.lastOpenedAt = new Date().toISOString();
316
+ await this.config.save(config);
317
+ }
318
+ }
319
+
276
320
  getSessionId() {
277
321
  return this.currentSession?.id || 'none';
278
322
  }
@@ -0,0 +1,72 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import { SessionManager } from './manager.js';
8
+
9
+ function createConfigStub() {
10
+ const state = {};
11
+ return {
12
+ state,
13
+ async load() {
14
+ return state;
15
+ },
16
+ async save(config) {
17
+ Object.assign(state, config);
18
+ },
19
+ async setProjectCurrent(projectPath) {
20
+ state.project = state.project || {};
21
+ state.project.current = projectPath;
22
+ state.project.lastOpenedAt = 'now';
23
+ },
24
+ };
25
+ }
26
+
27
+ test('new sessions remember the current project path in config', async () => {
28
+ const root = await mkdtemp(path.join(tmpdir(), 'winter-session-project-'));
29
+ const config = createConfigStub();
30
+ const session = new SessionManager(config);
31
+ session.winterDir = path.join(root, '.winter');
32
+ session.sessionsDir = path.join(session.winterDir, 'sessions');
33
+
34
+ const projectPath = path.join(root, 'demo-project');
35
+ const created = await session.init({ project: projectPath });
36
+
37
+ assert.equal(created, undefined);
38
+ assert.equal(session.currentSession.project, projectPath);
39
+ assert.equal(config.state.project.current, projectPath);
40
+ });
41
+
42
+ test('switchSession updates the remembered project anchor', async () => {
43
+ const root = await mkdtemp(path.join(tmpdir(), 'winter-session-switch-'));
44
+ const config = createConfigStub();
45
+ const session = new SessionManager(config);
46
+ session.winterDir = path.join(root, '.winter');
47
+ session.sessionsDir = path.join(session.winterDir, 'sessions');
48
+
49
+ const firstProject = path.join(root, 'project-a');
50
+ const secondProject = path.join(root, 'project-b');
51
+
52
+ await session.init({ project: firstProject });
53
+ const firstSessionId = session.getSessionId();
54
+
55
+ const secondSessionPath = path.join(session.sessionsDir, 'active', 'session-b.json');
56
+ await writeFile(secondSessionPath, JSON.stringify({
57
+ id: 'session-b',
58
+ createdAt: new Date().toISOString(),
59
+ updatedAt: new Date().toISOString(),
60
+ project: secondProject,
61
+ context: {},
62
+ plans: [],
63
+ memory: [],
64
+ history: [],
65
+ }, null, 2));
66
+
67
+ await session.switchSession('session-b');
68
+
69
+ assert.notEqual(firstSessionId, 'session-b');
70
+ assert.equal(session.getSessionId(), 'session-b');
71
+ assert.equal(config.state.project.current, secondProject);
72
+ });
@@ -998,7 +998,7 @@ export class ToolExecutor {
998
998
  success: true,
999
999
  url,
1000
1000
  content: cleanText.substring(0, 15000), // Cho phép đọc dài hơn
1001
- length: text.length
1001
+ length: cleanText.length
1002
1002
  };
1003
1003
  } catch (error) {
1004
1004
  return { success: false, error: error.message, url };