thatgfsj-code 1.0.2 → 2.2.9

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/.nwt/meta.json +5 -0
  2. package/CHANGELOG.md +244 -0
  3. package/DEVELOPMENT.md +286 -0
  4. package/README.md +23 -1
  5. package/dist/app/index.d.ts +14 -0
  6. package/dist/app/index.d.ts.map +1 -1
  7. package/dist/app/index.js +21 -5
  8. package/dist/app/index.js.map +1 -1
  9. package/dist/cmd/index.d.ts +21 -0
  10. package/dist/cmd/index.d.ts.map +1 -1
  11. package/dist/cmd/index.js +144 -12
  12. package/dist/cmd/index.js.map +1 -1
  13. package/dist/session/index.d.ts +11 -0
  14. package/dist/session/index.d.ts.map +1 -1
  15. package/dist/session/index.js +83 -1
  16. package/dist/session/index.js.map +1 -1
  17. package/dist/tools/nwt.d.ts +14 -4
  18. package/dist/tools/nwt.d.ts.map +1 -1
  19. package/dist/tools/nwt.js +160 -13
  20. package/dist/tools/nwt.js.map +1 -1
  21. package/dist/tui/components/ChatList.d.ts.map +1 -1
  22. package/dist/tui/components/ChatList.js +4 -3
  23. package/dist/tui/components/ChatList.js.map +1 -1
  24. package/dist/tui/components/Header.d.ts.map +1 -1
  25. package/dist/tui/components/Header.js +2 -1
  26. package/dist/tui/components/Header.js.map +1 -1
  27. package/dist/tui/components/ToolCall.d.ts +1 -1
  28. package/dist/tui/components/ToolCall.d.ts.map +1 -1
  29. package/dist/tui/components/ToolCall.js +9 -4
  30. package/dist/tui/components/ToolCall.js.map +1 -1
  31. package/dist/tui/hooks/useChat.d.ts.map +1 -1
  32. package/dist/tui/hooks/useChat.js +47 -6
  33. package/dist/tui/hooks/useChat.js.map +1 -1
  34. package/dist/tui/hooks/useCommands.d.ts.map +1 -1
  35. package/dist/tui/hooks/useCommands.js +21 -0
  36. package/dist/tui/hooks/useCommands.js.map +1 -1
  37. package/dist/tui/welcome.d.ts.map +1 -1
  38. package/dist/tui/welcome.js +3 -2
  39. package/dist/tui/welcome.js.map +1 -1
  40. package/dist/utils/thinking.d.ts +59 -0
  41. package/dist/utils/thinking.d.ts.map +1 -0
  42. package/dist/utils/thinking.js +107 -0
  43. package/dist/utils/thinking.js.map +1 -0
  44. package/dist/version.d.ts +16 -0
  45. package/dist/version.d.ts.map +1 -0
  46. package/dist/version.js +16 -0
  47. package/dist/version.js.map +1 -0
  48. package/install.bat +63 -0
  49. package/install.ps1 +238 -0
  50. package/install.sh +113 -0
  51. package/package.json +5 -2
  52. package/src/app/index.ts +21 -5
  53. package/src/cmd/index.tsx +145 -13
  54. package/src/session/index.ts +82 -1
  55. package/src/tools/nwt.ts +181 -13
  56. package/src/tui/components/ChatList.tsx +15 -6
  57. package/src/tui/components/Header.tsx +2 -1
  58. package/src/tui/components/ToolCall.tsx +46 -10
  59. package/src/tui/hooks/useChat.ts +50 -6
  60. package/src/tui/hooks/useCommands.ts +22 -0
  61. package/src/tui/welcome.ts +3 -2
  62. package/src/utils/thinking.ts +122 -0
  63. package/src/version.ts +16 -0
