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.
Files changed (182) hide show
  1. package/CHANGELOG.md +15 -67
  2. package/dist/app/index.d.ts +28 -5
  3. package/dist/app/index.d.ts.map +1 -1
  4. package/dist/app/index.js +69 -10
  5. package/dist/app/index.js.map +1 -1
  6. package/dist/cache/fingerprint.d.ts +49 -0
  7. package/dist/cache/fingerprint.d.ts.map +1 -0
  8. package/dist/cache/fingerprint.js +100 -0
  9. package/dist/cache/fingerprint.js.map +1 -0
  10. package/dist/cache/index.d.ts +15 -0
  11. package/dist/cache/index.d.ts.map +1 -0
  12. package/dist/cache/index.js +15 -0
  13. package/dist/cache/index.js.map +1 -0
  14. package/dist/cache/smartModel.d.ts +40 -0
  15. package/dist/cache/smartModel.d.ts.map +1 -0
  16. package/dist/cache/smartModel.js +43 -0
  17. package/dist/cache/smartModel.js.map +1 -0
  18. package/dist/cache/stats.d.ts +93 -0
  19. package/dist/cache/stats.d.ts.map +1 -0
  20. package/dist/cache/stats.js +155 -0
  21. package/dist/cache/stats.js.map +1 -0
  22. package/dist/cache/volatile.d.ts +32 -0
  23. package/dist/cache/volatile.d.ts.map +1 -0
  24. package/dist/cache/volatile.js +44 -0
  25. package/dist/cache/volatile.js.map +1 -0
  26. package/dist/cmd/index.js +49 -57
  27. package/dist/cmd/index.js.map +1 -1
  28. package/dist/config/index.d.ts.map +1 -1
  29. package/dist/config/index.js +8 -0
  30. package/dist/config/index.js.map +1 -1
  31. package/dist/config/types.d.ts +14 -0
  32. package/dist/config/types.d.ts.map +1 -1
  33. package/dist/llm/anthropic.d.ts +55 -5
  34. package/dist/llm/anthropic.d.ts.map +1 -1
  35. package/dist/llm/anthropic.js +172 -26
  36. package/dist/llm/anthropic.js.map +1 -1
  37. package/dist/llm/index.d.ts +26 -8
  38. package/dist/llm/index.d.ts.map +1 -1
  39. package/dist/llm/index.js +83 -15
  40. package/dist/llm/index.js.map +1 -1
  41. package/dist/llm/openai.d.ts +28 -8
  42. package/dist/llm/openai.d.ts.map +1 -1
  43. package/dist/llm/openai.js +58 -8
  44. package/dist/llm/openai.js.map +1 -1
  45. package/dist/llm/provider.d.ts +40 -8
  46. package/dist/llm/provider.d.ts.map +1 -1
  47. package/dist/llm/provider.js +8 -1
  48. package/dist/llm/provider.js.map +1 -1
  49. package/dist/prompts/index.d.ts +32 -0
  50. package/dist/prompts/index.d.ts.map +1 -1
  51. package/dist/prompts/index.js +33 -11
  52. package/dist/prompts/index.js.map +1 -1
  53. package/dist/session/index.d.ts +32 -3
  54. package/dist/session/index.d.ts.map +1 -1
  55. package/dist/session/index.js +51 -36
  56. package/dist/session/index.js.map +1 -1
  57. package/dist/skills/brainstorming.js +33 -33
  58. package/dist/skills/code-review.js +35 -35
  59. package/dist/skills/executing-plans.js +18 -18
  60. package/dist/skills/frontend-design.js +25 -25
  61. package/dist/skills/git-workflow.js +26 -26
  62. package/dist/skills/improve-architecture.js +28 -28
  63. package/dist/skills/neuroweave.js +37 -37
  64. package/dist/skills/playwright.js +62 -62
  65. package/dist/skills/prototype.js +20 -20
  66. package/dist/skills/subagent.js +19 -19
  67. package/dist/skills/supabase.js +34 -34
  68. package/dist/skills/systematic-debugging.js +34 -34
  69. package/dist/skills/tdd.js +29 -29
  70. package/dist/skills/triage.js +25 -25
  71. package/dist/skills/verification.js +22 -22
  72. package/dist/skills/writing-plans.js +32 -32
  73. package/dist/tools/nwt.js +16 -16
  74. package/dist/tui/app.d.ts.map +1 -1
  75. package/dist/tui/app.js +9 -2
  76. package/dist/tui/app.js.map +1 -1
  77. package/dist/tui/components/Header.d.ts +8 -0
  78. package/dist/tui/components/Header.d.ts.map +1 -1
  79. package/dist/tui/components/Header.js +17 -4
  80. package/dist/tui/components/Header.js.map +1 -1
  81. package/dist/tui/components/InitWizard.d.ts +12 -1
  82. package/dist/tui/components/InitWizard.d.ts.map +1 -1
  83. package/dist/tui/components/InitWizard.js +58 -3
  84. package/dist/tui/components/InitWizard.js.map +1 -1
  85. package/dist/tui/components/ToolCall.d.ts.map +1 -1
  86. package/dist/tui/components/ToolCall.js +1 -6
  87. package/dist/tui/components/ToolCall.js.map +1 -1
  88. package/dist/tui/hooks/useChat.d.ts +22 -0
  89. package/dist/tui/hooks/useChat.d.ts.map +1 -1
  90. package/dist/tui/hooks/useChat.js +82 -46
  91. package/dist/tui/hooks/useChat.js.map +1 -1
  92. package/dist/tui/hooks/useCommands.d.ts.map +1 -1
  93. package/dist/tui/hooks/useCommands.js +57 -0
  94. package/dist/tui/hooks/useCommands.js.map +1 -1
  95. package/dist/tui/welcome.d.ts.map +1 -1
  96. package/dist/tui/welcome.js +2 -3
  97. package/dist/tui/welcome.js.map +1 -1
  98. package/dist/types.d.ts +79 -6
  99. package/dist/types.d.ts.map +1 -1
  100. package/dist/types.js +7 -0
  101. package/dist/types.js.map +1 -1
  102. package/dist/utils/stableStringify.d.ts +21 -0
  103. package/dist/utils/stableStringify.d.ts.map +1 -0
  104. package/dist/utils/stableStringify.js +53 -0
  105. package/dist/utils/stableStringify.js.map +1 -0
  106. package/dist/utils/thinking.d.ts.map +1 -1
  107. package/dist/utils/thinking.js +0 -3
  108. package/dist/utils/thinking.js.map +1 -1
  109. package/package.json +6 -6
  110. package/src/app/index.ts +180 -124
  111. package/src/cache/fingerprint.ts +101 -0
  112. package/src/cache/index.ts +15 -0
  113. package/src/cache/smartModel.ts +52 -0
  114. package/src/cache/stats.ts +199 -0
  115. package/src/cache/volatile.ts +47 -0
  116. package/src/cmd/index.tsx +288 -292
  117. package/src/config/index.ts +156 -148
  118. package/src/config/providers.ts +234 -234
  119. package/src/config/types.ts +67 -53
  120. package/src/hooks/index.ts +111 -111
  121. package/src/llm/anthropic.ts +388 -243
  122. package/src/llm/gemini.ts +169 -169
  123. package/src/llm/index.ts +265 -200
  124. package/src/llm/openai.ts +243 -196
  125. package/src/llm/provider.ts +66 -34
  126. package/src/prompts/index.ts +260 -220
  127. package/src/session/compactor.ts +103 -103
  128. package/src/session/index.ts +181 -168
  129. package/src/session/message.ts +42 -42
  130. package/src/skills/brainstorming.ts +43 -43
  131. package/src/skills/code-review.ts +45 -45
  132. package/src/skills/executing-plans.ts +27 -27
  133. package/src/skills/frontend-design.ts +35 -35
  134. package/src/skills/git-workflow.ts +36 -36
  135. package/src/skills/improve-architecture.ts +38 -38
  136. package/src/skills/index.ts +136 -136
  137. package/src/skills/neuroweave.ts +47 -47
  138. package/src/skills/playwright.ts +72 -72
  139. package/src/skills/prototype.ts +30 -30
  140. package/src/skills/subagent.ts +28 -28
  141. package/src/skills/supabase.ts +44 -44
  142. package/src/skills/systematic-debugging.ts +44 -44
  143. package/src/skills/tdd.ts +39 -39
  144. package/src/skills/triage.ts +35 -35
  145. package/src/skills/verification.ts +31 -31
  146. package/src/skills/writing-plans.ts +42 -42
  147. package/src/tools/nwt.ts +598 -598
  148. package/src/tools/types.ts +122 -122
  149. package/src/tui/app.tsx +199 -186
  150. package/src/tui/components/ChatList.tsx +41 -41
  151. package/src/tui/components/ChatMessage.tsx +54 -54
  152. package/src/tui/components/Header.tsx +56 -29
  153. package/src/tui/components/InitWizard.tsx +217 -132
  154. package/src/tui/components/Markdown.tsx +35 -35
  155. package/src/tui/components/ModelSelector.tsx +87 -87
  156. package/src/tui/components/StatusBar.tsx +30 -30
  157. package/src/tui/components/Thinking.tsx +17 -17
  158. package/src/tui/components/ToolCall.tsx +102 -107
  159. package/src/tui/components/UserInput.tsx +131 -131
  160. package/src/tui/hooks/useChat.ts +287 -238
  161. package/src/tui/hooks/useCommands.ts +234 -176
  162. package/src/tui/index.ts +6 -6
  163. package/src/tui/welcome.ts +177 -178
  164. package/src/types.ts +104 -42
  165. package/src/utils/diff.ts +71 -71
  166. package/src/utils/project.ts +99 -99
  167. package/src/utils/stableStringify.ts +47 -0
  168. package/src/utils/thinking.ts +118 -121
  169. package/tests/cache/fingerprint.test.ts +75 -0
  170. package/tests/cache/stableStringify.test.ts +43 -0
  171. package/tests/cache/stats.test.ts +146 -0
  172. package/tests/cache/volatile.test.ts +75 -0
  173. package/tests/smoke-pollution.mjs +78 -0
  174. package/tests/smoke-thinking.mjs +110 -0
  175. package/tests/smoke-tool-stream.mjs +69 -0
  176. package/tests/smoke-tool.mjs +117 -0
  177. package/.nwt/meta.json +0 -5
  178. package/dist/version.d.ts +0 -16
  179. package/dist/version.d.ts.map +0 -1
  180. package/dist/version.js +0 -16
  181. package/dist/version.js.map +0 -1
  182. package/src/version.ts +0 -16
