newmark-agent 0.5.8 → 0.5.10

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.
@@ -355,7 +355,7 @@ class Agent {
355
355
  this.config.set('skills', 'auto_download', 'disabled');
356
356
  }
357
357
  const modeStr = this.config.getStr('agent', 'default_mode');
358
- this.mode = (['plan', 'goal', 'flow'].includes(modeStr) ? modeStr : 'build');
358
+ this.mode = (['plan', 'chat', 'goal', 'flow'].includes(modeStr) ? modeStr : 'build');
359
359
  const inputStr = this.config.getStr('general', 'default_input');
360
360
  this.inputMode = inputStr === 'next' ? 'next' : 'guide';
361
361
  const configuredModel = this.config.getStr('models', 'default_model');
@@ -399,6 +399,8 @@ class Agent {
399
399
  }
400
400
  }
401
401
  setMode(m) {
402
+ if (!['build', 'plan', 'chat', 'goal', 'flow'].includes(m))
403
+ m = 'build';
402
404
  if (m === 'goal' && !this.goal) {
403
405
  this.goal = new GoalStateImpl('Set your objective');
404
406
  }
@@ -5362,7 +5364,24 @@ class Agent {
5362
5364
  if (action !== 'update')
5363
5365
  return JSON.stringify({ ok: false, error: `Unknown linked_plan action: ${action}` });
5364
5366
  const expectedRevision = Number(input.expected_revision ?? input.expectedRevision);
5365
- return JSON.stringify({ ok: true, linkedPlan: this.updateLinkedPlan(String(input.markdown || ''), expectedRevision) }, null, 2);
5367
+ const current = this.getLinkedPlan();
5368
+ let markdown = input.markdown === undefined ? current.markdown : String(input.markdown);
5369
+ if (input.append !== undefined)
5370
+ markdown = `${current.markdown}${String(input.append)}`;
5371
+ if (input.old_text !== undefined || input.oldText !== undefined) {
5372
+ const oldText = String(input.old_text ?? input.oldText ?? '');
5373
+ if (!oldText)
5374
+ throw new Error('linked_plan old_text must not be empty.');
5375
+ const matches = current.markdown.split(oldText).length - 1;
5376
+ if (!matches)
5377
+ throw new Error('linked_plan old_text was not found.');
5378
+ const replaceAll = input.replace_all === true || input.replaceAll === true;
5379
+ if (matches > 1 && !replaceAll)
5380
+ throw new Error(`linked_plan old_text matched ${matches} places; pass replace_all=true or a unique fragment.`);
5381
+ const newText = String(input.new_text ?? input.newText ?? '');
5382
+ markdown = replaceAll ? current.markdown.split(oldText).join(newText) : current.markdown.replace(oldText, newText);
5383
+ }
5384
+ return JSON.stringify({ ok: true, linkedPlan: this.updateLinkedPlan(markdown, expectedRevision) }, null, 2);
5366
5385
  }
5367
5386
  catch (error) {
5368
5387
  return JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) });
@@ -7965,7 +7984,24 @@ class Agent {
7965
7984
  }));
7966
7985
  }
