@tyvm/knowhow 0.0.148 → 0.0.150
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/src/agents/base/base.ts +18 -9
- package/src/agents/tools/startAgentTask.ts +5 -2
- package/src/auth/keyAuth.ts +10 -4
- package/src/auth/registerIdentity.ts +67 -0
- package/src/chat/modules/AgentModule.ts +39 -7
- package/src/clients/index.ts +34 -1
- package/src/commands/agent.ts +1 -0
- package/src/commands/agents.ts +4 -1
- package/src/commands/misc.ts +17 -2
- package/src/commands/processes.ts +116 -6
- package/src/hashes.ts +2 -0
- package/src/processes/ProcessManager.ts +2 -1
- package/src/processes/managedProcessShim.ts +17 -5
- package/src/processors/Base64ImageDetector.ts +28 -4
- package/src/processors/JsonCompressor.ts +14 -0
- package/src/processors/TokenCompressor.ts +17 -14
- package/src/services/AgentService.ts +2 -2
- package/src/services/AgentSyncFs.ts +42 -0
- package/src/services/modules/computerUse.ts +3 -1
- package/tests/clients/AIClient.test.ts +98 -15
- package/tests/processes/managedProcessShim.test.ts +59 -0
- package/tests/processors/Base64ImageDetector.test.ts +21 -0
- package/tests/processors/TokenCompressor.test.ts +18 -0
- package/tests/unit/auth/registerIdentity.test.ts +93 -0
- package/tests/unit/hashes.test.ts +23 -0
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/base/base.js +14 -9
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/agents/tools/startAgentTask.js +2 -2
- package/ts_build/src/agents/tools/startAgentTask.js.map +1 -1
- package/ts_build/src/auth/keyAuth.js +10 -3
- package/ts_build/src/auth/keyAuth.js.map +1 -1
- package/ts_build/src/auth/registerIdentity.d.ts +7 -0
- package/ts_build/src/auth/registerIdentity.js +62 -0
- package/ts_build/src/auth/registerIdentity.js.map +1 -0
- package/ts_build/src/chat/modules/AgentModule.d.ts +2 -0
- package/ts_build/src/chat/modules/AgentModule.js +27 -7
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/clients/index.js +19 -1
- package/ts_build/src/clients/index.js.map +1 -1
- package/ts_build/src/commands/agent.js +1 -0
- package/ts_build/src/commands/agent.js.map +1 -1
- package/ts_build/src/commands/agents.js +1 -1
- package/ts_build/src/commands/agents.js.map +1 -1
- package/ts_build/src/commands/misc.js +12 -1
- package/ts_build/src/commands/misc.js.map +1 -1
- package/ts_build/src/commands/processes.js +125 -6
- package/ts_build/src/commands/processes.js.map +1 -1
- package/ts_build/src/hashes.js +1 -0
- package/ts_build/src/hashes.js.map +1 -1
- package/ts_build/src/processes/ProcessManager.js +2 -1
- package/ts_build/src/processes/ProcessManager.js.map +1 -1
- package/ts_build/src/processes/managedProcessShim.js +13 -7
- package/ts_build/src/processes/managedProcessShim.js.map +1 -1
- package/ts_build/src/processors/Base64ImageDetector.d.ts +1 -0
- package/ts_build/src/processors/Base64ImageDetector.js +19 -2
- package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
- package/ts_build/src/processors/JsonCompressor.js +7 -0
- package/ts_build/src/processors/JsonCompressor.js.map +1 -1
- package/ts_build/src/processors/TokenCompressor.d.ts +1 -1
- package/ts_build/src/processors/TokenCompressor.js +9 -8
- package/ts_build/src/processors/TokenCompressor.js.map +1 -1
- package/ts_build/src/services/AgentService.d.ts +1 -1
- package/ts_build/src/services/AgentService.js.map +1 -1
- package/ts_build/src/services/AgentSyncFs.d.ts +1 -0
- package/ts_build/src/services/AgentSyncFs.js +34 -0
- package/ts_build/src/services/AgentSyncFs.js.map +1 -1
- package/ts_build/src/services/modules/computerUse.d.ts +3 -1
- package/ts_build/tests/clients/AIClient.test.js +73 -11
- package/ts_build/tests/clients/AIClient.test.js.map +1 -1
- package/ts_build/tests/processes/managedProcessShim.test.d.ts +1 -0
- package/ts_build/tests/processes/managedProcessShim.test.js +92 -0
- package/ts_build/tests/processes/managedProcessShim.test.js.map +1 -0
- package/ts_build/tests/processors/Base64ImageDetector.test.js +18 -0
- package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
- package/ts_build/tests/processors/TokenCompressor.test.js +15 -0
- package/ts_build/tests/processors/TokenCompressor.test.js.map +1 -1
- package/ts_build/tests/unit/auth/registerIdentity.test.d.ts +1 -0
- package/ts_build/tests/unit/auth/registerIdentity.test.js +86 -0
- package/ts_build/tests/unit/auth/registerIdentity.test.js.map +1 -0
- package/ts_build/tests/unit/hashes.test.d.ts +1 -0
- package/ts_build/tests/unit/hashes.test.js +25 -0
- package/ts_build/tests/unit/hashes.test.js.map +1 -0
|
@@ -251,6 +251,24 @@ export class Base64ImageProcessor {
|
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
private isUsableImageUrl(url: unknown): url is string {
|
|
255
|
+
if (typeof url !== "string") return false;
|
|
256
|
+
|
|
257
|
+
// Data URLs are only usable when they contain an image format this processor
|
|
258
|
+
// recognizes. In particular, reject TokenCompressor placeholders that happen
|
|
259
|
+
// to be located inside an otherwise valid-looking image_url content part.
|
|
260
|
+
if (url.startsWith("data:")) {
|
|
261
|
+
return this.isBase64Image(url).isImage;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const parsed = new URL(url);
|
|
266
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
267
|
+
} catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
254
272
|
private processToolMessageContent(message: Message): void {
|
|
255
273
|
// Tool messages have string content that might be a JSON string containing image data
|
|
256
274
|
if (typeof message.content === "string" && message.content.trim()) {
|
|
@@ -267,13 +285,19 @@ export class Base64ImageProcessor {
|
|
|
267
285
|
// model to see a raw base64 JSON string instead of an actual image.)
|
|
268
286
|
const candidates = Array.isArray(parsed) ? parsed : [parsed];
|
|
269
287
|
const isImagePart = (p: any) =>
|
|
270
|
-
p &&
|
|
288
|
+
p &&
|
|
289
|
+
p.type === "image_url" &&
|
|
290
|
+
this.isUsableImageUrl(p.image_url?.url);
|
|
271
291
|
const isTextPart = (p: any) =>
|
|
272
292
|
p && p.type === "text" && typeof p.text === "string";
|
|
293
|
+
const hasInvalidImagePart = candidates.some(
|
|
294
|
+
(p: any) => p?.type === "image_url" && !isImagePart(p)
|
|
295
|
+
);
|
|
273
296
|
|
|
274
|
-
// Only rewrite
|
|
275
|
-
//
|
|
276
|
-
|
|
297
|
+
// Only rewrite content containing valid provider-ready images. If an
|
|
298
|
+
// image URL was compressed or is malformed, preserve the JSON as text so
|
|
299
|
+
// the agent can inspect/expand it instead of failing the next API call.
|
|
300
|
+
if (!hasInvalidImagePart && candidates.some(isImagePart)) {
|
|
277
301
|
message.content = candidates.filter(
|
|
278
302
|
(p) => isImagePart(p) || isTextPart(p)
|
|
279
303
|
);
|
|
@@ -398,6 +398,20 @@ export class JsonCompressor {
|
|
|
398
398
|
}
|
|
399
399
|
|
|
400
400
|
|
|
401
|
+
// Image content parts must remain atomic. Compressing a data URL independently
|
|
402
|
+
// leaves a valid-looking image_url object whose URL is only a placeholder.
|
|
403
|
+
// The containing response can still be compressed, but expanding it must
|
|
404
|
+
// restore an image part with its usable URL intact.
|
|
405
|
+
if (
|
|
406
|
+
obj &&
|
|
407
|
+
typeof obj === "object" &&
|
|
408
|
+
!Array.isArray(obj) &&
|
|
409
|
+
obj.type === "image_url" &&
|
|
410
|
+
typeof obj.image_url?.url === "string"
|
|
411
|
+
) {
|
|
412
|
+
return obj;
|
|
413
|
+
}
|
|
414
|
+
|
|
401
415
|
// Handle objects - try low-signal detection first, then process properties (depth-first)
|
|
402
416
|
if (obj && typeof obj === "object") {
|
|
403
417
|
// Check if this exact object (by original content) is a duplicate
|
|
@@ -45,6 +45,22 @@ export class TokenCompressor implements JsonCompressorStorage {
|
|
|
45
45
|
// JSON compression handler
|
|
46
46
|
private jsonCompressor: JsonCompressor;
|
|
47
47
|
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Tool names whose responses should never be compressed.
|
|
51
|
+
* These are compression/retrieval tools themselves - compressing their output
|
|
52
|
+
* would prevent the agent from seeing the uncompressed data it just retrieved.
|
|
53
|
+
*/
|
|
54
|
+
static DO_NOT_COMPRESS: ReadonlySet<string> = new Set([
|
|
55
|
+
"expandTokens",
|
|
56
|
+
"grepToolResponse",
|
|
57
|
+
"jqToolResponse",
|
|
58
|
+
"tailToolResponse",
|
|
59
|
+
"listStoredToolResponses",
|
|
60
|
+
"loadImageAsBase64" // need a proper way to do this
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
|
|
48
64
|
constructor(toolsService?: ToolsService) {
|
|
49
65
|
this.jsonCompressor = new JsonCompressor(
|
|
50
66
|
this,
|
|
@@ -290,19 +306,6 @@ export class TokenCompressor implements JsonCompressorStorage {
|
|
|
290
306
|
}
|
|
291
307
|
}
|
|
292
308
|
|
|
293
|
-
/**
|
|
294
|
-
* Tool names whose responses should never be compressed.
|
|
295
|
-
* These are compression/retrieval tools themselves - compressing their output
|
|
296
|
-
* would prevent the agent from seeing the uncompressed data it just retrieved.
|
|
297
|
-
*/
|
|
298
|
-
static readonly COMPRESSION_TOOL_NAMES: ReadonlySet<string> = new Set([
|
|
299
|
-
"expandTokens",
|
|
300
|
-
"grepToolResponse",
|
|
301
|
-
"jqToolResponse",
|
|
302
|
-
"tailToolResponse",
|
|
303
|
-
"listStoredToolResponses",
|
|
304
|
-
]);
|
|
305
|
-
|
|
306
309
|
createProcessor(
|
|
307
310
|
filterFn?: (msg: Message) => boolean
|
|
308
311
|
): MessageProcessorFunction {
|
|
@@ -344,7 +347,7 @@ export class TokenCompressor implements JsonCompressorStorage {
|
|
|
344
347
|
if (
|
|
345
348
|
message.role === "tool" &&
|
|
346
349
|
message.name &&
|
|
347
|
-
TokenCompressor.
|
|
350
|
+
TokenCompressor.DO_NOT_COMPRESS.has(message.name)
|
|
348
351
|
) {
|
|
349
352
|
continue;
|
|
350
353
|
}
|
|
@@ -76,14 +76,14 @@ export class AgentService {
|
|
|
76
76
|
return this.agentContext ?? { Tools: this.tools, Events: this.events };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
public getAgent(name: string):
|
|
79
|
+
public getAgent<T extends IAgent = IAgent>(name: string): T {
|
|
80
80
|
const agent = this.agents.get(name);
|
|
81
81
|
if (!agent) {
|
|
82
82
|
throw new Error(
|
|
83
83
|
`Agent ${name} not found. Options are: ${this.listAgents()}`
|
|
84
84
|
);
|
|
85
85
|
}
|
|
86
|
-
return agent;
|
|
86
|
+
return agent as T;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
public listAgents(): string[] {
|
|
@@ -87,6 +87,47 @@ export class AgentSyncFs {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Clean up old managed-process directories (.knowhow/processes/<id>/) that
|
|
92
|
+
* were created by spawnManaged / execCommand. These are identified by the
|
|
93
|
+
* presence of a status.json file inside the directory. Only directories
|
|
94
|
+
* older than maxAgeMs are removed.
|
|
95
|
+
*/
|
|
96
|
+
static async cleanupOldManagedProcessDirs(maxAgeMs: number): Promise<void> {
|
|
97
|
+
try {
|
|
98
|
+
const processesPath = path.dirname(AgentSyncFs.sharedBasePath);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await fs.access(processesPath);
|
|
102
|
+
} catch {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const entries = await fs.readdir(processesPath, { withFileTypes: true });
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (!entry.isDirectory()) continue;
|
|
111
|
+
const dirPath = path.join(processesPath, entry.name);
|
|
112
|
+
const statusPath = path.join(dirPath, "status.json");
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await fs.access(statusPath);
|
|
116
|
+
const stats = await fs.stat(dirPath);
|
|
117
|
+
const age = now - stats.mtimeMs;
|
|
118
|
+
if (age > maxAgeMs) {
|
|
119
|
+
console.log(`🧹 Cleaning up old process directory: ${entry.name}`);
|
|
120
|
+
await fs.rm(dirPath, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// No status.json or can't stat — skip (e.g. the agents/ subdir)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(`❌ Error during managed process dir cleanup:`, error);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
90
131
|
/**
|
|
91
132
|
* Update task status
|
|
92
133
|
*/
|
|
@@ -617,6 +658,7 @@ export class AgentSyncFs {
|
|
|
617
658
|
|
|
618
659
|
// Also clean up old execCommand background log files in .knowhow/processes/*.txt
|
|
619
660
|
await AgentSyncFs.cleanupOldProcessLogs(threeDaysMs);
|
|
661
|
+
await AgentSyncFs.cleanupOldManagedProcessDirs(threeDaysMs);
|
|
620
662
|
} catch (error) {
|
|
621
663
|
console.error(`❌ Error during cleanup:`, error);
|
|
622
664
|
}
|
|
@@ -26,13 +26,15 @@ export interface Region extends Point, Size {}
|
|
|
26
26
|
export type MouseButton = "left" | "right" | "middle";
|
|
27
27
|
|
|
28
28
|
export interface OverlayPrimitive {
|
|
29
|
-
kind: "rect" | "circle" | "line" | "point";
|
|
29
|
+
kind: "rect" | "circle" | "line" | "point" | "text";
|
|
30
30
|
x: number;
|
|
31
31
|
y: number;
|
|
32
32
|
width?: number;
|
|
33
33
|
height?: number;
|
|
34
34
|
x2?: number;
|
|
35
35
|
y2?: number;
|
|
36
|
+
text?: string;
|
|
37
|
+
fontSize?: number;
|
|
36
38
|
color?: string;
|
|
37
39
|
lineWidth?: number;
|
|
38
40
|
}
|
|
@@ -187,6 +187,98 @@ describe("AIClient", () => {
|
|
|
187
187
|
expect(result.model).toBe("unknown-model");
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
// ── New tests: explicit provider + slashed model prefix stripping ────────
|
|
191
|
+
|
|
192
|
+
it("knowhow provider: resolves 'anthropic/claude-sonnet-4-6' to model stored as 'anthropic/claude-sonnet-4-6'", () => {
|
|
193
|
+
aiClient.registerClient("knowhow", new FakeClient());
|
|
194
|
+
aiClient.registerModels("knowhow", [
|
|
195
|
+
"anthropic/claude-sonnet-4-6",
|
|
196
|
+
"openai/gpt-4",
|
|
197
|
+
"google/gemini-pro",
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
// Exact match: provider=knowhow, model=anthropic/claude-sonnet-4-6
|
|
201
|
+
const result = aiClient.detectProviderModel("knowhow", "anthropic/claude-sonnet-4-6");
|
|
202
|
+
expect(result.provider).toBe("knowhow");
|
|
203
|
+
expect(result.model).toBe("anthropic/claude-sonnet-4-6");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("knowhow provider: resolves bare 'claude-sonnet-4-6' to 'anthropic/claude-sonnet-4-6' stored on knowhow", () => {
|
|
207
|
+
aiClient.registerClient("knowhow", new FakeClient());
|
|
208
|
+
aiClient.registerModels("knowhow", [
|
|
209
|
+
"anthropic/claude-sonnet-4-6",
|
|
210
|
+
"openai/gpt-4",
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
// Bare model name — should resolve by stripping the provider prefix from
|
|
214
|
+
// the registered model and matching on the trailing segment
|
|
215
|
+
const result = aiClient.detectProviderModel("knowhow", "claude-sonnet-4-6");
|
|
216
|
+
expect(result.provider).toBe("knowhow");
|
|
217
|
+
expect(result.model).toBe("anthropic/claude-sonnet-4-6");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("fireworks provider: resolves 'kimi-k3' to 'accounts/fireworks/models/kimi-k3'", () => {
|
|
221
|
+
aiClient.registerClient("fireworks", new FakeClient());
|
|
222
|
+
aiClient.registerModels("fireworks", [
|
|
223
|
+
"accounts/fireworks/models/kimi-k3",
|
|
224
|
+
"accounts/fireworks/models/kimi-k2p7-code",
|
|
225
|
+
"accounts/fireworks/models/minimax-m3",
|
|
226
|
+
"accounts/fireworks/models/deepseek-v3-2",
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
const result = aiClient.detectProviderModel("fireworks", "kimi-k3");
|
|
230
|
+
expect(result.provider).toBe("fireworks");
|
|
231
|
+
expect(result.model).toBe("accounts/fireworks/models/kimi-k3");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("fireworks provider: resolves 'accounts/fireworks/models/kimi-k3' (full path) correctly", () => {
|
|
235
|
+
aiClient.registerClient("fireworks", new FakeClient());
|
|
236
|
+
aiClient.registerModels("fireworks", [
|
|
237
|
+
"accounts/fireworks/models/kimi-k3",
|
|
238
|
+
"accounts/fireworks/models/deepseek-v4-pro",
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
// Full path exact match
|
|
242
|
+
const result = aiClient.detectProviderModel("fireworks", "accounts/fireworks/models/kimi-k3");
|
|
243
|
+
expect(result.provider).toBe("fireworks");
|
|
244
|
+
expect(result.model).toBe("accounts/fireworks/models/kimi-k3");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("explicit provider: does NOT leak to a different provider when stripping slashes", () => {
|
|
248
|
+
aiClient.registerClient("knowhow", new FakeClient());
|
|
249
|
+
aiClient.registerModels("knowhow", ["anthropic/claude-sonnet-4-6"]);
|
|
250
|
+
|
|
251
|
+
// 'fake' provider doesn't have this model — should not find it on knowhow
|
|
252
|
+
const result = aiClient.detectProviderModel("fake", "claude-sonnet-4-6");
|
|
253
|
+
// Falls back: provider=fake, model=claude-sonnet-4-6 (no cross-provider leak)
|
|
254
|
+
expect(result.provider).toBe("fake");
|
|
255
|
+
expect(result.model).toBe("claude-sonnet-4-6");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("empty provider: finds 'claude-sonnet-4-6' cross-provider on knowhow (stored as 'anthropic/claude-sonnet-4-6')", () => {
|
|
259
|
+
aiClient.registerClient("knowhow", new FakeClient());
|
|
260
|
+
aiClient.registerModels("knowhow", [
|
|
261
|
+
"anthropic/claude-sonnet-4-6",
|
|
262
|
+
"openai/gpt-4",
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
const result = aiClient.detectProviderModel("", "claude-sonnet-4-6");
|
|
266
|
+
expect(result.provider).toBe("knowhow");
|
|
267
|
+
expect(result.model).toBe("anthropic/claude-sonnet-4-6");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("empty provider: finds 'kimi-k3' cross-provider on fireworks (stored as 'accounts/fireworks/models/kimi-k3')", () => {
|
|
271
|
+
aiClient.registerClient("fireworks", new FakeClient());
|
|
272
|
+
aiClient.registerModels("fireworks", [
|
|
273
|
+
"accounts/fireworks/models/kimi-k3",
|
|
274
|
+
"accounts/fireworks/models/deepseek-v4-pro",
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
const result = aiClient.detectProviderModel("", "kimi-k3");
|
|
278
|
+
expect(result.provider).toBe("fireworks");
|
|
279
|
+
expect(result.model).toBe("accounts/fireworks/models/kimi-k3");
|
|
280
|
+
});
|
|
281
|
+
|
|
190
282
|
it("should detect real provider when model exists", () => {
|
|
191
283
|
// Register an anthropic provider explicitly so we don't rely on env vars
|
|
192
284
|
aiClient.registerClient("anthropic", new FakeClient());
|
|
@@ -442,14 +534,14 @@ describe("AIClient", () => {
|
|
|
442
534
|
});
|
|
443
535
|
|
|
444
536
|
it("should handle empty provider and model strings", () => {
|
|
445
|
-
// Empty
|
|
446
|
-
|
|
447
|
-
expect(
|
|
448
|
-
expect(result.model.length).toBeGreaterThan(0);
|
|
537
|
+
// Empty provider + empty model: detectProviderModel returns the fallback (empty strings)
|
|
538
|
+
// getClient("", "") throws because "" is not a registered provider
|
|
539
|
+
expect(() => aiClient.getClient("", "")).toThrow();
|
|
449
540
|
|
|
450
541
|
const detection = aiClient.detectProviderModel("", "");
|
|
451
|
-
|
|
452
|
-
expect(detection?.
|
|
542
|
+
// Falls back to original values — both empty
|
|
543
|
+
expect(detection?.provider).toBe("");
|
|
544
|
+
expect(detection?.model).toBe("");
|
|
453
545
|
});
|
|
454
546
|
|
|
455
547
|
it("should handle malformed model formats", () => {
|
|
@@ -469,15 +561,6 @@ describe("AIClient", () => {
|
|
|
469
561
|
expect(detection).toBeDefined();
|
|
470
562
|
expect(detection?.provider).toBeDefined();
|
|
471
563
|
expect(detection?.model).toBeDefined();
|
|
472
|
-
// For malformed inputs that can't be parsed, should fallback to defaults
|
|
473
|
-
if (
|
|
474
|
-
input === "provider/" ||
|
|
475
|
-
input === "///" ||
|
|
476
|
-
input === "provider/model/"
|
|
477
|
-
) {
|
|
478
|
-
expect(detection?.provider?.length).toBeGreaterThan(0);
|
|
479
|
-
expect(detection?.model?.length).toBeGreaterThan(0);
|
|
480
|
-
}
|
|
481
564
|
});
|
|
482
565
|
});
|
|
483
566
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
|
|
6
|
+
describe("managed process shim", () => {
|
|
7
|
+
const testOnPosix = process.platform === "win32" ? test.skip : test;
|
|
8
|
+
|
|
9
|
+
testOnPosix("exits after a short command instead of leaking the shim process", async () => {
|
|
10
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-shim-test-"));
|
|
11
|
+
const configPath = path.join(directory, "launch.json");
|
|
12
|
+
const shimModule = path.resolve(__dirname, "../../src/processes/managedProcessShim");
|
|
13
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
14
|
+
id: "short-command",
|
|
15
|
+
command: process.execPath,
|
|
16
|
+
args: ["-e", "process.exit(0)"],
|
|
17
|
+
cwd: directory,
|
|
18
|
+
env: {},
|
|
19
|
+
background: true,
|
|
20
|
+
parentProcessId: null,
|
|
21
|
+
parentPid: null,
|
|
22
|
+
shell: false,
|
|
23
|
+
processesDir: path.dirname(directory),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const script = `require(${JSON.stringify(shimModule)})`
|
|
27
|
+
+ `.runManagedProcessShim(${JSON.stringify(configPath)})`
|
|
28
|
+
+ `.catch(error => { console.error(error); process.exitCode = 1; });`;
|
|
29
|
+
const shim = spawn(process.execPath, ["-r", "ts-node/register/transpile-only", "-e", script], {
|
|
30
|
+
cwd: path.resolve(__dirname, "../.."),
|
|
31
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
32
|
+
});
|
|
33
|
+
let stderr = "";
|
|
34
|
+
shim.stderr!.setEncoding("utf8");
|
|
35
|
+
shim.stderr!.on("data", (chunk) => { stderr += chunk; });
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
shim.kill("SIGKILL");
|
|
41
|
+
reject(new Error(`Shim PID ${shim.pid} did not exit after its command completed`));
|
|
42
|
+
}, 10000);
|
|
43
|
+
shim.once("exit", (code, signal) => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
resolve({ code, signal });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(stderr).toBe("");
|
|
50
|
+
expect(result).toEqual({ code: 0, signal: null });
|
|
51
|
+
const status = JSON.parse(fs.readFileSync(path.join(directory, "status.json"), "utf8"));
|
|
52
|
+
expect(status).toMatchObject({ shimPid: shim.pid, state: "exited", exitCode: 0 });
|
|
53
|
+
expect(() => process.kill(shim.pid!, 0)).toThrow();
|
|
54
|
+
} finally {
|
|
55
|
+
if (shim.exitCode === null && shim.signalCode === null) shim.kill("SIGKILL");
|
|
56
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -220,6 +220,27 @@ describe("Base64ImageDetector", () => {
|
|
|
220
220
|
expect(content[0].image_url.url).toBe(imageDataUrl);
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
it("should not promote a compressed image URL placeholder to image content", () => {
|
|
224
|
+
const toolResponseJson = JSON.stringify({
|
|
225
|
+
type: "image_url",
|
|
226
|
+
image_url: {
|
|
227
|
+
url: "[COMPRESSED_JSON_PROPERTY - 24614 tokens]\nKey: compressed_image_url",
|
|
228
|
+
detail: "high",
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
const modifiedMessages: Message[] = [
|
|
232
|
+
{
|
|
233
|
+
role: "tool",
|
|
234
|
+
content: toolResponseJson,
|
|
235
|
+
tool_call_id: "call_compressed_image",
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
detector.createProcessor()([], modifiedMessages);
|
|
240
|
+
|
|
241
|
+
expect(modifiedMessages[0].content).toBe(toolResponseJson);
|
|
242
|
+
});
|
|
243
|
+
|
|
223
244
|
it("should process tool messages with plain base64 string", () => {
|
|
224
245
|
const validPngBase64 =
|
|
225
246
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
|
@@ -415,6 +415,24 @@ describe("TokenCompressor", () => {
|
|
|
415
415
|
});
|
|
416
416
|
});
|
|
417
417
|
|
|
418
|
+
describe("image content compression", () => {
|
|
419
|
+
it("keeps image URLs intact when expanded", () => {
|
|
420
|
+
const imageUrl = `data:image/png;base64,${"A".repeat(20_000)}`;
|
|
421
|
+
const content = JSON.stringify({
|
|
422
|
+
type: "image_url",
|
|
423
|
+
image_url: { url: imageUrl, detail: "high" },
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const compressed = tokenCompressor.compressContent(content);
|
|
427
|
+
const key = compressed.match(/Key:\s*(\S+)/)?.[1];
|
|
428
|
+
|
|
429
|
+
expect(key).toBeDefined();
|
|
430
|
+
const expanded = tokenCompressor.retrieveFullString(key!);
|
|
431
|
+
expect(expanded).toBe(content);
|
|
432
|
+
expect(expanded).not.toContain("[COMPRESSED_JSON_PROPERTY");
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
418
436
|
describe("tool function integration", () => {
|
|
419
437
|
it("should register expandTokens function correctly", () => {
|
|
420
438
|
const toolsServiceCalls = mockToolsService.addFunctions.mock.calls;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
const registerPublicKey = jest.fn();
|
|
6
|
+
const exchangePublicKeyJwt = jest.fn();
|
|
7
|
+
const storeJwt = jest.fn();
|
|
8
|
+
const getOrCreatePublicKey = jest.fn();
|
|
9
|
+
const getConfig = jest.fn();
|
|
10
|
+
const updateConfig = jest.fn();
|
|
11
|
+
|
|
12
|
+
jest.mock("../../../src/auth/keyAuth", () => ({ registerPublicKey, exchangePublicKeyJwt, storeJwt }));
|
|
13
|
+
jest.mock("../../../src/auth/keyManager", () => ({ getOrCreatePublicKey }));
|
|
14
|
+
jest.mock("../../../src/config", () => ({ getConfig, updateConfig }));
|
|
15
|
+
|
|
16
|
+
import { decodeJwtPayload, extractOrgIdFromJwt, registerIdentity } from "../../../src/auth/registerIdentity";
|
|
17
|
+
|
|
18
|
+
function makeJwt(payload: Record<string, unknown>): string {
|
|
19
|
+
return `${Buffer.from("{}").toString("base64url")}.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.sig`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("registerIdentity", () => {
|
|
23
|
+
const originalCwd = process.cwd();
|
|
24
|
+
let workDir: string;
|
|
25
|
+
const sourceJwt = makeJwt({ org: "org-test" });
|
|
26
|
+
const publicKeyBase64 = Buffer.alloc(32, 5).toString("base64");
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
jest.clearAllMocks();
|
|
30
|
+
jest.spyOn(console, "log").mockImplementation(() => undefined);
|
|
31
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-register-identity-"));
|
|
32
|
+
fs.mkdirSync(path.join(workDir, ".knowhow"), { recursive: true });
|
|
33
|
+
fs.writeFileSync(path.join(workDir, ".knowhow", ".jwt"), sourceJwt);
|
|
34
|
+
process.chdir(workDir);
|
|
35
|
+
getOrCreatePublicKey.mockReturnValue({
|
|
36
|
+
publicKeyBase64,
|
|
37
|
+
fingerprint: "SHA256:test",
|
|
38
|
+
privateKeyPath: path.join(workDir, ".knowhow", "keys", "id_ed25519"),
|
|
39
|
+
});
|
|
40
|
+
registerPublicKey.mockResolvedValue(undefined);
|
|
41
|
+
exchangePublicKeyJwt.mockResolvedValue("identity.jwt.token");
|
|
42
|
+
getConfig.mockResolvedValue({ modelProviders: [] });
|
|
43
|
+
updateConfig.mockResolvedValue(undefined);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
process.chdir(originalCwd);
|
|
48
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
49
|
+
jest.restoreAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("registers from the project JWT and stores the exchanged JWT", async () => {
|
|
53
|
+
await registerIdentity({ apiUrl: "https://api.example" });
|
|
54
|
+
expect(registerPublicKey).toHaveBeenCalledWith(sourceJwt, publicKeyBase64, "https://api.example");
|
|
55
|
+
expect(exchangePublicKeyJwt).toHaveBeenCalledWith("org-test", "https://api.example", undefined);
|
|
56
|
+
expect(storeJwt).toHaveBeenCalledWith("identity.jwt.token");
|
|
57
|
+
expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({
|
|
58
|
+
orgId: "org-test",
|
|
59
|
+
modelProviders: [{ provider: "knowhow" }],
|
|
60
|
+
cliIdentityPath: expect.stringContaining("id_ed25519"),
|
|
61
|
+
}));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("uses an explicitly selected identity", async () => {
|
|
65
|
+
await registerIdentity({ identityPath: "/keys/id", apiUrl: "https://api.example" });
|
|
66
|
+
expect(getOrCreatePublicKey).toHaveBeenCalledWith("/keys/id");
|
|
67
|
+
expect(exchangePublicKeyJwt).toHaveBeenCalledWith("org-test", "https://api.example", "/keys/id");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("requires a nonempty project JWT", async () => {
|
|
71
|
+
fs.writeFileSync(path.join(workDir, ".knowhow", ".jwt"), " ");
|
|
72
|
+
await expect(registerIdentity()).rejects.toThrow("JWT file is empty");
|
|
73
|
+
expect(registerPublicKey).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not replace the JWT when registration fails", async () => {
|
|
77
|
+
registerPublicKey.mockRejectedValue(new Error("registration failed"));
|
|
78
|
+
await expect(registerIdentity()).rejects.toThrow("registration failed");
|
|
79
|
+
expect(storeJwt).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("JWT organization extraction", () => {
|
|
84
|
+
it("decodes payload and accepts canonical org", () => {
|
|
85
|
+
expect(decodeJwtPayload(makeJwt({ org: "one" }))).toMatchObject({ org: "one" });
|
|
86
|
+
expect(extractOrgIdFromJwt(makeJwt({ org: "one" }))).toBe("one");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects malformed and organization-less JWTs", () => {
|
|
90
|
+
expect(() => decodeJwtPayload("invalid")).toThrow("Invalid JWT format");
|
|
91
|
+
expect(() => extractOrgIdFromJwt(makeJwt({ sub: "user" }))).toThrow("organization claim");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { saveHashes } from "../../src/hashes";
|
|
5
|
+
|
|
6
|
+
describe("saveHashes", () => {
|
|
7
|
+
it("creates the .knowhow directory in a fresh checkout", async () => {
|
|
8
|
+
const originalCwd = process.cwd();
|
|
9
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-hashes-"));
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
process.chdir(tempDir);
|
|
13
|
+
await saveHashes({ build: { input: "abc123" } });
|
|
14
|
+
|
|
15
|
+
expect(
|
|
16
|
+
JSON.parse(fs.readFileSync(".knowhow/.hashes.json", "utf8"))
|
|
17
|
+
).toEqual({ build: { input: "abc123" } });
|
|
18
|
+
} finally {
|
|
19
|
+
process.chdir(originalCwd);
|
|
20
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
package/ts_build/package.json
CHANGED
|
@@ -1039,8 +1039,7 @@ let BaseAgent = class BaseAgent {
|
|
|
1039
1039
|
const response = await this.createAgentCompletion({
|
|
1040
1040
|
model,
|
|
1041
1041
|
messages: taskBreakdownMessages,
|
|
1042
|
-
|
|
1043
|
-
tool_choice: "auto",
|
|
1042
|
+
tool_choice: "none",
|
|
1044
1043
|
long_ttl_cache: this.runTime() > 300_000,
|
|
1045
1044
|
...(this.reasoningEffort !== undefined && {
|
|
1046
1045
|
reasoning_effort: this.reasoningEffort,
|
|
@@ -1049,9 +1048,13 @@ let BaseAgent = class BaseAgent {
|
|
|
1049
1048
|
reasoning_summary: this.summarizeReasoning,
|
|
1050
1049
|
}),
|
|
1051
1050
|
});
|
|
1052
|
-
|
|
1051
|
+
const breakdownContent = response.choices
|
|
1052
|
+
.map((c) => c.message.content)
|
|
1053
|
+
.filter((s) => typeof s === "string" && s.trim().length > 0)
|
|
1054
|
+
.join("\n\n");
|
|
1055
|
+
this.taskBreakdown = breakdownContent || "";
|
|
1053
1056
|
this.log(`task breakdown cost: ${response.usd_cost}`);
|
|
1054
|
-
return this.taskBreakdown;
|
|
1057
|
+
return this.taskBreakdown || undefined;
|
|
1055
1058
|
}
|
|
1056
1059
|
async compressMessages(messages) {
|
|
1057
1060
|
let resumeIndex = messages.length - 1;
|
|
@@ -1072,7 +1075,7 @@ let BaseAgent = class BaseAgent {
|
|
|
1072
1075
|
3. Next Steps - what we're about to do next to continue the user's original request.
|
|
1073
1076
|
4. Tasks remaining - what tasks are left from the initial task breakdown.
|
|
1074
1077
|
|
|
1075
|
-
Our initial task breakdown: ${this.taskBreakdown}
|
|
1078
|
+
${this.taskBreakdown ? `Our initial task breakdown: ${this.taskBreakdown}` : ""}
|
|
1076
1079
|
|
|
1077
1080
|
This summary will replace the older history. The latest agent interaction will remain verbatim:
|
|
1078
1081
|
|
|
@@ -1086,18 +1089,20 @@ let BaseAgent = class BaseAgent {
|
|
|
1086
1089
|
];
|
|
1087
1090
|
const response = await this.createAgentCompletion({
|
|
1088
1091
|
messages: compressMessagesPayload,
|
|
1092
|
+
tool_choice: "none",
|
|
1089
1093
|
});
|
|
1090
|
-
const summaries = response.choices
|
|
1094
|
+
const summaries = response.choices
|
|
1095
|
+
.map((c) => c.message.content)
|
|
1096
|
+
.filter((s) => typeof s === "string" && s.trim().length > 0);
|
|
1091
1097
|
this.summaries.push(...summaries);
|
|
1092
1098
|
const startMessages = [
|
|
1093
1099
|
{
|
|
1094
1100
|
role: "user",
|
|
1095
1101
|
content: `
|
|
1096
|
-
Initial task breakdown:
|
|
1097
|
-
${this.taskBreakdown}
|
|
1102
|
+
${this.taskBreakdown ? `Initial task breakdown:\n ${this.taskBreakdown}` : "(No task breakdown available — summarize what you know from context above)"}
|
|
1098
1103
|
|
|
1099
1104
|
We have just compressed the conversation to save memory:
|
|
1100
|
-
${JSON.stringify(summaries)}
|
|
1105
|
+
${summaries.length > 0 ? JSON.stringify(summaries) : "(summary unavailable — please continue from the task breakdown above)"}
|
|
1101
1106
|
|
|
1102
1107
|
Please continue the task from where we left off
|
|
1103
1108
|
`,
|