@tyvm/knowhow 0.0.118 → 0.0.119

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 (123) hide show
  1. package/package.json +1 -3
  2. package/src/agents/base/base.ts +72 -9
  3. package/src/agents/researcher/researcher.ts +9 -2
  4. package/src/agents/tools/list.ts +13 -2
  5. package/src/agents/tools/patch.ts +318 -32
  6. package/src/agents/tools/readFile.ts +48 -5
  7. package/src/chat/modules/AgentModule.ts +12 -0
  8. package/src/cli.ts +2 -0
  9. package/src/clients/anthropic.ts +12 -2
  10. package/src/clients/contextLimits.ts +77 -0
  11. package/src/commands/convert.ts +291 -0
  12. package/src/conversion.ts +15 -61
  13. package/src/index.ts +3 -0
  14. package/src/processors/TokenCompressor.ts +95 -9
  15. package/src/services/AgentSyncFs.ts +26 -4
  16. package/src/services/AgentSyncKnowhowWeb.ts +26 -4
  17. package/src/services/SyncedAgentWatcher.ts +8 -0
  18. package/src/services/conversion/ConversionService.ts +763 -0
  19. package/src/services/conversion/index.ts +2 -0
  20. package/src/services/conversion/types.ts +79 -0
  21. package/src/services/index.ts +8 -1
  22. package/src/services/modules/types.ts +2 -0
  23. package/src/services/watchers/FsSyncer.ts +6 -0
  24. package/src/services/watchers/RemoteSyncer.ts +5 -0
  25. package/tests/agents/tools/readFile.test.ts +88 -0
  26. package/tests/clients/AIClient.test.ts +5 -0
  27. package/tests/clients/contextLimits.test.ts +71 -0
  28. package/tests/patching/patchFileOutput.test.ts +217 -0
  29. package/tests/patching/regression-2026.test.ts +278 -0
  30. package/tests/processors/CustomVariables.test.ts +4 -4
  31. package/tests/processors/TokenCompressor.test.ts +59 -1
  32. package/tests/processors/tools/grepToolResponse.test.ts +72 -0
  33. package/tests/services/ConversionService.test.ts +154 -0
  34. package/tests/test.spec.ts +1 -1
  35. package/tests/unit/clients/AIClient.test.ts +8 -0
  36. package/ts_build/package.json +1 -3
  37. package/ts_build/src/agents/base/base.d.ts +3 -0
  38. package/ts_build/src/agents/base/base.js +46 -3
  39. package/ts_build/src/agents/base/base.js.map +1 -1
  40. package/ts_build/src/agents/researcher/researcher.js +5 -2
  41. package/ts_build/src/agents/researcher/researcher.js.map +1 -1
  42. package/ts_build/src/agents/tools/list.js +10 -2
  43. package/ts_build/src/agents/tools/list.js.map +1 -1
  44. package/ts_build/src/agents/tools/patch.js +202 -24
  45. package/ts_build/src/agents/tools/patch.js.map +1 -1
  46. package/ts_build/src/agents/tools/readFile.d.ts +1 -1
  47. package/ts_build/src/agents/tools/readFile.js +17 -4
  48. package/ts_build/src/agents/tools/readFile.js.map +1 -1
  49. package/ts_build/src/chat/modules/AgentModule.js +12 -0
  50. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  51. package/ts_build/src/cli.js +2 -0
  52. package/ts_build/src/cli.js.map +1 -1
  53. package/ts_build/src/clients/anthropic.js +7 -2
  54. package/ts_build/src/clients/anthropic.js.map +1 -1
  55. package/ts_build/src/clients/contextLimits.js +70 -0
  56. package/ts_build/src/clients/contextLimits.js.map +1 -1
  57. package/ts_build/src/commands/convert.d.ts +2 -0
  58. package/ts_build/src/commands/convert.js +275 -0
  59. package/ts_build/src/commands/convert.js.map +1 -0
  60. package/ts_build/src/conversion.js +6 -38
  61. package/ts_build/src/conversion.js.map +1 -1
  62. package/ts_build/src/index.d.ts +2 -0
  63. package/ts_build/src/index.js +4 -1
  64. package/ts_build/src/index.js.map +1 -1
  65. package/ts_build/src/processors/TokenCompressor.d.ts +2 -0
  66. package/ts_build/src/processors/TokenCompressor.js +57 -7
  67. package/ts_build/src/processors/TokenCompressor.js.map +1 -1
  68. package/ts_build/src/services/AgentSyncFs.d.ts +1 -0
  69. package/ts_build/src/services/AgentSyncFs.js +21 -4
  70. package/ts_build/src/services/AgentSyncFs.js.map +1 -1
  71. package/ts_build/src/services/AgentSyncKnowhowWeb.d.ts +1 -0
  72. package/ts_build/src/services/AgentSyncKnowhowWeb.js +21 -4
  73. package/ts_build/src/services/AgentSyncKnowhowWeb.js.map +1 -1
  74. package/ts_build/src/services/SyncedAgentWatcher.d.ts +3 -0
  75. package/ts_build/src/services/SyncedAgentWatcher.js +4 -0
  76. package/ts_build/src/services/SyncedAgentWatcher.js.map +1 -1
  77. package/ts_build/src/services/conversion/ConversionService.d.ts +18 -0
  78. package/ts_build/src/services/conversion/ConversionService.js +585 -0
  79. package/ts_build/src/services/conversion/ConversionService.js.map +1 -0
  80. package/ts_build/src/services/conversion/index.d.ts +2 -0
  81. package/ts_build/src/services/conversion/index.js +19 -0
  82. package/ts_build/src/services/conversion/index.js.map +1 -0
  83. package/ts_build/src/services/conversion/types.d.ts +49 -0
  84. package/ts_build/src/services/conversion/types.js +3 -0
  85. package/ts_build/src/services/conversion/types.js.map +1 -0
  86. package/ts_build/src/services/index.d.ts +3 -0
  87. package/ts_build/src/services/index.js +6 -1
  88. package/ts_build/src/services/index.js.map +1 -1
  89. package/ts_build/src/services/modules/index.d.ts +2 -0
  90. package/ts_build/src/services/modules/types.d.ts +2 -0
  91. package/ts_build/src/services/watchers/FsSyncer.d.ts +1 -0
  92. package/ts_build/src/services/watchers/FsSyncer.js +5 -0
  93. package/ts_build/src/services/watchers/FsSyncer.js.map +1 -1
  94. package/ts_build/src/services/watchers/RemoteSyncer.d.ts +1 -0
  95. package/ts_build/src/services/watchers/RemoteSyncer.js +4 -0
  96. package/ts_build/src/services/watchers/RemoteSyncer.js.map +1 -1
  97. package/ts_build/tests/agents/tools/readFile.test.d.ts +1 -0
  98. package/ts_build/tests/agents/tools/readFile.test.js +90 -0
  99. package/ts_build/tests/agents/tools/readFile.test.js.map +1 -0
  100. package/ts_build/tests/clients/AIClient.test.js +1 -0
  101. package/ts_build/tests/clients/AIClient.test.js.map +1 -1
  102. package/ts_build/tests/clients/contextLimits.test.d.ts +1 -0
  103. package/ts_build/tests/clients/contextLimits.test.js +57 -0
  104. package/ts_build/tests/clients/contextLimits.test.js.map +1 -0
  105. package/ts_build/tests/patching/patchFileOutput.test.d.ts +1 -0
  106. package/ts_build/tests/patching/patchFileOutput.test.js +187 -0
  107. package/ts_build/tests/patching/patchFileOutput.test.js.map +1 -0
  108. package/ts_build/tests/patching/regression-2026.test.js +214 -0
  109. package/ts_build/tests/patching/regression-2026.test.js.map +1 -1
  110. package/ts_build/tests/processors/CustomVariables.test.js +4 -4
  111. package/ts_build/tests/processors/CustomVariables.test.js.map +1 -1
  112. package/ts_build/tests/processors/TokenCompressor.test.js +37 -1
  113. package/ts_build/tests/processors/TokenCompressor.test.js.map +1 -1
  114. package/ts_build/tests/processors/tools/grepToolResponse.test.d.ts +1 -0
  115. package/ts_build/tests/processors/tools/grepToolResponse.test.js +40 -0
  116. package/ts_build/tests/processors/tools/grepToolResponse.test.js.map +1 -0
  117. package/ts_build/tests/services/ConversionService.test.d.ts +1 -0
  118. package/ts_build/tests/services/ConversionService.test.js +154 -0
  119. package/ts_build/tests/services/ConversionService.test.js.map +1 -0
  120. package/ts_build/tests/test.spec.js +1 -1
  121. package/ts_build/tests/test.spec.js.map +1 -1
  122. package/ts_build/tests/unit/clients/AIClient.test.js +3 -0
  123. package/ts_build/tests/unit/clients/AIClient.test.js.map +1 -1