7967
7986
  case 'memory_lab_update': {
7968
- const result = await this.updateMemoryLab({
7987
+ const selector = String(params.component || params.slug || '').trim();
7988
+ const prepared = selector ? this.memoryLab.preparePatch({
7989
+ component: selector,
7990
+ name: params.name === undefined ? undefined : String(params.name),
7991
+ description: params.description === undefined ? undefined : String(params.description),
7992
+ tags: params.tags === undefined ? undefined : (Array.isArray(params.tags) ? params.tags.map(String) : String(params.tags).split(/[,,\n]+/)),
7993
+ tagPaths: params.tagPaths === undefined ? undefined : (Array.isArray(params.tagPaths) ? params.tagPaths.filter(Array.isArray).map(pathValue => pathValue.map(String)) : []),
7994
+ content: params.content === undefined ? undefined : String(params.content),
7995
+ contentAppend: params.contentAppend === undefined && params.content_append === undefined ? undefined : String(params.contentAppend ?? params.content_append),
7996
+ oldText: params.oldText === undefined && params.old_text === undefined ? undefined : String(params.oldText ?? params.old_text),
7997
+ newText: String(params.newText ?? params.new_text ?? ''),
7998
+ replaceAll: params.replaceAll === true || params.replace_all === true,
7999
+ kind: params.kind === undefined ? undefined : (params.kind === 'folder' ? 'folder' : 'file'),
8000
+ expectedUpdatedAt: String(params.expectedUpdatedAt || params.expected_updated_at || ''),
8001
+ reason: String(params.reason || ''),
8002
+ source: String(params.source || ''),
8003
+ }) : undefined;
8004
+ const result = await this.updateMemoryLab(prepared || {
7969
8005
  name: String(params.name || ''),
7970
8006
  description: String(params.description || ''),
7971
8007
  tags: Array.isArray(params.tags) ? params.tags.map(String) : String(params.tags || '').split(/[,,\n]+/),
@@ -9127,6 +9163,8 @@ class Agent {
9127
9163
  parts.push(this.buildFeatureDisclosurePrompt());
9128
9164
  if (this.mode === 'plan')
9129
9165
  parts.push(`[Plan Tool Policy]\n${(0, toolPolicy_1.planModePolicyPrompt)()}`);
9166
+ if (this.mode === 'chat')
9167
+ parts.push(`[Chat Tool Policy]\n${(0, toolPolicy_1.chatModePolicyPrompt)()}`);
9130
9168
  const pm = this.config.getStr('workspace', 'prompt_mode') || 'both';
9131
9169
  const injectedPrompts = new Set();
9132
9170
  if ((pm === 'global_only' || pm === 'both') && globalPrompt) {
@@ -9247,7 +9285,7 @@ class Agent {
9247
9285
  `- Language policy: general.language=${language}; the UI can switch this at runtime and each turn must obey the current value. auto follows the user's dominant input language, en replies in English, zh replies in Simplified Chinese. Keep code, commands, file paths, JSON keys, model/provider names, tool names, quoted source text, and user-provided literals exactly as required by their source language.`,
9248
9286
  `- Workspace permissions: access_permission=${permission}; file tools are checked before execution and blocked when they exceed the configured workspace boundary.`,
9249
9287
  `- Remote repository safety: when the active workspace or any target path is inside a GitHub/remote-backed repository, proactively use repo_security_audit and file_audit before git_push, gh_pr_create, release packaging, public reporting, or cloud-side audit. Treat public remotes as public disclosure surfaces and keep private URLs, secrets, privacy addresses (credential URLs, private network addresses, local user paths), local runtime state, archives, Memory Lab, Work, config, and release outputs out of commits and summaries. git_push/gh_pr_create hard-block on detected high-risk findings until a second review resolves them and the action is retried with security_review_confirmed=true.`,
9250
- `- Mode engine: current mode=${this.modeName()}; Build works autonomously, Plan is fully read-only with no file modifications, Goal continues until completion unless paused, Flow follows saved workflow components.`,
9288
+ `- Mode engine: current mode=${this.modeName()}; Build works autonomously, Plan is fully read-only, Chat only performs web search/fetch evidence gathering and prompt synthesis, Goal continues until completion unless paused, Flow follows saved workflow components.`,
9251
9289
  `- Input mode: ${input}; Guide injects immediately, Next queues user intent for the following build turn.`,
9252
9290
  `- Option feedback: ${this.buildQuestionPolicyPrompt(optionFeedback)}`,
9253
9291
  `- Model policy: current model=${this.model || '(unset)'}, intelligence=${this.intelligence}, auto-switch=${modelSwitch}.`,
@@ -9318,6 +9356,14 @@ class Agent {
9318
9356
  'Only after the durable linked plan has actually been updated and the plan is complete, expose the fixed mode handoff asking whether execution should begin. Offer exactly these two choices in the user language: "是,执行此计划" / "否,请补充____" (or "Yes, execute this plan" / "No, please supplement _____"). This fixed handoff remains required when discretionary questions are disabled.',
9319
9357
  'A positive choice starts a new Build-mode input. A negative choice remains in Plan mode so the user can supply the missing details.',
9320
9358
  ]).join('\n');
9359
+ case 'chat':
9360
+ return withLanguage([
9361
+ 'CHAT MODE.',
9362
+ 'Use only web_search and web_fetch. You have no workspace, host, application, memory, task, browser-control, or write permissions.',
9363
+ 'Perform an online search to gather evidence before answering. Fetch primary or authoritative pages when the search snippets are insufficient.',
9364
+ 'After sufficient evidence is collected, summarize and answer the user as soon as possible. Stay concise and do not turn the request into a long-running Build, Plan, Goal, or Flow task.',
9365
+ 'Distinguish sourced facts from uncertainty and include useful source links in the final answer.',
9366
+ ]).join('\n');
9321
9367
  case 'goal': {
9322
9368
  const g = this.goal?.history() || '';
9323
9369
  const paused = this.goal?.paused ? '\n[GOAL PAUSED by user. Wait for resume.]' : '\n[Continue working until the goal is achieved.]';
@@ -35,6 +35,22 @@ export interface MemoryLabUpdateInput {
35
35
  reason?: string;
36
36
  source?: string;
37
37
  }
38
+ export interface MemoryLabPatchInput {
39
+ component: string;
40
+ name?: string;
41
+ description?: string;
42
+ tags?: string[];
43
+ tagPaths?: string[][];
44
+ content?: string;
45
+ contentAppend?: string;
46
+ oldText?: string;
47
+ newText?: string;
48
+ replaceAll?: boolean;
49
+ kind?: MemoryLabComponentKind;
50
+ expectedUpdatedAt?: string;
51
+ reason?: string;
52
+ source?: string;
53
+ }
38
54
  export interface MemoryLabPreparedUpdate extends MemoryLabUpdateInput {
39
55
  slug: string;
40
56
  description: string;
@@ -120,6 +136,7 @@ export declare class MemoryLabManager {
120
136
  read(componentSelector?: string): MemoryLabReadResult;
121
137
  visualizationSnapshot(): MemoryLabVisualizationResult;
122
138
  prepareUpdate(input: MemoryLabUpdateInput): MemoryLabPreparedUpdate;
139
+ preparePatch(input: MemoryLabPatchInput): MemoryLabPreparedUpdate;
123
140
  update(prepared: MemoryLabPreparedUpdate): MemoryLabWriteResult;
124
141
  query(input: {
125
142
  query: string;
@@ -91,7 +91,8 @@ class MemoryLabManager {
91
91
  'Use memory_lab_read to inspect index.json before deciding what memory is relevant.',
92
92
  'Use memory_lab_query for bounded task-relevant retrieval; do not inject the complete index when a focused query is sufficient.',
93
93
  'Use memory_lab_read with component/name/slug to read a component core markdown file.',
94
- 'Use memory_lab_update only when the user asks to create or update durable memory, passing name, description, tags, optional tagPaths, content, and optional kind=file|folder.',
94
+ 'Use memory_lab_update only when the user asks to create or update durable memory. Create with name, tags, and content; patch an existing component with component plus only changed fields.',
95
+ 'For small body edits prefer contentAppend or oldText/newText over resending the complete content.',
95
96
  'For an existing component, pass expectedUpdatedAt from the latest read/query result. A stale update is rejected instead of overwriting newer memory.',
96
97
  'Use memory_lab_delete only when the user explicitly asks to forget/remove durable memory. Delete moves the prior revision to Memory Lab/archive and records a policy event.',
97
98
  'Every mutation should include a concise reason and source. ADD, UPDATE, and DELETE decisions are append-only in policy.jsonl and are recoverable from archive.',
@@ -206,6 +207,47 @@ class MemoryLabManager {
206
207
  source: String(input.source || '').trim(),
207
208
  };
208
209
  }
210
+ preparePatch(input) {
211
+ const selector = String(input.component || '').trim();
212
+ if (!selector)
213
+ throw new Error('Memory component is required for a patch.');
214
+ const current = this.read(selector);
215
+ if (!current.ok || !current.component)
216
+ throw new Error(current.error || `Memory component not found: ${selector}`);
217
+ const existing = current.component.meta;
218
+ const oldContent = current.component.content;
219
+ let content = input.content !== undefined ? String(input.content) : oldContent;
220
+ if (input.contentAppend !== undefined)
221
+ content = `${oldContent}${String(input.contentAppend)}`;
222
+ if (input.oldText !== undefined) {
223
+ const oldText = String(input.oldText);
224
+ if (!oldText)
225
+ throw new Error('oldText must not be empty.');
226
+ const matches = oldContent.split(oldText).length - 1;
227
+ if (!matches)
228
+ throw new Error('oldText was not found in the Memory Lab component.');
229
+ if (matches > 1 && input.replaceAll !== true)
230
+ throw new Error(`oldText matched ${matches} places; pass replaceAll=true or a unique fragment.`);
231
+ content = input.replaceAll === true
232
+ ? oldContent.split(oldText).join(String(input.newText || ''))
233
+ : oldContent.replace(oldText, String(input.newText || ''));
234
+ }
235
+ const name = input.name === undefined ? existing.name : String(input.name);
236
+ if (this.slugify(name) !== current.component.slug) {
237
+ throw new Error('Renaming a Memory Lab component is not supported by incremental patch; create the new component then delete the old one.');
238
+ }
239
+ return this.prepareUpdate({
240
+ name,
241
+ description: input.description === undefined ? existing.description : String(input.description),
242
+ tags: input.tags === undefined ? existing.tags : input.tags,
243
+ tagPaths: input.tagPaths === undefined ? existing.tagPaths : input.tagPaths,
244
+ content,
245
+ kind: input.kind === undefined ? existing.kind : input.kind,
246
+ expectedUpdatedAt: String(input.expectedUpdatedAt || existing.updatedAt),
247
+ reason: input.reason,
248
+ source: input.source,
249
+ });
250
+ }
209
251
  update(prepared) {
210
252
  this.ensure();
211
253
  const index = this.loadIndex();
@@ -25,6 +25,7 @@ export declare function toolAvailability(name: string): ToolAvailability;
25
25
  export declare function evaluateToolPolicy(request: ToolPolicyRequest): ToolPolicyDecision;
26
26
  export declare function filterToolDefinitions<T>(definitions: T[], request: Omit<ToolPolicyRequest, 'name' | 'args'>): T[];
27
27
  export declare function planModePolicyPrompt(): string;
28
+ export declare function chatModePolicyPrompt(): string;
28
29
  export interface DeletionGuardDecision {
29
30
  blocked: boolean;
30
31
  reason?: string;
@@ -7,6 +7,7 @@ exports.toolAvailability = toolAvailability;
7
7
  exports.evaluateToolPolicy = evaluateToolPolicy;
8
8
  exports.filterToolDefinitions = filterToolDefinitions;
9
9
  exports.planModePolicyPrompt = planModePolicyPrompt;
10
+ exports.chatModePolicyPrompt = chatModePolicyPrompt;
10
11
  exports.evaluateDeletionGuard = evaluateDeletionGuard;
11
12
  const REQUIRED_TOOLS = new Set(['pwd', 'read', 'glob', 'grep']);
12
13
  const MODE_SCOPED_TOOLS = new Set([
@@ -81,6 +82,7 @@ exports.PLAN_COMPUTER_USE_ACTIONS = ['observe', 'app_list', 'app_observe'];
81
82
  exports.PLAN_BROWSER_USE_ACTIONS = ['observe', 'navigate', 'wait', 'extract'];
82
83
  const PLAN_COMPUTER_USE_ACTION_SET = new Set(exports.PLAN_COMPUTER_USE_ACTIONS);
83
84
  const PLAN_BROWSER_USE_ACTION_SET = new Set(exports.PLAN_BROWSER_USE_ACTIONS);
85
+ const CHAT_WEB_TOOLS = new Set(['web_search', 'web_fetch']);
84
86
  /**
85
87
  * 并发安全工具集合(DSH isConcurrencySafe 语义的 Newmark 落地)。
86
88
  *
@@ -140,6 +142,13 @@ function evaluateToolPolicy(request) {
140
142
  const base = { availability, settingsVisible: availability === 'configurable' };
141
143
  if (!name)
142
144
  return { ...base, allowed: false, reason: '[permission] Tool name is required.' };
145
+ if (request.mode === 'chat' && !CHAT_WEB_TOOLS.has(name)) {
146
+ return {
147
+ ...base,
148
+ allowed: false,
149
+ reason: `[permission] Chat mode only allows web_search and web_fetch. It has no workspace, host, application, memory, task, or other write access. Blocked: ${name}`,
150
+ };
151
+ }
143
152
  if (request.mode === 'plan') {
144
153
  if (name === 'computer_use') {
145
154
  const action = String(request.args?.action || '').trim();
@@ -182,6 +191,13 @@ function planModePolicyPrompt() {
182
191
  'Runtime policy rejects stale or hidden mutating tool calls even if a prompt asks for them.',
183
192
  ].join(' ');
184
193
  }
194
+ function chatModePolicyPrompt() {
195
+ return [
196
+ 'Chat mode is a narrow web-evidence mode.',
197
+ 'Only web_search and web_fetch are available; every workspace, host, application, memory, task, browser-control, and write capability is denied at runtime.',
198
+ 'Search the web for relevant evidence, fetch primary or authoritative sources when useful, then summarize and answer promptly instead of expanding into a long-running task.',
199
+ ].join(' ');
200
+ }
185
201
  /** 删除命令动词(跨 POSIX / PowerShell / cmd)。注意:不含单独 "remove"(避免匹配普通英文)。 */
186
202
  const DELETE_VERB_SOURCE = '(?:remove-item|rmdir|unlink|erase|del|rm|rd|ri)';
187
203
  const DELETE_VERB_BOUNDARY = new RegExp(`(?:^|[\\s;&|()\\n])${DELETE_VERB_SOURCE}(?:\\s|$)`, 'i');
@@ -1,4 +1,4 @@
1
- export type AgentMode = 'build' | 'plan' | 'goal' | 'flow';
1
+ export type AgentMode = 'build' | 'plan' | 'chat' | 'goal' | 'flow';
2
2
  export type InputMode = 'guide' | 'next';
3
3
  export type AgentStatus = 'idle' | 'working' | 'error' | 'goal_paused';
4
4
  /** Public routing identity used by renderer/main/runtime envelopes. */
@@ -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);