@tyvm/knowhow 0.0.118 → 0.0.120

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 (126) 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/CustomVariables.ts +45 -20
  15. package/src/processors/TokenCompressor.ts +95 -9
  16. package/src/services/AgentSyncFs.ts +26 -4
  17. package/src/services/AgentSyncKnowhowWeb.ts +26 -4
  18. package/src/services/SyncedAgentWatcher.ts +8 -0
  19. package/src/services/conversion/ConversionService.ts +763 -0
  20. package/src/services/conversion/index.ts +2 -0
  21. package/src/services/conversion/types.ts +79 -0
  22. package/src/services/index.ts +8 -1
  23. package/src/services/modules/types.ts +2 -0
  24. package/src/services/watchers/FsSyncer.ts +6 -0
  25. package/src/services/watchers/RemoteSyncer.ts +5 -0
  26. package/tests/agents/tools/readFile.test.ts +88 -0
  27. package/tests/clients/AIClient.test.ts +5 -0
  28. package/tests/clients/contextLimits.test.ts +71 -0
  29. package/tests/patching/patchFileOutput.test.ts +217 -0
  30. package/tests/patching/regression-2026.test.ts +278 -0
  31. package/tests/processors/CustomVariables.test.ts +4 -4
  32. package/tests/processors/TokenCompressor.test.ts +59 -1
  33. package/tests/processors/tools/grepToolResponse.test.ts +72 -0
  34. package/tests/services/ConversionService.test.ts +154 -0
  35. package/tests/test.spec.ts +1 -1
  36. package/tests/unit/clients/AIClient.test.ts +8 -0
  37. package/ts_build/package.json +1 -3
  38. package/ts_build/src/agents/base/base.d.ts +3 -0
  39. package/ts_build/src/agents/base/base.js +46 -3
  40. package/ts_build/src/agents/base/base.js.map +1 -1
  41. package/ts_build/src/agents/researcher/researcher.js +5 -2
  42. package/ts_build/src/agents/researcher/researcher.js.map +1 -1
  43. package/ts_build/src/agents/tools/list.js +10 -2
  44. package/ts_build/src/agents/tools/list.js.map +1 -1
  45. package/ts_build/src/agents/tools/patch.js +202 -24
  46. package/ts_build/src/agents/tools/patch.js.map +1 -1
  47. package/ts_build/src/agents/tools/readFile.d.ts +1 -1
  48. package/ts_build/src/agents/tools/readFile.js +17 -4
  49. package/ts_build/src/agents/tools/readFile.js.map +1 -1
  50. package/ts_build/src/chat/modules/AgentModule.js +12 -0
  51. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  52. package/ts_build/src/cli.js +2 -0
  53. package/ts_build/src/cli.js.map +1 -1
  54. package/ts_build/src/clients/anthropic.js +7 -2
  55. package/ts_build/src/clients/anthropic.js.map +1 -1
  56. package/ts_build/src/clients/contextLimits.js +70 -0
  57. package/ts_build/src/clients/contextLimits.js.map +1 -1
  58. package/ts_build/src/commands/convert.d.ts +2 -0
  59. package/ts_build/src/commands/convert.js +275 -0
  60. package/ts_build/src/commands/convert.js.map +1 -0
  61. package/ts_build/src/conversion.js +6 -38
  62. package/ts_build/src/conversion.js.map +1 -1
  63. package/ts_build/src/index.d.ts +2 -0
  64. package/ts_build/src/index.js +4 -1
  65. package/ts_build/src/index.js.map +1 -1
  66. package/ts_build/src/processors/CustomVariables.js +14 -12
  67. package/ts_build/src/processors/CustomVariables.js.map +1 -1
  68. package/ts_build/src/processors/TokenCompressor.d.ts +2 -0
  69. package/ts_build/src/processors/TokenCompressor.js +57 -7
  70. package/ts_build/src/processors/TokenCompressor.js.map +1 -1
  71. package/ts_build/src/services/AgentSyncFs.d.ts +1 -0
  72. package/ts_build/src/services/AgentSyncFs.js +21 -4
  73. package/ts_build/src/services/AgentSyncFs.js.map +1 -1
  74. package/ts_build/src/services/AgentSyncKnowhowWeb.d.ts +1 -0
  75. package/ts_build/src/services/AgentSyncKnowhowWeb.js +21 -4
  76. package/ts_build/src/services/AgentSyncKnowhowWeb.js.map +1 -1
  77. package/ts_build/src/services/SyncedAgentWatcher.d.ts +3 -0
  78. package/ts_build/src/services/SyncedAgentWatcher.js +4 -0
  79. package/ts_build/src/services/SyncedAgentWatcher.js.map +1 -1
  80. package/ts_build/src/services/conversion/ConversionService.d.ts +18 -0
  81. package/ts_build/src/services/conversion/ConversionService.js +585 -0
  82. package/ts_build/src/services/conversion/ConversionService.js.map +1 -0
  83. package/ts_build/src/services/conversion/index.d.ts +2 -0
  84. package/ts_build/src/services/conversion/index.js +19 -0
  85. package/ts_build/src/services/conversion/index.js.map +1 -0
  86. package/ts_build/src/services/conversion/types.d.ts +49 -0
  87. package/ts_build/src/services/conversion/types.js +3 -0
  88. package/ts_build/src/services/conversion/types.js.map +1 -0
  89. package/ts_build/src/services/index.d.ts +3 -0
  90. package/ts_build/src/services/index.js +6 -1
  91. package/ts_build/src/services/index.js.map +1 -1
  92. package/ts_build/src/services/modules/index.d.ts +2 -0
  93. package/ts_build/src/services/modules/types.d.ts +2 -0
  94. package/ts_build/src/services/watchers/FsSyncer.d.ts +1 -0
  95. package/ts_build/src/services/watchers/FsSyncer.js +5 -0
  96. package/ts_build/src/services/watchers/FsSyncer.js.map +1 -1
  97. package/ts_build/src/services/watchers/RemoteSyncer.d.ts +1 -0
  98. package/ts_build/src/services/watchers/RemoteSyncer.js +4 -0
  99. package/ts_build/src/services/watchers/RemoteSyncer.js.map +1 -1
  100. package/ts_build/tests/agents/tools/readFile.test.d.ts +1 -0
  101. package/ts_build/tests/agents/tools/readFile.test.js +90 -0
  102. package/ts_build/tests/agents/tools/readFile.test.js.map +1 -0
  103. package/ts_build/tests/clients/AIClient.test.js +1 -0
  104. package/ts_build/tests/clients/AIClient.test.js.map +1 -1
  105. package/ts_build/tests/clients/contextLimits.test.d.ts +1 -0
  106. package/ts_build/tests/clients/contextLimits.test.js +57 -0
  107. package/ts_build/tests/clients/contextLimits.test.js.map +1 -0
  108. package/ts_build/tests/patching/patchFileOutput.test.d.ts +1 -0
  109. package/ts_build/tests/patching/patchFileOutput.test.js +187 -0
  110. package/ts_build/tests/patching/patchFileOutput.test.js.map +1 -0
  111. package/ts_build/tests/patching/regression-2026.test.js +214 -0
  112. package/ts_build/tests/patching/regression-2026.test.js.map +1 -1
  113. package/ts_build/tests/processors/CustomVariables.test.js +4 -4
  114. package/ts_build/tests/processors/CustomVariables.test.js.map +1 -1
  115. package/ts_build/tests/processors/TokenCompressor.test.js +37 -1
  116. package/ts_build/tests/processors/TokenCompressor.test.js.map +1 -1
  117. package/ts_build/tests/processors/tools/grepToolResponse.test.d.ts +1 -0
  118. package/ts_build/tests/processors/tools/grepToolResponse.test.js +40 -0
  119. package/ts_build/tests/processors/tools/grepToolResponse.test.js.map +1 -0
  120. package/ts_build/tests/services/ConversionService.test.d.ts +1 -0
  121. package/ts_build/tests/services/ConversionService.test.js +154 -0
  122. package/ts_build/tests/services/ConversionService.test.js.map +1 -0
  123. package/ts_build/tests/test.spec.js +1 -1
  124. package/ts_build/tests/test.spec.js.map +1 -1
  125. package/ts_build/tests/unit/clients/AIClient.test.js +3 -0
  126. package/ts_build/tests/unit/clients/AIClient.test.js.map +1 -1