@@ -280,4 +280,282 @@ describe("Patch Engine - Edge Case Regression Suite", () => {
280
280
  expect(finalApplication).toContain("const c = 4;");
281
281
  });
282
282
  });
283
+
284
+ // --- Test Case 14: Insertion-Before-Anchor Reordering (real session bug) ---
285
+ // Reproduces a bug observed while editing SnapshotManager.ts: adding new lines
286
+ // ABOVE an existing `return snapshot;` (using that return as trailing context)
287
+ // anchored on the PRECEDING context line and inserted the new lines AFTER the
288
+ // return instead of before it. The added log line became unreachable dead code
289
+ // and, in a follow-up edit, the `return` was dropped entirely — a silent
290
+ // control-flow / correctness change. Hunk: insert N lines before a kept line.
291
+ describe("Error Type 14: Insertion-Before-Anchor Reordering", () => {
292
+ it("should insert new lines BEFORE the trailing-context line, not after it", () => {
293
+ const originalFileContent = ` if (rootfsStillExists) {
294
+ return snapshot;
295
+ }
296
+ this.logger.info("missing");
297
+ `;
298
+ // Add a comment + log + KEEP the existing `return snapshot;` as trailing context.
299
+ const insertBeforeReturnPatch = `@@ -1,3 +1,6 @@
300
+ if (rootfsStillExists) {
301
+ + // reusing local overlay
302
+ + this.logger.info("reusing");
303
+ return snapshot;
304
+ }
305
+ `;
306
+
307
+ const fixedPatchOutput = fixPatch(
308
+ originalFileContent,
309
+ insertBeforeReturnPatch
310
+ );
311
+ const finalApplication = applyPatch(
312
+ originalFileContent,
313
+ fixedPatchOutput
314
+ );
315
+
316
+ expect(finalApplication).not.toBe(false);
317
+ const out = finalApplication as string;
318
+ // The kept `return snapshot;` must still be present exactly once.
319
+ expect(out).toContain("return snapshot;");
320
+ // The new log must appear BEFORE the return (otherwise it is unreachable).
321
+ const idxLog = out.indexOf('this.logger.info("reusing")');
322
+ const idxReturn = out.indexOf("return snapshot;");
323
+ expect(idxLog).toBeGreaterThanOrEqual(0);
324
+ expect(idxReturn).toBeGreaterThanOrEqual(0);
325
+ expect(idxLog).toBeLessThan(idxReturn);
326
+ });
327
+ });
328
+
329
+ // --- Test Case 15: Append-After-Function Scope Bleed (real session bug) ---
330
+ // Reproduces a bug observed while editing test-cross-machine-restore.ts: appending
331
+ // a brand-new top-level function right after an existing function's closing brace
332
+ // (using the function body + closing `}` as leading context) caused the new
333
+ // function to be injected INSIDE the existing function's body, between its
334
+ // signature and its `return`, corrupting both functions. The anchor latched onto
335
+ // the wrong `}` (a shared/duplicate brace earlier in the file).
336
+ describe("Error Type 15: Append-After-Function Scope Bleed", () => {
337
+ it("should append a new function AFTER the anchor function's closing brace, leaving it intact", () => {
338
+ const originalFileContent = `function getArg(flag) {
339
+ return idx;
340
+ }
341
+ function hasFlag(flag) {
342
+ return args.includes(flag);
343
+ }
344
+ const x = 1;
345
+ `;
346
+ // Anchor on hasFlag's full body + closing brace, then append a new function.
347
+ const appendFunctionPatch = `@@ -4,3 +4,8 @@
348
+ function hasFlag(flag) {
349
+ return args.includes(flag);
350
+ }
351
+ +
352
+ +function helper(lines) {
353
+ + return lines.some((l) => true);
354
+ +}
355
+ `;
356
+
357
+ const fixedPatchOutput = fixPatch(
358
+ originalFileContent,
359
+ appendFunctionPatch
360
+ );
361
+ const finalApplication = applyPatch(
362
+ originalFileContent,
363
+ fixedPatchOutput
364
+ );
365
+
366
+ expect(finalApplication).not.toBe(false);
367
+ const out = finalApplication as string;
368
+ // The new function must exist.
369
+ expect(out).toContain("function helper(lines)");
370
+ // CRITICAL: hasFlag must remain a contiguous, intact function — the new
371
+ // function must NOT be injected between its signature and its return.
372
+ expect(out).toMatch(
373
+ /function hasFlag\(flag\) \{\s*\n\s*return args\.includes\(flag\);\s*\n\}/
374
+ );
375
+ // helper must come AFTER hasFlag, not before / inside it.
376
+ const idxHasFlag = out.indexOf("function hasFlag");
377
+ const idxHelper = out.indexOf("function helper");
378
+ expect(idxHelper).toBeGreaterThan(idxHasFlag);
379
+ });
380
+ });
381
+
382
+ // --- Test Case 16: Block-Replace-And-Append (try-block placement + new methods) ---
383
+ // Reproduces a bug where a patch that:
384
+ // (a) removes a large inline body from inside a try{} block and replaces it with
385
+ // a single call, AND
386
+ // (b) appends brand-new methods after the method's closing brace
387
+ // was auto-corrected incorrectly:
388
+ // - The replacement call landed inside the `catch (error) {` block instead of
389
+ // inside the `try {` block where the removed code lived.
390
+ // - The appended new methods were silently dropped entirely.
391
+ describe("Error Type 16: Block-Replace-And-Append (try-block placement + append)", () => {
392
+ it("should place the replacement call inside the try block (before catch) and preserve appended new methods", () => {
393
+ // Source mirrors the original onSessionStop method before refactoring.
394
+ const originalFileContent = [
395
+ " /**",
396
+ " * Finalize billing when a session stops",
397
+ " */",
398
+ " async onSessionStop(sessionKey: string): Promise<void> {",
399
+ " try {",
400
+ " const event = await prisma.cloudBillingEvent.findUnique({",
401
+ " where: { sessionKey },",
402
+ " });",
403
+ "",
404
+ " if (!event) {",
405
+ " this.logger.warn(",
406
+ " `[CloudBilling] No billing event found for session: ${sessionKey}`",
407
+ " );",
408
+ " return;",
409
+ " }",
410
+ "",
411
+ " if (event.status !== \"running\") {",
412
+ " this.logger.info(",
413
+ " `[CloudBilling] Session ${sessionKey} already billed (status: ${event.status})`",
414
+ " );",
415
+ " return;",
416
+ " }",
417
+ "",
418
+ " const stoppedAt = new Date();",
419
+ " const uptimeMs = stoppedAt.getTime() - event.startedAt.getTime();",
420
+ " const uptimeMinutes = uptimeMs / 1000 / 60;",
421
+ " const ratePerMinute = parseSandboxSpecRate(event.serverSpec) ?? RATE;",
422
+ " const costUsd = uptimeMinutes * ratePerMinute;",
423
+ "",
424
+ " await prisma.cloudBillingEvent.update({",
425
+ " where: { sessionKey },",
426
+ " data: {",
427
+ " stoppedAt,",
428
+ " uptimeMinutes,",
429
+ " costUsd,",
430
+ " status: \"billed\",",
431
+ " billedAt: new Date(),",
432
+ " },",
433
+ " });",
434
+ "",
435
+ " const result = await this.usageService.deductCredits(",
436
+ " event.orgId,",
437
+ " costUsd,",
438
+ " \"cloud\",",
439
+ " undefined,",
440
+ " event.orgUserId ?? undefined",
441
+ " );",
442
+ "",
443
+ " await this.usageService.recordUsage(",
444
+ " event.orgId,",
445
+ " \"cloud\",",
446
+ " event.serverSpec,",
447
+ " costUsd,",
448
+ " event.orgUserId ?? undefined,",
449
+ " \"cloud\",",
450
+ " result.fundedFrom",
451
+ " );",
452
+ "",
453
+ " this.logger.info(",
454
+ " `[CloudBilling] Session ${sessionKey} billed`",
455
+ " );",
456
+ " } catch (error) {",
457
+ " this.logger.error(",
458
+ " `[CloudBilling] Failed to bill session ${sessionKey}:`,",
459
+ " error",
460
+ " );",
461
+ " }",
462
+ " }",
463
+ ].join("\n");
464
+
465
+ // Patch: remove the inline finalize body, replace with single call,
466
+ // AND append two new methods after the closing brace.
467
+ const refactorPatch = [
468
+ "@@ -19,43 +19,7 @@",
469
+ "",
470
+ " if (event.status !== \"running\") {",
471
+ " this.logger.info(",
472
+ " `[CloudBilling] Session ${sessionKey} already billed (status: ${event.status})`",
473
+ " );",
474
+ " return;",
475
+ " }",
476
+ "",
477
+ "- const stoppedAt = new Date();",
478
+ "- const uptimeMs = stoppedAt.getTime() - event.startedAt.getTime();",
479
+ "- const uptimeMinutes = uptimeMs / 1000 / 60;",
480
+ "- const ratePerMinute = parseSandboxSpecRate(event.serverSpec) ?? RATE;",
481
+ "- const costUsd = uptimeMinutes * ratePerMinute;",
482
+ "-",
483
+ "- await prisma.cloudBillingEvent.update({",
484
+ "- where: { sessionKey },",
485
+ "- data: {",
486
+ "- stoppedAt,",
487
+ "- uptimeMinutes,",
488
+ "- costUsd,",
489
+ "- status: \"billed\",",
490
+ "- billedAt: new Date(),",
491
+ "- },",
492
+ "- });",
493
+ "-",
494
+ "- const result = await this.usageService.deductCredits(",
495
+ "- event.orgId,",
496
+ "- costUsd,",
497
+ "- \"cloud\",",
498
+ "- undefined,",
499
+ "- event.orgUserId ?? undefined",
500
+ "- );",
501
+ "-",
502
+ "- await this.usageService.recordUsage(",
503
+ "- event.orgId,",
504
+ "- \"cloud\",",
505
+ "- event.serverSpec,",
506
+ "- costUsd,",
507
+ "- event.orgUserId ?? undefined,",
508
+ "- \"cloud\",",
509
+ "- result.fundedFrom",
510
+ "- );",
511
+ "-",
512
+ "- this.logger.info(",
513
+ "- `[CloudBilling] Session ${sessionKey} billed`",
514
+ "- );",
515
+ "+ await this._finalizeEvent(event);",
516
+ " } catch (error) {",
517
+ " this.logger.error(",
518
+ " `[CloudBilling] Failed to bill session ${sessionKey}:`,",
519
+ "@@ -62,3 +26,19 @@",
520
+ " }",
521
+ " }",
522
+ "+",
523
+ "+ async onSessionStopByResource(",
524
+ "+ resourceType: string,",
525
+ "+ resourceId: string",
526
+ "+ ): Promise<void> {",
527
+ "+ try {",
528
+ "+ this.logger.info(`[CloudBilling] stop by resource ${resourceType}/${resourceId}`);",
529
+ "+ } catch (error) {",
530
+ "+ this.logger.error(`[CloudBilling] Failed:`, error);",
531
+ "+ }",
532
+ "+ }",
533
+ "+",
534
+ "+ private async _finalizeEvent(event: { id: string; sessionKey: string; status: string; startedAt: Date; serverSpec: string; orgId: string; orgUserId: string | null; }): Promise<void> {",
535
+ "+ this.logger.info(`[CloudBilling] finalizing ${event.sessionKey}`);",
536
+ "+ }",
537
+ ].join("\n");
538
+
539
+ const fixedPatchOutput = fixPatch(originalFileContent, refactorPatch);
540
+ const finalApplication = applyPatch(originalFileContent, fixedPatchOutput);
541
+
542
+ expect(finalApplication).not.toBe(false);
543
+ const out = finalApplication as string;
544
+
545
+ // (1) The replacement call must appear BEFORE the catch block (inside the try block).
546
+ const idxFinalizeCall = out.indexOf("await this._finalizeEvent(event);");
547
+ const idxCatch = out.indexOf("} catch (error) {");
548
+ expect(idxFinalizeCall).toBeGreaterThanOrEqual(0);
549
+ expect(idxCatch).toBeGreaterThanOrEqual(0);
550
+ expect(idxFinalizeCall).toBeLessThan(idxCatch);
551
+
552
+ // (2) The catch block should only contain the logger.error, NOT _finalizeEvent.
553
+ const catchBlock = out.slice(idxCatch);
554
+ expect(catchBlock).not.toContain("await this._finalizeEvent(event);");
555
+
556
+ // (3) The appended new methods must be present in the output.
557
+ expect(out).toContain("async onSessionStopByResource(");
558
+ expect(out).toContain("private async _finalizeEvent(");
559
+ });
560
+ });
283
561
  });
