newmark-agent 0.5.7 → 0.5.9

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.
@@ -43,9 +43,6 @@ const child_process_1 = require("child_process");
43
43
  const agentKernelDiagnostics_1 = require("../core/agentKernelDiagnostics");
44
44
  const chat_messages_1 = require("../providers/chat-messages");
45
45
  const providers_1 = require("../providers");
46
- // Keep provider requests below the release-harness/user-visible command
47
- // deadline. A provider that does not answer must produce one bounded error;
48
- // it must not restart the same request through every Windows transport.
49
46
  // Provider responses are intentionally unbounded. User cancellation, transport
50
47
  // errors, and tool-specific limits remain the only automatic stop conditions.
51
48
  const DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS = 0;
@@ -939,7 +936,7 @@ class LLMProvider {
939
936
  * `provider_adapters_v2` context flag. Request serialization and SSE
940
937
  * normalization are delegated to the shared provider adapters while the
941
938
  * transport orchestration (loopback node-http, fetch -> node-http fallback,
942
- * 120s/30s timeouts) and the 4xx Chat -> Responses downgrade stay here.
939
+ * cancellation-only streaming and the 4xx Chat -> Responses downgrade) stay here.
943
940
  * The emitted request body and StreamToken stream are byte-equivalent to
944
941
  * the legacy inlined path.
945
942
  */
@@ -1082,9 +1079,9 @@ class LLMProvider {
1082
1079
  }
1083
1080
  /**
1084
1081
  * Loopback-aware transport injected into adapter `execute`. Streaming
1085
- * requests retain the fetch-to-node fallback for transport failures, while
1086
- * a local deadline is returned directly so one request cannot become a
1087
- * second Windows fallback request.
1082
+ * requests retain the fetch-to-node fallback for transport failures. They
1083
+ * have no response deadline; only caller cancellation or a concrete
1084
+ * transport/provider failure may end the request.
1088
1085
  */