package/install.sh ADDED
@@ -0,0 +1,113 @@
1
+ #!/bin/bash
2
+ # Thatgfsj Code Installer for macOS/Linux
3
+ # Usage: curl -sL https://raw.githubusercontent.com/Thatgfsj/thatgfsj-code/main/install.sh | bash
4
+
5
+ set -e
6
+
7
+ # Colors
8
+ RED='\033[0;31m'
9
+ GREEN='\033[0;32m'
10
+ YELLOW='\033[1;33m'
11
+ CYAN='\033[0;36m'
12
+ NC='\033[0m' # No Color
13
+
14
+ echo ""
15
+ echo -e "${CYAN} Thatgfsj Code 安装向导${NC}"
16
+ echo -e "${CYAN} =======================${NC}"
17
+ echo ""
18
+
19
+ # ============== Step 1: Check Node.js ==============
20
+ echo -e "${YELLOW}[*] 检查 Node.js...${NC}"
21
+
22
+ if command -v node &> /dev/null; then
23
+ NODE_VERSION=$(node --version)
24
+ MAJOR_VERSION=$(echo $NODE_VERSION | cut -d'v' -f2 | cut -d'.' -f1)
25
+ if [ "$MAJOR_VERSION" -ge 18 ]; then
26
+ echo -e "${GREEN}[✓] Node.js $NODE_VERSION 已安装${NC}"
27
+ else
28
+ echo -e "${RED}[✗] Node.js 版本过低,需要 v18+${NC}"
29
+ echo " 请访问 https://nodejs.org 升级"
30
+ exit 1
31
+ fi
32
+ else
33
+ echo -e "${YELLOW}[*] 未检测到 Node.js,开始安装...${NC}"
34
+
35
+ # Try Homebrew (macOS)
36
+ if command -v brew &> /dev/null; then
37
+ echo -e "${YELLOW}[*] 使用 Homebrew 安装...${NC}"
38
+ brew install node
39
+ # Try apt (Ubuntu/Debian)
40
+ elif command -v apt-get &> /dev/null; then
41
+ echo -e "${YELLOW}[*] 使用 apt 安装...${NC}"
42
+ curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
43
+ sudo apt-get install -y nodejs
44
+ # Try yum (CentOS/RHEL)
45
+ elif command -v yum &> /dev/null; then
46
+ echo -e "${YELLOW}[*] 使用 yum 安装...${NC}"
47
+ curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
48
+ sudo yum install -y nodejs
49
+ else
50
+ echo -e "${RED}[✗] 未找到包管理器${NC}"
51
+ echo " 请手动安装: https://nodejs.org"
52
+ exit 1
53
+ fi
54
+ fi
55
+
56
+ # ============== Step 2: Clone/Update ==============
57
+ echo -e "${YELLOW}[*] 准备安装 Thatgfsj Code...${NC}"
58
+
59
+ INSTALL_DIR="$HOME/thatgfsj-code"
60
+
61
+ if [ -d "$INSTALL_DIR" ]; then
62
+ echo -e "${YELLOW}[*] 检测到已有安装,正在更新...${NC}"
63
+ cd "$INSTALL_DIR"
64
+ git pull origin main 2>/dev/null || {
65
+ echo -e "${YELLOW}[*] 更新失败,重新克隆...${NC}"
66
+ rm -rf "$INSTALL_DIR"
67
+ }
68
+ fi
69
+
70
+ if [ ! -d "$INSTALL_DIR" ]; then
71
+ echo -e "${YELLOW}[*] 克隆仓库...${NC}"
72
+ git clone https://github.com/Thatgfsj/thatgfsj-code.git "$INSTALL_DIR"
73
+ fi
74
+
75
+ cd "$INSTALL_DIR"
76
+
77
+ if [ ! -f "package.json" ]; then
78
+ echo -e "${RED}[✗] 安装目录无效${NC}"
79
+ exit 1
80
+ fi
81
+
82
+ echo -e "${GREEN}[✓] 代码准备完成: $INSTALL_DIR${NC}"
83
+
84
+ # ============== Step 3: Install Dependencies ==============
85
+ echo -e "${YELLOW}[*] 安装依赖...${NC}"
86
+
87
+ npm install
88
+ npm run build
89
+
90
+ echo -e "${GREEN}[✓] 依赖安装完成${NC}"
91
+
92
+ # ============== Step 4: Link Command ==============
93
+ echo -e "${YELLOW}[*] 设置命令...${NC}"
94
+
95
+ npm link
96
+
97
+ echo -e "${GREEN}[✓] 命令 'gfcode' 已可用${NC}"
98
+
99
+ # ============== Done ==============
100
+ echo ""
101
+ echo -e "${CYAN} ======================================${NC}"
102
+ echo -e "${GREEN} 安装完成!${NC}"
103
+ echo -e "${CYAN} ======================================${NC}"
104
+ echo ""
105
+ echo -e " ${WHITE}使用方法:${NC}"
106
+ echo -e " gfcode init - 重新配置"
107
+ echo -e " gfcode - 启动交互模式"
108
+ echo -e " gfcode '你的问题' - 直接提问"
109
+ echo -e " gfcode explain '代码' - 解释代码"
110
+ echo -e " gfcode debug '代码' - 调试代码"
111
+ echo ""
112
+ echo -e " 文档: ${CYAN}https://github.com/Thatgfsj/thatgfsj-code${NC}"
113
+ echo ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatgfsj-code",
3
- "version": "1.0.2",
3
+ "version": "2.2.9",
4
4
  "description": "Thatgfsj Code - AI Coding Assistant",