@@ -220,21 +220,26 @@ export class CustomVariables {
220
220
 
221
221
  /**
222
222
  * Creates a message processor function that substitutes variables in messages
223
+ *
224
+ * CACHING NOTE: To preserve Anthropic prefix-cache stability, we only apply
225
+ * variable substitution to the *last* (most-recently-added) message in the
226
+ * modifiedMessages array. Historical messages have already been processed on
227
+ * prior turns; mutating them would change their content mid-conversation and
228
+ * instantly bust the entire downstream cache window.
223
229
  */
224
230
  createProcessor(
225
231
  filterFn?: (msg: Message) => boolean
226
232
  ): MessageProcessorFunction {
227
233
  return async (originalMessages: Message[], modifiedMessages: Message[]) => {
228
- // Process messages in place - substitute variables before tool calls are executed
229
- for (let i = 0; i < modifiedMessages.length; i++) {
230
- const message = modifiedMessages[i];
231
-
232
- if (filterFn && !filterFn(message)) {
233
- continue;
234
- }
235
-
236
- // Apply variable substitution
237
- modifiedMessages[i] = this.processMessage(message);
234
+ // Only process the last message (the newly added one).
235
+ // Processing historical messages would mutate already-committed content
236
+ // and destroy Anthropic's prefix-cache match from that point onwards.
237
+ const lastIndex = modifiedMessages.length - 1;
238
+ if (lastIndex < 0) return;
239
+
240
+ const message = modifiedMessages[lastIndex];
241
+ if (!filterFn || filterFn(message)) {
242
+ modifiedMessages[lastIndex] = this.processMessage(message);
238
243
  }
239
244
  };
240
245
  }
@@ -357,6 +362,13 @@ export class CustomVariables {
357
362
  * This helps the LLM discover that it can avoid re-outputting long strings
358
363
  * (e.g. JWTs, file contents) by storing them once with setVariable or
359
364
  * storeToolCallToVariable and then referencing them via {{varName}}.
365
+ *
366
+ * CACHING NOTE: To protect Anthropic prefix-cache stability this processor
367
+ * keeps at most ONE hint message in the array. Before injecting a new hint
368
+ * it removes the previous one (identified by a stable sentinel prefix) so
369
+ * the total message count — and therefore every cache breakpoint — stays
370
+ * constant. The hint content is also kept deterministic (no per-call
371
+ * volatile numbers) so that re-injecting the same hint is a cache no-op.
360
372
  */
361
373
  createRepetitionHintProcessor(options: {
362
374
  minLength?: number; // Minimum string length to consider (default: 50)
@@ -377,11 +389,26 @@ export class CustomVariables {
377
389
  // ~100 base + 30 per example = ~190 tokens for the hint message itself
378
390
  const hintMessageTokens = options.hintMessageTokens ?? (100 + maxExamples * 30);
379
391
 
380
- // Throttle state: track message count at last hint emission
392
+ // Stable sentinel that uniquely identifies hint messages injected by this processor.
393
+ // Must be a fixed string so the cache sees identical bytes on re-injection.
394
+ const HINT_SENTINEL = "⚠️ [knowhow:repetition-hint]";
395
+
396
+ // Throttle state: track the index of the message that was current when we
397
+ // last evaluated (ignoring the hint message itself in the count).
381
398
  let lastHintAtMessageCount = -Infinity;
382
399
 
383
400
  return async (originalMessages: Message[], modifiedMessages: Message[]) => {
384
- // Throttle: only emit hint if enough new messages have been added since last hint
401
+ // Remove any previously injected hint so we never accumulate duplicates
402
+ // and so the message array length stays predictable for cache breakpoints.
403
+ const existingHintIndex = modifiedMessages.findIndex(
404
+ (m) => typeof m.content === "string" && m.content.startsWith(HINT_SENTINEL)
405
+ );
406
+ if (existingHintIndex !== -1) {
407
+ modifiedMessages.splice(existingHintIndex, 1);
408
+ }
409
+
410
+ // Throttle: only re-evaluate if enough *real* messages have arrived since last hint.
411
+ // We exclude the hint message itself from the count (already removed above).
385
412
  const currentMessageCount = modifiedMessages.length;
386
413
  if (currentMessageCount - lastHintAtMessageCount < throttleMessages) {
387
414
  return;
@@ -480,22 +507,20 @@ export class CustomVariables {
480
507
  }
481
508
 
482
509
  // Build example variable suggestions
483
- const examples = repeatedEntries.slice(0, maxExamples).map(({ str, count, toolNames }, i) => {
510
+ // Use stable previews (fixed 80-char slices) to keep hint content
511
+ // deterministic across calls — volatile token counts are omitted so
512
+ // re-injecting the same hint does not bust the Anthropic prefix cache.
513
+ const examples = repeatedEntries.slice(0, maxExamples).map(({ str, toolNames }, i) => {
484
514
  const preview = str.trim().slice(0, 80).replace(/\s+/g, " ");
485
515
  const ellipsis = str.length > 80 ? "…" : "";
486
516
  const varName = `var${i + 1}`;
487
- const charsSaved = (count - 1) * str.length;
488
- const tokensSaved = Math.round(charsSaved / 4);
489
- return (
490
- ` • \`${varName}\` (used ${count}x in ${[...toolNames].join(", ")}, ~${tokensSaved} tokens saveable): "${preview}${ellipsis}"`
491
- );
517
+ return ` • \`${varName}\` (in ${[...toolNames].join(", ")}): "${preview}${ellipsis}"`;
492
518
  });
493
519
 
494
520
  modifiedMessages.push({
495
521
  role: "user",
496
522
  content:
497
- `⚠️ Tool inputs have large repetitions detected in: ${repeatedTools.join(", ")} ` +
498
- `(~${grossTokensSaved} tokens saveable, ~${netTokensSaved} net after this reminder). ` +
523
+ `${HINT_SENTINEL} Tool inputs have large repetitions detected in: ${repeatedTools.join(", ")}. ` +
499
524
  `Consider storing repeated values with \`setVariable\` or \`storeToolCallToVariable\`, ` +
500
525
  `then reference them via {{variableName}} in future tool calls.\n` +
501
526
  `Top repeated values to consider storing as variables:\n` +
@@ -32,6 +32,12 @@ export class TokenCompressor implements JsonCompressorStorage {
32
32
  private compressionThreshold: number = 4000;
33
33
  private characterLimit: number = this.compressionThreshold * 4;
34
34
 
35
+ // Minimum size (in characters) a standalone chunk should have. Leftover content
36
+ // smaller than this is merged into an adjacent chunk so we never emit tiny,
37
+ // low-value chunks that force the agent into extra round-trips to reassemble a
38
+ // file. Defaults to half of the per-chunk character limit.
39
+ private minChunkCharacters: number = Math.floor(this.characterLimit / 2);
40
+
35
41
  // Largest size retrievable without re-compressing
36
42
  public maxTokens: number = this.compressionThreshold * 2;
37
43
 
@@ -56,6 +62,7 @@ export class TokenCompressor implements JsonCompressorStorage {
56
62
  public setCompressionThreshold(threshold: number): void {
57
63
  this.compressionThreshold = threshold;
58
64
  this.characterLimit = threshold * 4; // Update character limit based on new threshold
65
+ this.minChunkCharacters = Math.floor(this.characterLimit / 2);
59
66
  this.jsonCompressor.updateSettings(threshold, this.maxTokens);
60
67
  }
61
68
 
@@ -88,7 +95,7 @@ export class TokenCompressor implements JsonCompressorStorage {
88
95
  const chunkKeys: string[] = [];
89
96
  let remaining = content;
90
97
 
91
- // Split from the end, creating chunks that will be linked
98
+ // Split from the end, creating chunks that will be linked.
92
99
  while (remaining.length > this.characterLimit) {
93
100
  const chunkStart = remaining.length - this.characterLimit;
94
101
  const chunk = remaining.substring(chunkStart);
@@ -96,9 +103,19 @@ export class TokenCompressor implements JsonCompressorStorage {
96
103
  remaining = remaining.substring(0, chunkStart);
97
104
  }
98
105
 
99
- // The remaining part becomes the first chunk
106
+ // The remaining part becomes the first chunk. To avoid producing a tiny,
107
+ // low-value leading chunk (which forces the agent into extra round-trips to
108
+ // reassemble a file), merge it into the next chunk when it falls below the
109
+ // minimum chunk size. Only split it out as its own chunk when it is large
110
+ // enough to stand on its own.
100
111
  if (remaining.length > 0) {
101
- chunks.unshift(remaining);
112
+ if (chunks.length > 0 && remaining.length < this.minChunkCharacters) {
113
+ // Prepend the small leftover onto the first existing chunk so every
114
+ // emitted chunk carries a meaningful amount of content.
115
+ chunks[0] = remaining + chunks[0];
116
+ } else {
117
+ chunks.unshift(remaining);
118
+ }
102
119
  }
103
120
 
104
121
  // Store chunks and create chain of references
@@ -131,7 +148,7 @@ export class TokenCompressor implements JsonCompressorStorage {
131
148
  200
132
149
  )}...\n[Use ${
133
150
  this.toolName
134
- } tool with key "${firstKey}" to retrieve content. Follow NEXT_CHUNK_KEY references for complete content]\n[TIP: try jqToolResponse,grepToolResponse,tailToolResponse to filter/search/map without repeated expandTokens calls]`;
151
+ } tool with key "${firstKey}" to retrieve the FULL content in one call (chunks are auto-stitched; pass fromLine/toLine for a ranged read)]\n[TIP: try jqToolResponse,grepToolResponse,tailToolResponse to filter/search/map with REAL line numbers without expanding at all]`;
135
152
  }
136
153
 
137
154
  /**
@@ -282,6 +299,38 @@ export class TokenCompressor implements JsonCompressorStorage {
282
299
  return this.storage[key] || null;
283
300
  }
284
301
 
302
+ /**
303
+ * Retrieves the fully reassembled content for a key, following any
304
+ * `NEXT_CHUNK_KEY` references and stripping the chunk-linking markers. This
305
+ * lets callers get the complete content in a single call instead of chasing a
306
+ * chain of nested keys.
307
+ */
308
+ retrieveFullString(key: string): string | null {
309
+ let currentKey: string | null = key;
310
+ let result = "";
311
+ let found = false;
312
+ const visited = new Set<string>();
313
+
314
+ while (currentKey && !visited.has(currentKey)) {
315
+ visited.add(currentKey);
316
+ const chunk = this.storage[currentKey];
317
+ if (chunk === undefined) {
318
+ break;
319
+ }
320
+ found = true;
321
+
322
+ const nextMatch = chunk.match(/\n\n\[NEXT_CHUNK_KEY:\s*([^\s\]]+)\]/);
323
+ const nextKey = nextMatch ? nextMatch[1] : null;
324
+
325
+ // Strip the trailing NEXT_CHUNK_KEY marker before stitching.
326
+ const cleaned = chunk.replace(/\n\n\[NEXT_CHUNK_KEY:\s*[^\s\]]+\]/, "");
327
+ result += cleaned;
328
+ currentKey = nextKey;
329
+ }
330
+
331
+ return found ? result : null;
332
+ }
333
+
285
334
  storeString(key: string, value: string): void {
286
335
  if (this.estimateTokens(value) > this.maxTokens) {
287
336
  // adjust max tokens so we can always retrieve this without re-compressing
@@ -307,15 +356,42 @@ export class TokenCompressor implements JsonCompressorStorage {
307
356
  if (toolsService) {
308
357
  toolsService.addTools([expandTokensDefinition]);
309
358
  toolsService.addFunctions({
310
- [this.toolName]: (key: string) => {
311
- const data = this.retrieveString(key);
359
+ [this.toolName]: (key: string, fromLine?: number, toLine?: number) => {
360
+ // Auto-stitch: follow any NEXT_CHUNK_KEY chain and return the full,
361
+ // reassembled content so the agent never has to chase nested keys.
362
+ const data = this.retrieveFullString(key);
312
363
 
313
- if (!data) {
364
+ if (data === null) {
314
365
  return `Error: No data found for key "${key}". Available keys: ${this.getStorageKeys().join(
315
366
  ", "
316
367
  )}`;
317
368
  }
318
- return data;
369
+
370
+ // Optional ranged read: return only the requested 1-based, inclusive
371
+ // line range, prefixed with real line numbers for easy mapping back.
372
+ const hasRange =
373
+ typeof fromLine === "number" || typeof toLine === "number";
374
+ if (!hasRange) {
375
+ return data;
376
+ }
377
+
378
+ const lines = data.split("\n");
379
+ const totalLines = lines.length;
380
+ const start = Math.max(1, typeof fromLine === "number" ? fromLine : 1);
381
+ const end = Math.min(
382
+ totalLines,
383
+ typeof toLine === "number" ? toLine : totalLines
384
+ );
385
+
386
+ if (start > end) {
387
+ return `Error: Invalid line range for key "${key}": fromLine (${start}) is greater than toLine (${end}). Content has ${totalLines} lines.`;
388
+ }
389
+
390
+ const numbered: string[] = [];
391
+ for (let i = start; i <= end; i++) {
392
+ numbered.push(`${i}: ${lines[i - 1]}`);
393
+ }
394
+ return numbered.join("\n");
319
395
  },
320
396
  });
321
397
  }
@@ -420,7 +496,7 @@ export const expandTokensDefinition: Tool = {
420
496
  function: {
421
497
  name: "expandTokens",
422
498
  description:
423
- "Retrieve a chunk of compressed data that was stored during message processing. The returned content may contain a `NEXT_CHUNK_KEY` to retrieve subsequent chunks. NOTE: Can also use jqToolResponse, grepToolResponse, tailToolResponse to access/filter/search compressed content without needing to expand all tokens.",
499
+ "Retrieve compressed data that was stored during message processing. The full content is automatically reassembled (any chunk chain is followed for you, so you never need to chase NEXT_CHUNK_KEY references). Optionally pass fromLine/toLine (1-based, inclusive) to return just a range of lines, prefixed with real line numbers. NOTE: Can also use jqToolResponse, grepToolResponse, tailToolResponse to access/filter/search compressed content without needing to expand all tokens.",
424
500
  parameters: {
425
501
  type: "object",
426
502
  positional: true,
@@ -429,6 +505,16 @@ export const expandTokensDefinition: Tool = {
429
505
  type: "string",
430
506
  description: "The key of the compressed data to retrieve",
431
507
  },
508
+ fromLine: {
509
+ type: "number",
510
+ description:
511
+ "Optional 1-based start line (inclusive) for a ranged read of the reassembled content.",
512
+ },
513
+ toLine: {
514
+ type: "number",
515
+ description:
516
+ "Optional 1-based end line (inclusive). Defaults to the end of the content when omitted.",
517
+ },
432
518
  },
433
519
  required: ["key"],
434
520
  },
@@ -220,10 +220,15 @@ export class AgentSyncFs {
220
220
  );
221
221
  this.lastInputContent = input;
222
222
 
223
- agent.addPendingUserMessage({
224
- role: "user",
225
- content: input,
226
- });
223
+ // Detect slash commands in the input
224
+ if (input.trim().startsWith("/")) {
225
+ await this.handleInputCommand(agent, input.trim());
226
+ } else {
227
+ agent.addPendingUserMessage({
228
+ role: "user",
229
+ content: input,
230
+ });
231
+ }
227
232
 
228
233
  // Clear the input file after processing
229
234
  await this.writeInput("");
@@ -233,6 +238,23 @@ export class AgentSyncFs {
233
238
  }
234
239
  }
235
240
 
241
+ /**
242
+ * Handle slash commands sent via input.txt
243
+ */
244
+ private async handleInputCommand(agent: BaseAgent, input: string): Promise<void> {
245
+ const [command, ...rest] = input.split(" ");
246
+ const message = rest.join(" ").trim() || undefined;
247
+
248
+ if (command === "/poke") {
249
+ console.log(`🫵 Received /poke command for task ${this.taskId}`);
250
+ agent.interrupt(message);
251
+ } else {
252
+ // Unknown command — treat as a regular message
253
+ console.warn(`⚠️ Unknown command "${command}", treating as message`);
254
+ agent.addPendingUserMessage({ role: "user", content: input });
255
+ }
256
+ }
257
+
236
258
  /**
237
259
  * Wait for resume by monitoring status file
238
260
  */
@@ -151,10 +151,15 @@ export class AgentSyncKnowhowWeb {
151
151
  // Inject pending messages into the agent
152
152
  const messageIds: string[] = [];
153
153
  for (const msg of pendingMessages) {
154
- agent.addPendingUserMessage({
155
- role: msg.role as "user" | "assistant",
156
- content: msg.message,
157
- });
154
+ // Detect slash commands in messages
155
+ if (msg.message.trim().startsWith("/")) {
156
+ await this.handleMessageCommand(agent, msg.message.trim());
157
+ } else {
158
+ agent.addPendingUserMessage({
159
+ role: msg.role as "user" | "assistant",
160
+ content: msg.message,
161
+ });
162
+ }
158
163
  messageIds.push(msg.id);
159
164
  }
160
165
 
@@ -167,6 +172,23 @@ export class AgentSyncKnowhowWeb {
167
172
  }
168
173
  }
169
174
 
175
+ /**
176
+ * Handle slash commands sent as pending messages
177
+ */
178
+ private async handleMessageCommand(agent: BaseAgent, input: string): Promise<void> {
179
+ const [command, ...rest] = input.split(" ");
180
+ const message = rest.join(" ").trim() || undefined;
181
+
182
+ if (command === "/poke") {
183
+ console.log(`🫵 Received /poke command for task ${this.knowhowTaskId}`);
184
+ agent.interrupt(message);
185
+ } else {
186
+ // Unknown command — treat as a regular message
187
+ console.warn(`⚠️ Unknown command "${command}", treating as message`);
188
+ agent.addPendingUserMessage({ role: "user", content: input });
189
+ }
190
+ }
191
+
170
192
  /**
171
193
  * Wait for the agent to be resumed or killed via API
172
194
  * Polls the API every 2 seconds, with a 1 hour timeout
@@ -13,6 +13,8 @@ export interface SyncedAgentWatcher {
13
13
  stopWatching(): void;
14
14
  /** The task ID being watched */
15
15
  taskId: string;
16
+ /** Interrupt the agent's current tool call or AI completion */
17
+ interrupt(message?: string): Promise<void>;
16
18
  /** The agent name being watched */
17
19
  agentName: string;
18
20
  /** Send a message to the remote agent */
@@ -55,6 +57,7 @@ export interface AttachableAgent {
55
57
  pause(): void | Promise<void>;
56
58
  unpause(): void | Promise<void>;
57
59
  kill(): void | Promise<void>;
60
+ interrupt(message?: string): void | Promise<void>;
58
61
  addPendingUserMessage(message: Message): void;
59
62
  }
60
63
 
@@ -97,6 +100,11 @@ export class WatcherBackedAgent implements AttachableAgent {
97
100
  this.agentEvents.emit(this.eventTypes.done, "Agent killed");
98
101
  }
99
102
 
103
+ async interrupt(message?: string): Promise<void> {
104
+ await this.watcher.interrupt(message);
105
+ console.log("🫵 Interrupt sent to remote agent.");
106
+ }
107
+
100
108
  addPendingUserMessage(message: Message): void {
101
109
  // Fire-and-forget — errors are logged but not surfaced
102
110
  const text = typeof message.content === "string" ? message.content : "";