@@ -826,7 +826,7 @@ describe("CustomVariables", () => {
826
826
  },
827
827
  ];
828
828
 
829
- const processor = customVariables.createRepetitionHintProcessor({ minLength: 50, minRepetitions: 2 });
829
+ const processor = customVariables.createRepetitionHintProcessor({ minLength: 50, minRepetitions: 2, hintMessageTokens: 0 });
830
830
  const modified = JSON.parse(JSON.stringify(messages));
831
831
  await processor(messages, modified);
832
832
 
@@ -901,7 +901,7 @@ describe("CustomVariables", () => {
901
901
  tool_calls: [{ id: "c3", type: "function", function: { name: "toolZ", arguments: JSON.stringify({ auth: longString }) } }],
902
902
  },
903
903
  ];
904
- const processor = customVariables.createRepetitionHintProcessor({ minLength: 50, minRepetitions: 2 });
904
+ const processor = customVariables.createRepetitionHintProcessor({ minLength: 50, minRepetitions: 2, hintMessageTokens: 0 });
905
905
  const modified = JSON.parse(JSON.stringify(messages));
906
906
  await processor(messages, modified);
907
907
  const hint = modified[modified.length - 1];
@@ -911,7 +911,7 @@ describe("CustomVariables", () => {
911
911
  });
