wave-agent-sdk 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/dist/agent.d.ts +58 -4
  2. package/dist/agent.js +91 -19
  3. package/dist/builtin/index.js +2 -0
  4. package/dist/builtin/skills/settings.js +1 -12
  5. package/dist/builtin/skills/wave-daemon.d.ts +1 -0
  6. package/dist/builtin/skills/wave-daemon.js +194 -0
  7. package/dist/constants/images.d.ts +26 -0
  8. package/dist/constants/images.js +26 -0
  9. package/dist/constants/index.d.ts +16 -0
  10. package/dist/constants/index.js +16 -0
  11. package/dist/constants/memory.d.ts +26 -0
  12. package/dist/constants/memory.js +34 -0
  13. package/dist/constants/messages.d.ts +11 -0
  14. package/dist/constants/messages.js +11 -0
  15. package/dist/constants/plugins.d.ts +8 -0
  16. package/dist/constants/plugins.js +8 -0
  17. package/dist/constants/tools.d.ts +1 -0
  18. package/dist/constants/tools.js +1 -0
  19. package/dist/core/plugin.d.ts +53 -13
  20. package/dist/core/plugin.js +134 -26
  21. package/dist/core/session.d.ts +1 -1
  22. package/dist/core/session.js +1 -1
  23. package/dist/exec/catalog.d.ts +140 -0
  24. package/dist/exec/catalog.js +470 -0
  25. package/dist/exec/catalogAnnouncement.d.ts +89 -0
  26. package/dist/exec/catalogAnnouncement.js +293 -0
  27. package/dist/exec/constants.d.ts +51 -0
  28. package/dist/exec/constants.js +51 -0
  29. package/dist/exec/execRuntime.d.ts +55 -0
  30. package/dist/exec/execRuntime.js +217 -0
  31. package/dist/exec/workerSource.d.ts +28 -0
  32. package/dist/exec/workerSource.js +299 -0
  33. package/dist/host/index.d.ts +23 -0
  34. package/dist/host/index.js +23 -0
  35. package/dist/index.d.ts +5 -0
  36. package/dist/index.js +6 -0
  37. package/dist/managers/MemoryRuleManager.d.ts +6 -0
  38. package/dist/managers/MemoryRuleManager.js +12 -0
  39. package/dist/managers/aiManager.d.ts +35 -1
  40. package/dist/managers/aiManager.js +190 -21
  41. package/dist/managers/backgroundTaskManager.js +14 -0
  42. package/dist/managers/hookManager.d.ts +13 -0
  43. package/dist/managers/hookManager.js +31 -4
  44. package/dist/managers/liveConfigManager.d.ts +33 -0
  45. package/dist/managers/liveConfigManager.js +103 -8
  46. package/dist/managers/lspManager.d.ts +9 -0
  47. package/dist/managers/lspManager.js +47 -18
  48. package/dist/managers/mcpManager.d.ts +45 -10
  49. package/dist/managers/mcpManager.js +103 -1
  50. package/dist/managers/messageManager.d.ts +48 -5
  51. package/dist/managers/messageManager.js +107 -21
  52. package/dist/managers/permissionManager.d.ts +40 -0
  53. package/dist/managers/permissionManager.js +63 -8
  54. package/dist/managers/pluginManager.d.ts +46 -2
  55. package/dist/managers/pluginManager.js +117 -11
  56. package/dist/managers/pluginScopeManager.d.ts +15 -2
  57. package/dist/managers/pluginScopeManager.js +20 -1
  58. package/dist/managers/skillManager.d.ts +19 -0
  59. package/dist/managers/skillManager.js +44 -0
  60. package/dist/managers/slashCommandManager.d.ts +10 -0
  61. package/dist/managers/slashCommandManager.js +35 -3
  62. package/dist/managers/subagentManager.d.ts +8 -0
  63. package/dist/managers/subagentManager.js +20 -0
  64. package/dist/managers/toolManager.d.ts +29 -3
  65. package/dist/managers/toolManager.js +87 -13
  66. package/dist/prompts/autoMemory.d.ts +9 -0
  67. package/dist/prompts/autoMemory.js +30 -31
  68. package/dist/prompts/autoMemoryExtraction.d.ts +4 -0
  69. package/dist/prompts/autoMemoryExtraction.js +8 -111
  70. package/dist/prompts/memoryTypes.d.ts +63 -0
  71. package/dist/prompts/memoryTypes.js +191 -0
  72. package/dist/services/GitService.d.ts +7 -0
  73. package/dist/services/GitService.js +23 -0
  74. package/dist/services/MarketplaceService.d.ts +101 -17
  75. package/dist/services/MarketplaceService.js +318 -119
  76. package/dist/services/artifactContent.d.ts +84 -0
  77. package/dist/services/artifactContent.js +204 -0
  78. package/dist/services/artifactSession.d.ts +6 -0
  79. package/dist/services/artifactSession.js +17 -0
  80. package/dist/services/autoMemoryService.js +5 -13
  81. package/dist/services/configurationService.d.ts +60 -9
  82. package/dist/services/configurationService.js +129 -54
  83. package/dist/services/contentSummarizer.d.ts +15 -0
  84. package/dist/services/contentSummarizer.js +45 -0
  85. package/dist/services/execAvailability.d.ts +9 -0
  86. package/dist/services/execAvailability.js +32 -0
  87. package/dist/services/fileWatcher.js +61 -6
  88. package/dist/services/initializationService.js +19 -15
  89. package/dist/services/interactionService.d.ts +9 -1
  90. package/dist/services/interactionService.js +28 -8
  91. package/dist/services/jsonlHandler.d.ts +84 -0
  92. package/dist/services/jsonlHandler.js +209 -14
  93. package/dist/services/memory.d.ts +3 -1
  94. package/dist/services/memory.js +13 -9
  95. package/dist/services/officialMarketplaceMirror.js +3 -2
  96. package/dist/services/pluginLoader.d.ts +12 -4
  97. package/dist/services/pluginLoader.js +38 -7
  98. package/dist/services/remoteSettingsService.js +16 -2
  99. package/dist/services/session.d.ts +74 -0
  100. package/dist/services/session.js +144 -3
  101. package/dist/services/sessionEntries.d.ts +2 -0
  102. package/dist/services/sessionEntries.js +20 -0
  103. package/dist/stdio/index.d.ts +3 -1
  104. package/dist/stdio/index.js +3 -1
  105. package/dist/stdio/notificationRouter.js +1 -0
  106. package/dist/stdio/stdioAgent.d.ts +14 -7
  107. package/dist/stdio/stdioAgent.js +19 -0
  108. package/dist/tools/artifactTool.js +406 -273
  109. package/dist/tools/bashTool.js +8 -6
  110. package/dist/tools/editTool.js +6 -3
  111. package/dist/tools/execTool.d.ts +2 -0
  112. package/dist/tools/execTool.js +165 -0
  113. package/dist/tools/grepTool.js +7 -1
  114. package/dist/tools/readTool.js +30 -2
  115. package/dist/tools/types.d.ts +34 -8
  116. package/dist/tools/webFetchTool.js +15 -166
  117. package/dist/tools/workflowTool.js +40 -8
  118. package/dist/tools/writeTool.js +6 -3
  119. package/dist/types/agent.d.ts +20 -5
  120. package/dist/types/configuration.d.ts +39 -1
  121. package/dist/types/marketplace.d.ts +40 -2
  122. package/dist/types/mcp.d.ts +39 -0
  123. package/dist/types/permissions.d.ts +22 -0
  124. package/dist/types/permissions.js +17 -0
  125. package/dist/types/plugins.d.ts +26 -2
  126. package/dist/types/skills.d.ts +15 -0
  127. package/dist/utils/constants.d.ts +10 -0
  128. package/dist/utils/constants.js +10 -0
  129. package/dist/utils/containerSetup.js +43 -0
  130. package/dist/utils/convertMessagesForAPI.d.ts +7 -1
  131. package/dist/utils/convertMessagesForAPI.js +64 -14
  132. package/dist/utils/fileChangeReminder.d.ts +20 -0
  133. package/dist/utils/fileChangeReminder.js +153 -0
  134. package/dist/utils/fileSearch.js +4 -3
  135. package/dist/utils/fileUtils.d.ts +33 -0
  136. package/dist/utils/fileUtils.js +81 -0
  137. package/dist/utils/frontmatterYaml.d.ts +33 -0
  138. package/dist/utils/frontmatterYaml.js +192 -0
  139. package/dist/utils/imageBudget.d.ts +85 -0
  140. package/dist/utils/imageBudget.js +109 -0
  141. package/dist/utils/imageDimensions.d.ts +83 -0
  142. package/dist/utils/imageDimensions.js +232 -0
  143. package/dist/utils/imageProcessor.d.ts +66 -0
  144. package/dist/utils/imageProcessor.js +84 -0
  145. package/dist/utils/imageRewrite.d.ts +29 -0
  146. package/dist/utils/imageRewrite.js +251 -0
  147. package/dist/utils/markdownParser.d.ts +5 -1
  148. package/dist/utils/markdownParser.js +9 -51
  149. package/dist/utils/mcpInstructions.d.ts +61 -0
  150. package/dist/utils/mcpInstructions.js +126 -0
  151. package/dist/utils/mcpUtils.d.ts +7 -0
  152. package/dist/utils/mcpUtils.js +11 -2
  153. package/dist/utils/memoryAge.d.ts +32 -0
  154. package/dist/utils/memoryAge.js +47 -0
  155. package/dist/utils/memoryEntrypoint.d.ts +20 -0
  156. package/dist/utils/memoryEntrypoint.js +49 -0
  157. package/dist/utils/memoryIndex.d.ts +30 -0
  158. package/dist/utils/memoryIndex.js +76 -0
  159. package/dist/utils/messageOperations.d.ts +6 -2
  160. package/dist/utils/messageOperations.js +40 -29
  161. package/dist/utils/nestedMemory.d.ts +22 -0
  162. package/dist/utils/nestedMemory.js +61 -0
  163. package/dist/utils/npmTarball.d.ts +19 -0
  164. package/dist/utils/npmTarball.js +92 -0
  165. package/dist/utils/pluginSource.d.ts +37 -0
  166. package/dist/utils/pluginSource.js +73 -0
  167. package/dist/utils/ripgrep.d.ts +18 -4
  168. package/dist/utils/ripgrep.js +56 -4
  169. package/dist/utils/runtimeDeps.d.ts +35 -0
  170. package/dist/utils/runtimeDeps.js +426 -0
  171. package/dist/utils/skillParser.js +22 -52
  172. package/dist/utils/subagentParser.js +39 -43
  173. package/dist/utils/userSettings.d.ts +90 -0
  174. package/dist/utils/userSettings.js +291 -0
  175. package/package.json +10 -7