1089
1086
  buildProviderAdapterTransport() {
1090
1087
  return async (request, signal) => {
@@ -1096,7 +1093,9 @@ class LLMProvider {
1096
1093
  forwardAbort();
1097
1094
  else
1098
1095
  signal?.addEventListener('abort', forwardAbort, { once: true });
1099
- const effectiveTimeout = this.effectiveRequestTimeout(120000);
1096
+ // Streaming provider responses are intentionally unbounded. Only
1097
+ // caller cancellation or an explicit provider failure may end them.
1098
+ const effectiveTimeout = 0;
1100
1099
  const timer = effectiveTimeout > 0
1101
1100
  ? setTimeout(() => abort.abort(providerTimeoutError(effectiveTimeout)), effectiveTimeout)
1102
1101
  : undefined;
@@ -1247,7 +1246,9 @@ class LLMProvider {
1247
1246
  forwardAbort();
1248
1247
  else
1249
1248
  signal?.addEventListener('abort', forwardAbort, { once: true });
1250
- const effectiveTimeout = this.effectiveRequestTimeout(120000);
1249
+ // Streaming provider responses are intentionally unbounded. Do not turn
1250
+ // silence into an empty-response failure.
1251
+ const effectiveTimeout = 0;
1251
1252
  const timeout = effectiveTimeout > 0
1252
1253
  ? setTimeout(() => abort.abort(providerTimeoutError(effectiveTimeout)), effectiveTimeout)
1253
1254
  : undefined;
@@ -1285,10 +1286,14 @@ class LLMProvider {
1285
1286
  }
1286
1287
  const decoder = new TextDecoder();
1287
1288
  let buffer = '';
1288
- let currentToolCall = null;
1289
+ const toolCalls = new Map();
1290
+ const toolCallOrder = [];
1291
+ let syntheticToolIndex = 0;
1292
+ let lastToolIndex = 0;
1289
1293
  let currentReasoningContent = '';
1290
1294
  let contentPolicyBlocked = false;
1291
1295
  let emittedContent = false;
1296
+ let explicitCompletion = false;
1292
1297
  const streamSignal = signal || new AbortController().signal;
1293
1298
  while (true) {
1294
1299
  const { done, value } = await (0, providers_1.readProviderStreamChunk)(reader, streamSignal);
@@ -1302,8 +1307,10 @@ class LLMProvider {
1302
1307
  if (!trimmed.startsWith('data: '))
1303
1308
  continue;
1304
1309
  const data = trimmed.slice(6);
1305
- if (data === '[DONE]')
1310
+ if (data === '[DONE]') {
1311
+ explicitCompletion = true;
1306
1312
  continue;
1313
+ }
1307
1314
  try {
1308
1315
  const json = JSON.parse(data);
1309
1316
  if (json.usage)
@@ -1324,27 +1331,54 @@ class LLMProvider {
1324
1331
  }
1325
1332
  if (delta.tool_calls) {
1326
1333
  for (const tc of delta.tool_calls) {
1327
- if (tc.id) {
1328
- if (currentToolCall) {
1329
- yield { type: 'tool_call', text: '', toolCall: currentToolCall, reasoningContent: currentReasoningContent || undefined };
1330
- }
1331
- currentToolCall = { id: tc.id, name: tc.function?.name || '', arguments: tc.function?.arguments || '' };
1332
- }
1333
- else if (tc.function?.arguments && currentToolCall) {
1334
- currentToolCall.arguments += tc.function.arguments;
1334
+ const rawIndex = Number(tc.index);
1335
+ const index = Number.isInteger(rawIndex) && rawIndex >= 0
1336
+ ? rawIndex
1337
+ : (tc.id ? syntheticToolIndex++ : lastToolIndex);
1338
+ lastToolIndex = index;
1339
+ let call = toolCalls.get(index);
1340
+ if (!call && (tc.id || tc.function?.name)) {
1341
+ call = { id: tc.id || '', name: tc.function?.name || '', argumentParts: [] };
1342
+ toolCalls.set(index, call);
1343
+ toolCallOrder.push(index);
1335
1344
  }
1345
+ if (!call)
1346
+ continue;
1347
+ if (tc.id && !call.id)
1348
+ call.id = tc.id;
1349
+ if (tc.function?.name && !call.name)
1350
+ call.name = tc.function.name;
1351
+ if (tc.function?.arguments)
1352
+ call.argumentParts.push(tc.function.arguments);
1336
1353
  }
1337
1354
  }
1338
1355
  }
1339
1356
  catch { /* skip malformed JSON */ }
1340
1357
  }
1341
1358
  }
1342
- if (currentToolCall && currentToolCall.arguments) {
1343
- yield { type: 'tool_call', text: '', toolCall: currentToolCall, reasoningContent: currentReasoningContent || undefined };
1359
+ if (toolCallOrder.length) {
1360
+ for (const index of toolCallOrder) {
1361
+ const call = toolCalls.get(index);
1362
+ if (!call)
1363
+ continue;
1364
+ yield {
1365
+ type: 'tool_call',
1366
+ text: '',
1367
+ toolCall: {
1368
+ id: call.id,
1369
+ name: call.name,
1370
+ arguments: (0, providers_1.assembleCompatibleToolArguments)(call.argumentParts),
1371
+ },
1372
+ reasoningContent: currentReasoningContent || undefined,
1373
+ };
1374
+ }
1344
1375
  }
1345
1376
  else if (!emittedContent && contentPolicyBlocked) {
1346
1377
  yield { type: 'text', text: '[Error] Content policy refusal (content_filter).' };
1347
1378
  }
1379
+ else if (!explicitCompletion && !emittedContent && !currentReasoningContent) {
1380
+ yield { type: 'text', text: '[LLM Error] GitHub Models stream ended before an explicit completion.' };
1381
+ }
1348
1382
  }
1349
1383
  finally {
1350
1384
  reader?.releaseLock();
package/dist/main.js CHANGED
@@ -77,6 +77,7 @@ const compat_1 = require("./core/compat");
77
77
  const dshCompatibility_1 = require("./core/dshCompatibility");
78
78
  const mcpManager_1 = require("./core/mcpManager");
79
79
  const workEventCoalescer_1 = require("./core/workEventCoalescer");
80
+ const terminalOutputBuffer_1 = require("./core/terminalOutputBuffer");
80
81
  const cli_help_1 = require("./cli-help");
81
82
  const APP_NAME = 'Newmark Agent';
82
83
  const APP_ID = 'ai.newmark.agent';
@@ -4451,10 +4452,16 @@ else {
4451
4452
  }
4452
4453
  });
4453
4454
  const ptySessions = new Map();
4454
- const sendTerminalData = (sessionId, session, text) => {
4455
- session.buffer = `${session.buffer}${text}`.slice(-256 * 1024);
4455
+ const terminalOutput = new terminalOutputBuffer_1.TerminalOutputBuffer((sessionId, text) => {
4456
+ const session = ptySessions.get(sessionId);
4457
+ if (session)
4458
+ session.buffer = terminalOutput.history(sessionId);
4456
4459
  if (mainWindow && !mainWindow.isDestroyed())
4457
4460
  mainWindow.webContents.send('pty:data', sessionId, text);
4461
+ });
4462
+ const sendTerminalData = (sessionId, session, text) => {
4463
+ terminalOutput.push(sessionId, text);
4464
+ session.buffer = terminalOutput.history(sessionId);
4458
4465
  };
4459
4466
  electron_1.ipcMain.handle('pty:spawn', async (_event, shellId) => {
4460
4467
  const sessionId = (0, crypto_1.randomUUID)().slice(0, 8);
@@ -4476,7 +4483,9 @@ else {
4476
4483
  ptySessions.set(sessionId, session);
4477
4484
  proc.onData(text => sendTerminalData(sessionId, session, text));
4478
4485
  proc.onExit(event => {
4486
+ terminalOutput.flush(sessionId);
4479
4487
  ptySessions.delete(sessionId);
4488
+ terminalOutput.close(sessionId);
4480
4489
  if (mainWindow && !mainWindow.isDestroyed()) {
4481
4490
  mainWindow.webContents.send('pty:exit', sessionId, event.exitCode);
4482
4491
  }
@@ -4534,7 +4543,7 @@ else {
4534
4543
  const session = ptySessions.get(sessionId);
4535
4544
  if (!session)
4536
4545
  return { buffer: '' };
4537
- return { buffer: session.buffer };
4546
+ return { buffer: terminalOutput.history(sessionId) || session.buffer };
4538
4547
  });
4539
4548
  const terminalOwnerFor = (conversationId, actorId) => (0, terminalTakeover_1.normalizeTerminalTakeoverOwner)({
4540
4549
  backend: wslBackendEnabled() ? 'wsl' : (process.platform === 'win32' ? 'windows' : process.platform),
@@ -105,6 +105,8 @@ class ChatCompletionsAdapter {
105
105
  let contentPolicyBlocked = false;
106
106
  let emittedContent = false;
107
107
  let emittedTool = false;
108
+ let emittedReasoning = false;
109
+ let explicitCompletion = false;
108
110
  try {
109
111
  while (true) {
110
112
  const { done, value } = await (0, provider_events_1.readProviderStreamChunk)(reader, signal);
@@ -118,8 +120,10 @@ class ChatCompletionsAdapter {
118
120
  if (!trimmed.startsWith('data: '))
119
121
  continue;
120
122
  const data = trimmed.slice(6);
121
- if (data === '[DONE]')
123
+ if (data === '[DONE]') {
124
+ explicitCompletion = true;
122
125
  continue;
126
+ }
123
127
  let json;
124
128
  try {
125
129
  json = JSON.parse(data);
@@ -135,13 +139,18 @@ class ChatCompletionsAdapter {
135
139
  if ((0, provider_events_1.isContentPolicyBlocked)(json))
136
140
  contentPolicyBlocked = true;
137
141
  const choices = Array.isArray(json.choices) ? json.choices : [];
138
- const delta = choices[0]?.delta;
142
+ const choice = choices[0];
143
+ if (choice?.finish_reason !== undefined && choice.finish_reason !== null)
144
+ explicitCompletion = true;
145
+ const delta = choice?.delta;
139
146
  if (!delta)
140
147
  continue;
141
148
  if (delta.reasoning_content) {
142
149
  const reasoning = this.extractText(delta.reasoning_content);
143
- if (reasoning)
150
+ if (reasoning) {
151
+ emittedReasoning = true;
144
152
  yield { type: 'reasoning.summary.delta', delta: reasoning };
153
+ }
145
154
  }
146
155
  const textDelta = this.extractText(delta.content);
147
156
  if (textDelta) {
@@ -190,7 +199,7 @@ class ChatCompletionsAdapter {
190
199
  type: 'tool_call.completed',
191
200
  id: currentToolCall.id,
192
201
  name: currentToolCall.name,
193
- arguments: currentToolCall.argumentParts.join(''),
202
+ arguments: (0, provider_events_1.assembleCompatibleToolArguments)(currentToolCall.argumentParts),
194
203
  };
195
204
  }
196
205
  }
@@ -198,6 +207,10 @@ class ChatCompletionsAdapter {
198
207
  yield { type: 'response.failed', error: '[Error] Content policy refusal (content_filter).' };
199
208
  return;
200
209
  }
210
+ if (!explicitCompletion && !emittedContent && !emittedTool && !emittedReasoning) {
211
+ yield { type: 'response.failed', error: '[LLM Error] Chat stream ended before an explicit completion.' };
212
+ return;
213
+ }
201
214
  yield { type: 'response.completed' };
202
215
  }
203
216
  finally {
@@ -29,6 +29,13 @@ export declare function parseProviderSse(raw: string): Array<{
29
29
  event?: string;
30
30
  data: string;
31
31
  }>;
32
+ /**
33
+ * Compatible gateways may stream function arguments as JSON deltas or repeat
34
+ * a cumulative snapshot on every SSE frame. Keep the normal incremental form
35
+ * when it parses, then fall back to snapshot folding. Returning malformed
36
+ * concatenated snapshots would erase the model's correction at tool parsing.
37
+ */
38
+ export declare function assembleCompatibleToolArguments(parts: string[]): string;
32
39
  /**
33
40
  * Detect content-policy refusals across the provider failure shapes.
34
41
  * Mirrors `LLMProvider.contentPolicyBlocked` semantics exactly so the
@@ -5,6 +5,7 @@ exports.providerAbortError = providerAbortError;
5
5
  exports.providerStreamTimeoutError = providerStreamTimeoutError;
6
6
  exports.readProviderStreamChunk = readProviderStreamChunk;
7
7
  exports.parseProviderSse = parseProviderSse;
8
+ exports.assembleCompatibleToolArguments = assembleCompatibleToolArguments;
8
9
  exports.isContentPolicyBlocked = isContentPolicyBlocked;
9
10
  exports.normalizeProviderUsage = normalizeProviderUsage;
10
11
  exports.estimateRequestTokens = estimateRequestTokens;
@@ -105,6 +106,45 @@ function parseProviderSse(raw) {
105
106
  }
106
107
  return events;
107
108
  }
109
+ /**
110
+ * Compatible gateways may stream function arguments as JSON deltas or repeat
111
+ * a cumulative snapshot on every SSE frame. Keep the normal incremental form
112
+ * when it parses, then fall back to snapshot folding. Returning malformed
113
+ * concatenated snapshots would erase the model's correction at tool parsing.
114
+ */
115
+ function assembleCompatibleToolArguments(parts) {
116
+ const nonEmpty = (parts || []).map(String).filter(part => part && part !== 'null');
117
+ if (!nonEmpty.length)
118
+ return '{}';
119
+ const isJsonObject = (value) => {
120
+ try {
121
+ const parsed = JSON.parse(value);
122
+ return !!parsed && typeof parsed === 'object' && !Array.isArray(parsed);
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ };
128
+ const incremental = nonEmpty.join('');
129
+ if (isJsonObject(incremental))
130
+ return incremental;
131
+ let compatible = '';
132
+ for (const incoming of nonEmpty) {
133
+ if (!compatible)
134
+ compatible = incoming;
135
+ else if (incoming === compatible)
136
+ continue;
137
+ else if (incoming.startsWith(compatible))
138
+ compatible = incoming;
139
+ else if (compatible.startsWith(incoming))
140
+ continue;
141
+ else
142
+ compatible += incoming;
143
+ }
144
+ if (isJsonObject(compatible))
145
+ return compatible;
146
+ return [...nonEmpty].reverse().find(isJsonObject) || compatible;
147
+ }
108
148
  /**
109
149
  * Detect content-policy refusals across the provider failure shapes.
110
150
  * Mirrors `LLMProvider.contentPolicyBlocked` semantics exactly so the
@@ -155,6 +155,7 @@ class ResponsesAdapter {
155
155
  const key = `${String(payload.item_id || '')}:${String(payload.summary_index || 0)}`;
156
156
  const delta = this.extractText(payload.delta);
157
157
  if (delta) {
158
+ emittedContent = true;
158
159
  reasoningSummaries.set(key, (reasoningSummaries.get(key) || '') + delta);
159
160
  yield { type: 'reasoning.summary.delta', delta };
160
161
  }
@@ -185,7 +186,7 @@ class ResponsesAdapter {
185
186
  calls.set(key, {
186
187
  id: String(item.call_id || item.id || key),
187
188
  name: String(item.name || ''),
188
- arguments: String(item.arguments || ''),
189
+ argumentParts: item.arguments ? [String(item.arguments)] : [],
189
190
  emitted: false,
190
191
  });
191
192
  }
@@ -193,9 +194,10 @@ class ResponsesAdapter {
193
194
  }
194
195
  if (eventType === 'response.function_call_arguments.delta') {
195
196
  const key = String(payload.item_id || payload.call_id || payload.output_index || '');
196
- const call = calls.get(key) || { id: String(payload.call_id || key), name: String(payload.name || ''), arguments: '', emitted: false };
197
+ const call = calls.get(key) || { id: String(payload.call_id || key), name: String(payload.name || ''), argumentParts: [], emitted: false };
197
198
  const delta = String(payload.delta || '');
198
- call.arguments += delta;
199
+ if (delta)
200
+ call.argumentParts.push(delta);
199
201
  calls.set(key, call);
200
202
  yield { type: 'tool_call.arguments.delta', id: call.id, delta };
201
203
  continue;
@@ -207,19 +209,21 @@ class ResponsesAdapter {
207
209
  const call = calls.get(key) || {
208
210
  id: String(item.call_id || item.id || key),
209
211
  name: String(item.name || ''),
210
- arguments: String(item.arguments || ''),
212
+ argumentParts: item.arguments ? [String(item.arguments)] : [],
211
213
  emitted: false,
212
214
  };
213
215
  call.id = String(item.call_id || call.id);
214
216
  call.name = String(item.name || call.name);
215
- call.arguments = typeof item.arguments === 'string' ? item.arguments : call.arguments;
217
+ if (typeof item.arguments === 'string' && item.arguments)
218
+ call.argumentParts.push(item.arguments);
216
219
  if (!call.emitted) {
217
220
  call.emitted = true;
221
+ const argumentsJson = (0, provider_events_1.assembleCompatibleToolArguments)(call.argumentParts);
218
222
  yield { type: 'tool_call.started', id: call.id, name: call.name };
219
- if (call.arguments && call.arguments !== '{}') {
220
- yield { type: 'tool_call.arguments.delta', id: call.id, delta: call.arguments };
223
+ if (argumentsJson !== '{}') {
224
+ yield { type: 'tool_call.arguments.delta', id: call.id, delta: argumentsJson };
221
225
  }
222
- yield { type: 'tool_call.completed', id: call.id, name: call.name, arguments: call.arguments };
226
+ yield { type: 'tool_call.completed', id: call.id, name: call.name, arguments: argumentsJson };
223
227
  }
224
228
  calls.set(key, call);
225
229
  }
@@ -252,9 +256,19 @@ class ResponsesAdapter {
252
256
  yield { type: 'response.failed', error: '[LLM Error] Responses stream ended before response.completed.' };
253
257
  }
254
258
  else if (!emittedContent && calls.size === 0) {
255
- yield { type: 'response.failed', error: '[Error] Empty Responses stream.' };
259
+ yield { type: 'response.failed', error: '[Error] Provider returned an empty response.' };
256
260
  }
257
261
  else {
262
+ // Some compatible Responses providers omit output_item.done but still
263
+ // complete the response. Preserve that valid tool activity and fold
264
+ // cumulative argument snapshots before handing it to the kernel.
265
+ for (const call of calls.values()) {
266
+ if (call.emitted)
267
+ continue;
268
+ const argumentsJson = (0, provider_events_1.assembleCompatibleToolArguments)(call.argumentParts);
269
+ yield { type: 'tool_call.started', id: call.id, name: call.name };
270
+ yield { type: 'tool_call.completed', id: call.id, name: call.name, arguments: argumentsJson };
271
+ }
258
272
  yield { type: 'response.completed' };
259
273
  }
260
274
  }
@@ -44,6 +44,7 @@ const browserControl_1 = require("../core/browserControl");
44
44
  const browserUse_1 = require("../core/browserUse");
45
45
  const conversationTarget_1 = require("../core/conversationTarget");
46
46
  const memoryLab_1 = require("../core/memoryLab");
47
+ const flow_1 = require("../core/flow");
47
48
  const compat_1 = require("../core/compat");
48
49
  const terminalTakeover_1 = require("./terminalTakeover");
49
50
  const computerUse_1 = require("./computerUse");
@@ -396,7 +397,7 @@ class ToolExecutor {
396
397
  t('subagent_send', 'Persist a mailbox message to a same-conversation peer agent. Target by exact id (preferred) or name.', { id: { type: 'string', description: 'Exact peer id from subagent_list.' }, name: { type: 'string', description: 'Convenience peer name.' }, message: { type: 'string' }, prompt: { type: 'string', description: 'Legacy alias for message.' }, kind: { type: 'string', enum: ['directive', 'question', 'result', 'handoff'] }, reply_to: { type: 'string' }, correlation_id: { type: 'string' } }, []),
397
398
  t('subagent_result', 'Return the persisted transcript, mailbox summary, status, and latest result for a peer agent. Target by exact id (preferred) or name.', { id: { type: 'string', description: 'Exact peer id from subagent_list.' }, name: { type: 'string', description: 'Convenience peer name.' } }, []),
398
399
  t('subagent_close', 'Close a same-conversation peer. Root can close any peer; a peer can close only itself. Target by exact id (preferred) or name.', { id: { type: 'string', description: 'Exact peer id from subagent_list.' }, name: { type: 'string', description: 'Convenience peer name.' } }, []),
399
- t('linked_plan', 'Read or update the current conversation linked Markdown plan. Update requires the current expected_revision.', { action: { type: 'string', enum: ['get', 'update'] }, markdown: { type: 'string' }, expected_revision: { type: 'number' } }, ['action']),
400
+ t('linked_plan', 'Read or incrementally update the current conversation linked Markdown plan. Update requires expected_revision. Prefer append or old_text/new_text for local changes; markdown remains the legacy full replacement path.', { action: { type: 'string', enum: ['get', 'update'] }, markdown: { type: 'string' }, append: { type: 'string' }, old_text: { type: 'string' }, new_text: { type: 'string' }, replace_all: { type: 'boolean' }, expected_revision: { type: 'number' } }, ['action']),
400
401
  t('build_history_query', 'Read the concrete public work details (tool calls, results, file changes, guides) of one historical Build Block. Call it proactively when the current task continues, fixes, verifies, or depends on earlier work: reuse the returned activity instead of re-investigating from scratch. Do not call it merely to answer completion status already exposed by the prompt. Select by newest-to-oldest history_index, or by run_id returned from an earlier query. Every activity/guide content is bounded to max_chars (default 2000) to keep the read lean and cache-friendly.', { history_index: { type: 'number', minimum: 1, description: '1-based historical Build Block index from the request ledger; 1 is the newest previous task.' }, run_id: { type: 'string', description: 'Exact run id returned by an earlier build_history_query result.' }, max_events: { type: 'number', minimum: 1, maximum: 200, description: 'Maximum trailing public work events; defaults to 80.' }, max_chars: { type: 'number', minimum: 100, maximum: 4000, description: 'Per-event/per-guide content character bound; defaults to 2000.' } }, []),
401
402
  t('context_compress', 'Actively compress the LLM context history for this conversation. This collapses older history entries into a concise summary while preserving the recent tail, which reduces context tokens and cost. IMPORTANT: it affects only the LLM context (what the model sees); the displayed conversation history shown to the user is never altered. Call this when the conversation is long, token pressure is high, or you judge that older turns are no longer needed in full. Idempotent and safe: repeated calls produce incremental summaries.', { keep_recent: { type: 'number', minimum: 2, maximum: 60, description: 'Recent message count to keep uncompressed at the tail. Defaults to the configured keep_recent_messages.' }, force: { type: 'boolean', description: 'Compress even if the context is not yet over the automatic threshold. Defaults to false.' } }, []),
402
403
  t('context_history_manage', 'Manage the LLM context history for this conversation without affecting the displayed conversation history. This is the active context-management surface. The hot cache stays bounded; evicted folded segments remain in a conversation-isolated append-only cold archive and are loaded only by explicit search/read/restore calls. Actions: list returns a bounded index of current context entries; remove declares one long-term entry for unload (see below); summarize folds a contiguous current range; restore reinserts a folded segment when its summary marker is still present; search finds matching hot or archived segments; read returns one bounded segment without injecting the whole archive; status reports budgets, hot cache, cold archive, the protected recent zone, and pending removals. The recent context tail and last user message are protected from remove/summarize unless dangerous is true. For cache-optimization, remove ONLY targets long-term history (never the protected recent tail or last user message) and does NOT unload immediately: the declared entry stays in context for the rest of the current Build Block so the provider prefix cache stays stable, then is physically removed when the Block ends — applying to subsequent Blocks only.', {
@@ -426,11 +427,11 @@ class ToolExecutor {
426
427
  t('skill_download', 'Download a skill', { name: { type: 'string' }, source: { type: 'string' } }, ['name', 'source']),
427
428
  t('skill', 'Search enabled skill metadata or load one exact skill body on demand. Use query when unsure, then name to load the selected skill.', { query: { type: 'string', maxLength: 200 }, name: { type: 'string', maxLength: 200 } }, []),
428
429
  t('flow_list', 'List available Newmark Flow workflows from the Flow folder so the agent can choose one.', {}, []),
429
- t('flow_save', 'Design or update a Newmark Flow workflow. Components must be an array of dialog/logic objects compatible with *.Flow.json.', { name: { type: 'string' }, components: { type: 'array' } }, ['name', 'components']),
430
+ t('flow_save', 'Create or incrementally update a Newmark Flow workflow. Use action=upsert with one component or action=delete with component_id and confirm=true for local edits. action=replace plus components remains the legacy full replacement path.', { name: { type: 'string' }, action: { type: 'string', enum: ['replace', 'upsert', 'delete'] }, components: { type: 'array' }, component: { type: 'object' }, component_id: { type: 'number' }, confirm: { type: 'boolean' } }, ['name']),
430
431
  t('flow_run', 'Trigger an existing Newmark Flow workflow by name with optional input and start component.', { name: { type: 'string' }, input: { type: 'string' }, start: { type: 'number' } }, ['name']),
431
432
  t('memory_lab_read', 'Read Memory Lab index.json, its path, and usage instructions. Optionally pass component/name/slug to read a memory component core markdown.', { component: { type: 'string' }, name: { type: 'string' }, slug: { type: 'string' } }, []),
432
433
  t('memory_lab_query', 'Retrieve a bounded task-relevant Memory Lab set with deterministic scoring and adaptive early stopping. Prefer this over loading the complete index when a focused query is sufficient.', { query: { type: 'string', minLength: 1 }, limit: { type: 'number', minimum: 1, maximum: 12 }, max_chars: { type: 'number', minimum: 1000, maximum: 48000 } }, ['query']),
433
- t('memory_lab_update', 'ADD or UPDATE a Memory Lab component. Existing memory should include expectedUpdatedAt from the latest read/query so stale writes fail closed. Prior revisions are archived and the Policy decision is logged.', { name: { type: 'string' }, description: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } }, tagPaths: { type: 'array', items: { type: 'array', items: { type: 'string' } } }, content: { type: 'string' }, kind: { type: 'string', enum: ['file', 'folder'] }, expectedUpdatedAt: { type: 'string' }, reason: { type: 'string' }, source: { type: 'string' } }, ['name', 'tags', 'content']),
434
+ t('memory_lab_update', 'Create or incrementally patch a Memory Lab component. Create with name/tags/content. For an existing component pass component plus expectedUpdatedAt and only changed fields; prefer contentAppend or oldText/newText for small body edits. Prior revisions are archived and stale writes fail closed.', { component: { type: 'string' }, name: { type: 'string' }, description: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } }, tagPaths: { type: 'array', items: { type: 'array', items: { type: 'string' } } }, content: { type: 'string' }, contentAppend: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' }, replaceAll: { type: 'boolean' }, kind: { type: 'string', enum: ['file', 'folder'] }, expectedUpdatedAt: { type: 'string' }, reason: { type: 'string' }, source: { type: 'string' } }, []),
434
435
  t('memory_lab_delete', 'DELETE obsolete durable memory only when the user explicitly asks to forget/remove it. The prior revision is moved to Memory Lab/archive and the Policy decision is logged.', { component: { type: 'string' }, name: { type: 'string' }, slug: { type: 'string' }, expectedUpdatedAt: { type: 'string' }, reason: { type: 'string' }, source: { type: 'string' } }, []),
435
436
  t('memory_lab_reindex', 'Rebuild and organize Memory Lab index links. Routed through Agent runtime when invoked by the model.', {}, []),
436
437
  t('automation_list', 'List persisted Newmark automations so the agent can inspect scheduled work.', {}, []),
@@ -986,7 +987,7 @@ class ToolExecutor {
986
987
  case 'skill_download': return await this.skillDownload(g('name'), g('source'), context.signal);
987
988
  case 'skill': return '[skill] Routed to Agent runtime.';
988
989
  case 'flow_list': return this.flowList();
989
- case 'flow_save': return this.flowSave(g('name'), args.components);
990
+ case 'flow_save': return this.flowSave(g('name'), args);
990
991
  case 'flow_run': return `[flow_run] Routed to Agent runtime: ${g('name')}`;
991
992
  case 'memory_lab_read': return this.memoryLabRead(g('component') || g('name') || g('slug'));
992
993
  case 'memory_lab_query': return '[memory_lab_query] Routed to Agent runtime for bounded Policy retrieval.';
@@ -1561,13 +1562,45 @@ class ToolExecutor {
1561
1562
  return `[flow_list] ${e}`;
1562
1563
  }
1563
1564
  }
1564
- flowSave(name, componentsRaw) {
1565
+ flowSave(name, input) {
1565
1566
  const cleanName = (name || '').replace(/[<>:"/\\|?*]/g, '-').trim();
1566
1567
  if (!cleanName)
1567
1568
  return '[flow_save] Workflow name is required.';
1569
+ const dir = path.join(this.root, 'Flow');
1570
+ const target = path.join(dir, `${cleanName}.Flow.json`);
1571
+ const action = String(input.action || (Array.isArray(input.components) ? 'replace' : 'upsert')).toLowerCase();
1572
+ let componentsRaw = input.components;
1573
+ if (action === 'upsert') {
1574
+ if (!input.component || typeof input.component !== 'object')
1575
+ return '[flow_save] component is required for action=upsert.';
1576
+ const existing = flow_1.FlowEngine.load(dir, cleanName)?.components || [];
1577
+ const component = input.component;
1578
+ const requestedId = Number(component.id);
1579
+ if (!Number.isFinite(requestedId))
1580
+ return '[flow_save] component.id is required for action=upsert.';
1581
+ componentsRaw = [...existing.filter(item => item.id !== requestedId), component].sort((a, b) => Number(a.id) - Number(b.id));
1582
+ }
1583
+ else if (action === 'delete') {
1584
+ if (input.confirm !== true)
1585
+ return '[flow_save] action=delete requires confirm=true.';
1586
+ const componentId = Number(input.component_id);
1587
+ if (!Number.isFinite(componentId))
1588
+ return '[flow_save] component_id is required for action=delete.';
1589
+ const existing = flow_1.FlowEngine.load(dir, cleanName);
1590
+ if (!existing)
1591
+ return `[flow_save] Workflow not found: ${cleanName}`;
1592
+ const remaining = existing.components.filter(item => item.id !== componentId);
1593
+ if (remaining.length === existing.components.length)
1594
+ return `[flow_save] Component not found: ${componentId}`;
1595
+ componentsRaw = remaining;
1596
+ }
1597
+ else if (action !== 'replace') {
1598
+ return `[flow_save] Unknown action: ${action}`;
1599
+ }
1568
1600
  if (!Array.isArray(componentsRaw))
1569
- return '[flow_save] components must be an array.';
1570
- const components = componentsRaw.map((raw, idx) => {
1601
+ return '[flow_save] components must be an array for action=replace.';
1602
+ const componentInputs = componentsRaw;
1603
+ const components = componentInputs.map((raw, idx) => {
1571
1604
  const c = raw;
1572
1605
  const type = c.type === 'logic' ? 'logic' : 'dialog';
1573
1606
  if (type === 'logic') {
@@ -1588,10 +1621,9 @@ class ToolExecutor {
1588
1621
  };
1589
1622
  });
1590
1623
  const workflow = { name: cleanName, components };
1591
- const dir = path.join(this.root, 'Flow');
1592
1624
  fs.mkdirSync(dir, { recursive: true });
1593
- fs.writeFileSync(path.join(dir, `${cleanName}.Flow.json`), JSON.stringify(workflow, null, 2), 'utf-8');
1594
- return `[flow_save] OK: ${cleanName}.Flow.json`;
1625
+ fs.writeFileSync(target, JSON.stringify(workflow, null, 2), 'utf-8');
1626
+ return `[flow_save] OK (${action}): ${cleanName}.Flow.json`;
1595
1627
  }
1596
1628
  memoryLabRead(selector) {
1597
1629
  const lab = new memoryLab_1.MemoryLabManager(this.root);