912
912
 
913
913
  it("should use default options when none provided", async () => {
914
- const longString = "a".repeat(60); // > 50 default minLength
914
+ const longString = "a".repeat(800); // long enough to produce net positive token savings with default hintMessageTokens
915
915
  const messages: Message[] = [
916
916
  {
917
917
  role: "assistant",
@@ -955,7 +955,7 @@ describe("CustomVariables", () => {
955
955
  },
956
956
  ];
957
957
 
958
- const processor = customVariables.createRepetitionHintProcessor({ minLength: 50, minRepetitions: 2, minSubstringLength: 50 });
958
+ const processor = customVariables.createRepetitionHintProcessor({ minLength: 50, minRepetitions: 2, minSubstringLength: 50, hintMessageTokens: 0 });
959
959
  const modified = JSON.parse(JSON.stringify(messages));
960
960
  await processor(messages, modified);
961
961
 
@@ -105,7 +105,9 @@ describe("TokenCompressor", () => {
105
105
  });
106
106
 
107
107
  it("should create chain of NEXT_CHUNK_KEY references", () => {
108
- const content = "a".repeat(20000);
108
+ // Large enough to require multiple full-size chunks even after the
109
+ // minimum-chunk-size merge (characterLimit is 16000, minChunk is 8000).
110
+ const content = "a".repeat(40000);
109
111
  tokenCompressor.compressStringInChunks(content);
110
112
 
111
113
  const keys = tokenCompressor.getStorageKeys();
@@ -115,6 +117,22 @@ describe("TokenCompressor", () => {
115
117
  const firstChunk = tokenCompressor.retrieveString(keys[0]);
116
118
  expect(firstChunk).toContain("NEXT_CHUNK_KEY");
117
119
  });
120
+
121
+ it("should merge a small leftover chunk so no tiny chunks are emitted", () => {
122
+ // characterLimit is 16000; a 20000-char input would naively split into a
123
+ // 16000-char chunk and a tiny 4000-char leading chunk. The leftover is
124
+ // below the minimum chunk size, so it should be merged into a single chunk.
125
+ const content = "a".repeat(20000);
126
+ tokenCompressor.compressStringInChunks(content);
127
+
128
+ const keys = tokenCompressor.getStorageKeys();
129
+ expect(keys.length).toBe(1);
130
+
131
+ const onlyChunk = tokenCompressor.retrieveString(keys[0]);
132
+ expect(onlyChunk).not.toContain("NEXT_CHUNK_KEY");
133
+ // The full content should be retrievable from the single chunk.
134
+ expect(tokenCompressor.retrieveFullString(keys[0])).toBe(content);
135
+ });
118
136
  });
119
137
 
120
138
  describe("compressJsonProperties", () => {
@@ -316,6 +334,46 @@ describe("TokenCompressor", () => {
316
334
  expect(result).toContain("Error: No data found for key");
317
335
  expect(result).toContain("Available keys:");
318
336
  });
337
+
338
+ it("should auto-stitch a multi-chunk chain into the full content", () => {
339
+ // Force a multi-chunk compression then expand the first key.
340
+ const content = "a".repeat(40000);
341
+ const placeholder = tokenCompressor.compressStringInChunks(content);
342
+ const firstKey = placeholder.match(/Key:\s*(\S+)/)[1];
343
+
344
+ const toolsServiceCalls = mockToolsService.addFunctions.mock.calls;
345
+ const functions = toolsServiceCalls[0][0];
346
+ const result = functions.expandTokens(firstKey);
347
+
348
+ // Full content is returned in one call with no chunk-linking markers.
349
+ expect(result).toBe(content);
350
+ expect(result).not.toContain("NEXT_CHUNK_KEY");
351
+ });
352
+
353
+ it("should support a ranged read with real line numbers", () => {
354
+ const key = "ranged_key";
355
+ const value = ["line one", "line two", "line three", "line four"].join(
356
+ "\n"
357
+ );
358
+ tokenCompressor.storeString(key, value);
359
+
360
+ const toolsServiceCalls = mockToolsService.addFunctions.mock.calls;
361
+ const functions = toolsServiceCalls[0][0];
362
+ const result = functions.expandTokens(key, 2, 3);
363
+
364
+ expect(result).toBe("2: line two\n3: line three");
365
+ });
366
+
367
+ it("should error on an invalid line range", () => {
368
+ const key = "bad_range_key";
369
+ tokenCompressor.storeString(key, "a\nb\nc");
370
+
371
+ const toolsServiceCalls = mockToolsService.addFunctions.mock.calls;
372
+ const functions = toolsServiceCalls[0][0];
373
+ const result = functions.expandTokens(key, 5, 2);
374
+
375
+ expect(result).toContain("Error: Invalid line range");
376
+ });
319
377
  });
320
378
 
321
379
  describe("edge cases", () => {
@@ -0,0 +1,72 @@
1
+ import { executeGrep } from "../../../src/processors/tools/grepToolResponse";
2
+
3
+ /**
4
+ * Verifies grepToolResponse operates on the decompressed/plain stored content
5
+ * and returns REAL source line numbers. This is the core feedback win: now that
6
+ * readFile returns plain text (no unified-diff wrapper), a grep hit maps straight
7
+ * back to an editable source location.
8
+ */
9
+ describe("executeGrep", () => {
10
+ const toolCallId = "call_grep_test";
11
+
12
+ // Plain source content as readFile now returns it (no Index:/@@/+ wrapper).
13
+ const fileContent = [
14
+ "import { foo } from './foo';", // line 1
15
+ "", // line 2
16
+ "export class CloudBillingService {", // line 3
17
+ " async chargeCredits(amount: number) {", // line 4
18
+ " return amount;", // line 5
19
+ " }", // line 6
20
+ "}", // line 7
21
+ ].join("\n");
22
+
23
+ it("returns matches with real 1-based source line numbers", async () => {
24
+ const result = await executeGrep(
25
+ fileContent,
26
+ toolCallId,
27
+ "chargeCredits",
28
+ [toolCallId]
29
+ );
30
+
31
+ // The match is on source line 4 and must be reported as such.
32
+ expect(result).toContain("> 4: ");
33
+ expect(result).toContain("async chargeCredits(amount: number) {");
34
+ // No diff-prefix noise should be present.
35
+ expect(result).not.toContain("+import");
36
+ expect(result).not.toContain("Index:");
37
+ });
38
+
39
+ it("includes surrounding context with correct line numbers", async () => {
40
+ const result = await executeGrep(
41
+ fileContent,
42
+ toolCallId,
43
+ "chargeCredits",
44
+ [toolCallId],
45
+ { contextBefore: 1, contextAfter: 1 }
46
+ );
47
+
48
+ expect(result).toContain(" 3: export class CloudBillingService {");
49
+ expect(result).toContain("> 4: ");
50
+ expect(result).toContain(" 5: return amount;");
51
+ });
52
+
53
+ it("returns a helpful error when no response is stored", async () => {
54
+ const result = await executeGrep("", toolCallId, "anything", [
55
+ "other_call",
56
+ ]);
57
+
58
+ expect(result).toContain("No tool response found");
59
+ expect(result).toContain("other_call");
60
+ });
61
+
62
+ it("reports when there are no matches", async () => {
63
+ const result = await executeGrep(
64
+ fileContent,
65
+ toolCallId,
66
+ "doesNotExistAnywhere",
67
+ [toolCallId]
68
+ );
69
+
70
+ expect(result).toContain("No matches found");
71
+ });
72
+ });
@@ -0,0 +1,154 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { ConversionService } from "../../src/services/conversion/ConversionService";
5
+ import { Converter, ConvertInput, ConverterContext, ConvertResult } from "../../src/services/conversion/types";
6
+
7
+ // Minimal stubs
8
+ const stubClients = {} as any;
9
+ const stubMediaProcessor = {
10
+ processAudio: async () => ["chunk1", "chunk2"],
11
+ } as any;
12
+
13
+ function makeService() {
14
+ return new ConversionService(stubClients, stubMediaProcessor);
15
+ }
16
+
17
+ describe("ConversionService", () => {
18
+ describe("register / list", () => {
19
+ it("should register and list converters", () => {
20
+ const svc = makeService();
21
+ const initial = svc.list().length;
22
+ const conv: Converter = {
23
+ name: "fake-pdf-to-text",
24
+ inputExts: ["pdf"],
25
+ outputType: "text",
26
+ convert: async () => ({ outputType: "text", text: "hello" }),
27
+ };
28
+ svc.register(conv);
29
+ expect(svc.list().length).toBe(initial + 1);
30
+ expect(svc.list().find((c) => c.name === "fake-pdf-to-text")).toBeDefined();
31
+ });
32
+ });
33
+
34
+ describe("convert - path composition", () => {
35
+ it("should chain pdf->image + image->text converters to produce text from a pdf", async () => {
36
+ const svc = makeService();
37
+
38
+ // Create a temp pdf-like file
39
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "conv-test-"));
40
+ const pdfPath = path.join(tmpDir, "test.pdf");
41
+ fs.writeFileSync(pdfPath, "fake pdf content");
42
+
43
+ // Register pdf -> image converter
44
+ const pdfToImage: Converter = {
45
+ name: "fake-pdf-to-image",
46
+ inputExts: ["pdf"],
47
+ outputType: "image",
48
+ convert: async (input: ConvertInput, _ctx: ConverterContext): Promise<ConvertResult> => {
49
+ const imgPath = path.join(tmpDir, "page.png");
50
+ fs.writeFileSync(imgPath, "fake image bytes");
51
+ return { outputType: "image", files: [imgPath] };
52
+ },
53
+ };
54
+
55
+ // Register image -> text converter
56
+ const imageToText: Converter = {
57
+ name: "fake-image-to-text",
58
+ inputModality: "image",
59
+ outputType: "text",
60
+ convert: async (_input: ConvertInput, _ctx: ConverterContext): Promise<ConvertResult> => {
61
+ return { outputType: "text", text: "extracted text from image" };
62
+ },
63
+ };
64
+
65
+ svc.register(pdfToImage);
66
+ svc.register(imageToText);
67
+
68
+ const result = await svc.convert(pdfPath, "text", { force: true });
69
+ expect(result.outputType).toBe("text");
70
+ expect(result.text).toBe("extracted text from image");
71
+
72
+ // cleanup
73
+ fs.rmSync(tmpDir, { recursive: true, force: true });
74
+ });
75
+ });
76
+
77
+ describe("quality gate fallthrough", () => {
78
+ it("should fall through to a second converter when isGoodEnough returns false for the first", async () => {
79
+ const svc = makeService();
80
+
81
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "conv-qg-"));
82
+ const pdfPath = path.join(tmpDir, "test.pdf");
83
+ // Write a file > 500KB so the default quality gate fires
84
+ fs.writeFileSync(pdfPath, Buffer.alloc(600 * 1024, "x"));
85
+
86
+ let firstCalled = false;
87
+ let secondCalled = false;
88
+
89
+ // First converter returns bad text (short)
90
+ const badConverter: Converter = {
91
+ name: "bad-pdf-converter",
92
+ inputExts: ["pdf"],
93
+ outputType: "text",
94
+ convert: async (): Promise<ConvertResult> => {
95
+ firstCalled = true;
96
+ return { outputType: "text", text: "short" }; // < 50 chars, file > 500KB -> fails quality gate
97
+ },
98
+ };
99
+
100
+ // Second converter returns good text
101
+ const goodConverter: Converter = {
102
+ name: "good-pdf-converter",
103
+ inputExts: ["pdf"],
104
+ outputType: "text",
105
+ convert: async (): Promise<ConvertResult> => {
106
+ secondCalled = true;
107
+ return { outputType: "text", text: "a".repeat(100) };
108
+ },
109
+ };
110
+
111
+ svc.register(badConverter);
112
+ svc.register(goodConverter);
113
+
114
+ const result = await svc.convert(pdfPath, "text", { force: true });
115
+
116
+ expect(firstCalled).toBe(true);
117
+ expect(secondCalled).toBe(true);
118
+ expect(result.text).toBe("a".repeat(100));
119
+
120
+ fs.rmSync(tmpDir, { recursive: true, force: true });
121
+ });
122
+ });
123
+
124
+ describe("convertToText", () => {
125
+ it("should return text string from text passthrough for a plain text file", async () => {
126
+ const svc = makeService();
127
+
128
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "conv-txt-"));
129
+ const txtPath = path.join(tmpDir, "hello.txt");
130
+ fs.writeFileSync(txtPath, "hello world");
131
+
132
+ const text = await svc.convertToText(txtPath, { force: true });
133
+ expect(text).toBe("hello world");
134
+
135
+ fs.rmSync(tmpDir, { recursive: true, force: true });
136
+ });
137
+ });
138
+
139
+ describe("startLine/endLine slicing", () => {
140
+ it("should slice text output by line range", async () => {
141
+ const svc = makeService();
142
+
143
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "conv-slice-"));
144
+ const txtPath = path.join(tmpDir, "lines.txt");
145
+ fs.writeFileSync(txtPath, "line1\nline2\nline3\nline4\nline5");
146
+
147
+ const text = await svc.convertToText(txtPath, { force: true, startLine: 2, endLine: 4 });
148
+ const lines = text.split("\n");
149
+ expect(lines).toEqual(["line2", "line3", "line4"]);
150
+
151
+ fs.rmSync(tmpDir, { recursive: true, force: true });
152
+ });
153
+ });
154
+ });
@@ -136,7 +136,7 @@ describe("Agent Tools Tests", () => {
136
136
  const result = await patchFile(filePath, patch);
137
137
 
138
138
  // Verify the function returns a success message
139
- expect(result).toContain("Patch applied successfully");
139
+ expect(result).toContain("Patch applied to");
140
140
  }, 60000); // Increase timeout to 60 seconds
