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
@@ -9,6 +9,25 @@ export class InitializationService {
9
9
  const startTime = performance.now();
10
10
  // Set global logger early so managers can use it during initialization
11
11
  setGlobalLogger(logger || null);
12
+ // Load the remote settings disk cache synchronously, before anything reads
13
+ // the configuration chain. Two consumers depend on it being this early:
14
+ // loadMergedConfiguration (below) merges the cached managed settings (env,
15
+ // model, disallowedTools), and plugin loading (also below) reads the managed
16
+ // enabledPlugins / marketplaces so an admin-pushed plugin is installed and
17
+ // enabled on this very startup (spec enterprise server-managed-config
18
+ // 「托管配置下发插件市场与启用列表」场景 1). Settings `env` is stored in the
19
+ // per-session env snapshot (NOT process.env), except WAVE_SERVER_URL which is
20
+ // mirrored to process.env so the network fetch (below) can read it via
21
+ // authService.getServerUrl(); no race.
22
+ try {
23
+ const phaseStart = performance.now();
24
+ await remoteSettingsService.initialize();
25
+ logger?.debug(`Initialization Phase [Remote Settings Cache] took ${(performance.now() - phaseStart).toFixed(2)}ms`);
26
+ }
27
+ catch (error) {
28
+ logger?.error("Failed to initialize remote settings:", error);
29
+ // Don't throw error to prevent app startup failure - continue without remote settings
30
+ }
12
31
  // Initialize managers first
13
32
  try {
14
33
  const phaseStart = performance.now();
@@ -44,21 +63,6 @@ export class InitializationService {
44
63
  logger?.error("Failed to initialize MCP servers:", error);
45
64
  // Don't throw error to prevent app startup failure
46
65
  }
47
- // Load remote settings disk cache synchronously.
48
- // Must happen BEFORE loadMergedConfiguration so cached managed settings
49
- // (env, model, disallowedTools) are merged into the config. Settings `env`
50
- // is stored in the per-session env snapshot (NOT process.env), except
51
- // WAVE_SERVER_URL which is mirrored to process.env so the network fetch
52
- // (below) can read it via authService.getServerUrl(); no race.
53
- try {
54
- const phaseStart = performance.now();
55
- await remoteSettingsService.initialize();
56
- logger?.debug(`Initialization Phase [Remote Settings Cache] took ${(performance.now() - phaseStart).toFixed(2)}ms`);
57
- }
58
- catch (error) {
59
- logger?.error("Failed to initialize remote settings:", error);
60
- // Don't throw error to prevent app startup failure - continue without remote settings
61
- }
62
66
  // Initialize hooks configuration
63
67
  try {
64
68
  const phaseStart = performance.now();
@@ -18,11 +18,19 @@ export interface InteractionContext {
18
18
  taskManager: TaskManager;
19
19
  options: AgentOptions;
20
20
  abortMessage: () => void;
21
+ /**
22
+ * Move every directory-derived piece of session state (session transcript
23
+ * location, project rules, memory caches) to another working directory.
24
+ * Used when restoring a session that lives in a different directory.
25
+ */
26
+ switchWorkdir: (workdir: string) => Promise<void>;
21
27
  }
22
28
  export declare class InteractionService {
23
29
  static sendMessage(context: InteractionContext, content: string, images?: Array<{
24
30
  path: string;
25
31
  mimeType: string;
26
32
  }>): Promise<void>;
27
- static restoreSession(context: InteractionContext, sessionId: string): Promise<void>;
33
+ static restoreSession(context: InteractionContext, sessionId: string, restoreOptions?: {
34
+ workdir?: string;
35
+ }): Promise<void>;
28
36
  }
@@ -75,8 +75,14 @@ export class InteractionService {
75
75
  // Loading state will be automatically updated by the useEffect that watches messages
76
76
  }
77
77
  }
78
- static async restoreSession(context, sessionId) {
79
- const { messageManager, hookManager, logger, subagentManager, taskManager, options, abortMessage, } = context;
78
+ static async restoreSession(context, sessionId, restoreOptions) {
79
+ const { messageManager, hookManager, logger, subagentManager, taskManager, options, abortMessage, switchWorkdir, } = context;
80
+ // The target session may live in another project directory. Everything
81
+ // below that reads a path (loading the transcript, the SessionStart hook's
82
+ // transcript path, the next append) must use the target directory, never
83
+ // the current one — otherwise the switch writes a second copy of the same
84
+ // session into the current project directory.
85
+ const targetWorkdir = restoreOptions?.workdir ?? messageManager.getWorkdir();
80
86
  // 1. Validation
81
87
  if (!sessionId || sessionId === messageManager.getSessionId()) {
82
88
  return; // No-op if session ID is invalid or already current
@@ -89,6 +95,9 @@ export class InteractionService {
89
95
  logger?.warn("Failed to save current session before restore:", error);
90
96
  // Continue with restoration even if save fails
91
97
  }
98
+ // 2b. Flush point for the session being left behind: it is about to be
99
+ // read from disk again by whoever lists sessions next.
100
+ await messageManager.reAppendSessionMetadata();
92
101
  // 3. Run SessionEnd hooks for the current session (cleanup before switching)
93
102
  const currentSessionId = messageManager.getSessionId();
94
103
  const currentTranscriptPath = messageManager.getTranscriptPath();
@@ -101,18 +110,29 @@ export class InteractionService {
101
110
  }
102
111
  }
103
112
  // 4. Load target session
104
- const sessionData = await loadSessionFromJsonl(sessionId, messageManager.getWorkdir());
113
+ const sessionData = await loadSessionFromJsonl(sessionId, targetWorkdir);
105
114
  if (!sessionData) {
106
115
  throw new Error(`Session not found: ${sessionId}`);
107
116
  }
108
- // 5. Clean current state
117
+ // 5. Move this session to the target directory. Only after the target
118
+ // transcript has loaded successfully, so a failed restore leaves the
119
+ // current session (and its directory) untouched.
120
+ if (targetWorkdir !== messageManager.getWorkdir()) {
121
+ await switchWorkdir(targetWorkdir);
122
+ }
123
+ // 6. Clean current state
109
124
  abortMessage(); // Abort any running operations
110
125
  subagentManager.cleanup(); // Clean up active subagents
111
- // 6. Rebuild usage (in correct order)
126
+ // 7. Rebuild usage (in correct order)
112
127
  messageManager.rebuildUsageFromMessages(sessionData.messages);
113
- // 7. Initialize session state last
128
+ // 8. Initialize session state last
114
129
  messageManager.initializeFromSession(sessionData);
115
- // 8. Run SessionStart hooks for the restored session and inject additional
130
+ // 8b. Flush point for the session we just took over. `initializeFromSession`
131
+ // has adopted the title recovered by the load-time whole-file scan, so
132
+ // this is where a title that had slid out of the tail window is written
133
+ // back at EOF (Claude Code's `adoptResumedSessionFile` does the same).
134
+ await messageManager.reAppendSessionMetadata();
135
+ // 9. Run SessionStart hooks for the restored session and inject additional
116
136
  // context as a meta user message (matches Claude Code's resume behavior:
117
137
  // SessionEnd then SessionStart, hook messages appended to the conversation)
118
138
  if (hookManager) {
@@ -131,7 +151,7 @@ export class InteractionService {
131
151
  }
132
152
  // Update task manager with the root session ID to ensure continuity across compactions
133
153
  taskManager.setTaskListId(sessionData.id);
134
- // 9. Load tasks for the restored session
154
+ // 10. Load tasks for the restored session
135
155
  const tasks = await taskManager.listTasks();
136
156
  options.callbacks?.onTasksChange?.(tasks);
137
157
  }
@@ -24,6 +24,20 @@ export interface SessionMetadataHeader {
24
24
  /** Git branch at creation time (`git branch --show-current`), when the directory is a git repo. */
25
25
  gitBranch?: string;
26
26
  }
27
+ /**
28
+ * User-set conversation title, appended as its own JSONL entry on rename.
29
+ * A dedicated entry type (rather than a field on the append-only metadata
30
+ * header) is what lets the title be changed after creation without rewriting
31
+ * the file: it is appended at rename time, and re-appended at EOF at the
32
+ * session's sparse flush points (`reAppendSessionMetadata` — exit, compaction,
33
+ * resume, rewind) rather than on every save.
34
+ */
35
+ export interface CustomTitleEntry {
36
+ type: "custom-title";
37
+ customTitle: string;
38
+ /** Session the title belongs to (the file is per-session; kept for traceability). */
39
+ sessionId?: string;
40
+ }
27
41
  /**
28
42
  * JSONL handler class for message persistence operations
29
43
  */
@@ -49,6 +63,26 @@ export declare class JsonlHandler {
49
63
  * Append a single message to JSONL file
50
64
  */
51
65
  appendMessage(filePath: string, message: Message): Promise<void>;
66
+ /**
67
+ * Append a user-set conversation title (`{"type":"custom-title",...}`).
68
+ *
69
+ * Appending — never rewriting — keeps the file's append-only contract intact
70
+ * and leaves the metadata header and every message untouched. The entry has
71
+ * no `timestamp`, so it can never be mistaken for a message by readers that
72
+ * filter on timestamps.
73
+ */
74
+ appendCustomTitle(filePath: string, customTitle: string, sessionId?: string): Promise<void>;
75
+ /**
76
+ * Read the session's user-set title from the tail window, if present.
77
+ *
78
+ * Scans the tail and keeps the newest entry, so a rename always wins over an
79
+ * earlier one. Only the tail is read: this is the cheap path used by session
80
+ * listing and by `reAppendSessionMetadata`'s external-writer refresh, and the
81
+ * write side re-appends the entry at EOF at its sparse flush points. An entry
82
+ * that later messages have already pushed out of the window is invisible
83
+ * here — that is what `readMessagesAndCustomTitle` recovers on resume.
84
+ */
85
+ readCustomTitle(filePath: string): Promise<string | undefined>;
52
86
  /**
53
87
  * Append multiple messages to JSONL file
54
88
  */
@@ -61,6 +95,24 @@ export declare class JsonlHandler {
61
95
  * Read all messages from JSONL file (simplified - no metadata handling)
62
96
  */
63
97
  read(filePath: string): Promise<Message[]>;
98
+ /**
99
+ * Read the whole file once, returning every message plus the newest
100
+ * `custom-title` entry.
101
+ *
102
+ * `readCustomTitle` only scans the tail window, so a title that later
103
+ * messages pushed out of it is invisible there. The whole file still holds
104
+ * it, and the resume path collects it here: that recovered value is what a
105
+ * later `reAppendSessionMetadata` writes back at EOF, which is what makes an
106
+ * evicted title self-heal instead of being lost for good.
107
+ *
108
+ * @param filePath - Path to the session JSONL file
109
+ * @returns The messages (reserved entries filtered out) and the newest
110
+ * non-empty custom title, when the file carries one
111
+ */
112
+ readMessagesAndCustomTitle(filePath: string): Promise<{
113
+ messages: Message[];
114
+ customTitle?: string;
115
+ }>;
64
116
  /**
65
117
  * Get the last message from JSONL file using efficient file reading (simplified)
66
118
  */
@@ -89,6 +141,38 @@ export declare class JsonlHandler {
89
141
  * @returns The persisted metadata, or null when the file has no header
90
142
  */
91
143
  readMetadata(filePath: string): Promise<SessionMetadataHeader | null>;
144
+ /**
145
+ * Rewrite a session file keeping only its first `keepMessageCount` messages.
146
+ *
147
+ * This is the one operation that removes history from a session file
148
+ * (`/rewind`); every other write appends. It is a filter over the file's
149
+ * original lines rather than a re-serialization of parsed messages:
150
+ *
151
+ * - Reserved entries (`metadata` header, `custom-title`, and any type added
152
+ * to `RESERVED_ENTRY_TYPES` later) are carried over byte for byte, wherever
153
+ * they sit in the file. Rebuilding the file from `Message[]` used to lose
154
+ * them — `read()` filters reserved entries out, so a rewrite could not see
155
+ * the header or the title — which silently degraded the session to the
156
+ * "legacy file without a header" shape: `createdAt` fell back to
157
+ * `new Date()`, `gitBranch` vanished, and the title could never be read
158
+ * back again.
159
+ * - Lines that are neither reserved nor a message (no `timestamp`) are also
160
+ * carried over, so an entry type written by a newer version survives a
161
+ * rewind performed by an older one.
162
+ * - Only message lines past `keepMessageCount` are dropped.
163
+ *
164
+ * The result is written to a temp file and renamed into place, so an
165
+ * interrupted rewrite leaves the original file untouched.
166
+ *
167
+ * The "is this a message line" test deliberately mirrors `read()`
168
+ * (`!isReservedEntry(type) && timestamp`) and the partial-trailing-line
169
+ * tolerance mirrors its interrupted-append handling, so the caller's message
170
+ * count and this file's line order stay in step.
171
+ *
172
+ * @param filePath - Session JSONL path (must already exist)
173
+ * @param keepMessageCount - Number of leading messages to keep
174
+ */
175
+ truncateSession(filePath: string, keepMessageCount: number): Promise<void>;
92
176
  /**
93
177
  * Validate messages before writing
94
178
  */
@@ -2,10 +2,17 @@
2
2
  * JSONL file operations service
3
3
  * Handles reading and writing JSONL (JSON Lines) session files for improved performance
4
4
  */
5
- import { appendFile, readFile, writeFile, stat, mkdir } from "fs/promises";
5
+ import { appendFile, readFile, writeFile, stat, mkdir, rename, unlink, } from "fs/promises";
6
+ import { randomUUID } from "crypto";
6
7
  import { dirname } from "path";
7
- import { getLastLine, readFirstNLines, readTailLines, } from "../utils/fileUtils.js";
8
+ import { forEachLine, getLastLine, readFirstNLines, readTailLines, } from "../utils/fileUtils.js";
8
9
  import { extractLatestTotalTokens } from "../utils/tokenCalculation.js";
10
+ import { isReservedEntry } from "./sessionEntries.js";
11
+ /**
12
+ * Flush threshold for `truncateSession`'s output buffer. Only bounds the
13
+ * rewrite's memory — the file it produces is identical regardless of value.
14
+ */
15
+ const TRUNCATE_FLUSH_BYTES = 64 * 1024;
9
16
  /**
10
17
  * JSONL handler class for message persistence operations
11
18
  */
@@ -43,6 +50,49 @@ export class JsonlHandler {
43
50
  async appendMessage(filePath, message) {
44
51
  return this.appendMessages(filePath, [message]);
45
52
  }
53
+ /**
54
+ * Append a user-set conversation title (`{"type":"custom-title",...}`).
55
+ *
56
+ * Appending — never rewriting — keeps the file's append-only contract intact
57
+ * and leaves the metadata header and every message untouched. The entry has
58
+ * no `timestamp`, so it can never be mistaken for a message by readers that
59
+ * filter on timestamps.
60
+ */
61
+ async appendCustomTitle(filePath, customTitle, sessionId) {
62
+ const entry = {
63
+ type: "custom-title",
64
+ customTitle,
65
+ ...(sessionId ? { sessionId } : {}),
66
+ };
67
+ await this.ensureDirectory(dirname(filePath));
68
+ await appendFile(filePath, `${JSON.stringify(entry)}\n`, "utf8");
69
+ }
70
+ /**
71
+ * Read the session's user-set title from the tail window, if present.
72
+ *
73
+ * Scans the tail and keeps the newest entry, so a rename always wins over an
74
+ * earlier one. Only the tail is read: this is the cheap path used by session
75
+ * listing and by `reAppendSessionMetadata`'s external-writer refresh, and the
76
+ * write side re-appends the entry at EOF at its sparse flush points. An entry
77
+ * that later messages have already pushed out of the window is invisible
78
+ * here — that is what `readMessagesAndCustomTitle` recovers on resume.
79
+ */
80
+ async readCustomTitle(filePath) {
81
+ for (const line of (await readTailLines(filePath)).reverse()) {
82
+ try {
83
+ const parsed = JSON.parse(line);
84
+ if (parsed.type !== "custom-title")
85
+ continue;
86
+ return typeof parsed.customTitle === "string" && parsed.customTitle
87
+ ? parsed.customTitle
88
+ : undefined;
89
+ }
90
+ catch {
91
+ // Partial line at the tail window's boundary — keep looking.
92
+ }
93
+ }
94
+ return undefined;
95
+ }
46
96
  /**
47
97
  * Append multiple messages to JSONL file
48
98
  */
@@ -87,6 +137,23 @@ export class JsonlHandler {
87
137
  * Read all messages from JSONL file (simplified - no metadata handling)
88
138
  */
89
139
  async read(filePath) {
140
+ return (await this.readMessagesAndCustomTitle(filePath)).messages;
141
+ }
142
+ /**
143
+ * Read the whole file once, returning every message plus the newest
144
+ * `custom-title` entry.
145
+ *
146
+ * `readCustomTitle` only scans the tail window, so a title that later
147
+ * messages pushed out of it is invisible there. The whole file still holds
148
+ * it, and the resume path collects it here: that recovered value is what a
149
+ * later `reAppendSessionMetadata` writes back at EOF, which is what makes an
150
+ * evicted title self-heal instead of being lost for good.
151
+ *
152
+ * @param filePath - Path to the session JSONL file
153
+ * @returns The messages (reserved entries filtered out) and the newest
154
+ * non-empty custom title, when the file carries one
155
+ */
156
+ async readMessagesAndCustomTitle(filePath) {
90
157
  try {
91
158
  const content = await readFile(filePath, "utf8");
92
159
  // append() always terminates a written batch with "\n" (see append),
@@ -98,16 +165,26 @@ export class JsonlHandler {
98
165
  .map((line) => line.trim())
99
166
  .filter((line) => line.length > 0);
100
167
  if (lines.length === 0) {
101
- return [];
168
+ return { messages: [] };
102
169
  }
103
170
  const allMessages = [];
104
- // Parse all messages, skipping the metadata header line (if any)
171
+ let customTitle;
172
+ // Parse every line: real messages are kept, reserved entries (the
173
+ // metadata header, and custom titles) are read for their own fields.
105
174
  for (let i = 0; i < lines.length; i++) {
106
175
  const line = lines[i];
107
176
  try {
108
177
  const message = JSON.parse(line);
109
- // Metadata header line: not a message, skip
110
- if (message.type === "metadata")
178
+ if (message.type === "custom-title") {
179
+ // Last one wins: a later rename supersedes an earlier one.
180
+ if (typeof message.customTitle === "string" &&
181
+ message.customTitle) {
182
+ customTitle = message.customTitle;
183
+ }
184
+ continue;
185
+ }
186
+ // Any other reserved entry (metadata header): not a message
187
+ if (isReservedEntry(message.type))
111
188
  continue;
112
189
  if (message.timestamp)
113
190
  allMessages.push(message);
@@ -125,11 +202,11 @@ export class JsonlHandler {
125
202
  throw new Error(`Invalid JSON at line ${i + 1}: ${error}`);
126
203
  }
127
204
  }
128
- return allMessages;
205
+ return { messages: allMessages, customTitle };
129
206
  }
130
207
  catch (error) {
131
208
  if (error.code === "ENOENT") {
132
- return [];
209
+ return { messages: [] };
133
210
  }
134
211
  throw new Error(`Failed to read JSONL file "${filePath}": ${error}`);
135
212
  }
@@ -156,15 +233,28 @@ export class JsonlHandler {
156
233
  }
157
234
  try {
158
235
  const parsed = JSON.parse(lastLine);
159
- // A file whose only line is the metadata header has no messages yet
160
- if (parsed.type === "metadata") {
161
- return null;
236
+ if (!isReservedEntry(parsed.type)) {
237
+ return parsed;
162
238
  }
163
- return parsed;
164
239
  }
165
240
  catch (error) {
166
241
  throw new Error(`Invalid JSON in last line of "${filePath}": ${error}`);
167
242
  }
243
+ // The file ends with a reserved entry — a `custom-title` appended by a
244
+ // rename, or the metadata header of a session with no messages yet. Both
245
+ // are non-messages, so walk the tail window back to the newest message.
246
+ for (const line of (await readTailLines(filePath)).reverse()) {
247
+ try {
248
+ const parsed = JSON.parse(line);
249
+ if (isReservedEntry(parsed.type))
250
+ continue;
251
+ return parsed;
252
+ }
253
+ catch {
254
+ // Partial line at the tail window's boundary — keep looking.
255
+ }
256
+ }
257
+ return null;
168
258
  }
169
259
  catch (error) {
170
260
  throw new Error(`Failed to get last message from "${filePath}": ${error}`);
@@ -188,8 +278,8 @@ export class JsonlHandler {
188
278
  for (const line of await readTailLines(filePath)) {
189
279
  try {
190
280
  const parsed = JSON.parse(line);
191
- // Metadata header line: not a message, skip
192
- if (parsed.type === "metadata")
281
+ // Reserved entry (metadata header, custom-title): not a message
282
+ if (isReservedEntry(parsed.type))
193
283
  continue;
194
284
  messages.push(parsed);
195
285
  }
@@ -229,6 +319,111 @@ export class JsonlHandler {
229
319
  }
230
320
  return null;
231
321
  }
322
+ /**
323
+ * Rewrite a session file keeping only its first `keepMessageCount` messages.
324
+ *
325
+ * This is the one operation that removes history from a session file
326
+ * (`/rewind`); every other write appends. It is a filter over the file's
327
+ * original lines rather than a re-serialization of parsed messages:
328
+ *
329
+ * - Reserved entries (`metadata` header, `custom-title`, and any type added
330
+ * to `RESERVED_ENTRY_TYPES` later) are carried over byte for byte, wherever
331
+ * they sit in the file. Rebuilding the file from `Message[]` used to lose
332
+ * them — `read()` filters reserved entries out, so a rewrite could not see
333
+ * the header or the title — which silently degraded the session to the
334
+ * "legacy file without a header" shape: `createdAt` fell back to
335
+ * `new Date()`, `gitBranch` vanished, and the title could never be read
336
+ * back again.
337
+ * - Lines that are neither reserved nor a message (no `timestamp`) are also
338
+ * carried over, so an entry type written by a newer version survives a
339
+ * rewind performed by an older one.
340
+ * - Only message lines past `keepMessageCount` are dropped.
341
+ *
342
+ * The result is written to a temp file and renamed into place, so an
343
+ * interrupted rewrite leaves the original file untouched.
344
+ *
345
+ * The "is this a message line" test deliberately mirrors `read()`
346
+ * (`!isReservedEntry(type) && timestamp`) and the partial-trailing-line
347
+ * tolerance mirrors its interrupted-append handling, so the caller's message
348
+ * count and this file's line order stay in step.
349
+ *
350
+ * @param filePath - Session JSONL path (must already exist)
351
+ * @param keepMessageCount - Number of leading messages to keep
352
+ */
353
+ async truncateSession(filePath, keepMessageCount) {
354
+ const tempPath = `${filePath}.tmp.${process.pid}.${randomUUID()}`;
355
+ try {
356
+ // Materialize the temp file up front so an empty result still renames.
357
+ await writeFile(tempPath, "", "utf8");
358
+ let keptMessages = 0;
359
+ let pendingBrokenLine = false;
360
+ let buffered = [];
361
+ let bufferedLength = 0;
362
+ const flush = async () => {
363
+ if (buffered.length === 0) {
364
+ return;
365
+ }
366
+ await appendFile(tempPath, buffered.join(""), "utf8");
367
+ buffered = [];
368
+ bufferedLength = 0;
369
+ };
370
+ const keepLine = async (line) => {
371
+ const chunk = `${line}\n`;
372
+ buffered.push(chunk);
373
+ bufferedLength += chunk.length;
374
+ if (bufferedLength >= TRUNCATE_FLUSH_BYTES) {
375
+ await flush();
376
+ }
377
+ };
378
+ const handleLine = async (raw) => {
379
+ const line = raw.trim();
380
+ if (line.length === 0) {
381
+ return;
382
+ }
383
+ let parsed;
384
+ try {
385
+ parsed = JSON.parse(line);
386
+ }
387
+ catch (error) {
388
+ if (pendingBrokenLine) {
389
+ throw new Error(`Invalid JSON in session file "${filePath}": ${error}`);
390
+ }
391
+ pendingBrokenLine = true;
392
+ return;
393
+ }
394
+ // A parseable line after a broken one proves the broken line was not
395
+ // the file's last, so it is corruption rather than a partial write.
396
+ if (pendingBrokenLine) {
397
+ throw new Error(`Invalid JSON in session file "${filePath}": truncated trailing line`);
398
+ }
399
+ if (isReservedEntry(parsed.type) || !parsed.timestamp) {
400
+ await keepLine(line);
401
+ return;
402
+ }
403
+ if (keptMessages < keepMessageCount) {
404
+ keptMessages++;
405
+ await keepLine(line);
406
+ }
407
+ };
408
+ const { endsWithNewline } = await forEachLine(filePath, handleLine);
409
+ // A broken line is only acceptable as the last line of a file that lacks
410
+ // a trailing newline — the residue of an interrupted append.
411
+ if (pendingBrokenLine && endsWithNewline) {
412
+ throw new Error(`Invalid JSON in session file "${filePath}"`);
413
+ }
414
+ await flush();
415
+ await rename(tempPath, filePath);
416
+ }
417
+ catch (error) {
418
+ try {
419
+ await unlink(tempPath);
420
+ }
421
+ catch {
422
+ // Cleanup is best effort — the original error is more important.
423
+ }
424
+ throw error;
425
+ }
426
+ }
232
427
  /**
233
428
  * Validate messages before writing
234
429
  */
@@ -19,7 +19,9 @@ export declare class MemoryService {
19
19
  */
20
20
  ensureAutoMemoryDirectory(workdir: string): Promise<void>;
21
21
  /**
22
- * Get the first 200 lines of MEMORY.md.
22
+ * Get the auto-memory entrypoint (`MEMORY.md`), bounded by both the line and
23
+ * the character cap. Truncation appends a warning naming the cap that fired,
24
+ * so the model can tell the index it is reading is incomplete.
23
25
  */
24
26
  getAutoMemoryContent(workdir: string): Promise<string>;
25
27
  ensureUserMemoryFile(): Promise<void>;
@@ -3,8 +3,11 @@ import * as path from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { USER_MEMORY_FILE, DATA_DIRECTORY } from "../utils/constants.js";
5
5
  import { logger } from "../utils/globalLogger.js";
6
+ import { atomicWriteFile } from "../utils/atomicWrite.js";
6
7
  import { getGitCommonDir } from "../utils/gitUtils.js";
7
8
  import { pathEncoder } from "../utils/pathEncoder.js";
9
+ import { MEMORY_ENTRYPOINT_NAME } from "../constants/memory.js";
10
+ import { truncateEntrypointContent } from "../utils/memoryEntrypoint.js";
8
11
  export class MemoryService {
9
12
  constructor(container) {
10
13
  this.container = container;
@@ -51,7 +54,7 @@ export class MemoryService {
51
54
  catch (error) {
52
55
  if (error.code === "ENOENT") {
53
56
  const initialContent = "# Project Memory\n\nThis file serves as an index for the project's auto-memory. Wave uses this to track knowledge across sessions.\n\n";
54
- await fs.writeFile(memoryFile, initialContent, "utf-8");
57
+ await atomicWriteFile(memoryFile, initialContent);
55
58
  logger.debug(`Created auto-memory file: ${memoryFile}`);
56
59
  }
57
60
  else {
@@ -65,18 +68,19 @@ export class MemoryService {
65
68
  }
66
69
  }
67
70
  /**
68
- * Get the first 200 lines of MEMORY.md.
71
+ * Get the auto-memory entrypoint (`MEMORY.md`), bounded by both the line and
72
+ * the character cap. Truncation appends a warning naming the cap that fired,
73
+ * so the model can tell the index it is reading is incomplete.
69
74
  */
70
75
  async getAutoMemoryContent(workdir) {
71
76
  if (this._cachedAutoMemoryContent !== null) {
72
77
  return this._cachedAutoMemoryContent;
73
78
  }
74
79
  const memoryDir = this.getAutoMemoryDirectory(workdir);
75
- const memoryFile = path.join(memoryDir, "MEMORY.md");
80
+ const memoryFile = path.join(memoryDir, MEMORY_ENTRYPOINT_NAME);
76
81
  try {
77
- const content = await fs.readFile(memoryFile, "utf-8");
78
- const lines = content.split("\n").slice(0, 200);
79
- this._cachedAutoMemoryContent = lines.join("\n");
82
+ const raw = await fs.readFile(memoryFile, "utf-8");
83
+ this._cachedAutoMemoryContent = truncateEntrypointContent(raw).content;
80
84
  return this._cachedAutoMemoryContent;
81
85
  }
82
86
  catch (error) {
@@ -103,7 +107,7 @@ export class MemoryService {
103
107
  userMemoryFile: USER_MEMORY_FILE,
104
108
  });
105
109
  const initialContent = "# User Memory\n\nThis is the user-level memory file, recording important information and context across projects.\n\n";
106
- await fs.writeFile(USER_MEMORY_FILE, initialContent, "utf-8");
110
+ await atomicWriteFile(USER_MEMORY_FILE, initialContent);
107
111
  logger.debug(`Created user memory file: ${USER_MEMORY_FILE}`);
108
112
  }
109
113
  else {
@@ -223,7 +227,7 @@ export class MemoryService {
223
227
  */
224
228
  async writeUserMemoryContent(content) {
225
229
  await this.ensureUserMemoryFile();
226
- await fs.writeFile(USER_MEMORY_FILE, content, "utf-8");
230
+ await atomicWriteFile(USER_MEMORY_FILE, content);
227
231
  this._cachedUserMemory = null;
228
232
  this._cachedCombinedMemory = null;
229
233
  logger.debug("User memory content written", {
@@ -240,7 +244,7 @@ export class MemoryService {
240
244
  async writeProjectMemoryContent(workdir, content) {
241
245
  const memoryFilePath = path.join(workdir, "AGENTS.md");
242
246
  await fs.mkdir(workdir, { recursive: true });
243
- await fs.writeFile(memoryFilePath, content, "utf-8");
247
+ await atomicWriteFile(memoryFilePath, content);
244
248
  this._cachedProjectMemory = null;
245
249
  this._cachedCombinedMemory = null;
246
250
  logger.debug("Project memory content written", {
@@ -37,8 +37,9 @@ const STAGING_SUFFIX = ".staging";
37
37
  * - TEST(验证用): `https://codechat.codewave-test.163yun.com/wave-plugins-official/`
38
38
  *
39
39
  * Override with env var `WAVE_OFFICIAL_MARKET_MIRROR_BASE_URL` (highest
40
- * precedence). 注意:prod 该 URL 的 ingress/内容尚未上线,镜像请求会 404 → 按既有
41
- * 设计回退 git 兜底(ALLOW_OFFICIAL_MARKET_GIT_FALLBACK),行为安全。
40
+ * precedence). 两套环境的镜像通道均已上线且有内容(2026-09-23 实测:prod 与 test 的
41
+ * `/latest` 均返回 sha、`{sha}.zip` 均可下载)。镜像请求若失败(网络/超时/404/内容异常),
42
+ * 仍按既有设计回退 git 兜底(ALLOW_OFFICIAL_MARKET_GIT_FALLBACK),语义不变。
42
43
  */
43
44
  const DEFAULT_OFFICIAL_MARKET_MIRROR_BASE_URL = "https://codechat.codewave.163.com/wave-plugins-official/";
44
45
  /** Env var override for the mirror base URL (highest precedence). */