@tyvm/knowhow 0.0.111 → 0.0.112
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/package.json +1 -1
- package/scripts/test-repetition-hint.ts +234 -0
- package/src/chat/CliChatService.ts +14 -6
- package/src/chat/modules/AgentModule.ts +4 -1
- package/src/chat/modules/ClipboardImageModule.ts +136 -0
- package/src/chat/modules/InternalChatModule.ts +3 -0
- package/src/chat/modules/RendererModule.ts +30 -2
- package/src/clients/xai.ts +20 -3
- package/src/processors/CustomVariables.ts +175 -0
- package/src/services/EventService.ts +5 -33
- package/src/utils/index.ts +1 -0
- package/tests/fixtures/fake-secret.txt +1 -0
- package/tests/manual/modalities/xai.modalities.test.ts +1 -1
- package/tests/processors/CustomVariables.test.ts +416 -1
- package/ts_build/package.json +1 -1
- package/ts_build/src/chat/CliChatService.js +9 -4
- package/ts_build/src/chat/CliChatService.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.js +3 -1
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/chat/modules/ClipboardImageModule.d.ts +15 -0
- package/ts_build/src/chat/modules/ClipboardImageModule.js +157 -0
- package/ts_build/src/chat/modules/ClipboardImageModule.js.map +1 -0
- package/ts_build/src/chat/modules/InternalChatModule.d.ts +1 -0
- package/ts_build/src/chat/modules/InternalChatModule.js +3 -0
- package/ts_build/src/chat/modules/InternalChatModule.js.map +1 -1
- package/ts_build/src/chat/modules/RendererModule.js +30 -1
- package/ts_build/src/chat/modules/RendererModule.js.map +1 -1
- package/ts_build/src/clients/xai.js +14 -2
- package/ts_build/src/clients/xai.js.map +1 -1
- package/ts_build/src/processors/CustomVariables.d.ts +10 -0
- package/ts_build/src/processors/CustomVariables.js +127 -0
- package/ts_build/src/processors/CustomVariables.js.map +1 -1
- package/ts_build/src/services/EventService.d.ts +0 -4
- package/ts_build/src/services/EventService.js +4 -15
- package/ts_build/src/services/EventService.js.map +1 -1
- package/ts_build/src/utils/index.js.map +1 -1
- package/ts_build/tests/manual/modalities/xai.modalities.test.js +1 -1
- package/ts_build/tests/manual/modalities/xai.modalities.test.js.map +1 -1
- package/ts_build/tests/processors/CustomVariables.test.js +347 -0
- package/ts_build/tests/processors/CustomVariables.test.js.map +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env ts-node
|
|
2
|
+
/**
|
|
3
|
+
* Test script: runs the repetition hint processor logic against a real agent metadata file
|
|
4
|
+
* and prints whether the hint would fire and why/why not.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx ts-node scripts/test-repetition-hint.ts [path-to-metadata.json]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
|
|
12
|
+
const metadataPath =
|
|
13
|
+
process.argv[2] ||
|
|
14
|
+
"/Users/micah/dev/knowhow-web/.knowhow/processes/agents/1779684572-can-you-try-setting-mysql-postgres-this-sandbox-with/metadata.json";
|
|
15
|
+
|
|
16
|
+
interface ToolCall {
|
|
17
|
+
id: string;
|
|
18
|
+
type: string;
|
|
19
|
+
function: {
|
|
20
|
+
name: string;
|
|
21
|
+
arguments: string | Record<string, any>;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Message {
|
|
26
|
+
role: string;
|
|
27
|
+
content?: string;
|
|
28
|
+
tool_calls?: ToolCall[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---- Replica of processor logic (mirrors CustomVariables.ts) ----
|
|
32
|
+
|
|
33
|
+
function extractStringValues(obj: any, results: string[] = []): string[] {
|
|
34
|
+
if (typeof obj === "string") {
|
|
35
|
+
results.push(obj);
|
|
36
|
+
} else if (Array.isArray(obj)) {
|
|
37
|
+
for (const item of obj) extractStringValues(item, results);
|
|
38
|
+
} else if (obj && typeof obj === "object") {
|
|
39
|
+
for (const val of Object.values(obj)) extractStringValues(val, results);
|
|
40
|
+
}
|
|
41
|
+
return results;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getToolCallStrings(toolCall: ToolCall): string[] {
|
|
45
|
+
try {
|
|
46
|
+
const args = toolCall.function.arguments;
|
|
47
|
+
const parsed = typeof args === "string" ? JSON.parse(args) : args;
|
|
48
|
+
return extractStringValues(parsed);
|
|
49
|
+
} catch {
|
|
50
|
+
const args = toolCall.function.arguments;
|
|
51
|
+
return [typeof args === "string" ? args : JSON.stringify(args)];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function collectToolCallStrings(
|
|
56
|
+
messages: Message[],
|
|
57
|
+
minLength: number
|
|
58
|
+
): Array<{ value: string; toolName: string }> {
|
|
59
|
+
const collected: Array<{ value: string; toolName: string }> = [];
|
|
60
|
+
for (const message of messages) {
|
|
61
|
+
if (!message.tool_calls) continue;
|
|
62
|
+
for (const toolCall of message.tool_calls) {
|
|
63
|
+
const strings = getToolCallStrings(toolCall);
|
|
64
|
+
for (const str of strings) {
|
|
65
|
+
if (str.length >= minLength) {
|
|
66
|
+
collected.push({ value: str, toolName: toolCall.function.name });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return collected;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function longestCommonSubstring(a: string, b: string, minLength: number): string | null {
|
|
75
|
+
let best = "";
|
|
76
|
+
for (let i = 0; i < a.length - minLength + 1; i++) {
|
|
77
|
+
for (let j = a.length; j > i + minLength - 1; j--) {
|
|
78
|
+
const sub = a.slice(i, j);
|
|
79
|
+
if (sub.length <= best.length) break; // already found longer, skip shorter
|
|
80
|
+
if (b.includes(sub)) {
|
|
81
|
+
best = sub;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return best.length >= minLength ? best : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runProcessor(
|
|
90
|
+
messages: Message[],
|
|
91
|
+
minLength = 50,
|
|
92
|
+
minRepetitions = 2,
|
|
93
|
+
minSubstringLength = 50
|
|
94
|
+
): { wouldHint: boolean; repeatedTools: string[]; details: Map<string, { count: number; tools: Set<string> }> } {
|
|
95
|
+
const stringCounts = new Map<string, { count: number; tools: Set<string> }>();
|
|
96
|
+
const toolStrings = collectToolCallStrings(messages, minLength);
|
|
97
|
+
|
|
98
|
+
// Step 1: exact full-string matches
|
|
99
|
+
for (const { value, toolName } of toolStrings) {
|
|
100
|
+
const existing = stringCounts.get(value);
|
|
101
|
+
if (existing) {
|
|
102
|
+
existing.count++;
|
|
103
|
+
existing.tools.add(toolName);
|
|
104
|
+
} else {
|
|
105
|
+
stringCounts.set(value, { count: 1, tools: new Set([toolName]) });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Step 2: repeated substrings across different full strings
|
|
110
|
+
// e.g. the same JWT embedded in many different commands
|
|
111
|
+
const substringCounts = new Map<string, { count: number; tools: Set<string> }>();
|
|
112
|
+
for (let i = 0; i < toolStrings.length; i++) {
|
|
113
|
+
for (let j = i + 1; j < toolStrings.length; j++) {
|
|
114
|
+
const a = toolStrings[i];
|
|
115
|
+
const b = toolStrings[j];
|
|
116
|
+
if (a.value === b.value) continue; // already handled by exact match
|
|
117
|
+
const common = longestCommonSubstring(a.value, b.value, minSubstringLength);
|
|
118
|
+
if (common) {
|
|
119
|
+
const existing = substringCounts.get(common);
|
|
120
|
+
if (existing) {
|
|
121
|
+
existing.count++;
|
|
122
|
+
existing.tools.add(a.toolName);
|
|
123
|
+
existing.tools.add(b.toolName);
|
|
124
|
+
} else {
|
|
125
|
+
substringCounts.set(common, { count: 1, tools: new Set([a.toolName, b.toolName]) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Merge substring counts: count = number of unique pairs, count+1 = number of occurrences
|
|
132
|
+
for (const [sub, info] of substringCounts.entries()) {
|
|
133
|
+
if (info.count + 1 >= minRepetitions && !stringCounts.has(sub)) {
|
|
134
|
+
stringCounts.set(sub, { count: info.count + 1, tools: info.tools });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Find entries that exceed the repetition threshold
|
|
139
|
+
const repeatedTools: string[] = [];
|
|
140
|
+
for (const [, info] of stringCounts.entries()) {
|
|
141
|
+
if (info.count >= minRepetitions) {
|
|
142
|
+
for (const toolName of info.tools) {
|
|
143
|
+
if (!repeatedTools.includes(toolName)) repeatedTools.push(toolName);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { wouldHint: repeatedTools.length > 0, repeatedTools, details: stringCounts };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- Main ----
|
|
152
|
+
|
|
153
|
+
const raw = fs.readFileSync(metadataPath, "utf-8");
|
|
154
|
+
const metadata = JSON.parse(raw);
|
|
155
|
+
const threads: Message[][] = metadata.threads || [];
|
|
156
|
+
|
|
157
|
+
console.log(`\n=== Repetition Hint Processor Test ===`);
|
|
158
|
+
console.log(`File: ${metadataPath}`);
|
|
159
|
+
console.log(`Threads: ${threads.length}`);
|
|
160
|
+
|
|
161
|
+
for (let ti = 0; ti < threads.length; ti++) {
|
|
162
|
+
const thread = threads[ti];
|
|
163
|
+
const toolCallMsgs = thread.filter((m) => m.tool_calls && m.tool_calls.length > 0);
|
|
164
|
+
console.log(`\n--- Thread ${ti}: ${thread.length} messages, ${toolCallMsgs.length} with tool calls ---`);
|
|
165
|
+
|
|
166
|
+
// Run with OLD logic (exact matches only)
|
|
167
|
+
console.log(`\n[OLD Processor] exact full-string matches only, minLength=50, minRepetitions=2`);
|
|
168
|
+
const oldResult = runProcessor(thread, 50, 2, Infinity);
|
|
169
|
+
if (oldResult.wouldHint) {
|
|
170
|
+
console.log(`✅ Would hint! Repeated tools: ${oldResult.repeatedTools.join(", ")}`);
|
|
171
|
+
} else {
|
|
172
|
+
console.log(`❌ Would NOT hint (bug - missed embedded repetitions).`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Run with NEW logic (exact + substring)
|
|
176
|
+
console.log(`\n[NEW Processor] exact + substring matching, minLength=50, minRepetitions=2, minSubstringLength=50`);
|
|
177
|
+
const newResult = runProcessor(thread, 50, 2, 50);
|
|
178
|
+
if (newResult.wouldHint) {
|
|
179
|
+
console.log(`✅ Would hint! Repeated tools: ${newResult.repeatedTools.join(", ")}`);
|
|
180
|
+
// Show top repeated substrings
|
|
181
|
+
const repeated = Array.from(newResult.details.entries())
|
|
182
|
+
.filter(([, info]) => info.count >= 2)
|
|
183
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
184
|
+
.slice(0, 5);
|
|
185
|
+
console.log(`\n Top repeated values (count, tools, preview):`);
|
|
186
|
+
for (const [str, info] of repeated) {
|
|
187
|
+
console.log(` count=${info.count}, tools=${[...info.tools].join(",")}`);
|
|
188
|
+
console.log(` value=${JSON.stringify(str.slice(0, 120))}`);
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
console.log(`❌ Would NOT hint.`);
|
|
192
|
+
// Show top large strings for diagnosis
|
|
193
|
+
const toolStrings = collectToolCallStrings(thread, 50);
|
|
194
|
+
console.log(`\n Total large strings in tool calls: ${toolStrings.length}`);
|
|
195
|
+
const top = toolStrings.slice(0, 3);
|
|
196
|
+
for (const { value, toolName } of top) {
|
|
197
|
+
console.log(` tool=${toolName}, len=${value.length}, preview=${JSON.stringify(value.slice(0, 100))}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check for Bearer tokens specifically
|
|
202
|
+
console.log(`\n[Bearer Token Check]`);
|
|
203
|
+
const jwtMap = new Map<string, { count: number; tools: Set<string> }>();
|
|
204
|
+
const jwtPattern = /Bearer ([\w\-\.]+)/;
|
|
205
|
+
for (const msg of thread) {
|
|
206
|
+
if (!msg.tool_calls) continue;
|
|
207
|
+
for (const tc of msg.tool_calls) {
|
|
208
|
+
const args = typeof tc.function.arguments === "string"
|
|
209
|
+
? tc.function.arguments
|
|
210
|
+
: JSON.stringify(tc.function.arguments);
|
|
211
|
+
const match = jwtPattern.exec(args);
|
|
212
|
+
if (match) {
|
|
213
|
+
const jwt = match[1];
|
|
214
|
+
const existing = jwtMap.get(jwt);
|
|
215
|
+
if (existing) {
|
|
216
|
+
existing.count++;
|
|
217
|
+
existing.tools.add(tc.function.name);
|
|
218
|
+
} else {
|
|
219
|
+
jwtMap.set(jwt, { count: 1, tools: new Set([tc.function.name]) });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (jwtMap.size > 0) {
|
|
225
|
+
for (const [jwt, info] of jwtMap.entries()) {
|
|
226
|
+
console.log(` ⚠️ Bearer token appears ${info.count} times in: ${[...info.tools].join(", ")}`);
|
|
227
|
+
console.log(` ${jwt.slice(0, 80)}...`);
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
console.log(` No Bearer tokens found in tool calls.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
console.log("\n=== Done ===\n");
|
|
@@ -216,12 +216,20 @@ export class CliChatService implements ChatService {
|
|
|
216
216
|
} else {
|
|
217
217
|
// Input starts with "/" but no matching command found - warn the user
|
|
218
218
|
const availableCommands = this.getCommandsForActiveModes();
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
219
|
+
// If the input looks like a filepath (contains path separators or file extensions),
|
|
220
|
+
// don't treat it as a failed command - let it fall through to modules
|
|
221
|
+
const looksLikeFilepath =
|
|
222
|
+
commandName.includes("/") ||
|
|
223
|
+
commandName.includes(".") ||
|
|
224
|
+
commandName.includes("\\");
|
|
225
|
+
if (!looksLikeFilepath) {
|
|
226
|
+
console.log(
|
|
227
|
+
`Unknown command "/${commandName}". Available commands: ${availableCommands
|
|
228
|
+
.map((cmd) => `/${cmd.name}`)
|
|
229
|
+
.join(", ")}`
|
|
230
|
+
);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
225
233
|
}
|
|
226
234
|
}
|
|
227
235
|
|
|
@@ -756,10 +756,12 @@ export class AgentModule extends BaseChatModule {
|
|
|
756
756
|
),
|
|
757
757
|
];
|
|
758
758
|
|
|
759
|
+
const customVariables = new CustomVariables(agent.tools);
|
|
760
|
+
|
|
759
761
|
agent.messageProcessor.setProcessors("pre_call", [
|
|
760
762
|
new Base64ImageProcessor(agent.tools).createProcessor(),
|
|
761
763
|
...caching,
|
|
762
|
-
|
|
764
|
+
customVariables.createProcessor(),
|
|
763
765
|
]);
|
|
764
766
|
|
|
765
767
|
agent.messageProcessor.setProcessors("post_call", [
|
|
@@ -770,6 +772,7 @@ export class AgentModule extends BaseChatModule {
|
|
|
770
772
|
agent.messageProcessor.setProcessors("post_tools", [
|
|
771
773
|
new Base64ImageProcessor(agent.tools).createProcessor(),
|
|
772
774
|
...caching,
|
|
775
|
+
customVariables.createRepetitionHintProcessor(),
|
|
773
776
|
]);
|
|
774
777
|
|
|
775
778
|
// Set up event listeners
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { exec } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { ChatModule, ChatCommand, ChatMode, ChatContext } from "../types";
|
|
7
|
+
import { CliChatService } from "../CliChatService";
|
|
8
|
+
|
|
9
|
+
const execAsync = promisify(exec);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Attempts to capture an image from the system clipboard and save it to a temp file.
|
|
13
|
+
* Returns the filepath if successful, or null if the clipboard has no image.
|
|
14
|
+
*/
|
|
15
|
+
export async function captureClipboardImage(): Promise<string | null> {
|
|
16
|
+
const tmpFile = path.join(os.tmpdir(), `knowhow-clipboard-${Date.now()}.png`);
|
|
17
|
+
|
|
18
|
+
const platform = process.platform;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
if (platform === "darwin") {
|
|
22
|
+
// macOS: use pngpaste if available, fall back to osascript
|
|
23
|
+
try {
|
|
24
|
+
await execAsync(`pngpaste "${tmpFile}"`);
|
|
25
|
+
if (fs.existsSync(tmpFile)) return tmpFile;
|
|
26
|
+
} catch {
|
|
27
|
+
// pngpaste not available or no image in clipboard, try osascript
|
|
28
|
+
const script = `
|
|
29
|
+
tell application "System Events"
|
|
30
|
+
set theImage to the clipboard as «class PNGf»
|
|
31
|
+
set fileRef to open for access POSIX file "${tmpFile}" with write permission
|
|
32
|
+
write theImage to fileRef
|
|
33
|
+
close access fileRef
|
|
34
|
+
end tell
|
|
35
|
+
`;
|
|
36
|
+
try {
|
|
37
|
+
await execAsync(`osascript -e '${script.replace(/'/g, "'\\''")}'`);
|
|
38
|
+
if (fs.existsSync(tmpFile)) return tmpFile;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} else if (platform === "linux") {
|
|
44
|
+
// Linux: try xclip first, then xsel
|
|
45
|
+
try {
|
|
46
|
+
await execAsync(`xclip -selection clipboard -t image/png -o > "${tmpFile}"`);
|
|
47
|
+
if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size > 0) return tmpFile;
|
|
48
|
+
} catch {
|
|
49
|
+
try {
|
|
50
|
+
await execAsync(`xsel --clipboard --output > "${tmpFile}"`);
|
|
51
|
+
if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size > 0) return tmpFile;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} else if (platform === "win32") {
|
|
57
|
+
// Windows: use PowerShell
|
|
58
|
+
const ps = `
|
|
59
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
60
|
+
$img = [System.Windows.Forms.Clipboard]::GetImage()
|
|
61
|
+
if ($img -ne $null) {
|
|
62
|
+
$img.Save('${tmpFile.replace(/\\/g, "\\\\")}')
|
|
63
|
+
Write-Output 'saved'
|
|
64
|
+
} else {
|
|
65
|
+
Write-Output 'no image'
|
|
66
|
+
}
|
|
67
|
+
`;
|
|
68
|
+
const { stdout } = await execAsync(`powershell -Command "${ps.replace(/"/g, '\\"')}"`);
|
|
69
|
+
if (stdout.trim() === "saved" && fs.existsSync(tmpFile)) return tmpFile;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Silently fail - clipboard doesn't contain an image
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Clean up empty file if created
|
|
76
|
+
try {
|
|
77
|
+
if (fs.existsSync(tmpFile) && fs.statSync(tmpFile).size === 0) {
|
|
78
|
+
fs.unlinkSync(tmpFile);
|
|
79
|
+
}
|
|
80
|
+
} catch { /* ignore */ }
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class ClipboardImageModule implements ChatModule {
|
|
86
|
+
name = "clipboard-image";
|
|
87
|
+
description = "Handles clipboard image paste detection and capture";
|
|
88
|
+
commands: ChatCommand[] = [];
|
|
89
|
+
modes: ChatMode[] = [];
|
|
90
|
+
|
|
91
|
+
getCommands(): ChatCommand[] {
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
name: "paste",
|
|
95
|
+
description: "Capture image from clipboard and send to agent",
|
|
96
|
+
handler: this.handlePasteCommand.bind(this),
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
getModes(): ChatMode[] {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async initialize(chatService: CliChatService): Promise<void> {
|
|
106
|
+
// Register /paste command
|
|
107
|
+
chatService.registerCommand({
|
|
108
|
+
name: "paste",
|
|
109
|
+
description: "Capture image from clipboard and send to agent",
|
|
110
|
+
handler: this.handlePasteCommand.bind(this),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async handlePasteCommand(args: string[]): Promise<{ handled: boolean; contents?: string }> {
|
|
116
|
+
console.log("🔍 Checking clipboard for image...");
|
|
117
|
+
const filepath = await captureClipboardImage();
|
|
118
|
+
|
|
119
|
+
if (!filepath) {
|
|
120
|
+
console.log("No image found in clipboard. Copy an image first, then use /paste.");
|
|
121
|
+
return { handled: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(`📋 Image captured: ${filepath}`);
|
|
125
|
+
|
|
126
|
+
// Return as not-handled so the filepath flows through to modules (AgentModule)
|
|
127
|
+
return { handled: false, contents: filepath };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async handleInput(input: string, context: ChatContext): Promise<boolean> {
|
|
131
|
+
return false; // This module only handles commands
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async cleanup(): Promise<void> {
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -12,8 +12,10 @@ import { ShellCommandModule } from "./ShellCommandModule";
|
|
|
12
12
|
import { RendererModule } from "./RendererModule";
|
|
13
13
|
import { SessionsModule } from "./SessionsModule";
|
|
14
14
|
import { RemoteSyncModule } from "./RemoteSyncModule";
|
|
15
|
+
import { ClipboardImageModule } from "./ClipboardImageModule";
|
|
15
16
|
|
|
16
17
|
export class InternalChatModule implements ChatModule {
|
|
18
|
+
private clipboardImageModule = new ClipboardImageModule();
|
|
17
19
|
private chatService?: CliChatService;
|
|
18
20
|
name = "internal";
|
|
19
21
|
description = "Internal chat module aggregating all functionality";
|
|
@@ -57,6 +59,7 @@ export class InternalChatModule implements ChatModule {
|
|
|
57
59
|
await this.customCommandsModule.initialize(chatService);
|
|
58
60
|
await this.remoteSyncModule.initialize(chatService);
|
|
59
61
|
await this.shellCommandModule.initialize(chatService);
|
|
62
|
+
await this.clipboardImageModule.initialize(chatService);
|
|
60
63
|
|
|
61
64
|
// Register our own commands (exit and multi) - not duplicated by BaseChatModule
|
|
62
65
|
chatService.registerCommand({
|
|
@@ -6,6 +6,7 @@ import { ChatCommand, ChatMode, ChatContext, ChatService } from "../types";
|
|
|
6
6
|
import { loadRenderer } from "../renderer/loadRenderer";
|
|
7
7
|
import { ConsoleRenderer } from "../renderer";
|
|
8
8
|
import { AgentModule } from "./AgentModule";
|
|
9
|
+
import { getConfig, updateConfig } from "../../config";
|
|
9
10
|
|
|
10
11
|
const BUILTIN_RENDERERS = ["basic", "compact", "fancy"];
|
|
11
12
|
|
|
@@ -27,10 +28,25 @@ export class RendererModule extends BaseChatModule {
|
|
|
27
28
|
async initialize(service: ChatService): Promise<void> {
|
|
28
29
|
await super.initialize(service);
|
|
29
30
|
|
|
30
|
-
// Initialize context.renderer
|
|
31
|
+
// Initialize context.renderer from config or default to ConsoleRenderer
|
|
31
32
|
const context = service.getContext();
|
|
32
33
|
if (!context.renderer) {
|
|
33
|
-
|
|
34
|
+
try {
|
|
35
|
+
const config = await getConfig();
|
|
36
|
+
const savedName = config.chat?.renderer;
|
|
37
|
+
if (savedName && savedName !== "basic") {
|
|
38
|
+
const savedRenderer = await loadRenderer(savedName);
|
|
39
|
+
this.currentRendererName = savedName;
|
|
40
|
+
service.setContext({ renderer: savedRenderer });
|
|
41
|
+
} else {
|
|
42
|
+
service.setContext({ renderer: new ConsoleRenderer() });
|
|
43
|
+
if (savedName === "basic" || !savedName) {
|
|
44
|
+
this.currentRendererName = "basic";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
service.setContext({ renderer: new ConsoleRenderer() });
|
|
49
|
+
}
|
|
34
50
|
}
|
|
35
51
|
}
|
|
36
52
|
|
|
@@ -85,6 +101,18 @@ export class RendererModule extends BaseChatModule {
|
|
|
85
101
|
this.chatService?.setContext({ renderer: newRenderer });
|
|
86
102
|
this.currentRendererName = specifier;
|
|
87
103
|
|
|
104
|
+
// Persist renderer preference to config
|
|
105
|
+
try {
|
|
106
|
+
const config = await getConfig();
|
|
107
|
+
config.chat = {
|
|
108
|
+
...config.chat,
|
|
109
|
+
renderer: specifier,
|
|
110
|
+
};
|
|
111
|
+
await updateConfig(config);
|
|
112
|
+
} catch (saveErr: any) {
|
|
113
|
+
console.warn(`⚠️ Could not save renderer preference: ${saveErr.message}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
// Rewire agent rendering event listeners to the new renderer so live
|
|
89
117
|
// events are forwarded correctly even mid-session.
|
|
90
118
|
// This works because wireAgentRendering() always reads `this.renderer`
|
package/src/clients/xai.ts
CHANGED
|
@@ -425,7 +425,7 @@ export class GenericXAIClient implements GenericClient {
|
|
|
425
425
|
options: VideoStatusOptions
|
|
426
426
|
): Promise<VideoStatusResponse> {
|
|
427
427
|
const statusResponse = await fetch(
|
|
428
|
-
`https://api.x.ai/v1/videos/${options.jobId}`,
|
|
428
|
+
`https://api.x.ai/v1/videos/generations/${options.jobId}`,
|
|
429
429
|
{
|
|
430
430
|
method: "GET",
|
|
431
431
|
headers: {
|
|
@@ -485,8 +485,25 @@ export class GenericXAIClient implements GenericClient {
|
|
|
485
485
|
async downloadVideo(
|
|
486
486
|
options: FileDownloadOptions
|
|
487
487
|
): Promise<FileDownloadResponse> {
|
|
488
|
-
// XAI returns a URL
|
|
489
|
-
|
|
488
|
+
// XAI returns a presigned URL from the status endpoint, not raw bytes.
|
|
489
|
+
// options.fileId is the request_id (jobId) — we need to fetch the status
|
|
490
|
+
// to get the actual video URL, then download from there.
|
|
491
|
+
let url = options.uri;
|
|
492
|
+
if (!url) {
|
|
493
|
+
const statusResponse = await fetch(
|
|
494
|
+
`https://api.x.ai/v1/videos/generations/${options.fileId}`,
|
|
495
|
+
{ headers: { Authorization: `Bearer ${this.apiKey}` } }
|
|
496
|
+
);
|
|
497
|
+
if (!statusResponse.ok) {
|
|
498
|
+
const errorText = await statusResponse.text();
|
|
499
|
+
throw new Error(`XAI video status fetch failed: ${statusResponse.status} ${errorText}`);
|
|
500
|
+
}
|
|
501
|
+
const statusData = await statusResponse.json();
|
|
502
|
+
url = statusData.video?.url;
|
|
503
|
+
if (!url) {
|
|
504
|
+
throw new Error(`XAI video not ready yet or no URL available (status: ${statusData.status})`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
490
507
|
|
|
491
508
|
const response = await fetch(url);
|
|
492
509
|
if (!response.ok) {
|