@@ -1,3 +1,4 @@
1
+ import { imageFileCacheKey, planOutboundImage } from "./imageRewrite.js";
1
2
  import { convertImageToBase64 } from "./messageOperations.js";
2
3
  import { taskNotificationToXml } from "./notificationXml.js";
3
4
  import { recoverTruncatedJson, stripAnsiColors } from "./stringUtils.js";
@@ -35,11 +36,17 @@ function safeToolArguments(args) {
35
36
  /**
36
37
  * Convert message format to API call format, stopping when a compacted message is encountered.
37
38
  * Messages with no meaningful content or tool calls are filtered out.
39
+ *
40
+ * Async because every outbound image is routed through `planOutboundImage`,
41
+ * which may have to re-encode an oversized image with the optional `sharp`
42
+ * codec (`utils/imageRewrite.ts`). Images that are already inside the budget —
43
+ * the common case — take a synchronous fast path and come out byte-identical.
44
+ *
38
45
  * @param messages Message list
39
46
  * @param options Optional conversion options (e.g. supportsVision)
40
47
  * @returns Converted API message format list
41
48
  */
42
- export function convertMessagesForAPI(messages, options) {
49
+ export async function convertMessagesForAPI(messages, options) {
43
50
  const supportsVision = options?.supportsVision !== false;
44
51
  const recentMessages = [];
45
52
  const startIndex = messages.length - 1;
@@ -70,7 +77,7 @@ export function convertMessagesForAPI(messages, options) {
70
77
  if (toolBlocks.length > 0) {
71
78
  // Collect image user messages to place after all tool messages
72
79
  const imageUserMessages = [];
73
- toolBlocks.forEach((toolBlock) => {
80
+ for (const toolBlock of toolBlocks) {
74
81
  // Only add completed tool blocks (i.e., stage is 'end')
75
82
  if (toolBlock.id && toolBlock.stage === "end") {
76
83
  completedToolIds.add(toolBlock.id);
@@ -84,18 +91,34 @@ export function convertMessagesForAPI(messages, options) {
84
91
  if (toolBlock.images && toolBlock.images.length > 0) {
85
92
  if (supportsVision) {
86
93
  const contentParts = [];
87
- toolBlock.images.forEach((image) => {
94
+ for (const image of toolBlock.images) {
88
95
  const imageUrl = image.data.startsWith("data:")
89
96
  ? image.data
90
97
  : `data:${image.mediaType || "image/png"};base64,${image.data}`;
98
+ // Tool-produced images (Read screenshots, MCP results, Exec
99
+ // output) go through the same outbound budget as attachments:
100
+ // an image the gateway would reject is shrunk, or omitted with
101
+ // an actionable note, instead of failing the whole request.
102
+ const plan = await planOutboundImage({
103
+ dataUrl: imageUrl,
104
+ cacheKey: imageUrl,
105
+ sourcePath: image.path,
106
+ });
107
+ if (plan.kind === "omit") {
108
+ contentParts.push({ type: "text", text: plan.note });
109
+ continue;
110
+ }
91
111
  contentParts.push({
92
112
  type: "image_url",
93
113
  image_url: {
94
- url: imageUrl,
114
+ url: plan.dataUrl,
95
115
  detail: "auto",
96
116
  },
97
117
  });
98
- });
118
+ if (plan.note) {
119
+ contentParts.push({ type: "text", text: plan.note });
120
+ }
121
+ }
99
122
  imageUserMessages.push({
100
123
  role: "user",
101
124
  content: contentParts,
@@ -129,7 +152,7 @@ export function convertMessagesForAPI(messages, options) {
129
152
  }
130
153
  }
131
154
  }
132
- });
155
+ }
133
156
  // Insert image user messages after all tool messages but before the
134
157
  // assistant message (which will be unshifted next). Since tool messages
135
158
  // were unshifted to the front, we splice images right after them.
@@ -218,7 +241,7 @@ export function convertMessagesForAPI(messages, options) {
218
241
  else if (message.role === "user") {
219
242
  // User messages converted to standard format
220
243
  const contentParts = [];
221
- message.blocks.forEach((block) => {
244
+ for (const block of message.blocks) {
222
245
  // Add text content - only if it has meaningful content
223
246
  if (block.type === "text" &&
224
247
  block.content &&
@@ -253,28 +276,55 @@ export function convertMessagesForAPI(messages, options) {
253
276
  });
254
277
  }
255
278
  else {
256
- block.imageUrls.forEach((imageUrl) => {
279
+ for (const imageUrl of block.imageUrls) {
257
280
  // Check if it's already base64, convert if not
258
281
  const isDataUrl = imageUrl.startsWith("data:image/");
259
282
  let finalImageUrl = imageUrl;
260
283
  if (!isDataUrl) {
261
- // If it's a file path, it needs to be converted to base64
284
+ // If it's a file path, it needs to be converted to base64.
285
+ // Unreadable/empty/unknown-format files come back as
286
+ // undefined: skip the image rather than send an empty payload.
287
+ let converted;
262
288
  try {
263
- finalImageUrl = convertImageToBase64(imageUrl);
289
+ converted = convertImageToBase64(imageUrl);
264
290
  }
265
291
  catch (error) {
266
292
  logger.error("Failed to convert image path to base64:", imageUrl, error);
267
293
  // Skip this image, do not add to content
268
- return;
294
+ continue;
269
295
  }
296
+ if (!converted) {
297
+ logger.warn("Skipping unusable image file:", imageUrl);
298
+ continue;
299
+ }
300
+ finalImageUrl = converted;
301
+ }
302
+ // Outbound budget: an image the vision gateway would reject, or
303
+ // one over our own size budget, is shrunk here (or replaced by an
304
+ // actionable note when it cannot be). See utils/imageRewrite.ts.
305
+ const plan = await planOutboundImage({
306
+ dataUrl: finalImageUrl,
307
+ cacheKey: isDataUrl
308
+ ? finalImageUrl
309
+ : imageFileCacheKey(imageUrl),
310
+ sourcePath: isDataUrl ? undefined : imageUrl,
311
+ });
312
+ if (plan.kind === "omit") {
313
+ contentParts.push({ type: "text", text: plan.note });
314
+ continue;
270
315
  }
271
316
  contentParts.push({
272
317
  type: "image_url",
273
318
  image_url: {
274
- url: finalImageUrl,
319
+ url: plan.dataUrl,
275
320
  detail: "auto",
276
321
  },
277
322
  });
323
+ // Tell the model when the image it sees is not the full-size
324
+ // original, so it can map coordinates back.
325
+ if (plan.note) {
326
+ contentParts.push({ type: "text", text: plan.note });
327
+ }
278
328
  // Aligned with Claude Code: when the image comes from a local
279
329
  // file (not an inline dataURL), append its source path as text
280
330
  // metadata so the model can reference the file with tools
@@ -285,7 +335,7 @@ export function convertMessagesForAPI(messages, options) {
285
335
  text: `[Image source: ${imageUrl}]`,
286
336
  });
287
337
  }
288
- });
338
+ }
289
339
  }
290
340
  }
291
341
  // If there is a tool block in user message, add its result
@@ -302,7 +352,7 @@ export function convertMessagesForAPI(messages, options) {
302
352
  text: `A background agent completed a task:\n${taskNotificationToXml(block)}`,
303
353
  });
304
354
  }
305
- });
355
+ }
306
356
  // Only add user message if there is meaningful content
307
357
  if (contentParts.length > 0) {
308
358
  // Filter out empty text parts
@@ -0,0 +1,20 @@
1
+ import type { ReadFileState } from "../tools/types.js";
2
+ /** Max number of changed files reported in a single reminder. */
3
+ export declare const CHANGED_FILES_MAX_FILES = 10;
4
+ /** Max bytes of the changed-lines snippet rendered for a single file. */
5
+ export declare const CHANGED_FILES_SNIPPET_MAX_BYTES = 2048;
6
+ /** Max bytes of the whole reminder (all reported files combined). */
7
+ export declare const CHANGED_FILES_TOTAL_MAX_BYTES = 8192;
8
+ /**
9
+ * Build a compact "what changed" snippet from the new file content: trim the
10
+ * common prefix/suffix, then render the differing region with line numbers.
11
+ * A deletion-only region renders as a removal note. Truncates at a line
12
+ * boundary to `maxBytes`. Returns "" when the contents are identical.
13
+ */
14
+ export declare function buildChangedSnippet(oldContent: string, newContent: string, maxBytes?: number): string;
15
+ /**
16
+ * Detect files that changed on disk since they were read and return a reminder
17
+ * body for the agent, or null when nothing changed. Refreshes the read state
18
+ * for every detected change (see the module comment).
19
+ */
20
+ export declare function getChangedFilesReminder(readFileState: ReadFileState | undefined): Promise<string | null>;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * External file-change detection for the main agent loop (aligned with Claude
3
+ * Code's `changed_files` attachment).
4
+ *
5
+ * For every file recorded in the session read state by a *full* read/write,
6
+ * this compares the on-disk mtime against the mtime recorded at read time and,
7
+ * when the file is newer AND the content actually differs, produces a short
8
+ * changed-lines summary. The read state entry is then refreshed to the new
9
+ * mtime/hash/content so that (a) the same change is reported only once, and
10
+ * (b) a subsequent Edit/Write passes the staleness check — the agent has just
11
+ * been told the new content.
12
+ *
13
+ * The writers this surfaces are the ones other than the main agent: the
14
+ * auto-memory extraction fork, an external editor, another session. The main
15
+ * agent's own Write/Edit refresh the read state on write, so they never show up
16
+ * here.
17
+ */
18
+ import { readFile, stat } from "node:fs/promises";
19
+ import { createHash } from "node:crypto";
20
+ /** Max number of changed files reported in a single reminder. */
21
+ export const CHANGED_FILES_MAX_FILES = 10;
22
+ /** Max bytes of the changed-lines snippet rendered for a single file. */
23
+ export const CHANGED_FILES_SNIPPET_MAX_BYTES = 2048;
24
+ /** Max bytes of the whole reminder (all reported files combined). */
25
+ export const CHANGED_FILES_TOTAL_MAX_BYTES = 8192;
26
+ function sha256(text) {
27
+ return createHash("sha256").update(text).digest("hex");
28
+ }
29
+ /** UTF-8 byte length — CJK content is ~3 bytes/char, so `.length` would under-count. */
30
+ function byteLength(text) {
31
+ return Buffer.byteLength(text, "utf-8");
32
+ }
33
+ /**
34
+ * Build a compact "what changed" snippet from the new file content: trim the
35
+ * common prefix/suffix, then render the differing region with line numbers.
36
+ * A deletion-only region renders as a removal note. Truncates at a line
37
+ * boundary to `maxBytes`. Returns "" when the contents are identical.
38
+ */
39
+ export function buildChangedSnippet(oldContent, newContent, maxBytes = CHANGED_FILES_SNIPPET_MAX_BYTES) {
40
+ const oldLines = oldContent.replace(/\r\n/g, "\n").split("\n");
41
+ const newLines = newContent.replace(/\r\n/g, "\n").split("\n");
42
+ const shared = Math.min(oldLines.length, newLines.length);
43
+ let prefix = 0;
44
+ while (prefix < shared && oldLines[prefix] === newLines[prefix])
45
+ prefix++;
46
+ let suffix = 0;
47
+ const maxSuffix = shared - prefix;
48
+ while (suffix < maxSuffix &&
49
+ oldLines[oldLines.length - 1 - suffix] ===
50
+ newLines[newLines.length - 1 - suffix]) {
51
+ suffix++;
52
+ }
53
+ const changedOld = oldLines.slice(prefix, oldLines.length - suffix);
54
+ const changedNew = newLines.slice(prefix, newLines.length - suffix);
55
+ if (changedOld.length === 0 && changedNew.length === 0)
56
+ return "";
57
+ if (changedNew.length === 0) {
58
+ const plural = changedOld.length === 1 ? "line" : "lines";
59
+ return `(${changedOld.length} ${plural} removed at line ${prefix + 1})`;
60
+ }
61
+ const rendered = [];
62
+ let used = 0;
63
+ let truncated = 0;
64
+ for (let i = 0; i < changedNew.length; i++) {
65
+ const line = `${String(prefix + i + 1).padStart(4, " ")} | ${changedNew[i]}`;
66
+ const cost = byteLength(line) + 1; // + trailing newline
67
+ if (used + cost > maxBytes) {
68
+ truncated = changedNew.length - i;
69
+ break;
70
+ }
71
+ rendered.push(line);
72
+ used += cost;
73
+ }
74
+ let snippet = rendered.join("\n");
75
+ if (truncated > 0) {
76
+ snippet += `\n... [${truncated} lines truncated] ...`;
77
+ }
78
+ return snippet;
79
+ }
80
+ /**
81
+ * Detect files that changed on disk since they were read and return a reminder
82
+ * body for the agent, or null when nothing changed. Refreshes the read state
83
+ * for every detected change (see the module comment).
84
+ */
85
+ export async function getChangedFilesReminder(readFileState) {
86
+ if (!readFileState || readFileState.size === 0)
87
+ return null;
88
+ const blocks = [];
89
+ let totalBytes = 0;
90
+ for (const [filePath, state] of readFileState) {
91
+ if (blocks.length >= CHANGED_FILES_MAX_FILES)
92
+ break;
93
+ // Partial reads cache only a slice — no reliable diff baseline.
94
+ if (state.offset !== undefined || state.limit !== undefined)
95
+ continue;
96
+ let mtimeMs;
97
+ let newContent;
98
+ try {
99
+ const stats = await stat(filePath);
100
+ mtimeMs = stats.mtime.getTime();
101
+ // Not newer than the recorded mtime → untouched, or touched by the main
102
+ // agent's own Write/Edit (which refreshes the entry to the post-write mtime).
103
+ if (mtimeMs <= state.mtime)
104
+ continue;
105
+ newContent = await readFile(filePath, "utf-8");
106
+ }
107
+ catch {
108
+ // Deleted or transiently unreadable — leave the entry alone and move on.
109
+ continue;
110
+ }
111
+ // Refresh the baseline so the same observation is not re-processed on every
112
+ // turn and a subsequent Edit passes the staleness check (the agent has just
113
+ // been told the new content).
114
+ const refresh = () => {
115
+ readFileState.set(filePath, {
116
+ mtime: mtimeMs,
117
+ hash: sha256(newContent),
118
+ source: "changed",
119
+ content: newContent,
120
+ offset: undefined,
121
+ limit: undefined,
122
+ });
123
+ };
124
+ const oldContent = state.content;
125
+ // No baseline, or the file was touched but its content is unchanged
126
+ // (git checkout / editor round-trip save / cloud sync / antivirus).
127
+ if (oldContent === undefined || oldContent === newContent) {
128
+ refresh();
129
+ continue;
130
+ }
131
+ const snippet = buildChangedSnippet(oldContent, newContent);
132
+ if (!snippet) {
133
+ refresh();
134
+ continue;
135
+ }
136
+ const block = `${filePath}\n${snippet}`;
137
+ if (totalBytes + byteLength(block) > CHANGED_FILES_TOTAL_MAX_BYTES) {
138
+ // Leave the entry un-refreshed so the change is reported on a later turn
139
+ // rather than silently dropped.
140
+ break;
141
+ }
142
+ refresh();
143
+ blocks.push(block);
144
+ totalBytes += byteLength(block);
145
+ }
146
+ if (blocks.length === 0)
147
+ return null;
148
+ return [
149
+ "The following files were modified on disk since you last read them — by another process such as a background agent, an external editor, or another session. Your earlier read of these files is out of date; re-read a file before editing it if the changed lines below are not enough.",
150
+ "",
151
+ blocks.join("\n\n"),
152
+ ].join("\n");
153
+ }
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "child_process";
2
- import { rgPath } from "./ripgrep.js";
2
+ import { getRgPath } from "./ripgrep.js";
3
3
  import fuzzysort from "fuzzysort";
4
4
  import { logger } from "./globalLogger.js";
5
5
  const EXCLUDED_FILES = [".git", ".DS_Store"];
@@ -7,12 +7,13 @@ const EXCLUDED_FILES = [".git", ".DS_Store"];
7
7
  * Execute ripgrep to get all file paths
8
8
  */
9
9
  async function getAllFiles(workingDirectory) {
10
- if (!rgPath) {
10
+ const rgBinary = getRgPath();
11
+ if (!rgBinary) {
11
12
  throw new Error("ripgrep is not available");
12
13
  }
13
14
  const rgArgs = ["--files", "--color=never", "--hidden"];
14
15
  return new Promise((resolve, reject) => {
15
- const child = spawn(rgPath, rgArgs, {
16
+ const child = spawn(rgBinary, rgArgs, {
16
17
  cwd: workingDirectory,
17
18
  stdio: ["ignore", "pipe", "pipe"],
18
19
  });
@@ -13,6 +13,25 @@ export declare function readFirstLine(filePath: string): Promise<string>;
13
13
  * @return {Promise<string[]>} - Array of non-empty lines (up to maxLines).
14
14
  */
15
15
  export declare function readFirstNLines(filePath: string, maxLines: number): Promise<string[]>;
16
+ /**
17
+ * Streams a file line by line without holding it in memory.
18
+ *
19
+ * Splits on `\n` and hands each segment to `onLine`; a trailing `\r` is left
20
+ * for the callback's own `trim()`. When the file does not end with a newline,
21
+ * the trailing partial segment is delivered too, so callers can apply the same
22
+ * "interrupted append" tolerance `JsonlHandler.read()` uses — its
23
+ * `endsWithNewline` flag is exactly the return value here.
24
+ *
25
+ * Memory stays at one line: chunks are consumed as they arrive and are not
26
+ * accumulated.
27
+ *
28
+ * @param {string} filePath - The path to the file.
29
+ * @param {(line: string) => void | Promise<void>} onLine - Called per segment, in file order.
30
+ * @return {Promise<{ endsWithNewline: boolean }>} - Whether the file ended with a newline (empty files count as true).
31
+ */
32
+ export declare function forEachLine(filePath: string, onLine: (line: string) => void | Promise<void>): Promise<{
33
+ endsWithNewline: boolean;
34
+ }>;
16
35
  /**
17
36
  * Reads a file from the end and returns the last non-empty line.
18
37
  *
@@ -37,6 +56,20 @@ export declare function getLastLine(filePath: string, minLength?: number): Promi
37
56
  * @return {Promise<string[]>} - Trailing non-empty lines, or [] if unreadable.
38
57
  */
39
58
  export declare function readTailLines(filePath: string, maxBytes?: number): Promise<string[]>;
59
+ /**
60
+ * Synchronously read up to `maxBytes` from the end of a file and return the
61
+ * raw tail text (blank lines preserved). When the window starts mid-line the
62
+ * partial first line is dropped, so the result holds complete lines only.
63
+ *
64
+ * Synchronous on purpose: the caller (`BackgroundTaskManager.getOutput`) sits
65
+ * on the synchronous `getBackgroundTaskOutput` path that hosts and the CLI
66
+ * already consume. The window is capped, so the blocking read stays small.
67
+ *
68
+ * @param {string} filePath - The path to the file.
69
+ * @param {number} maxBytes - Size of the tail window (default 64KB).
70
+ * @return {string} - Tail text, or "" when the file is empty or unreadable.
71
+ */
72
+ export declare function readTailTextSync(filePath: string, maxBytes?: number): string;
40
73
  /**
41
74
  * Suggests similar paths if a file is not found.
42
75
  */
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs/promises";
2
+ import fsSync from "node:fs";
2
3
  import { createReadStream } from "node:fs";
3
4
  import path from "node:path";
4
5
  import { glob } from "glob";
@@ -68,6 +69,44 @@ export async function readFirstNLines(filePath, maxLines) {
68
69
  fileStream.destroy();
69
70
  }
70
71
  }
72
+ /**
73
+ * Streams a file line by line without holding it in memory.
74
+ *
75
+ * Splits on `\n` and hands each segment to `onLine`; a trailing `\r` is left
76
+ * for the callback's own `trim()`. When the file does not end with a newline,
77
+ * the trailing partial segment is delivered too, so callers can apply the same
78
+ * "interrupted append" tolerance `JsonlHandler.read()` uses — its
79
+ * `endsWithNewline` flag is exactly the return value here.
80
+ *
81
+ * Memory stays at one line: chunks are consumed as they arrive and are not
82
+ * accumulated.
83
+ *
84
+ * @param {string} filePath - The path to the file.
85
+ * @param {(line: string) => void | Promise<void>} onLine - Called per segment, in file order.
86
+ * @return {Promise<{ endsWithNewline: boolean }>} - Whether the file ended with a newline (empty files count as true).
87
+ */
88
+ export async function forEachLine(filePath, onLine) {
89
+ const fileStream = createReadStream(filePath, { encoding: "utf8" });
90
+ try {
91
+ let carry = "";
92
+ for await (const chunk of fileStream) {
93
+ carry += chunk;
94
+ let newlineIndex = carry.indexOf("\n");
95
+ while (newlineIndex !== -1) {
96
+ await onLine(carry.slice(0, newlineIndex));
97
+ carry = carry.slice(newlineIndex + 1);
98
+ newlineIndex = carry.indexOf("\n");
99
+ }
100
+ }
101
+ if (carry.length > 0) {
102
+ await onLine(carry);
103
+ }
104
+ return { endsWithNewline: carry.length === 0 };
105
+ }
106
+ finally {
107
+ fileStream.destroy();
108
+ }
109
+ }
71
110
  /**
72
111
  * Reads a file from the end and returns the last non-empty line.
73
112
  *
@@ -173,6 +212,48 @@ export async function readTailLines(filePath, maxBytes = 64 * 1024) {
173
212
  }
174
213
  }
175
214
  }
215
+ /**
216
+ * Synchronously read up to `maxBytes` from the end of a file and return the
217
+ * raw tail text (blank lines preserved). When the window starts mid-line the
218
+ * partial first line is dropped, so the result holds complete lines only.
219
+ *
220
+ * Synchronous on purpose: the caller (`BackgroundTaskManager.getOutput`) sits
221
+ * on the synchronous `getBackgroundTaskOutput` path that hosts and the CLI
222
+ * already consume. The window is capped, so the blocking read stays small.
223
+ *
224
+ * @param {string} filePath - The path to the file.
225
+ * @param {number} maxBytes - Size of the tail window (default 64KB).
226
+ * @return {string} - Tail text, or "" when the file is empty or unreadable.
227
+ */
228
+ export function readTailTextSync(filePath, maxBytes = 64 * 1024) {
229
+ let fd;
230
+ try {
231
+ const fileSize = fsSync.statSync(filePath).size;
232
+ if (fileSize === 0)
233
+ return "";
234
+ const readSize = Math.min(maxBytes, fileSize);
235
+ const start = fileSize - readSize;
236
+ const buffer = Buffer.alloc(readSize);
237
+ fd = fsSync.openSync(filePath, "r");
238
+ const bytesRead = fsSync.readSync(fd, buffer, 0, readSize, start);
239
+ const text = buffer.subarray(0, bytesRead).toString("utf8");
240
+ if (start === 0)
241
+ return text;
242
+ // The window began mid-line, so its first line is partial (and may start
243
+ // with a byte-truncated multibyte character): drop it.
244
+ const firstNewline = text.indexOf("\n");
245
+ return firstNewline === -1 ? "" : text.slice(firstNewline + 1);
246
+ }
247
+ catch {
248
+ // Missing file, permissions, race with deletion: no tail to show.
249
+ return "";
250
+ }
251
+ finally {
252
+ if (fd !== undefined) {
253
+ fsSync.closeSync(fd);
254
+ }
255
+ }
256
+ }
176
257
  /**
177
258
  * Simple Levenshtein distance implementation
178
259
  */
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared YAML frontmatter reader for wave's markdown artifacts: skills
3
+ * (SKILL.md), subagents, custom slash commands, memory rules and memory files.
4
+ *
5
+ * Wave ships no YAML dependency, so this is a hand-rolled parser for the subset
6
+ * those files actually use: `key: value`, block lists (`key:` + indented
7
+ * `- item`), block scalars (`key: >-` / `key: |` and their chomping variants)
8
+ * and indented multi-line plain scalars. Block scalars matter in practice — a
9
+ * folded `description: >-` used to be read as the literal string ">-", which
10
+ * silently threw away the whole "when to use this" text (see spec
11
+ * `ecosystem/agent-skills` 场景 2, `multi-agent/subagent` 场景 6,
12
+ * `ui/slash-commands` 场景 3, `core/memory-management` 场景 4/13).
13
+ *
14
+ * Values are strings or string arrays only — no boolean/number coercion — so
15
+ * callers comparing against `"true"` or `parseInt`-ing the value keep working.
16
+ */
17
+ export type FrontmatterValue = string | string[];
18
+ export type ParsedFrontmatter = Record<string, FrontmatterValue>;
19
+ /**
20
+ * Split a markdown file into its leading `---` frontmatter block and the body.
21
+ * `yaml` is null when the file has no frontmatter; the body is then the whole
22
+ * content, untrimmed.
23
+ */
24
+ export declare function splitFrontmatter(content: string): {
25
+ yaml: string | null;
26
+ body: string;
27
+ };
28
+ /**
29
+ * Parse a frontmatter block (without the `---` delimiters) into key/value
30
+ * pairs. Unparseable input yields an empty object; callers that require fields
31
+ * report the missing ones themselves.
32
+ */
33
+ export declare function parseFrontmatterYaml(yamlContent: string): ParsedFrontmatter;