@@ -1,99 +1,99 @@
1
- /**
2
- * Project Context - Detect project type and gather info
3
- * Migrated from old src/utils/project-context.ts + getProjectContext() from index.ts
4
- */
5
-
6
- import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
7
- import { join } from 'path';
8
-
9
- export interface ProjectInfo {
10
- type: string;
11
- name: string;
12
- path: string;
13
- fileCount: number;
14
- dependencies: number;
15
- }
16
-
17
- export interface PackageJson {
18
- name?: string;
19
- version?: string;
20
- dependencies?: Record<string, string>;
21
- devDependencies?: Record<string, string>;
22
- }
23
-
24
- export class ProjectContext {
25
- /**
26
- * Get project context string for display
27
- */
28
- static getSummary(cwd: string = process.cwd()): string {
29
- const info: string[] = [];
30
-
31
- try {
32
- const pkgPath = join(cwd, 'package.json');
33
- if (existsSync(pkgPath)) {
34
- const pkg: PackageJson = JSON.parse(readFileSync(pkgPath, 'utf-8'));
35
- info.push(`📦 Project: ${cwd}`);
36
- info.push(` Name: ${pkg.name || 'unknown'}`);
37
- const depCount = Object.keys(pkg.dependencies || {}).length;
38
- if (depCount > 0) info.push(` Deps: ${depCount} packages`);
39
- } else {
40
- info.push(`📁 Working dir: ${cwd}`);
41
- }
42
-
43
- const fileCount = ProjectContext.countCodeFiles(cwd);
44
- if (fileCount > 0) {
45
- info.push(` Files: ${fileCount} code files`);
46
- }
47
- } catch {
48
- info.push(`📁 Working dir: ${cwd}`);
49
- }
50
-
51
- return info.join('\n');
52
- }
53
-
54
- /**
55
- * Count code files in directory (skipping node_modules, .git, etc.)
56
- */
57
- static countCodeFiles(dir: string, maxDepth = 3): number {
58
- let count = 0;
59
- const codeExts = /\.(ts|js|py|go|rs|java|cpp|c|h|tsx|jsx|vue|svelte)$/;
60
-
61
- const walk = (d: string, depth: number) => {
62
- if (depth > maxDepth) return;
63
- try {
64
- const items = readdirSync(d);
65
- for (const item of items) {
66
- if (['node_modules', '.git', 'dist', 'build', '__pycache__'].includes(item)) continue;
67
- const fullPath = join(d, item);
68
- try {
69
- const stat = statSync(fullPath);
70
- if (stat.isDirectory()) {
71
- walk(fullPath, depth + 1);
72
- } else if (codeExts.test(item)) {
73
- count++;
74
- }
75
- } catch {
76
- // Skip inaccessible files
77
- }
78
- }
79
- } catch {
80
- // Skip inaccessible directories
81
- }
82
- };
83
-
84
- walk(dir, 0);
85
- return count;
86
- }
87
-
88
- /**
89
- * Detect project type from files
90
- */
91
- static detectType(cwd: string = process.cwd()): string {
92
- if (existsSync(join(cwd, 'package.json'))) return 'node';
93
- if (existsSync(join(cwd, 'Cargo.toml'))) return 'rust';
94
- if (existsSync(join(cwd, 'go.mod'))) return 'go';
95
- if (existsSync(join(cwd, 'pyproject.toml')) || existsSync(join(cwd, 'requirements.txt'))) return 'python';
96
- if (existsSync(join(cwd, 'build.gradle'))) return 'java';
97
- return 'unknown';
98
- }
99
- }
1
+ /**
2
+ * Project Context - Detect project type and gather info
3
+ * Migrated from old src/utils/project-context.ts + getProjectContext() from index.ts
4
+ */
5
+
6
+ import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
7
+ import { join } from 'path';
8
+
9
+ export interface ProjectInfo {
10
+ type: string;
11
+ name: string;
12
+ path: string;
13
+ fileCount: number;
14
+ dependencies: number;
15
+ }
16
+
17
+ export interface PackageJson {
18
+ name?: string;
19
+ version?: string;
20
+ dependencies?: Record<string, string>;
21
+ devDependencies?: Record<string, string>;
22
+ }
23
+
24
+ export class ProjectContext {
25
+ /**
26
+ * Get project context string for display
27
+ */
28
+ static getSummary(cwd: string = process.cwd()): string {
29
+ const info: string[] = [];
30
+
31
+ try {
32
+ const pkgPath = join(cwd, 'package.json');
33
+ if (existsSync(pkgPath)) {
34
+ const pkg: PackageJson = JSON.parse(readFileSync(pkgPath, 'utf-8'));
35
+ info.push(`📦 Project: ${cwd}`);
36
+ info.push(` Name: ${pkg.name || 'unknown'}`);
37
+ const depCount = Object.keys(pkg.dependencies || {}).length;
38
+ if (depCount > 0) info.push(` Deps: ${depCount} packages`);
39
+ } else {
40
+ info.push(`📁 Working dir: ${cwd}`);
41
+ }
42
+
43
+ const fileCount = ProjectContext.countCodeFiles(cwd);
44
+ if (fileCount > 0) {
45
+ info.push(` Files: ${fileCount} code files`);
46
+ }
47
+ } catch {
48
+ info.push(`📁 Working dir: ${cwd}`);
49
+ }
50
+
51
+ return info.join('\n');
52
+ }
53
+
54
+ /**
55
+ * Count code files in directory (skipping node_modules, .git, etc.)
56
+ */
57
+ static countCodeFiles(dir: string, maxDepth = 3): number {
58
+ let count = 0;
59
+ const codeExts = /\.(ts|js|py|go|rs|java|cpp|c|h|tsx|jsx|vue|svelte)$/;
60
+
61
+ const walk = (d: string, depth: number) => {
62
+ if (depth > maxDepth) return;
63
+ try {
64
+ const items = readdirSync(d);
65
+ for (const item of items) {
66
+ if (['node_modules', '.git', 'dist', 'build', '__pycache__'].includes(item)) continue;
67
+ const fullPath = join(d, item);
68
+ try {
69
+ const stat = statSync(fullPath);
70
+ if (stat.isDirectory()) {
71
+ walk(fullPath, depth + 1);
72
+ } else if (codeExts.test(item)) {
73
+ count++;
74
+ }
75
+ } catch {
76
+ // Skip inaccessible files
77
+ }
78
+ }
79
+ } catch {
80
+ // Skip inaccessible directories
81
+ }
82
+ };
83
+
84
+ walk(dir, 0);
85
+ return count;
86
+ }
87
+
88
+ /**
89
+ * Detect project type from files
90
+ */
91
+ static detectType(cwd: string = process.cwd()): string {
92
+ if (existsSync(join(cwd, 'package.json'))) return 'node';
93
+ if (existsSync(join(cwd, 'Cargo.toml'))) return 'rust';
94
+ if (existsSync(join(cwd, 'go.mod'))) return 'go';
95
+ if (existsSync(join(cwd, 'pyproject.toml')) || existsSync(join(cwd, 'requirements.txt'))) return 'python';
96
+ if (existsSync(join(cwd, 'build.gradle'))) return 'java';
97
+ return 'unknown';
98
+ }
99
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Deterministic JSON serializer.
3
+ *
4
+ * JSON.stringify in V8 iterates keys in insertion order, which means objects
5
+ * built with literal `{a, b, c}` are stable, but anything built via spread,
6
+ * Object.fromEntries, or merged maps can produce different byte sequences
7
+ * between requests. That byte-sequence instability is fatal for prompt-cache
8
+ * hit-rate: a single reordered key invalidates the cache fingerprint.
9
+ *
10
+ * stableStringify sorts object keys lexicographically before recursing, so
11
+ * any pair of structurally equal objects produces identical JSON text.
12
+ *
13
+ * Performance note: O(n log n) on key counts per object. For typical request
14
+ * bodies (<200 KB) the overhead is negligible (<5 ms). The main hot path is
15
+ * the tool/parameters tree which is small and stable in practice.
16
+ *
17
+ * Reference: Reasonix-style "stableStringify" requirement for prefix-cache
18
+ * byte-level stability (see DeepSeek Reasonix, Section 3).
19
+ */
20
+ export function stableStringify(value: unknown): string {
21
+ return stringify(value);
22
+ }
23
+
24
+ function stringify(value: unknown): string {
25
+ if (value === null) return 'null';
26
+ if (value === undefined) return 'null'; // OpenAI wire format does not carry undefined
27
+ const t = typeof value;
28
+ if (t === 'string') return JSON.stringify(value);
29
+ if (t === 'number') return Number.isFinite(value as number) ? String(value) : 'null';
30
+ if (t === 'boolean') return value ? 'true' : 'false';
31
+ if (Array.isArray(value)) {
32
+ return '[' + value.map((v) => stringify(v)).join(',') + ']';
33
+ }
34
+ if (t === 'object') {
35
+ const obj = value as Record<string, unknown>;
36
+ const keys = Object.keys(obj).sort();
37
+ const parts: string[] = [];
38
+ for (const k of keys) {
39
+ const v = obj[k];
40
+ if (v === undefined) continue; // omit undefined to match OpenAI/Anthropic convention
41
+ parts.push(JSON.stringify(k) + ':' + stringify(v));
42
+ }
43
+ return '{' + parts.join(',') + '}';
44
+ }
45
+ // functions / symbols / bigints: drop
46
+ return 'null';
47
+ }
@@ -1,122 +1,119 @@
1
- /**
2
- * Thinking block extraction + compression
3
- *
4
- * Models that aren't truly reasoning-capable (Qwen, DeepSeek-V3 chat,
5
- * Kimi, etc.) still emit `<think>...</think>` blocks as plain text
6
- * inside their response. These blocks can be hundreds of lines of
7
- * internal monologue that the user doesn't want to see scroll by.
8
- *
9
- * opencode handles this by separating reasoning into its own stream
10
- * part at the protocol level. We don't have that luxury — the model
11
- * is emitting everything as a single `content` field. So we use the
12
- * same regex-strip approach opencode uses internally (see
13
- * packages/opencode/src/session/prompt.ts line 244).
14
- *
15
- * Three block delimiters are supported:
16
- * 1. <think>...</think> — DeepSeek / Qwen / Kimi
17
- * 2. <reasoning>...</reasoning> — OpenRouter-style
18
- * 3. [THINK]...[/THINK] — some custom fine-tunes
19
- *
20
- * Usage:
21
- * const { thinking, conclusion } = splitThinking(fullContent);
22
- * if (thinking && compress) {
23
- * render(`💭 ${summarize(thinking)}\n${conclusion}`);
24
- * } else {
25
- * render(fullContent);
26
- * }
27
- */
28
-
29
- export interface ThinkingSplit {
30
- /** Raw `<think>...` content, or empty if none. */
31
- thinking: string;
32
- /** Content with thinking blocks stripped + trimmed. */
33
- conclusion: string;
34
- /** How many lines the thinking block spanned. */
35
- thinkingLines: number;
36
- /** First non-empty line of the thinking block (heuristic summary). */
37
- thinkingHint: string;
38
- }
39
-
40
- /**
41
- * All known thinking-block delimiters, in priority order. The regex
42
- * flags `gi` (global, case-insensitive) so `[THINK]` and `<think>`
43
- * are both caught, and so multiple blocks collapse cleanly.
44
- */
45
- const THINKING_PATTERNS: RegExp[] = [
46
- /<think>[\s\S]*?<\/think>/gi,
47
- /<thinking>[\s\S]*?<\/thinking>/gi,
48
- /<reasoning>[\s\S]*?<\/reasoning>/gi,
49
- /<THINK>[\s\S]*?<\/THINK>/gi, // v2.2.7 edge: case variant
50
- /\[THINK\][\s\S]*?\[\/THINK\]/gi,
51
- /\[\/?think\]/gi, // bare [think] / [/think] (rare)
52
- ];
53
-
54
- export function splitThinking(content: string): ThinkingSplit {
55
- if (!content) {
56
- return { thinking: '', conclusion: '', thinkingLines: 0, thinkingHint: '' };
57
- }
58
-
59
- let thinking = '';
60
- let remaining = content;
61
-
62
- for (const pat of THINKING_PATTERNS) {
63
- const matches = remaining.match(pat);
64
- if (matches) {
65
- thinking += matches.join('\n');
66
- remaining = remaining.replace(pat, '');
67
- }
68
- }
69
-
70
- const conclusion = remaining.trim();
71
- const thinkingLines = thinking ? thinking.split('\n').length : 0;
72
- const thinkingHint = firstNonEmptyLine(thinking);
73
-
74
- return { thinking, conclusion, thinkingLines, thinkingHint };
75
- }
76
-
77
- /**
78
- * Build a short, single-line summary of a thinking block, suitable
79
- * for collapsed display. Returns something like:
80
- * "💭 thought for 24 lines: The user is asking about Win+E..."
81
- * Falls back to "(no hint)" if the block is empty.
82
- */
83
- export function summarizeThinking(split: ThinkingSplit): string {
84
- if (!split.thinking) return '';
85
- const hint = split.thinkingHint.length > 60
86
- ? split.thinkingHint.slice(0, 57) + '...'
87
- : split.thinkingHint;
88
- return `💭 thought for ${split.thinkingLines} line${split.thinkingLines === 1 ? '' : 's'}${hint ? `: ${hint}` : ''}`;
89
- }
90
-
91
- /**
92
- * Render full content with thinking blocks compressed (collapsed to
93
- * a single-line indicator). If `showThinking` is true, returns the
94
- * content unchanged (debug mode).
95
- */
96
- export function compressThinking(content: string, showThinking: boolean = false): string {
97
- if (showThinking || !content) return content;
98
- const split = splitThinking(content);
99
- if (!split.thinking) return content;
100
- const summary = summarizeThinking(split);
101
- return summary ? `${summary}\n${split.conclusion}` : split.conclusion;
102
- }
103
-
104
- function firstNonEmptyLine(s: string): string {
105
- if (!s) return '';
106
- for (const line of s.split('\n')) {
107
- // Strip any leading/trailing thinking-tag fragments that may have
108
- // been captured by the regex (e.g. "<think>The user said..."
109
- // or "...</think>"). We strip ALL occurrences to handle the
110
- // case where the first line wraps across a tag.
111
- const t = line
112
- .trim()
113
- .replace(/<\/?think>/gi, '')
114
- .replace(/<\/?reasoning>/gi, '')
115
- .replace(/\[\/?THINK\]/gi, '')
116
- .trim();
117
- if (t) return t;
118
- }
119
- return '';
120
- }
121
-
1
+ /**
2
+ * Thinking block extraction + compression
3
+ *
4
+ * Models that aren't truly reasoning-capable (Qwen, DeepSeek-V3 chat,
5
+ * Kimi, etc.) still emit `<think>...</think>` blocks as plain text
6
+ * inside their response. These blocks can be hundreds of lines of
7
+ * internal monologue that the user doesn't want to see scroll by.
8
+ *
9
+ * opencode handles this by separating reasoning into its own stream
10
+ * part at the protocol level. We don't have that luxury — the model
11
+ * is emitting everything as a single `content` field. So we use the
12
+ * same regex-strip approach opencode uses internally (see
13
+ * packages/opencode/src/session/prompt.ts line 244).
14
+ *
15
+ * Three block delimiters are supported:
16
+ * 1. <think>...</think> — DeepSeek / Qwen / Kimi
17
+ * 2. <reasoning>...</reasoning> — OpenRouter-style
18
+ * 3. [THINK]...[/THINK] — some custom fine-tunes
19
+ *
20
+ * Usage:
21
+ * const { thinking, conclusion } = splitThinking(fullContent);
22
+ * if (thinking && compress) {
23
+ * render(`💭 ${summarize(thinking)}\n${conclusion}`);
24
+ * } else {
25
+ * render(fullContent);
26
+ * }
27
+ */
28
+
29
+ export interface ThinkingSplit {
30
+ /** Raw `<think>...` content, or empty if none. */
31
+ thinking: string;
32
+ /** Content with thinking blocks stripped + trimmed. */
33
+ conclusion: string;
34
+ /** How many lines the thinking block spanned. */
35
+ thinkingLines: number;
36
+ /** First non-empty line of the thinking block (heuristic summary). */
37
+ thinkingHint: string;
38
+ }
39
+
40
+ /**
41
+ * All known thinking-block delimiters, in priority order. The regex
42
+ * flags `gi` (global, case-insensitive) so `[THINK]` and `<think>`
43
+ * are both caught, and so multiple blocks collapse cleanly.
44
+ */
45
+ const THINKING_PATTERNS: RegExp[] = [
46
+ /<think>[\s\S]*?<\/think>/gi,
47
+ /<reasoning>[\s\S]*?<\/reasoning>/gi,
48
+ /\[THINK\][\s\S]*?\[\/THINK\]/gi,
49
+ ];
50
+
51
+ export function splitThinking(content: string): ThinkingSplit {
52
+ if (!content) {
53
+ return { thinking: '', conclusion: '', thinkingLines: 0, thinkingHint: '' };
54
+ }
55
+
56
+ let thinking = '';
57
+ let remaining = content;
58
+
59
+ for (const pat of THINKING_PATTERNS) {
60
+ const matches = remaining.match(pat);
61
+ if (matches) {
62
+ thinking += matches.join('\n');
63
+ remaining = remaining.replace(pat, '');
64
+ }
65
+ }
66
+
67
+ const conclusion = remaining.trim();
68
+ const thinkingLines = thinking ? thinking.split('\n').length : 0;
69
+ const thinkingHint = firstNonEmptyLine(thinking);
70
+
71
+ return { thinking, conclusion, thinkingLines, thinkingHint };
72
+ }
73
+
74
+ /**
75
+ * Build a short, single-line summary of a thinking block, suitable
76
+ * for collapsed display. Returns something like:
77
+ * "💭 thought for 24 lines: The user is asking about Win+E..."
78
+ * Falls back to "(no hint)" if the block is empty.
79
+ */
80
+ export function summarizeThinking(split: ThinkingSplit): string {
81
+ if (!split.thinking) return '';
82
+ const hint = split.thinkingHint.length > 60
83
+ ? split.thinkingHint.slice(0, 57) + '...'
84
+ : split.thinkingHint;
85
+ return `💭 thought for ${split.thinkingLines} line${split.thinkingLines === 1 ? '' : 's'}${hint ? `: ${hint}` : ''}`;
86
+ }
87
+
88
+ /**
89
+ * Render full content with thinking blocks compressed (collapsed to
90
+ * a single-line indicator). If `showThinking` is true, returns the
91
+ * content unchanged (debug mode).
92
+ */
93
+ export function compressThinking(content: string, showThinking: boolean = false): string {
94
+ if (showThinking || !content) return content;
95
+ const split = splitThinking(content);
96
+ if (!split.thinking) return content;
97
+ const summary = summarizeThinking(split);
98
+ return summary ? `${summary}\n${split.conclusion}` : split.conclusion;
99
+ }
100
+
101
+ function firstNonEmptyLine(s: string): string {
102
+ if (!s) return '';
103
+ for (const line of s.split('\n')) {
104
+ // Strip any leading/trailing thinking-tag fragments that may have
105
+ // been captured by the regex (e.g. "<think>The user said..."
106
+ // or "...</think>"). We strip ALL occurrences to handle the
107
+ // case where the first line wraps across a tag.
108
+ const t = line
109
+ .trim()
110
+ .replace(/<\/?think>/gi, '')
111
+ .replace(/<\/?reasoning>/gi, '')
112
+ .replace(/\[\/?THINK\]/gi, '')
113
+ .trim();
114
+ if (t) return t;
115
+ }
116
+ return '';
117
+ }
118
+
122
119
  export { THINKING_PATTERNS };
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { fingerprint, fingerprintTools, fingerprintSystemPrefix } from '../../src/cache/fingerprint.js';
3
+ import type { Tool } from '../../src/tools/types.js';
4
+ import type { SystemSegment } from '../../src/prompts/index.js';
5
+
6
+ describe('fingerprint', () => {
7
+ it('returns same hash for structurally equal objects', () => {
8
+ const a = { name: 'file', description: 'Read files', inputSchema: { type: 'object' } };
9
+ const b = { inputSchema: { type: 'object' }, description: 'Read files', name: 'file' };
10
+ expect(fingerprint(a)).toBe(fingerprint(b));
11
+ });
12
+
13
+ it('returns different hash for different content', () => {
14
+ expect(fingerprint({ name: 'a' })).not.toBe(fingerprint({ name: 'b' }));
15
+ });
16
+
17
+ it('returns 16-char hex string', () => {
18
+ const fp = fingerprint({ x: 1 });
19
+ expect(fp).toMatch(/^[0-9a-f]{16}$/);
20
+ });
21
+
22
+ it('fingerprintTools ignores non-wire Tool metadata', () => {
23
+ // Two Tool objects with same name/description/inputSchema but different
24
+ // internal metadata (e.g. version, internal flags) must produce the
25
+ // same fingerprint, because only the three wire fields matter for
26
+ // upstream prompt cache lookup.
27
+ const t1: Tool = {
28
+ name: 'file',
29
+ description: 'Read/write files',
30
+ parameters: [],
31
+ inputSchema: { type: 'object', properties: { path: { type: 'string' } } },
32
+ execute: async () => ({ success: true }),
33
+ // @ts-expect-error: simulate extra internal metadata
34
+ version: '1.0.0',
35
+ };
36
+ const t2: Tool = {
37
+ name: 'file',
38
+ description: 'Read/write files',
39
+ parameters: [],
40
+ inputSchema: { type: 'object', properties: { path: { type: 'string' } } },
41
+ execute: async () => ({ success: true }),
42
+ // @ts-expect-error
43
+ version: '2.0.0',
44
+ };
45
+ expect(fingerprintTools([t1])).toBe(fingerprintTools([t2]));
46
+ });
47
+
48
+ it('fingerprintSystemPrefix excludes volatile segments', () => {
49
+ const segments: SystemSegment[] = [
50
+ { name: 'identity', content: 'You are Thatgfsj', volatile: false },
51
+ { name: 'tools', content: '## Tools\nfile, shell', volatile: false },
52
+ { name: 'nwt', content: 'Recent: did X, did Y, did Z', volatile: true },
53
+ { name: 'date', content: '2026-08-08 12:34:56', volatile: true },
54
+ ];
55
+ // Hash should be stable when only the volatile segments change.
56
+ const baseFp = fingerprintSystemPrefix(segments);
57
+
58
+ const segmentsMutated: SystemSegment[] = [
59
+ { name: 'identity', content: 'You are Thatgfsj', volatile: false },
60
+ { name: 'tools', content: '## Tools\nfile, shell', volatile: false },
61
+ { name: 'nwt', content: 'Recent: did X, did Y, did ZZZZZZ', volatile: true }, // mutated
62
+ { name: 'date', content: '2099-12-31 00:00:00', volatile: true }, // mutated
63
+ ];
64
+ expect(fingerprintSystemPrefix(segmentsMutated)).toBe(baseFp);
65
+
66
+ // But change to an immutable segment should break the hash.
67
+ const segmentsIdentityChanged: SystemSegment[] = [
68
+ { name: 'identity', content: 'You are a different agent', volatile: false },
69
+ { name: 'tools', content: '## Tools\nfile, shell', volatile: false },
70
+ { name: 'nwt', content: 'Recent: did X, did Y, did Z', volatile: true },
71
+ { name: 'date', content: '2026-08-08 12:34:56', volatile: true },
72
+ ];
73
+ expect(fingerprintSystemPrefix(segmentsIdentityChanged)).not.toBe(baseFp);
74
+ });
75
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { stableStringify } from '../../src/utils/stableStringify.js';
3
+
4
+ describe('stableStringify', () => {
5
+ it('produces identical output for keys inserted in different orders', () => {
6
+ const a = { name: 'file', description: 'Read files', inputSchema: { type: 'object' } };
7
+ const b = { inputSchema: { type: 'object' }, description: 'Read files', name: 'file' };
8
+ expect(stableStringify(a)).toBe(stableStringify(b));
9
+ });
10
+
11
+ it('omits undefined values (matches OpenAI/Anthropic wire convention)', () => {
12
+ expect(stableStringify({ a: 1, b: undefined, c: 2 })).toBe(stableStringify({ a: 1, c: 2 }));
13
+ });
14
+
15
+ it('handles nested objects recursively', () => {
16
+ const a = { tools: [{ name: 'a', args: { x: 1 } }, { name: 'b', args: { y: 2 } }] };
17
+ const b = { tools: [{ args: { x: 1 }, name: 'a' }, { args: { y: 2 }, name: 'b' }] };
18
+ expect(stableStringify(a)).toBe(stableStringify(b));
19
+ });
20
+
21
+ it('handles primitives correctly', () => {
22
+ expect(stableStringify(null)).toBe('null');
23
+ expect(stableStringify('hello')).toBe('"hello"');
24
+ expect(stableStringify(42)).toBe('42');
25
+ expect(stableStringify(true)).toBe('true');
26
+ expect(stableStringify(false)).toBe('false');
27
+ });
28
+
29
+ it('escapes special characters in strings', () => {
30
+ expect(stableStringify('hello\nworld')).toBe('"hello\\nworld"');
31
+ expect(stableStringify('"quoted"')).toBe('"\\"quoted\\""');
32
+ });
33
+
34
+ it('drops NaN and Infinity to null (JSON-incompatible)', () => {
35
+ expect(stableStringify(NaN)).toBe('null');
36
+ expect(stableStringify(Infinity)).toBe('null');
37
+ });
38
+
39
+ it('produces empty {} for empty objects and [] for empty arrays', () => {
40
+ expect(stableStringify({})).toBe('{}');
41
+ expect(stableStringify([])).toBe('[]');
42
+ });
43
+ });