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
package/src/utils/project.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/thinking.ts
CHANGED
|
@@ -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
|
-
/<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
*
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
.
|
|
113
|
-
.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
});
|