5
5
  "main": "dist/cmd/index.js",
6
6
  "type": "module",
@@ -12,7 +12,10 @@
12
12
  "build": "tsc",
13
13
  "start": "node dist/cmd/index.js",
14
14
  "dev": "tsc && node dist/cmd/index.js",
15
- "link": "npm link"
15
+ "link": "npm link",
16
+ "test": "node tests/smoke-thinking.mjs && node tests/smoke-tool.mjs && node tests/smoke-pollution.mjs && node tests/edge-thinking.mjs && node tests/edge-tool.mjs && node tests/edge-pollution.mjs && node tests/edge-session.mjs && node tests/edge-cli.mjs",
17
+ "test:single": "node tests/",
18
+ "prepublishOnly": "npm run build && npm test"
16
19
  },
17
20
  "keywords": [
18
21
  "ai",
package/src/app/index.ts CHANGED
@@ -10,6 +10,7 @@ import { ToolRegistry } from '../tools/index.js';
10
10
  import { HookManager } from '../hooks/index.js';
11
11
  import { SystemPromptBuilder } from '../prompts/index.js';
12
12
  import { SkillRegistry } from '../skills/index.js';
13
+ import { compressThinking } from '../utils/thinking.js';
13
14
  import type { ChatMessage, ChatResponse } from '../types.js';
14
15
 
15
16
  export class App {
@@ -20,6 +21,12 @@ export class App {
20
21
  hooks: HookManager;
21
22
  prompts: SystemPromptBuilder;
22
23
  skills: SkillRegistry;
24
+ /**
25
+ * v2.2.5 (product 0.4.2): toggle <think> block compression. Default
26
+ * true. Toggled by `--show-thinking` on the CLI or `/thinking on|off`
27
+ * in the REPL.
28
+ */
29
+ showThinking: boolean = false;
23
30
 
24
31
  private constructor(
25
32
  config: ConfigManager,
@@ -83,6 +90,14 @@ export class App {
83
90
 
84
91
  /**
85
92
  * Run a single prompt (non-interactive mode)
93
+ *
94
+ * v2.2.4 (port from v2.1.0): persistence of the assistant message
95
+ * uses addMessageSafe, which drops the message if it contains
96
+ * pollution markers like "[已中断]".
97
+ *
98
+ * v2.2.5 (product 0.4.2): persistence also strips <think> blocks
99
+ * (and similar reasoning delimiters) when showThinking is false,
100
+ * so the conversation log stays compact.
86
101
  */
87
102
  async runPrompt(prompt: string): Promise<string> {
88
103
  this.session.addMessage('user', prompt);
@@ -94,15 +109,16 @@ export class App {
94
109
  fullResponse += chunk;
95
110
  }
96
111
  } catch (err) {
97
- // Re-throw after saving partial response
98
- if (fullResponse) {
99
- this.session.addMessage('assistant', fullResponse);
100
- }
112
+ // Re-throw without persisting partial response. Persisting
113
+ // truncated output here was the source of the [已中断] loop in
114
+ // v2.2.3.
101
115
  throw err;
102
116
  }
103
117
 
104
118
  console.log();
105
- this.session.addMessage('assistant', fullResponse);
119
+ // v2.2.5: compress <think> blocks before persisting.
120
+ const toPersist = compressThinking(fullResponse, this.showThinking);
121
+ this.session.addMessageSafe('assistant', toPersist);
106
122
  return fullResponse;
107
123
  }
108
124
  }
package/src/cmd/index.tsx CHANGED
@@ -2,18 +2,60 @@
2
2
 
3
3
  /**
4
4
  * Thatgfsj Code - CLI Entry Point
5
+ *
6
+ * v2.2.4 (product 0.4.1): three concrete fixes for the bugs visible
7
+ * in the 2.2.3 (product 0.4.0) session log:
8
+ *
9
+ * 1. Encoding: chcp 65001 used to be wrapped in a silent try/catch,
10
+ * and stdout never had its default encoding set. On Windows this
11
+ * caused Chinese characters from tool output to render as
12
+ * mojibake (ļ ʹ ϵͳ Ƿ ...).
13
+ *
14
+ * 2. [已中断] hallucination loop: 1.0.4's SessionManager had no
15
+ * anti-pollution filter. When the user pressed Ctrl+C mid-
16
+ * stream, the truncated assistant message (containing the
17
+ * model's literal "[已中断]" marker) got persisted to history.
18
+ * On the next turn, the model saw that and kept echoing it.
19
+ * v2.1.0 had the right fix (_wasAborted flag + filter) — we
20
+ * port it back here.
21
+ *
22
+ * 3. Tool output visual: 1.0.4 printed `@@TOOL@@{...}` markers
23
+ * inline with text, no separator, no truncation. Long tool
24
+ * output (registry dumps, dir listings) bled into the next
25
+ * AI message. Add separator + truncation + color separation.
5
26
  */
6
27
 
7
28
  if (process.platform === 'win32') {
29
+ // Switch Windows console to UTF-8 BEFORE any other code runs.
30
+ // v2.2.3 wrapped this in `try { } catch {}` which silently swallowed
31
+ // failures (e.g. when running under a non-interactive shell where
32
+ // chcp is meaningless). Now we still try, but we also fall back to
33
+ // setting the codepage via the parent process's stdio handles.
8
34
  try {
9
- require('child_process').execSync('chcp 65001', { stdio: 'ignore', windowsHide: true });
10
- } catch {}
35
+ require('child_process').execSync('chcp 65001 >NUL', { stdio: 'ignore', windowsHide: true });
36
+ } catch {
37
+ // Non-Windows shell, or chcp unavailable (e.g. git-bash on CI) —
38
+ // that's fine, Node defaults to UTF-8 in that environment.
39
+ }
40
+ }
41
+
42
+ // Force stdout/stderr to UTF-8 regardless of platform. Without this,
43
+ // Node will emit GBK-encoded bytes for non-ASCII characters on
44
+ // Windows even after `chcp 65001`, because the underlying file
45
+ // descriptors still report a non-UTF-8 code page.
46
+ if (process.stdout.setDefaultEncoding) {
47
+ process.stdout.setDefaultEncoding('utf8');
48
+ }
49
+ if (process.stderr.setDefaultEncoding) {
50
+ process.stderr.setDefaultEncoding('utf8');
11
51
  }
12
52
 
13
53
  import { program } from 'commander';
14
54
  import chalk from 'chalk';
15
55
  import { App } from '../app/index.js';
16
56
  import { WelcomeScreen } from '../tui/welcome.js';
57
+ import { compressThinking, summarizeThinking, splitThinking } from '../utils/thinking.js';
58
+ import { PRODUCT_VERSION } from '../version.js';
17
59
 
18
60
  process.on('uncaughtException', (error) => {
19
61
  console.error(chalk.red('\n Error:'), error.message);
@@ -28,11 +70,12 @@ process.on('unhandledRejection', (reason) => {
28
70
  program
29
71
  .name('gfcode')
30
72
  .description('Thatgfsj Code - AI Coding Assistant')
31
- .version('1.0.2')
73
+ .version(PRODUCT_VERSION)
32
74
  .argument('[prompt]', 'Task to execute (omit to start interactive mode)')
33
75
  .option('-m, --model <model>', 'Specify model')
34
76
  .option('-i, --interactive', 'Force interactive mode')
35
- .action(async (prompt: string | undefined, options: { model?: string; interactive?: boolean }) => {
77
+ .option('--show-thinking', 'Show full <think>...</think> reasoning blocks (default: compress to one-line summary)')
78
+ .action(async (prompt: string | undefined, options: { model?: string; interactive?: boolean; showThinking?: boolean }) => {
36
79
  try {
37
80
  const app = await App.create();
38
81
 
@@ -78,16 +121,36 @@ program
78
121
 
79
122
  app.session.addMessage('user', prompt);
80
123
  let fullResponse = '';
124
+ // v2.2.4: track whether the stream was aborted so we don't
125
+ // persist a truncated assistant message (which is what caused
126
+ // the [已中断] hallucination loop in v2.2.3).
127
+ const abortCtrl = new AbortController();
128
+ const onSigInt = () => { abortCtrl.abort(); };
129
+ process.once('SIGINT', onSigInt);
130
+ // v2.2.5 (product 0.4.2): compress <think> blocks at render time
131
+ // AND before persisting to history. Without this, every assistant
132
+ // message balloons history with reasoning the user has already
133
+ // seen compressed, and context windows blow up fast.
134
+ const showThinking = !!options.showThinking;
81
135
 
82
136
  try {
83
- process.stdout.write(chalk.gray(' Thinking...\r'));
137
+ process.stdout.write(chalk.gray(' Thinking...'));
84
138
  const stream = app.streamResponse();
85
139
 
140
+ // v2.2.5: stream-time rendering stays simple — we just buffer
141
+ // the full response. Post-process at the end (compress
142
+ // thinking blocks once). This avoids a state machine for
143
+ // "are we inside <think> right now" mid-stream which would
144
+ // be flaky if the model splits the tag across chunks.
86
145
  for await (const chunk of stream) {
87
- // Clear thinking line
88
- process.stdout.write('\r' + ' '.repeat(40) + '\r');
146
+ if (abortCtrl.signal.aborted) break;
147
+ // Clear the entire "Thinking..." line — must use enough
148
+ // spaces to overwrite any longer thinking indicator.
149
+ process.stdout.write('\r' + ' '.repeat(80) + '\r');
89
150
 
90
- // Parse tool messages
151
+ // Parse tool messages — these should NOT be compressed even
152
+ // when thinking is hidden, because they carry real signal
153
+ // (tool name + args + result).
91
154
  if (chunk.includes('@@TOOL@@')) {
92
155
  const parts = chunk.split('\n');
93
156
  for (const part of parts) {
@@ -97,26 +160,93 @@ program
97
160
  if (data.action === 'call') {
98
161
  console.log();
99
162
  console.log(chalk.cyan(` ⚙ ${data.name}: ${formatArgs(data.args)}`));
163
+ console.log(chalk.gray(' ' + '─'.repeat(40)));
100
164
  } else if (data.action === 'result') {
101
165
  const output = data.output || data.error || '';
102
- const lines = output.split('\n').slice(0, 10);
103
- for (const line of lines) {
166
+ const lines = output.split('\n');
167
+ const MAX_TOOL_LINES = 20;
168
+ const truncated = lines.length > MAX_TOOL_LINES;
169
+ const visible = truncated ? lines.slice(0, MAX_TOOL_LINES) : lines;
170
+ for (const line of visible) {
104
171
  console.log(chalk.gray(' │ ') + line);
105
172
  }
173
+ if (truncated) {
174
+ console.log(chalk.gray(` │ ... (${lines.length - MAX_TOOL_LINES} more lines truncated)`));
175
+ }
176
+ console.log(chalk.gray(' ' + '─'.repeat(40)));
106
177
  }
107
178
  } catch {}
108
179
  } else if (part) {
180
+ // Mid-stream text chunk — leave it raw. The post-
181
+ // process step below will compress any <think>
182
+ // blocks before printing them as the final rendered
183
+ // output. (We can't reliably strip mid-stream
184
+ // because the closing </think> might be in a later
185
+ // chunk.)
109
186
  process.stdout.write(chalk.cyan(' │ ') + part);
110
187
  }
111
188
  }
112
189
  } else {
113
- process.stdout.write(chunk);
190
+ // Plain AI text chunk — buffer only. Same reasoning:
191
+ // post-process at end avoids mid-stream delimiter
192
+ // races.
193
+ fullResponse += chunk;
194
+ // Show the chunk as it streams, but we'll re-render the
195
+ // final compressed version below. To avoid double-
196
+ // printing, only echo when we're NOT going to post-
197
+ // process (i.e., when showThinking is true and we want
198
+ // to see everything live).
199
+ if (showThinking) {
200
+ process.stdout.write(chalk.cyan(' │ ') + chunk);
201
+ }
202
+ }
203
+ }
204
+
205
+ // v2.2.5: post-process. Strip <think> blocks, summarize,
206
+ // and print the cleaned conclusion if we suppressed it
207
+ // during streaming.
208
+ if (!showThinking && !abortCtrl.signal.aborted && fullResponse) {
209
+ const split = splitThinking(fullResponse);
210
+ if (split.thinking) {
211
+ // Clear what we already streamed (we suppressed text
212
+ // chunks during streaming, so there's nothing to clear
213
+ // unless showThinking was on; in that case the text
214
+ // already contains the <think> block live and there's
215
+ // no point double-printing).
216
+ const summary = summarizeThinking(split);
217
+ if (summary) {
218
+ console.log(chalk.gray(` ${summary}`));
219
+ }
220
+ if (split.conclusion) {
221
+ console.log(chalk.cyan(' │ ') + split.conclusion);
222
+ }
223
+ } else if (fullResponse.trim()) {
224
+ // No thinking block found — print the conclusion if we
225
+ // hadn't already (showThinking=false suppresses
226
+ // streaming text).
227
+ console.log(chalk.cyan(' │ ') + fullResponse);
114
228
  }
115
- fullResponse += chunk;
116
229
  }
117
230
 
118
231
  console.log();
119
- app.session.addMessage('assistant', fullResponse);
232
+ // v2.2.4: skip persistence on abort.
233
+ if (!abortCtrl.signal.aborted) {
234
+ // v2.2.5: persist only the conclusion (no <think> blocks)
235
+ // when compression is enabled. This keeps the conversation
236
+ // log readable AND keeps context-window usage low.
237
+ const toPersist = showThinking
238
+ ? fullResponse
239
+ : compressThinking(fullResponse, false);
240
+ const accepted = app.session.addMessageSafe('assistant', toPersist);
241
+ if (!accepted) {
242
+ console.error(chalk.yellow(
243
+ ' ⚠️ Dropped assistant message containing [已中断] marker.\n' +
244
+ ' (prevents hallucination loop — try your question again)'
245
+ ));
246
+ }
247
+ } else {
248
+ console.log(chalk.yellow(' ⏹ Cancelled (response not saved)'));
249
+ }
120
250
  } catch (error: any) {
121
251
  const msg = error.message || String(error);
122
252
  if (msg.includes('401') || msg.includes('403') || msg.includes('Unauthorized')) {
@@ -131,6 +261,8 @@ program
131
261
  } else {
132
262
  console.error(chalk.red(`\n Error: ${msg}`));
133
263
  }
264
+ } finally {
265
+ process.removeListener('SIGINT', onSigInt);
134
266
  }
135
267
  }
136
268
  } catch (error: any) {
@@ -5,12 +5,65 @@
5
5
  import type { ChatMessage } from '../types.js';
6
6
  import { ContextCompactor } from './compactor.js';
7
7
 
8
+ /**
9
+ * v2.2.4 (port from v2.1.0): patterns that, if found in an assistant
10
+ * message, indicate the message is a truncated/aborted response that
11
+ * should NOT be persisted into history. Without this filter, the next
12
+ * turn's LLM sees the marker and starts echoing it back, creating a
13
+ * self-reinforcing "[已中断]" hallucination loop.
14
+ *
15
+ * v2.2.6.1 tightening (smoke-test driven): the design here is a
16
+ * two-tier check:
17
+ * 1. STRONG markers — unambiguous pollution, always drop:
18
+ * - `[已中断]` bracketed (the model emits this exact form)
19
+ * - `[interrupted]` bracketed English equivalent
20
+ * 2. WEAK markers — only drop if the message is also short (<200
21
+ * chars). Long messages mentioning "response truncated" are
22
+ * legitimate conversation (e.g. user complaints about past
23
+ * behavior), not pollution.
24
+ *
25
+ * Earlier drafts matched bare `已中断` substrings and "response
26
+ * truncated" anywhere in the message — both were too greedy and
27
+ * dropped legitimate Chinese/English conversation.
28
+ */
29
+ const POLLUTION_STRONG: RegExp[] = [
30
+ /\[已中断\]/,
31
+ /\[interrupted\]/i,
32
+ ];
33
+
34
+ const POLLUTION_WEAK: RegExp[] = [
35
+ /^\s*[\*#>\-`]*\s*\[已中断\]/m,
36
+ /^\s*[\*#>\-`]*\s*\[interrupted\]/im,
37
+ /\bresponse (was )?(truncated|cut off|interrupted)\b/i,
38
+ /\boutput (was )?(truncated|cut off|interrupted)\b/i,
39
+ ];
40
+
41
+ function looksPolluted(content: string): boolean {
42
+ if (!content) return false;
43
+ // Common gates (apply to both tiers):
44
+ // - Length: model got cut off mid-stream → pollution is short.
45
+ // A long message with [已中断] in the middle is the model
46
+ // legitimately referencing the marker, not pollution.
47
+ // - Question: ends with '?' → user complaining about past behavior.
48
+ // - Temporal: contains "last time" / "earlier" / etc. → past tense.
49
+ if (content.length >= 200) return false;
50
+ if (/\?\s*$/.test(content.trim())) return false;
51
+ if (/\b(last time|earlier|before|previously|yesterday)\b/i.test(content)) return false;
52
+ // STRONG markers — bracketed truncation markers, unambiguous.
53
+ if (POLLUTION_STRONG.some(p => p.test(content))) return true;
54
+ // WEAK markers — bare "response truncated" phrases.
55
+ if (POLLUTION_WEAK.some(p => p.test(content))) return true;
56
+ return false;
57
+ }
58
+
8
59
  export class SessionManager {
9
60
  private messages: ChatMessage[] = [];
10
61
  private sessionId: string;
11
62
  private createdAt: Date;
12
63
  private compactor: ContextCompactor;
13
64
  private maxMessages: number;
65
+ /** v2.2.4: counter for messages dropped by the pollution filter. */
66
+ private droppedCount: number = 0;
14
67
 
15
68
  constructor(maxMessages = 50) {
16
69
  this.maxMessages = maxMessages;
@@ -24,6 +77,26 @@ export class SessionManager {
24
77
  this.autoCompact();
25
78
  }
26
79
 
80
+ /**
81
+ * v2.2.4 (port from v2.1.0): same as addMessage but returns false
82
+ * (and skips the push) if the message content matches a known
83
+ * truncation/abort pollution pattern. Use this for assistant
84
+ * messages whose stream might have been aborted.
85
+ */
86
+ addMessageSafe(role: ChatMessage['role'], content: string, extras?: Partial<ChatMessage>): boolean {
87
+ if (role === 'assistant' && looksPolluted(content)) {
88
+ this.droppedCount++;
89
+ return false;
90
+ }
91
+ this.addMessage(role, content, extras);
92
+ return true;
93
+ }
94
+
95
+ /** v2.2.4: total messages dropped by addMessageSafe since session start. */
96
+ getDroppedCount(): number {
97
+ return this.droppedCount;
98
+ }
99
+
27
100
  getMessages(): ChatMessage[] {
28
101
  return [...this.messages];
29
102
  }
@@ -62,8 +135,16 @@ export class SessionManager {
62
135
 
63
136
  truncate(maxMessages?: number): void {
64
137
  if (maxMessages) {
138
+ // v2.2.7 edge fix: preserveRecent must be <= maxMessages - 1,
139
+ // otherwise the compactor's "always keep recent N" branch
140
+ // overrides the trim and no messages actually get dropped.
141
+ // Set preserveRecent to maxMessages-1 so anything beyond the
142
+ // last maxMessages-1 user/assistant msgs gets summarized.
65
143
  this.maxMessages = maxMessages;
66
- this.compactor = new ContextCompactor({ maxMessages });
144
+ this.compactor = new ContextCompactor({
145
+ maxMessages,
146
+ preserveRecent: Math.max(1, maxMessages - 1),
147
+ });
67
148
  }
68
149
  const { compacted } = this.compactor.compact(this.messages);
69
150
  this.messages = compacted;