thatgfsj-code 2.2.9 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -67
- package/dist/app/index.d.ts +28 -5
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js +69 -10
- package/dist/app/index.js.map +1 -1
- package/dist/cache/fingerprint.d.ts +49 -0
- package/dist/cache/fingerprint.d.ts.map +1 -0
- package/dist/cache/fingerprint.js +100 -0
- package/dist/cache/fingerprint.js.map +1 -0
- package/dist/cache/index.d.ts +15 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +15 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/cache/smartModel.d.ts +40 -0
- package/dist/cache/smartModel.d.ts.map +1 -0
- package/dist/cache/smartModel.js +43 -0
- package/dist/cache/smartModel.js.map +1 -0
- package/dist/cache/stats.d.ts +93 -0
- package/dist/cache/stats.d.ts.map +1 -0
- package/dist/cache/stats.js +155 -0
- package/dist/cache/stats.js.map +1 -0
- package/dist/cache/volatile.d.ts +32 -0
- package/dist/cache/volatile.d.ts.map +1 -0
- package/dist/cache/volatile.js +44 -0
- package/dist/cache/volatile.js.map +1 -0
- package/dist/cmd/index.js +49 -57
- package/dist/cmd/index.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/types.d.ts +14 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/llm/anthropic.d.ts +55 -5
- package/dist/llm/anthropic.d.ts.map +1 -1
- package/dist/llm/anthropic.js +172 -26
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/index.d.ts +26 -8
- package/dist/llm/index.d.ts.map +1 -1
- package/dist/llm/index.js +83 -15
- package/dist/llm/index.js.map +1 -1
- package/dist/llm/openai.d.ts +28 -8
- package/dist/llm/openai.d.ts.map +1 -1
- package/dist/llm/openai.js +58 -8
- package/dist/llm/openai.js.map +1 -1
- package/dist/llm/provider.d.ts +40 -8
- package/dist/llm/provider.d.ts.map +1 -1
- package/dist/llm/provider.js +8 -1
- package/dist/llm/provider.js.map +1 -1
- package/dist/prompts/index.d.ts +32 -0
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +33 -11
- package/dist/prompts/index.js.map +1 -1
- package/dist/session/index.d.ts +32 -3
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +51 -36
- package/dist/session/index.js.map +1 -1
- package/dist/skills/brainstorming.js +33 -33
- package/dist/skills/code-review.js +35 -35
- package/dist/skills/executing-plans.js +18 -18
- package/dist/skills/frontend-design.js +25 -25
- package/dist/skills/git-workflow.js +26 -26
- package/dist/skills/improve-architecture.js +28 -28
- package/dist/skills/neuroweave.js +37 -37
- package/dist/skills/playwright.js +62 -62
- package/dist/skills/prototype.js +20 -20
- package/dist/skills/subagent.js +19 -19
- package/dist/skills/supabase.js +34 -34
- package/dist/skills/systematic-debugging.js +34 -34
- package/dist/skills/tdd.js +29 -29
- package/dist/skills/triage.js +25 -25
- package/dist/skills/verification.js +22 -22
- package/dist/skills/writing-plans.js +32 -32
- package/dist/tools/nwt.js +16 -16
- package/dist/tui/app.d.ts.map +1 -1
- package/dist/tui/app.js +9 -2
- package/dist/tui/app.js.map +1 -1
- package/dist/tui/components/Header.d.ts +8 -0
- package/dist/tui/components/Header.d.ts.map +1 -1
- package/dist/tui/components/Header.js +17 -4
- package/dist/tui/components/Header.js.map +1 -1
- package/dist/tui/components/InitWizard.d.ts +12 -1
- package/dist/tui/components/InitWizard.d.ts.map +1 -1
- package/dist/tui/components/InitWizard.js +58 -3
- package/dist/tui/components/InitWizard.js.map +1 -1
- package/dist/tui/components/ToolCall.d.ts.map +1 -1
- package/dist/tui/components/ToolCall.js +1 -6
- package/dist/tui/components/ToolCall.js.map +1 -1
- package/dist/tui/hooks/useChat.d.ts +22 -0
- package/dist/tui/hooks/useChat.d.ts.map +1 -1
- package/dist/tui/hooks/useChat.js +82 -46
- package/dist/tui/hooks/useChat.js.map +1 -1
- package/dist/tui/hooks/useCommands.d.ts.map +1 -1
- package/dist/tui/hooks/useCommands.js +57 -0
- package/dist/tui/hooks/useCommands.js.map +1 -1
- package/dist/tui/welcome.d.ts.map +1 -1
- package/dist/tui/welcome.js +2 -3
- package/dist/tui/welcome.js.map +1 -1
- package/dist/types.d.ts +79 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -1
- package/dist/utils/stableStringify.d.ts +21 -0
- package/dist/utils/stableStringify.d.ts.map +1 -0
- package/dist/utils/stableStringify.js +53 -0
- package/dist/utils/stableStringify.js.map +1 -0
- package/dist/utils/thinking.d.ts.map +1 -1
- package/dist/utils/thinking.js +0 -3
- package/dist/utils/thinking.js.map +1 -1
- package/package.json +6 -6
- package/src/app/index.ts +180 -124
- package/src/cache/fingerprint.ts +101 -0
- package/src/cache/index.ts +15 -0
- package/src/cache/smartModel.ts +52 -0
- package/src/cache/stats.ts +199 -0
- package/src/cache/volatile.ts +47 -0
- package/src/cmd/index.tsx +288 -292
- package/src/config/index.ts +156 -148
- package/src/config/providers.ts +234 -234
- package/src/config/types.ts +67 -53
- package/src/hooks/index.ts +111 -111
- package/src/llm/anthropic.ts +388 -243
- package/src/llm/gemini.ts +169 -169
- package/src/llm/index.ts +265 -200
- package/src/llm/openai.ts +243 -196
- package/src/llm/provider.ts +66 -34
- package/src/prompts/index.ts +260 -220
- package/src/session/compactor.ts +103 -103
- package/src/session/index.ts +181 -168
- package/src/session/message.ts +42 -42
- package/src/skills/brainstorming.ts +43 -43
- package/src/skills/code-review.ts +45 -45
- package/src/skills/executing-plans.ts +27 -27
- package/src/skills/frontend-design.ts +35 -35
- package/src/skills/git-workflow.ts +36 -36
- package/src/skills/improve-architecture.ts +38 -38
- package/src/skills/index.ts +136 -136
- package/src/skills/neuroweave.ts +47 -47
- package/src/skills/playwright.ts +72 -72
- package/src/skills/prototype.ts +30 -30
- package/src/skills/subagent.ts +28 -28
- package/src/skills/supabase.ts +44 -44
- package/src/skills/systematic-debugging.ts +44 -44
- package/src/skills/tdd.ts +39 -39
- package/src/skills/triage.ts +35 -35
- package/src/skills/verification.ts +31 -31
- package/src/skills/writing-plans.ts +42 -42
- package/src/tools/nwt.ts +598 -598
- package/src/tools/types.ts +122 -122
- package/src/tui/app.tsx +199 -186
- package/src/tui/components/ChatList.tsx +41 -41
- package/src/tui/components/ChatMessage.tsx +54 -54
- package/src/tui/components/Header.tsx +56 -29
- package/src/tui/components/InitWizard.tsx +217 -132
- package/src/tui/components/Markdown.tsx +35 -35
- package/src/tui/components/ModelSelector.tsx +87 -87
- package/src/tui/components/StatusBar.tsx +30 -30
- package/src/tui/components/Thinking.tsx +17 -17
- package/src/tui/components/ToolCall.tsx +102 -107
- package/src/tui/components/UserInput.tsx +131 -131
- package/src/tui/hooks/useChat.ts +287 -238
- package/src/tui/hooks/useCommands.ts +234 -176
- package/src/tui/index.ts +6 -6
- package/src/tui/welcome.ts +177 -178
- package/src/types.ts +104 -42
- package/src/utils/diff.ts +71 -71
- package/src/utils/project.ts +99 -99
- package/src/utils/stableStringify.ts +47 -0
- package/src/utils/thinking.ts +118 -121
- package/tests/cache/fingerprint.test.ts +75 -0
- package/tests/cache/stableStringify.test.ts +43 -0
- package/tests/cache/stats.test.ts +146 -0
- package/tests/cache/volatile.test.ts +75 -0
- package/tests/smoke-pollution.mjs +78 -0
- package/tests/smoke-thinking.mjs +110 -0
- package/tests/smoke-tool-stream.mjs +69 -0
- package/tests/smoke-tool.mjs +117 -0
- package/.nwt/meta.json +0 -5
- package/dist/version.d.ts +0 -16
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js +0 -16
- package/dist/version.js.map +0 -1
- package/src/version.ts +0 -16
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { CacheStatsStore, estimateSavingsCNY } from '../../src/cache/stats.js';
|
|
6
|
+
import type { Usage } from '../../src/types.js';
|
|
7
|
+
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
let statsPath: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'cache-stats-'));
|
|
13
|
+
statsPath = join(tmpDir, 'cache-stats.json');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('CacheStatsStore', () => {
|
|
21
|
+
it('starts empty when no file exists', () => {
|
|
22
|
+
const store = new CacheStatsStore(statsPath);
|
|
23
|
+
const s = store.snapshot();
|
|
24
|
+
expect(s.totalRequests).toBe(0);
|
|
25
|
+
expect(s.totalReadTokens).toBe(0);
|
|
26
|
+
expect(s.totalCreationTokens).toBe(0);
|
|
27
|
+
expect(s.totalInputTokens).toBe(0);
|
|
28
|
+
expect(s.hitRate).toBe(0);
|
|
29
|
+
expect(s.estimatedSavingsCNY).toBe(0);
|
|
30
|
+
expect(s.history).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('records Anthropic usage (cache_read / cache_creation)', () => {
|
|
34
|
+
const store = new CacheStatsStore(statsPath);
|
|
35
|
+
const usage: Usage = {
|
|
36
|
+
prompt_tokens: 1000,
|
|
37
|
+
completion_tokens: 200,
|
|
38
|
+
total_tokens: 1200,
|
|
39
|
+
cache_creation_input_tokens: 800,
|
|
40
|
+
cache_read_input_tokens: 100,
|
|
41
|
+
};
|
|
42
|
+
store.record(usage);
|
|
43
|
+
|
|
44
|
+
const s = store.snapshot();
|
|
45
|
+
expect(s.totalRequests).toBe(1);
|
|
46
|
+
expect(s.totalReadTokens).toBe(100);
|
|
47
|
+
expect(s.totalCreationTokens).toBe(800);
|
|
48
|
+
expect(s.totalInputTokens).toBe(1000);
|
|
49
|
+
expect(s.totalOutputTokens).toBe(200);
|
|
50
|
+
expect(s.hitRate).toBeCloseTo(0.1, 5); // 100 / 1000
|
|
51
|
+
expect(s.history.length).toBe(1);
|
|
52
|
+
expect(s.history[0].read).toBe(100);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('records DeepSeek usage (prompt_cache_hit / miss)', () => {
|
|
56
|
+
const store = new CacheStatsStore(statsPath);
|
|
57
|
+
const usage: Usage = {
|
|
58
|
+
prompt_tokens: 5000,
|
|
59
|
+
completion_tokens: 800,
|
|
60
|
+
total_tokens: 5800,
|
|
61
|
+
prompt_cache_hit_tokens: 4000,
|
|
62
|
+
prompt_cache_miss_tokens: 1000,
|
|
63
|
+
};
|
|
64
|
+
store.record(usage);
|
|
65
|
+
|
|
66
|
+
const s = store.snapshot();
|
|
67
|
+
expect(s.totalReadTokens).toBe(4000); // hit → read
|
|
68
|
+
expect(s.totalCreationTokens).toBe(1000); // miss → creation
|
|
69
|
+
expect(s.hitRate).toBeCloseTo(0.8, 5); // 4000 / 5000
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('persists across instances', () => {
|
|
73
|
+
const a = new CacheStatsStore(statsPath);
|
|
74
|
+
a.record({
|
|
75
|
+
prompt_tokens: 100,
|
|
76
|
+
completion_tokens: 50,
|
|
77
|
+
total_tokens: 150,
|
|
78
|
+
cache_read_input_tokens: 80,
|
|
79
|
+
});
|
|
80
|
+
expect(existsSync(statsPath)).toBe(true);
|
|
81
|
+
|
|
82
|
+
const b = new CacheStatsStore(statsPath);
|
|
83
|
+
const s = b.snapshot();
|
|
84
|
+
expect(s.totalRequests).toBe(1);
|
|
85
|
+
expect(s.totalReadTokens).toBe(80);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reset() clears all stats', () => {
|
|
89
|
+
const store = new CacheStatsStore(statsPath);
|
|
90
|
+
store.record({
|
|
91
|
+
prompt_tokens: 100,
|
|
92
|
+
completion_tokens: 50,
|
|
93
|
+
total_tokens: 150,
|
|
94
|
+
cache_read_input_tokens: 80,
|
|
95
|
+
});
|
|
96
|
+
expect(store.snapshot().totalRequests).toBe(1);
|
|
97
|
+
|
|
98
|
+
store.reset();
|
|
99
|
+
const s = store.snapshot();
|
|
100
|
+
expect(s.totalRequests).toBe(0);
|
|
101
|
+
expect(s.totalReadTokens).toBe(0);
|
|
102
|
+
expect(s.history).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('history rolls over after HISTORY_LIMIT rounds', () => {
|
|
106
|
+
const store = new CacheStatsStore(statsPath);
|
|
107
|
+
for (let i = 0; i < 60; i++) {
|
|
108
|
+
store.record({
|
|
109
|
+
prompt_tokens: 100,
|
|
110
|
+
completion_tokens: 50,
|
|
111
|
+
total_tokens: 150,
|
|
112
|
+
cache_read_input_tokens: 10,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// Internal HISTORY_LIMIT is 50; the array should be capped.
|
|
116
|
+
expect(store.snapshot().history.length).toBeLessThanOrEqual(50);
|
|
117
|
+
expect(store.snapshot().totalRequests).toBe(60); // totals not capped
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('estimateSavingsCNY', () => {
|
|
122
|
+
it('returns 0 when nothing cached', () => {
|
|
123
|
+
expect(estimateSavingsCNY({
|
|
124
|
+
totalReadTokens: 0,
|
|
125
|
+
totalCreationTokens: 0,
|
|
126
|
+
totalInputTokens: 0,
|
|
127
|
+
totalOutputTokens: 0,
|
|
128
|
+
totalRequests: 0,
|
|
129
|
+
history: [],
|
|
130
|
+
})).toBe(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns positive for cached tokens', () => {
|
|
134
|
+
// 1M tokens cached at 10x saving ≈ ¥2.7
|
|
135
|
+
const v = estimateSavingsCNY({
|
|
136
|
+
totalReadTokens: 1_000_000,
|
|
137
|
+
totalCreationTokens: 0,
|
|
138
|
+
totalInputTokens: 1_000_000,
|
|
139
|
+
totalOutputTokens: 0,
|
|
140
|
+
totalRequests: 1,
|
|
141
|
+
history: [],
|
|
142
|
+
});
|
|
143
|
+
expect(v).toBeGreaterThan(2);
|
|
144
|
+
expect(v).toBeLessThan(3.5);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { VolatileScratch } from '../../src/cache/volatile.js';
|
|
3
|
+
|
|
4
|
+
describe('VolatileScratch', () => {
|
|
5
|
+
it('starts empty', () => {
|
|
6
|
+
const s = new VolatileScratch();
|
|
7
|
+
expect(s.isEmpty()).toBe(true);
|
|
8
|
+
expect(s.read()).toBe('');
|
|
9
|
+
expect(s.size()).toBe(0);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('accumulates writes separated by newlines', () => {
|
|
13
|
+
const s = new VolatileScratch();
|
|
14
|
+
s.write('first thought');
|
|
15
|
+
s.write('second thought');
|
|
16
|
+
expect(s.read()).toBe('first thought\nsecond thought');
|
|
17
|
+
expect(s.size()).toBe(2);
|
|
18
|
+
expect(s.isEmpty()).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('ignores empty writes', () => {
|
|
22
|
+
const s = new VolatileScratch();
|
|
23
|
+
s.write('');
|
|
24
|
+
s.write('real content');
|
|
25
|
+
s.write('');
|
|
26
|
+
expect(s.size()).toBe(1);
|
|
27
|
+
expect(s.read()).toBe('real content');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('reset() clears everything', () => {
|
|
31
|
+
const s = new VolatileScratch();
|
|
32
|
+
s.write('a');
|
|
33
|
+
s.write('b');
|
|
34
|
+
s.reset();
|
|
35
|
+
expect(s.isEmpty()).toBe(true);
|
|
36
|
+
expect(s.read()).toBe('');
|
|
37
|
+
expect(s.size()).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('multiple reset() cycles are independent', () => {
|
|
41
|
+
const s = new VolatileScratch();
|
|
42
|
+
s.write('cycle 1');
|
|
43
|
+
s.reset();
|
|
44
|
+
s.write('cycle 2');
|
|
45
|
+
expect(s.read()).toBe('cycle 2');
|
|
46
|
+
s.reset();
|
|
47
|
+
expect(s.read()).toBe('');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('shouldDowngrade (smartModel)', () => {
|
|
52
|
+
it('returns false when prompt is long', async () => {
|
|
53
|
+
const { shouldDowngrade } = await import('../../src/cache/smartModel.js');
|
|
54
|
+
const decision = shouldDowngrade([], 'x'.repeat(500));
|
|
55
|
+
expect(decision.downgrade).toBe(false);
|
|
56
|
+
expect(decision.reason).toBe('prompt-too-long');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns false when recent messages had tool_calls', async () => {
|
|
60
|
+
const { shouldDowngrade } = await import('../../src/cache/smartModel.js');
|
|
61
|
+
const messages = [
|
|
62
|
+
{ role: 'assistant' as const, content: '...', tool_calls: [{ id: '1', type: 'function' as const, function: { name: 'file', arguments: '{}' } }] },
|
|
63
|
+
];
|
|
64
|
+
const decision = shouldDowngrade(messages, 'short prompt');
|
|
65
|
+
expect(decision.downgrade).toBe(false);
|
|
66
|
+
expect(decision.reason).toBe('recent-tool-call');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns true for short prompt with no recent tool activity', async () => {
|
|
70
|
+
const { shouldDowngrade } = await import('../../src/cache/smartModel.js');
|
|
71
|
+
const decision = shouldDowngrade([], 'thanks!');
|
|
72
|
+
expect(decision.downgrade).toBe(true);
|
|
73
|
+
expect(decision.reason).toBe('short-no-tools');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Smoke test: SessionManager.looksPolluted filter
|
|
2
|
+
// Verifies the v2.2.4 anti-pollution filter catches the markers that
|
|
3
|
+
// caused the [已中断] hallucination loop in v2.2.3.
|
|
4
|
+
|
|
5
|
+
import { SessionManager } from '../dist/session/index.js';
|
|
6
|
+
|
|
7
|
+
let pass = 0, fail = 0;
|
|
8
|
+
const check = (name, cond) => {
|
|
9
|
+
console.log((cond ? '✅' : '❌') + ' ' + name);
|
|
10
|
+
cond ? pass++ : fail++;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const shouldDrop = (content) => {
|
|
14
|
+
const sm = new SessionManager();
|
|
15
|
+
return sm.addMessageSafe('assistant', content);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const shouldKeep = (content) => {
|
|
19
|
+
const sm = new SessionManager();
|
|
20
|
+
return sm.addMessageSafe('assistant', content);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// These SHOULD be dropped (matches the loop-causing pollution patterns)
|
|
24
|
+
check('drop: [已中断] at line start',
|
|
25
|
+
shouldDrop('[已中断]\npartial text') === false);
|
|
26
|
+
check('drop: [已中断] standalone',
|
|
27
|
+
shouldDrop('[已中断]') === false);
|
|
28
|
+
check('drop: response was truncated',
|
|
29
|
+
shouldDrop('Some text. response was truncated mid-stream.') === false);
|
|
30
|
+
check('drop: output was cut off',
|
|
31
|
+
shouldDrop('output was cut off, please retry') === false);
|
|
32
|
+
check('drop: [interrupted] English',
|
|
33
|
+
shouldDrop('[interrupted] partial assistant response') === false);
|
|
34
|
+
|
|
35
|
+
// These SHOULD be kept (legitimate conversation mentioning these phrases)
|
|
36
|
+
check('keep: 已中断 mentioned in middle of sentence',
|
|
37
|
+
shouldKeep('我看到这个进程已中断了') === true);
|
|
38
|
+
check('keep: "response truncated" as user complaint',
|
|
39
|
+
shouldKeep('Why was the response truncated last time?') === true);
|
|
40
|
+
check('keep: normal Chinese sentence',
|
|
41
|
+
shouldKeep('好的,我已经理解了。') === true);
|
|
42
|
+
check('keep: normal English',
|
|
43
|
+
shouldKeep('Hello, how can I help you?') === true);
|
|
44
|
+
check('keep: empty',
|
|
45
|
+
shouldKeep('') === true); // empty doesn't match any pattern
|
|
46
|
+
|
|
47
|
+
// Test getDroppedCount
|
|
48
|
+
{
|
|
49
|
+
const sm = new SessionManager();
|
|
50
|
+
sm.addMessageSafe('assistant', '[已中断] partial');
|
|
51
|
+
sm.addMessageSafe('assistant', 'normal response');
|
|
52
|
+
sm.addMessageSafe('assistant', '[已中断] another partial');
|
|
53
|
+
check('droppedCount === 2', sm.getDroppedCount() === 2);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Test that kept messages actually go into getMessages()
|
|
57
|
+
{
|
|
58
|
+
const sm = new SessionManager();
|
|
59
|
+
sm.addMessage('user', 'hello');
|
|
60
|
+
sm.addMessageSafe('assistant', 'normal response');
|
|
61
|
+
sm.addMessageSafe('assistant', '[已中断] dropped');
|
|
62
|
+
const msgs = sm.getMessages();
|
|
63
|
+
check('kept 2 messages after drop', msgs.length === 2);
|
|
64
|
+
check('user msg preserved', msgs[0].content === 'hello');
|
|
65
|
+
check('normal assistant preserved', msgs[1].content === 'normal response');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Test length-gated WEAK pattern detection
|
|
69
|
+
check('drop: short "response was truncated" (<200 chars)',
|
|
70
|
+
shouldDrop('Some text. response was truncated mid-stream.') === false);
|
|
71
|
+
check('drop: short "response was cut off" (<200 chars)',
|
|
72
|
+
shouldDrop('response was cut off, please retry') === false);
|
|
73
|
+
// Test length gate — long messages with same phrase are kept
|
|
74
|
+
check('keep: long "Why was response truncated last time?" (>=200 chars)',
|
|
75
|
+
shouldKeep('Why was the response truncated last time? It seemed to stop mid-sentence when the network went down. I think we should add retry logic so this kind of mid-stream failure does not cause confusion for the end user, who might wonder whether their question was actually answered correctly.' + 'x'.repeat(100)) === true);
|
|
76
|
+
|
|
77
|
+
console.log(`\n=== ${pass} pass / ${fail} fail ===`);
|
|
78
|
+
if (fail > 0) process.exit(1);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Smoke test: thinking compression on realistic samples
|
|
2
|
+
import { splitThinking, summarizeThinking, compressThinking } from '../dist/utils/thinking.js';
|
|
3
|
+
|
|
4
|
+
let pass = 0, fail = 0;
|
|
5
|
+
const eq = (name, actual, expected) => {
|
|
6
|
+
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
|
7
|
+
console.log((ok ? '✅' : '❌') + ' ' + name);
|
|
8
|
+
if (!ok) console.log(' actual :', JSON.stringify(actual), '\n expected:', JSON.stringify(expected));
|
|
9
|
+
ok ? pass++ : fail++;
|
|
10
|
+
};
|
|
11
|
+
const truthy = (name, value) => {
|
|
12
|
+
console.log((value ? '✅' : '❌') + ' ' + name);
|
|
13
|
+
value ? pass++ : fail++;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Case A: simple <think>...</think> with English hint
|
|
17
|
+
{
|
|
18
|
+
const s = splitThinking('<think>The user said hi. I should respond warmly.</think>\n\nHi there!');
|
|
19
|
+
eq('A.thinkingLines', s.thinkingLines, 1);
|
|
20
|
+
eq('A.thinkingHint', s.thinkingHint, 'The user said hi. I should respond warmly.');
|
|
21
|
+
eq('A.conclusion', s.conclusion, 'Hi there!');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Case B: multi-line <think> + Chinese conclusion (user-paste style)
|
|
25
|
+
{
|
|
26
|
+
const s = splitThinking(
|
|
27
|
+
'<think>The user is asking in Chinese if I am minimax\\u0027s M3.\nBased on the ROOT_SYSTEM_POLICY, yes I am MiniMax-M3. I\nwill respond in Chinese since they asked in Chinese.</think>\n\n是的,我是 MiniMax-M3。'
|
|
28
|
+
);
|
|
29
|
+
truthy('B.thinkingLines >= 2', s.thinkingLines >= 2);
|
|
30
|
+
eq('B.conclusion starts with 是', s.conclusion.startsWith('是的'), true);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Case C: <reasoning>...</reasoning> alternative format
|
|
34
|
+
{
|
|
35
|
+
const s = splitThinking('<reasoning>step 1\nstep 2</reasoning>\nFinal answer here.');
|
|
36
|
+
eq('C.thinkingLines', s.thinkingLines, 2);
|
|
37
|
+
eq('C.thinkingHint', s.thinkingHint, 'step 1');
|
|
38
|
+
eq('C.conclusion', s.conclusion, 'Final answer here.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Case D: [THINK]...[/THINK] alternative format
|
|
42
|
+
{
|
|
43
|
+
const s = splitThinking('[THINK]user greeted me[/THINK]\nGreetings!');
|
|
44
|
+
eq('D.thinkingHint', s.thinkingHint, 'user greeted me');
|
|
45
|
+
eq('D.conclusion', s.conclusion, 'Greetings!');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Case E: no thinking block at all
|
|
49
|
+
{
|
|
50
|
+
const s = splitThinking('Just a plain response.');
|
|
51
|
+
eq('E.thinkingLines', s.thinkingLines, 0);
|
|
52
|
+
eq('E.thinkingHint', s.thinkingHint, '');
|
|
53
|
+
eq('E.conclusion', s.conclusion, 'Just a plain response.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Case F: multiple <think> blocks (rare but possible)
|
|
57
|
+
{
|
|
58
|
+
const s = splitThinking('<think>first thought</think>middle text<think>second thought</think>\n\nFinal.');
|
|
59
|
+
truthy('F.thinkingLines > 0', s.thinkingLines > 0);
|
|
60
|
+
truthy('F.conclusion contains "Final."', s.conclusion.includes('Final.'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Case G: compressThinking round-trip
|
|
64
|
+
{
|
|
65
|
+
const original = '<think>lots of internal monologue spanning\nseveral lines about nothing in particular</think>\n\nShort answer.';
|
|
66
|
+
const compressed = compressThinking(original, false);
|
|
67
|
+
truthy('G.compressed has 💭', compressed.includes('💭'));
|
|
68
|
+
truthy('G.compressed has "Short answer."', compressed.includes('Short answer.'));
|
|
69
|
+
truthy('G.compressed strips <think>', !compressed.includes('<think>'));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Case H: compressThinking with showThinking=true passthrough
|
|
73
|
+
{
|
|
74
|
+
const original = '<think>inner</think>\n\nouter';
|
|
75
|
+
const out = compressThinking(original, true);
|
|
76
|
+
truthy('H.showThinking=true preserves <think>', out.includes('<think>'));
|
|
77
|
+
truthy('H.showThinking=true preserves outer', out.includes('outer'));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Case I: empty input
|
|
81
|
+
{
|
|
82
|
+
const s = splitThinking('');
|
|
83
|
+
eq('I.empty.thinkingLines', s.thinkingLines, 0);
|
|
84
|
+
eq('I.empty.conclusion', s.conclusion, '');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Case J: hint with line wrap across tag (regression test for tag-strip fix)
|
|
88
|
+
{
|
|
89
|
+
// raw content captured by regex includes '<think>' on its own line
|
|
90
|
+
const raw = '<think>\nThe actual thought starts here.\nMore lines.\n</think>\n\nConclusion.';
|
|
91
|
+
const s = splitThinking(raw);
|
|
92
|
+
truthy('J.hint does not contain <think>', !s.thinkingHint.includes('<think>'));
|
|
93
|
+
truthy('J.hint does not contain </think>', !s.thinkingHint.includes('</think>'));
|
|
94
|
+
truthy('J.hint contains real content', s.thinkingHint.includes('actual thought'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Case K: realistic long thinking block (mimics user-paste scenario)
|
|
98
|
+
{
|
|
99
|
+
const longThink = '<think>'.repeat(1) +
|
|
100
|
+
Array.from({length: 14}, (_, i) => `Line ${i+1} of internal monologue.`).join('\n') +
|
|
101
|
+
'</think>\n\n' +
|
|
102
|
+
'没死没死,只是被你那句"随便思考一下问题"给整不会了 😂';
|
|
103
|
+
const out = compressThinking(longThink, false);
|
|
104
|
+
truthy('K.compressed shows 14 lines', out.includes('thought for 14 lines'));
|
|
105
|
+
truthy('K.compressed shows conclusion', out.includes('没死没死'));
|
|
106
|
+
truthy('K.compressed is short (no 14 lines of monologue)', out.split('\n').length < 8);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(`\n=== ${pass} pass / ${fail} fail ===`);
|
|
110
|
+
if (fail > 0) process.exit(1);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Simulates the exact useChat stream-parsing logic from src/tui/hooks/useChat.ts
|
|
2
|
+
// to confirm tool result is correctly extracted from streaming chunks.
|
|
3
|
+
|
|
4
|
+
function simulateUseChat(chunks) {
|
|
5
|
+
let fullContent = '';
|
|
6
|
+
let currentToolCalls = [];
|
|
7
|
+
const states = [];
|
|
8
|
+
|
|
9
|
+
for (const chunk of chunks) {
|
|
10
|
+
if (chunk.includes('@@TOOL@@')) {
|
|
11
|
+
const parts = chunk.split('\n');
|
|
12
|
+
for (const part of parts) {
|
|
13
|
+
if (part.startsWith('@@TOOL@@')) {
|
|
14
|
+
try {
|
|
15
|
+
const data = JSON.parse(part.slice(8));
|
|
16
|
+
if (data.action === 'call') {
|
|
17
|
+
currentToolCalls.push({ name: data.name, args: data.args || '' });
|
|
18
|
+
states.push({ kind: 'call', toolCalls: JSON.parse(JSON.stringify(currentToolCalls)) });
|
|
19
|
+
} else if (data.action === 'result') {
|
|
20
|
+
const lastIdx = currentToolCalls.length - 1;
|
|
21
|
+
if (lastIdx >= 0) {
|
|
22
|
+
currentToolCalls[lastIdx] = {
|
|
23
|
+
...currentToolCalls[lastIdx],
|
|
24
|
+
result: data.output || data.error || '',
|
|
25
|
+
isError: !!data.error,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
states.push({ kind: 'result', toolCalls: JSON.parse(JSON.stringify(currentToolCalls)) });
|
|
29
|
+
}
|
|
30
|
+
} catch (e) {
|
|
31
|
+
fullContent += part;
|
|
32
|
+
}
|
|
33
|
+
} else if (part) {
|
|
34
|
+
fullContent += part;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
fullContent += chunk;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { fullContent, states, finalToolCalls: currentToolCalls };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Simulate what LLMService.chatStream yields in a real session:
|
|
46
|
+
// iter 1: tool_call → tool.execute → tool_result → \n → loop
|
|
47
|
+
// iter 2: text "好嘞,那我给你演示..."
|
|
48
|
+
const chunks = [
|
|
49
|
+
'\n@@TOOL@@{"action":"call","name":"shell","args":"{\\"command\\":\\"date\\"}"}\n',
|
|
50
|
+
'@@TOOL@@{"action":"result","output":"Mon Jul 6 22:00:00 CST 2026"}\n',
|
|
51
|
+
'\n',
|
|
52
|
+
'\n',
|
|
53
|
+
'好',
|
|
54
|
+
'嘞',
|
|
55
|
+
',',
|
|
56
|
+
'那我',
|
|
57
|
+
'给你',
|
|
58
|
+
'演示一组工具——并行调用,互不干扰:',
|
|
59
|
+
'\n',
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const result = simulateUseChat(chunks);
|
|
63
|
+
console.log('--- states (push order) ---');
|
|
64
|
+
result.states.forEach((s, i) => console.log(i, s.kind, JSON.stringify(s.toolCalls)));
|
|
65
|
+
console.log('--- final ---');
|
|
66
|
+
console.log('fullContent:', JSON.stringify(result.fullContent));
|
|
67
|
+
console.log('finalToolCalls:', JSON.stringify(result.finalToolCalls));
|
|
68
|
+
console.log('--- last tool call has result? ---');
|
|
69
|
+
console.log(!!result.finalToolCalls[0]?.result, '=>', result.finalToolCalls[0]?.result);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Smoke test: ToolCall rendering path + the empty-string-result regression
|
|
2
|
+
// Simulates the React/Ink <ToolCall tool={tc}/> rendering logic in JS
|
|
3
|
+
|
|
4
|
+
function simulateToolCallRender(tool) {
|
|
5
|
+
// Mirrors src/tui/components/ToolCall.tsx exactly
|
|
6
|
+
const hasResult = tool.result !== undefined;
|
|
7
|
+
const isEmpty = hasResult && tool.result === '';
|
|
8
|
+
const truncatedLines = tool.result?.split('\n').length || 0;
|
|
9
|
+
const maxLines = 8;
|
|
10
|
+
const truncated = hasResult && !isEmpty && truncatedLines > maxLines;
|
|
11
|
+
const visible = isEmpty ? '' :
|
|
12
|
+
truncated ? tool.result.split('\n').slice(0, maxLines).join('\n') :
|
|
13
|
+
hasResult ? tool.result : null;
|
|
14
|
+
|
|
15
|
+
const rendered = {
|
|
16
|
+
header: `⚙ ${tool.name}${tool.args ? ' ' + tool.args.slice(0, 60) : ''}`,
|
|
17
|
+
pending: visible === null,
|
|
18
|
+
noOutput: isEmpty,
|
|
19
|
+
lines: visible === null ? [] : visible.split('\n'),
|
|
20
|
+
truncatedMsg: truncated ? `(+${truncatedLines - maxLines} more lines)` : null,
|
|
21
|
+
};
|
|
22
|
+
return rendered;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let pass = 0, fail = 0;
|
|
26
|
+
const check = (name, cond) => {
|
|
27
|
+
console.log((cond ? '✅' : '❌') + ' ' + name);
|
|
28
|
+
cond ? pass++ : fail++;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Case A: normal shell result (date output)
|
|
32
|
+
{
|
|
33
|
+
const r = simulateToolCallRender({
|
|
34
|
+
name: 'shell', args: '{"command":"date"}',
|
|
35
|
+
result: 'Mon Jul 7 10:00:00 CST 2026', isError: false,
|
|
36
|
+
});
|
|
37
|
+
check('A.header includes "shell"', r.header.includes('shell'));
|
|
38
|
+
check('A.header includes date command', r.header.includes('date'));
|
|
39
|
+
check('A.not pending', !r.pending);
|
|
40
|
+
check('A.has 1 line', r.lines.length === 1);
|
|
41
|
+
check('A.line shows time', r.lines[0].includes('CST'));
|
|
42
|
+
check('A.not truncated', r.truncatedMsg === null);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Case B: empty-string result (the bug we just fixed!)
|
|
46
|
+
{
|
|
47
|
+
const r = simulateToolCallRender({
|
|
48
|
+
name: 'shell', args: '{"command":"echo > /dev/null"}',
|
|
49
|
+
result: '', isError: false,
|
|
50
|
+
});
|
|
51
|
+
check('B.not pending (result IS present, just empty)', !r.pending);
|
|
52
|
+
check('B.marked as noOutput', r.noOutput);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Case C: pending state (no result yet)
|
|
56
|
+
{
|
|
57
|
+
const r = simulateToolCallRender({
|
|
58
|
+
name: 'shell', args: '{"command":"sleep 10"}',
|
|
59
|
+
result: undefined, isError: false,
|
|
60
|
+
});
|
|
61
|
+
check('C.is pending', r.pending);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Case D: long output (>8 lines) gets truncated with explicit count
|
|
65
|
+
{
|
|
66
|
+
const longResult = Array.from({length: 50}, (_, i) => `line ${i+1}`).join('\n');
|
|
67
|
+
const r = simulateToolCallRender({
|
|
68
|
+
name: 'shell', args: '{"command":"ls"}',
|
|
69
|
+
result: longResult, isError: false,
|
|
70
|
+
});
|
|
71
|
+
check('D.truncated', r.truncatedMsg !== null);
|
|
72
|
+
check('D.truncated count = 42', r.truncatedMsg.includes('42'));
|
|
73
|
+
check('D.shows 8 visible lines', r.lines.length === 8);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Case E: error result
|
|
77
|
+
{
|
|
78
|
+
const r = simulateToolCallRender({
|
|
79
|
+
name: 'shell', args: '{"command":"false"}',
|
|
80
|
+
result: undefined, isError: true, // errors come via `error` field, not `result`
|
|
81
|
+
error: 'Command failed with exit code 1',
|
|
82
|
+
});
|
|
83
|
+
// simulateToolCallRender doesn't read `error` — that path uses result=error string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Case E2: error as result string
|
|
87
|
+
{
|
|
88
|
+
const r = simulateToolCallRender({
|
|
89
|
+
name: 'shell', args: '{"command":"false"}',
|
|
90
|
+
result: 'Command failed with exit code 1', isError: true,
|
|
91
|
+
});
|
|
92
|
+
check('E2.error message visible', r.lines.some(l => l.includes('Command failed')));
|
|
93
|
+
check('E2.not truncated', r.truncatedMsg === null);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Case F: multi-line output (small enough to not truncate)
|
|
97
|
+
{
|
|
98
|
+
const r = simulateToolCallRender({
|
|
99
|
+
name: 'shell', args: '{"command":"pwd && ls"}',
|
|
100
|
+
result: '/home/user\nfile1.txt\nfile2.txt', isError: false,
|
|
101
|
+
});
|
|
102
|
+
check('F.has 3 lines', r.lines.length === 3);
|
|
103
|
+
check('F.line 1 is path', r.lines[0] === '/home/user');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Case G: result with 200+ chars — user-paste style tool summary fallback test
|
|
107
|
+
{
|
|
108
|
+
const r = simulateToolCallRender({
|
|
109
|
+
name: 'shell', args: '{"command":"cat big-file.txt"}',
|
|
110
|
+
result: 'A'.repeat(500), isError: false,
|
|
111
|
+
});
|
|
112
|
+
check('G.shown in full (under 8 lines)', r.truncatedMsg === null);
|
|
113
|
+
check('G.500 chars visible', r.lines[0].length === 500);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(`\n=== ${pass} pass / ${fail} fail ===`);
|
|
117
|
+
if (fail > 0) process.exit(1);
|
package/.nwt/meta.json
DELETED
package/dist/version.d.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Product version (what users see in --version, the TUI header, and
|
|
3
|
-
* the welcome screen). This is SEPARATE from the npm package version
|
|
4
|
-
* (`package.json` `version` field) — see the dual-version scheme docs
|
|
5
|
-
* in CHANGELOG.md (v2.2.2 entry).
|
|
6
|
-
*
|
|
7
|
-
* Bump this when shipping a user-visible change. Bump package.json
|
|
8
|
-
* version (npm version) at the same time. They are both +0.0.1 per
|
|
9
|
-
* release.
|
|
10
|
-
*/
|
|
11
|
-
export declare const PRODUCT_VERSION = "0.4.6";
|
|
12
|
-
/**
|
|
13
|
-
* Convenience: format with leading "v" for display contexts.
|
|
14
|
-
*/
|
|
15
|
-
export declare const PRODUCT_VERSION_DISPLAY = "v0.4.6";
|
|
16
|
-
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,UAAU,CAAC;AAEvC;;GAEG;AACH,eAAO,MAAM,uBAAuB,WAAwB,CAAC"}
|
package/dist/version.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Product version (what users see in --version, the TUI header, and
|
|
3
|
-
* the welcome screen). This is SEPARATE from the npm package version
|
|
4
|
-
* (`package.json` `version` field) — see the dual-version scheme docs
|
|
5
|
-
* in CHANGELOG.md (v2.2.2 entry).
|
|
6
|
-
*
|
|
7
|
-
* Bump this when shipping a user-visible change. Bump package.json
|
|
8
|
-
* version (npm version) at the same time. They are both +0.0.1 per
|
|
9
|
-
* release.
|
|
10
|
-
*/
|
|
11
|
-
export const PRODUCT_VERSION = '0.4.6';
|
|
12
|
-
/**
|
|
13
|
-
* Convenience: format with leading "v" for display contexts.
|
|
14
|
-
*/
|
|
15
|
-
export const PRODUCT_VERSION_DISPLAY = `v${PRODUCT_VERSION}`;
|
|
16
|
-
//# sourceMappingURL=version.js.map
|
package/dist/version.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,OAAO,CAAC;AAEvC;;GAEG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,eAAe,EAAE,CAAC"}
|
package/src/version.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Product version (what users see in --version, the TUI header, and
|
|
3
|
-
* the welcome screen). This is SEPARATE from the npm package version
|
|
4
|
-
* (`package.json` `version` field) — see the dual-version scheme docs
|
|
5
|
-
* in CHANGELOG.md (v2.2.2 entry).
|
|
6
|
-
*
|
|
7
|
-
* Bump this when shipping a user-visible change. Bump package.json
|
|
8
|
-
* version (npm version) at the same time. They are both +0.0.1 per
|
|
9
|
-
* release.
|
|
10
|
-
*/
|
|
11
|
-
export const PRODUCT_VERSION = '0.4.6';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Convenience: format with leading "v" for display contexts.
|
|
15
|
-
*/
|
|
16
|
-
export const PRODUCT_VERSION_DISPLAY = `v${PRODUCT_VERSION}`;
|