141
141
 
142
142
  test("execCommand should execute a system command and return its output", async () => {
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  // Prevent real _initDefaultProviders from firing (it reads env vars / files)
12
+ // resolveClient returning null causes all DEFAULT_PROVIDERS to be skipped,
12
13
  jest.mock("../../../src/config", () => ({
13
14
  getConfig: jest.fn().mockResolvedValue({ modules: [] }),
14
15
  getGlobalConfig: jest.fn().mockResolvedValue({ modules: [] }),
@@ -20,6 +21,13 @@ jest.mock("../../../src/services/KnowhowClient", () => ({
20
21
  }));
21
22
 
22
23
  import { AIClient } from "../../../src/clients/index";
24
+
25
+ // Mock resolveClient AFTER import so the prototype exists.
26
+ // Returning null causes registerModelProviders to skip all DEFAULT_PROVIDERS,
27
+ // preventing any real HTTP calls to provider model endpoints.
28
+ beforeAll(() => {
29
+ jest.spyOn(AIClient.prototype as any, "resolveClient").mockReturnValue(null);
30
+ });
23
31
  import type { GenericClient } from "../../../src/clients/types";
24
32
 
25
33
  // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tyvm/knowhow",
3
- "version": "0.0.118",
3
+ "version": "0.0.119",
4
4
  "description": "ai cli with plugins and agents",
5
5
  "main": "ts_build/src/index.js",
6
6
  "bin": {
@@ -40,7 +40,6 @@
40
40
  "@types/jest": "^29.5.13",
41
41
  "@types/mocha": "^10.0.8",
42
42
  "@types/node": "^20.6.3",
43
- "@types/pdf-parse": "^1.1.4",
44
43
  "@types/ws": "^8.18.1",
45
44
  "jest": "^29.1.1",
46
45
  "prettier": "2.6.2",
@@ -67,7 +66,6 @@
67
66
  "minimatch": "^10.1.2",
68
67
  "node-jq": "^6.0.1",
69
68
  "openai": "4.89.1",
70
- "pdf-parse": "^1.1.1",
71
69
  "ws": "^8.18.1",
72
70
  "zod": "^3.25.0"
73
71
  },