thatgfsj-code 1.0.3 → 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 (184) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/DEVELOPMENT.md +286 -0
  3. package/dist/app/index.d.ts +40 -3
  4. package/dist/app/index.d.ts.map +1 -1
  5. package/dist/app/index.js +87 -12
  6. package/dist/app/index.js.map +1 -1
  7. package/dist/cache/fingerprint.d.ts +49 -0
  8. package/dist/cache/fingerprint.d.ts.map +1 -0
  9. package/dist/cache/fingerprint.js +100 -0
  10. package/dist/cache/fingerprint.js.map +1 -0
  11. package/dist/cache/index.d.ts +15 -0
  12. package/dist/cache/index.d.ts.map +1 -0
  13. package/dist/cache/index.js +15 -0
  14. package/dist/cache/index.js.map +1 -0
  15. package/dist/cache/smartModel.d.ts +40 -0
  16. package/dist/cache/smartModel.d.ts.map +1 -0
  17. package/dist/cache/smartModel.js +43 -0
  18. package/dist/cache/smartModel.js.map +1 -0
  19. package/dist/cache/stats.d.ts +93 -0
  20. package/dist/cache/stats.d.ts.map +1 -0
  21. package/dist/cache/stats.js +155 -0
  22. package/dist/cache/stats.js.map +1 -0
  23. package/dist/cache/volatile.d.ts +32 -0
  24. package/dist/cache/volatile.d.ts.map +1 -0
  25. package/dist/cache/volatile.js +44 -0
  26. package/dist/cache/volatile.js.map +1 -0
  27. package/dist/cmd/index.d.ts +21 -0
  28. package/dist/cmd/index.d.ts.map +1 -1
  29. package/dist/cmd/index.js +155 -31
  30. package/dist/cmd/index.js.map +1 -1
  31. package/dist/config/index.d.ts.map +1 -1
  32. package/dist/config/index.js +8 -0
  33. package/dist/config/index.js.map +1 -1
  34. package/dist/config/types.d.ts +14 -0
  35. package/dist/config/types.d.ts.map +1 -1
  36. package/dist/llm/anthropic.d.ts +55 -5
  37. package/dist/llm/anthropic.d.ts.map +1 -1
  38. package/dist/llm/anthropic.js +172 -26
  39. package/dist/llm/anthropic.js.map +1 -1
  40. package/dist/llm/index.d.ts +26 -8
  41. package/dist/llm/index.d.ts.map +1 -1
  42. package/dist/llm/index.js +83 -15
  43. package/dist/llm/index.js.map +1 -1
  44. package/dist/llm/openai.d.ts +28 -8
  45. package/dist/llm/openai.d.ts.map +1 -1
  46. package/dist/llm/openai.js +58 -8
  47. package/dist/llm/openai.js.map +1 -1
  48. package/dist/llm/provider.d.ts +40 -8
  49. package/dist/llm/provider.d.ts.map +1 -1
  50. package/dist/llm/provider.js +8 -1
  51. package/dist/llm/provider.js.map +1 -1
  52. package/dist/prompts/index.d.ts +32 -0
  53. package/dist/prompts/index.d.ts.map +1 -1
  54. package/dist/prompts/index.js +33 -11
  55. package/dist/prompts/index.js.map +1 -1
  56. package/dist/session/index.d.ts +43 -3
  57. package/dist/session/index.d.ts.map +1 -1
  58. package/dist/session/index.js +114 -17
  59. package/dist/session/index.js.map +1 -1
  60. package/dist/skills/brainstorming.js +33 -33
  61. package/dist/skills/code-review.js +35 -35
  62. package/dist/skills/executing-plans.js +18 -18
  63. package/dist/skills/frontend-design.js +25 -25
  64. package/dist/skills/git-workflow.js +26 -26
  65. package/dist/skills/improve-architecture.js +28 -28
  66. package/dist/skills/neuroweave.js +37 -37
  67. package/dist/skills/playwright.js +62 -62
  68. package/dist/skills/prototype.js +20 -20
  69. package/dist/skills/subagent.js +19 -19
  70. package/dist/skills/supabase.js +34 -34
  71. package/dist/skills/systematic-debugging.js +34 -34
  72. package/dist/skills/tdd.js +29 -29
  73. package/dist/skills/triage.js +25 -25
  74. package/dist/skills/verification.js +22 -22
  75. package/dist/skills/writing-plans.js +32 -32
  76. package/dist/tools/nwt.js +16 -16
  77. package/dist/tui/app.d.ts.map +1 -1
  78. package/dist/tui/app.js +9 -2
  79. package/dist/tui/app.js.map +1 -1
  80. package/dist/tui/components/ChatList.d.ts.map +1 -1
  81. package/dist/tui/components/ChatList.js +4 -3
  82. package/dist/tui/components/ChatList.js.map +1 -1
  83. package/dist/tui/components/Header.d.ts +8 -0
  84. package/dist/tui/components/Header.d.ts.map +1 -1
  85. package/dist/tui/components/Header.js +17 -3
  86. package/dist/tui/components/Header.js.map +1 -1
  87. package/dist/tui/components/InitWizard.d.ts +12 -1
  88. package/dist/tui/components/InitWizard.d.ts.map +1 -1
  89. package/dist/tui/components/InitWizard.js +58 -3
  90. package/dist/tui/components/InitWizard.js.map +1 -1
  91. package/dist/tui/components/ToolCall.d.ts +1 -1
  92. package/dist/tui/components/ToolCall.d.ts.map +1 -1
  93. package/dist/tui/components/ToolCall.js +4 -4
  94. package/dist/tui/components/ToolCall.js.map +1 -1
  95. package/dist/tui/hooks/useChat.d.ts +22 -0
  96. package/dist/tui/hooks/useChat.d.ts.map +1 -1
  97. package/dist/tui/hooks/useChat.js +127 -50
  98. package/dist/tui/hooks/useChat.js.map +1 -1
  99. package/dist/tui/hooks/useCommands.d.ts.map +1 -1
  100. package/dist/tui/hooks/useCommands.js +78 -0
  101. package/dist/tui/hooks/useCommands.js.map +1 -1
  102. package/dist/types.d.ts +79 -6
  103. package/dist/types.d.ts.map +1 -1
  104. package/dist/types.js +7 -0
  105. package/dist/types.js.map +1 -1
  106. package/dist/utils/stableStringify.d.ts +21 -0
  107. package/dist/utils/stableStringify.d.ts.map +1 -0
  108. package/dist/utils/stableStringify.js +53 -0
  109. package/dist/utils/stableStringify.js.map +1 -0
  110. package/dist/utils/thinking.d.ts +59 -0
  111. package/dist/utils/thinking.d.ts.map +1 -0
  112. package/dist/utils/thinking.js +104 -0
  113. package/dist/utils/thinking.js.map +1 -0
  114. package/install.bat +63 -0
  115. package/install.ps1 +238 -0
  116. package/install.sh +113 -0
  117. package/package.json +7 -4
  118. package/src/app/index.ts +180 -108
  119. package/src/cache/fingerprint.ts +101 -0
  120. package/src/cache/index.ts +15 -0
  121. package/src/cache/smartModel.ts +52 -0
  122. package/src/cache/stats.ts +199 -0
  123. package/src/cache/volatile.ts +47 -0
  124. package/src/cmd/index.tsx +288 -160
  125. package/src/config/index.ts +156 -148
  126. package/src/config/providers.ts +234 -234
  127. package/src/config/types.ts +67 -53
  128. package/src/hooks/index.ts +111 -111
  129. package/src/llm/anthropic.ts +388 -243
  130. package/src/llm/gemini.ts +169 -169
  131. package/src/llm/index.ts +265 -200
  132. package/src/llm/openai.ts +243 -196
  133. package/src/llm/provider.ts +66 -34
  134. package/src/prompts/index.ts +260 -220
  135. package/src/session/compactor.ts +103 -103
  136. package/src/session/index.ts +181 -87
  137. package/src/session/message.ts +42 -42
  138. package/src/skills/brainstorming.ts +43 -43
  139. package/src/skills/code-review.ts +45 -45
  140. package/src/skills/executing-plans.ts +27 -27
  141. package/src/skills/frontend-design.ts +35 -35
  142. package/src/skills/git-workflow.ts +36 -36
  143. package/src/skills/improve-architecture.ts +38 -38
  144. package/src/skills/index.ts +136 -136
  145. package/src/skills/neuroweave.ts +47 -47
  146. package/src/skills/playwright.ts +72 -72
  147. package/src/skills/prototype.ts +30 -30
  148. package/src/skills/subagent.ts +28 -28
  149. package/src/skills/supabase.ts +44 -44
  150. package/src/skills/systematic-debugging.ts +44 -44
  151. package/src/skills/tdd.ts +39 -39
  152. package/src/skills/triage.ts +35 -35
  153. package/src/skills/verification.ts +31 -31
  154. package/src/skills/writing-plans.ts +42 -42
  155. package/src/tools/nwt.ts +598 -598
  156. package/src/tools/types.ts +122 -122
  157. package/src/tui/app.tsx +199 -186
  158. package/src/tui/components/ChatList.tsx +41 -32
  159. package/src/tui/components/ChatMessage.tsx +54 -54
  160. package/src/tui/components/Header.tsx +56 -28
  161. package/src/tui/components/InitWizard.tsx +217 -132
  162. package/src/tui/components/Markdown.tsx +35 -35
  163. package/src/tui/components/ModelSelector.tsx +87 -87
  164. package/src/tui/components/StatusBar.tsx +30 -30
  165. package/src/tui/components/Thinking.tsx +17 -17
  166. package/src/tui/components/ToolCall.tsx +102 -71
  167. package/src/tui/components/UserInput.tsx +131 -131
  168. package/src/tui/hooks/useChat.ts +287 -194
  169. package/src/tui/hooks/useCommands.ts +234 -154
  170. package/src/tui/index.ts +6 -6
  171. package/src/tui/welcome.ts +177 -177
  172. package/src/types.ts +104 -42
  173. package/src/utils/diff.ts +71 -71
  174. package/src/utils/project.ts +99 -99
  175. package/src/utils/stableStringify.ts +47 -0
  176. package/src/utils/thinking.ts +119 -0
  177. package/tests/cache/fingerprint.test.ts +75 -0
  178. package/tests/cache/stableStringify.test.ts +43 -0
  179. package/tests/cache/stats.test.ts +146 -0
  180. package/tests/cache/volatile.test.ts +75 -0
  181. package/tests/smoke-pollution.mjs +78 -0
  182. package/tests/smoke-thinking.mjs +110 -0
  183. package/tests/smoke-tool-stream.mjs +69 -0
  184. package/tests/smoke-tool.mjs +117 -0
@@ -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
+ }
@@ -0,0 +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
+ /<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
+
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
+ });
@@ -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
+ });