@yeaft/webchat-agent 0.1.478 → 0.1.480

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.478",
3
+ "version": "0.1.480",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -24,6 +24,7 @@ import { recall } from './memory/recall.js';
24
24
  import { shouldConsolidate, consolidate } from './memory/consolidate.js';
25
25
  import { buildMemoryInjection } from './memory/layout.js';
26
26
  import { runStopHooks } from './stop-hooks.js';
27
+ import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
27
28
 
28
29
  /** Maximum number of turns before the engine stops to prevent infinite loops. */
29
30
  const MAX_TURNS = 25;
@@ -140,14 +141,16 @@ export class Engine {
140
141
 
141
142
  /**
142
143
  * Get the list of registered tool definitions (for passing to the adapter).
143
- * Prefers ToolRegistry (mode-aware) when available, falls back to legacy #tools Map.
144
+ * Prefers ToolRegistry when available, falls back to legacy #tools Map.
145
+ *
146
+ * task-297: mode-based filtering was removed — all registered tools are
147
+ * always exposed to the LLM.
144
148
  *
145
- * @param {string} [mode]
146
149
  * @returns {import('./llm/adapter.js').UnifiedToolDef[]}
147
150
  */
148
- #getToolDefs(mode) {
151
+ #getToolDefs() {
149
152
  if (this.#toolRegistry) {
150
- return this.#toolRegistry.getToolDefs(mode || 'chat');
153
+ return this.#toolRegistry.getToolDefs();
151
154
  }
152
155
  // Legacy path: no mode filtering
153
156
  const defs = [];
@@ -180,7 +183,7 @@ export class Engine {
180
183
 
181
184
  // Get tool names from the appropriate source
182
185
  const toolNames = this.#toolRegistry
183
- ? this.#toolRegistry.getToolNames(mode || 'chat')
186
+ ? this.#toolRegistry.getToolNames()
184
187
  : Array.from(this.#tools.keys());
185
188
 
186
189
  return buildSystemPrompt({
@@ -269,11 +272,22 @@ export class Engine {
269
272
  if (!this.#conversationStore) return;
270
273
  if (this.#config._readOnly) return;
271
274
 
275
+ // task-299 Phase 1: tag persisted messages with the current thread.
276
+ // getThreadStore() lazily seeds a default 'main' thread if not yet init'd.
277
+ let threadId = MAIN_THREAD_ID;
278
+ try {
279
+ threadId = getThreadStore().currentId || MAIN_THREAD_ID;
280
+ } catch {
281
+ // Defensive: any store failure falls back to 'main' so persistence
282
+ // never breaks because of thread bookkeeping.
283
+ }
284
+
272
285
  // Persist user message
273
286
  this.#conversationStore.append({
274
287
  role: 'user',
275
288
  content: userContent,
276
289
  mode,
290
+ threadId,
277
291
  });
278
292
 
279
293
  // Persist assistant message
@@ -282,6 +296,7 @@ export class Engine {
282
296
  content: assistantContent,
283
297
  mode,
284
298
  model: this.#config.model,
299
+ threadId,
285
300
  };
286
301
  if (toolCalls && toolCalls.length > 0) {
287
302
  assistantMsg.toolCalls = toolCalls;
@@ -326,7 +341,7 @@ export class Engine {
326
341
  * @param {{ prompt: string, mode?: string, messages?: Array, signal?: AbortSignal }} params
327
342
  * @yields {EngineEvent}
328
343
  */
329
- async *query({ prompt, mode = 'chat', messages = [], signal }) {
344
+ async *query({ prompt, mode, messages = [], signal }) {
330
345
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
331
346
  yield {
332
347
  type: 'error',
@@ -367,7 +382,7 @@ export class Engine {
367
382
  { role: 'user', content: prompt },
368
383
  ];
369
384
 
370
- const toolDefs = this.#getToolDefs(mode);
385
+ const toolDefs = this.#getToolDefs();
371
386
  let turnNumber = 0;
372
387
  let continueTurns = 0; // auto-continue counter
373
388
  let fullResponseText = '';
@@ -601,21 +616,21 @@ export class Engine {
601
616
  if (!hasTool) {
602
617
  output = `Error: unknown tool "${tc.name}"`;
603
618
  isError = true;
604
- yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true };
619
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, threadId: this.currentThreadId };
605
620
  } else {
606
621
  try {
607
- yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input };
622
+ yield { type: 'tool_start', id: tc.id, name: tc.name, input: tc.input, threadId: this.currentThreadId };
608
623
  if (this.#toolRegistry) {
609
624
  output = await this.#toolRegistry.execute(tc.name, tc.input, toolCtx);
610
625
  } else {
611
626
  const tool = this.#tools.get(tc.name);
612
627
  output = await tool.execute(tc.input, { signal });
613
628
  }
614
- yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false };
629
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: false, threadId: this.currentThreadId };
615
630
  } catch (err) {
616
631
  output = `Error: ${err.message}`;
617
632
  isError = true;
618
- yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true };
633
+ yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, threadId: this.currentThreadId };
619
634
  }
620
635
  }
621
636
 
@@ -687,6 +702,19 @@ export class Engine {
687
702
  /** @returns {import('./mcp.js').MCPManager|null} */
688
703
  get mcpManager() { return this.#mcpManager; }
689
704
 
705
+ /**
706
+ * task-299 Phase 1: the engine's current thread marker.
707
+ * Defaults to 'main' if the thread store is unreachable for any reason.
708
+ * @returns {string}
709
+ */
710
+ get currentThreadId() {
711
+ try {
712
+ return getThreadStore().currentId || MAIN_THREAD_ID;
713
+ } catch {
714
+ return MAIN_THREAD_ID;
715
+ }
716
+ }
717
+
690
718
  /** @returns {string|null} */
691
719
  get yeaftDir() { return this.#yeaftDir; }
692
720
 
package/unify/prompts.js CHANGED
@@ -95,8 +95,7 @@ function extractLangSection(content, language) {
95
95
  /** Loaded templates — read once at module load time. */
96
96
  const RAW_TEMPLATES = {
97
97
  base: readTemplate('base.md'),
98
- modeChat: readTemplate('mode-chat.md'),
99
- modeWorker: readTemplate('mode-worker.md'),
98
+ modeUnified: readTemplate('mode-unified.md'),
100
99
  modeDream: readTemplate('mode-dream.md'),
101
100
  toolGuidance: readTemplate('tool-guidance.md'),
102
101
  };
@@ -118,9 +117,7 @@ function getTemplate(key, language) {
118
117
  const PROMPTS = {
119
118
  en: {
120
119
  identity: 'You are Yeaft, a helpful AI assistant.',
121
- mode: (mode) => `Current mode: ${mode}`,
122
120
  date: (d) => `Date: ${d}`,
123
- work: 'You are in work mode. Break tasks into steps, execute them using tools, and report progress.',
124
121
  dream: 'You are in dream mode. Reflect on past conversations and consolidate memories.',
125
122
  tools: (names) => `Available tools: ${names}`,
126
123
  memoryHeader: '## User Memory',
@@ -130,9 +127,7 @@ const PROMPTS = {
130
127
  },
131
128
  zh: {
132
129
  identity: '你是 Yeaft,一个有用的 AI 助手。',
133
- mode: (mode) => `当前模式:${mode}`,
134
130
  date: (d) => `日期:${d}`,
135
- work: '你处于工作模式。将任务分解为步骤,使用工具执行,并报告进度。',
136
131
  dream: '你处于梦境模式。回顾过去的对话,整理和巩固记忆。',
137
132
  tools: (names) => `可用工具:${names}`,
138
133
  memoryHeader: '## 用户记忆',
@@ -146,12 +141,17 @@ const PROMPTS = {
146
141
  export const SUPPORTED_LANGUAGES = Object.keys(PROMPTS);
147
142
 
148
143
  /**
149
- * Build the system prompt for a given language and mode.
144
+ * Build the system prompt for a given language.
145
+ *
146
+ * task-297: chat/work mode distinction was removed. The prompt now always uses
147
+ * the unified mode template. The `mode` param is retained for backward compat
148
+ * — only `mode === 'dream'` triggers the dream-mode template (used by background
149
+ * memory maintenance); all other values fall through to unified mode.
150
150
  *
151
151
  * Prompt structure:
152
152
  * 1. Core identity (from template or fallback)
153
- * 2. Mode + date metadata
154
- * 3. Mode-specific behavioral instructions (from template or fallback)
153
+ * 2. Date metadata
154
+ * 3. Mode-specific behavioral instructions (unified, or dream)
155
155
  * 4. Tool list + tool guidance (from template)
156
156
  * 5. Skills section
157
157
  * 6. Memory section
@@ -170,7 +170,7 @@ export const SUPPORTED_LANGUAGES = Object.keys(PROMPTS);
170
170
  */
171
171
  export function buildSystemPrompt({
172
172
  language = 'en',
173
- mode = 'chat',
173
+ mode,
174
174
  toolNames = [],
175
175
  memory,
176
176
  memoryInjection,
@@ -192,23 +192,20 @@ export function buildSystemPrompt({
192
192
  parts.push(lang.identity);
193
193
  }
194
194
 
195
- // ─── 2. Mode + Date Metadata ───────────────────────────
196
- parts.push(lang.mode(mode));
195
+ // ─── 2. Date Metadata ──────────────────────────────────
197
196
  parts.push(lang.date(new Date().toISOString().split('T')[0]));
198
197
 
199
198
  // ─── 3. Mode-Specific Instructions ─────────────────────
200
- if (mode === 'work') {
201
- const workerTemplate = getTemplate('modeWorker', effectiveLang);
202
- parts.push(workerTemplate || lang.work);
203
- } else if (mode === 'dream') {
199
+ // task-297: single unified mode for all normal operation.
200
+ // `dream` is retained for background memory maintenance.
201
+ if (mode === 'dream') {
204
202
  const dreamTemplate = getTemplate('modeDream', effectiveLang);
205
203
  parts.push(dreamTemplate || lang.dream);
206
- } else if (mode === 'chat') {
207
- const chatTemplate = getTemplate('modeChat', effectiveLang);
208
- if (chatTemplate) {
209
- parts.push(chatTemplate);
204
+ } else {
205
+ const unifiedTemplate = getTemplate('modeUnified', effectiveLang);
206
+ if (unifiedTemplate) {
207
+ parts.push(unifiedTemplate);
210
208
  }
211
- // No fallback needed — chat mode previously had no instructions
212
209
  }
213
210
 
214
211
  // ─── 4. Tools + Tool Guidance ──────────────────────────
package/unify/session.js CHANGED
@@ -23,6 +23,7 @@ import { SkillManager, createSkillManager } from './skills.js';
23
23
  import { MCPManager } from './mcp.js';
24
24
  import { createFullRegistry } from './tools/index.js';
25
25
  import { initTaskStore } from './tools/task-tools.js';
26
+ import { initThreadStore } from './threads/store.js';
26
27
  import { Engine } from './engine.js';
27
28
  import { join } from 'path';
28
29
 
@@ -121,6 +122,11 @@ export async function loadSession(options = {}) {
121
122
  // ─── 5a. Initialize task store ─────────────────────────
122
123
  initTaskStore(yeaftDir, { readOnly: config._readOnly || false });
123
124
 
125
+ // ─── 5b. Initialize thread store (task-299 Phase 1) ────
126
+ // In-memory only for Phase 1; replaced by a file-backed store
127
+ // when task-298's data layer merges.
128
+ initThreadStore();
129
+
124
130
  // ─── 6. Load skills ────────────────────────────────────
125
131
  let skillManager;
126
132
  if (skipSkills) {
@@ -0,0 +1,181 @@
1
+ /**
2
+ * store.js — In-memory ThreadStore for Yeaft Unify (Phase 1 mock).
3
+ *
4
+ * Phase 1 intentionally keeps the implementation in-memory only: it lets the
5
+ * thread/task spawn tools ship and be tested before task-298's real
6
+ * filesystem layer is merged. When task-298 merges, this module will be
7
+ * replaced (or promoted to a shim) by a file-backed store with the same API.
8
+ *
9
+ * Responsibilities (Phase 1):
10
+ * - Maintain a map of threadId → thread metadata.
11
+ * - Track a "currentThreadId" marker for the engine.
12
+ * - Maintain attachments from threadId → taskId.
13
+ *
14
+ * A single "main" thread is created on construction so that pre-spawn
15
+ * messages have a valid threadId to carry.
16
+ */
17
+
18
+ import { randomUUID } from 'crypto';
19
+
20
+ /** Default / root thread id — every fresh ThreadStore has one. */
21
+ export const MAIN_THREAD_ID = 'main';
22
+
23
+ /**
24
+ * @typedef {Object} Thread
25
+ * @property {string} id
26
+ * @property {string} name
27
+ * @property {string} [goal]
28
+ * @property {string|null} parentThreadId
29
+ * @property {number} createdAt
30
+ * @property {number} updatedAt
31
+ */
32
+
33
+ export class ThreadStore {
34
+ /** @type {Map<string, Thread>} */
35
+ #threads;
36
+
37
+ /** @type {string} */
38
+ #currentId;
39
+
40
+ /** @type {Map<string, string>} threadId → taskId */
41
+ #attachments;
42
+
43
+ constructor() {
44
+ this.#threads = new Map();
45
+ this.#attachments = new Map();
46
+
47
+ const now = Date.now();
48
+ const main = {
49
+ id: MAIN_THREAD_ID,
50
+ name: 'main',
51
+ goal: '',
52
+ parentThreadId: null,
53
+ createdAt: now,
54
+ updatedAt: now,
55
+ };
56
+ this.#threads.set(main.id, main);
57
+ this.#currentId = main.id;
58
+ }
59
+
60
+ /** Get current thread id (defaults to 'main'). */
61
+ get currentId() {
62
+ return this.#currentId;
63
+ }
64
+
65
+ /** Total thread count (including 'main'). */
66
+ get size() {
67
+ return this.#threads.size;
68
+ }
69
+
70
+ /**
71
+ * Create a new thread.
72
+ * @param {{ name: string, goal?: string, parentThreadId?: string }} spec
73
+ * @returns {Thread}
74
+ */
75
+ create({ name, goal = '', parentThreadId = null } = {}) {
76
+ if (!name || typeof name !== 'string' || !name.trim()) {
77
+ throw new Error('thread name is required');
78
+ }
79
+ if (parentThreadId && !this.#threads.has(parentThreadId)) {
80
+ throw new Error(`parent thread not found: ${parentThreadId}`);
81
+ }
82
+ const id = `thr-${randomUUID().slice(0, 8)}`;
83
+ const now = Date.now();
84
+ const thread = {
85
+ id,
86
+ name: name.trim(),
87
+ goal: goal || '',
88
+ parentThreadId: parentThreadId || null,
89
+ createdAt: now,
90
+ updatedAt: now,
91
+ };
92
+ this.#threads.set(id, thread);
93
+ return thread;
94
+ }
95
+
96
+ /** @param {string} id */
97
+ get(id) {
98
+ return this.#threads.get(id) || null;
99
+ }
100
+
101
+ /** @returns {Thread[]} */
102
+ list() {
103
+ return [...this.#threads.values()];
104
+ }
105
+
106
+ /** @param {string} id */
107
+ has(id) {
108
+ return this.#threads.has(id);
109
+ }
110
+
111
+ /**
112
+ * Set the current thread marker. Throws if unknown.
113
+ * @param {string} id
114
+ */
115
+ switch(id) {
116
+ if (!this.#threads.has(id)) {
117
+ throw new Error(`thread not found: ${id}`);
118
+ }
119
+ this.#currentId = id;
120
+ const t = this.#threads.get(id);
121
+ t.updatedAt = Date.now();
122
+ }
123
+
124
+ /**
125
+ * Attach a task to a thread. Overwrites any existing attachment.
126
+ * @param {string} threadId
127
+ * @param {string} taskId
128
+ */
129
+ attachTask(threadId, taskId) {
130
+ if (!this.#threads.has(threadId)) {
131
+ throw new Error(`thread not found: ${threadId}`);
132
+ }
133
+ if (!taskId || typeof taskId !== 'string') {
134
+ throw new Error('taskId is required');
135
+ }
136
+ this.#attachments.set(threadId, taskId);
137
+ }
138
+
139
+ /**
140
+ * Get the taskId attached to a thread, if any.
141
+ * @param {string} threadId
142
+ * @returns {string|null}
143
+ */
144
+ attachedTask(threadId) {
145
+ return this.#attachments.get(threadId) || null;
146
+ }
147
+
148
+ /** @returns {Array<{ threadId: string, taskId: string }>} */
149
+ listAttachments() {
150
+ return [...this.#attachments.entries()].map(([threadId, taskId]) => ({ threadId, taskId }));
151
+ }
152
+ }
153
+
154
+ /** @type {ThreadStore|null} */
155
+ let threadStore = null;
156
+
157
+ /**
158
+ * Initialize the thread store. Safe to call multiple times — subsequent calls
159
+ * replace the store only if `force` is true (primarily for tests).
160
+ * @param {{ force?: boolean }} [opts]
161
+ * @returns {ThreadStore}
162
+ */
163
+ export function initThreadStore(opts = {}) {
164
+ if (!threadStore || opts.force) {
165
+ threadStore = new ThreadStore();
166
+ }
167
+ return threadStore;
168
+ }
169
+
170
+ /** @returns {ThreadStore} */
171
+ export function getThreadStore() {
172
+ if (!threadStore) {
173
+ threadStore = new ThreadStore();
174
+ }
175
+ return threadStore;
176
+ }
177
+
178
+ /** Test-only reset helper. */
179
+ export function _resetThreadStoreForTests() {
180
+ threadStore = null;
181
+ }
@@ -55,6 +55,15 @@ import {
55
55
  updatePlan,
56
56
  } from './task-tools.js';
57
57
 
58
+ // --- P1 Thread tools (task-299 Phase 1) ---
59
+ import {
60
+ spawnThread,
61
+ switchThread,
62
+ listThreads,
63
+ attachThreadToTask,
64
+ } from './thread-tools.js';
65
+ import { spawnTask, spawnSubtask } from './spawn-task-tools.js';
66
+
58
67
  // --- P2 Auxiliary tools ---
59
68
  import { jsRepl, jsReplReset } from './js-repl.js';
60
69
  import notebookEdit from './notebook-edit.js';
@@ -113,6 +122,14 @@ export const allTools = [
113
122
  followupTask,
114
123
  updatePlan,
115
124
 
125
+ // P1 Thread (task-299 Phase 1)
126
+ spawnThread,
127
+ switchThread,
128
+ listThreads,
129
+ attachThreadToTask,
130
+ spawnTask,
131
+ spawnSubtask,
132
+
116
133
  // P2 Auxiliary
117
134
  jsRepl,
118
135
  jsReplReset,
@@ -1,20 +1,14 @@
1
1
  /**
2
2
  * registry.js — Tool registration center for Yeaft Unify
3
3
  *
4
- * Manages tool registration, mode-based filtering, and execution dispatch.
4
+ * Manages tool registration and execution dispatch.
5
5
  * The engine uses this to get tool definitions for the LLM and to execute tool calls.
6
+ *
7
+ * NOTE (task-297): Mode-based filtering (chat/work) has been removed. Unify now runs as
8
+ * a single unified mode where all registered tools are available. Tool definitions may
9
+ * still carry a `modes` field, but the registry ignores it.
6
10
  */
7
11
 
8
- /**
9
- * Mode normalization map.
10
- * 'coordinator' and 'worker' inherit 'work' tools.
11
- * 'dream' has no tools by design (returns empty).
12
- */
13
- const MODE_ALIASES = {
14
- coordinator: 'work',
15
- worker: 'work',
16
- };
17
-
18
12
  export class ToolRegistry {
19
13
  /** @type {Map<string, import('./types.js').ToolDef>} */
20
14
  #tools = new Map();
@@ -69,35 +63,20 @@ export class ToolRegistry {
69
63
  }
70
64
 
71
65
  /**
72
- * Resolve a mode to its effective tool mode.
73
- * @param {string} mode
74
- * @returns {string}
75
- */
76
- static resolveMode(mode) {
77
- return MODE_ALIASES[mode] || mode;
78
- }
79
-
80
- /**
81
- * Get all tools available in a given mode.
82
- * @param {string} mode
66
+ * Get all registered tools (unfiltered).
83
67
  * @returns {import('./types.js').ToolDef[]}
84
68
  */
85
- getToolsForMode(mode) {
86
- const effectiveMode = ToolRegistry.resolveMode(mode);
87
- const result = [];
88
- for (const [, tool] of this.#tools) {
89
- if (tool.modes.includes(effectiveMode)) result.push(tool);
90
- }
91
- return result;
69
+ getAllTools() {
70
+ return Array.from(this.#tools.values());
92
71
  }
93
72
 
94
73
  /**
95
74
  * Get tool definitions for the LLM adapter.
96
- * @param {string} mode
75
+ * Returns all registered tools — mode filtering was removed in task-297.
97
76
  * @returns {{ name: string, description: string, parameters: object }[]}
98
77
  */
99
- getToolDefs(mode) {
100
- return this.getToolsForMode(mode).map(t => ({
78
+ getToolDefs() {
79
+ return this.getAllTools().map(t => ({
101
80
  name: t.name,
102
81
  description: t.description,
103
82
  parameters: t.parameters,
@@ -105,12 +84,11 @@ export class ToolRegistry {
105
84
  }
106
85
 
107
86
  /**
108
- * Get tool names available in a given mode.
109
- * @param {string} mode
87
+ * Get all registered tool names.
110
88
  * @returns {string[]}
111
89
  */
112
- getToolNames(mode) {
113
- return this.getToolsForMode(mode).map(t => t.name);
90
+ getToolNames() {
91
+ return Array.from(this.#tools.keys());
114
92
  }
115
93
 
116
94
  /**
@@ -0,0 +1,125 @@
1
+ /**
2
+ * spawn-task-tools.js — Thin semantic-sugar wrappers over TaskCreate.
3
+ *
4
+ * Phase 1 of task-299: expose `SpawnTask` and `SpawnSubtask` as first-class
5
+ * tool names so the LLM can "spawn" work items in the same vocabulary it
6
+ * uses to spawn threads. Both delegate to the existing TaskStore (shared
7
+ * with TaskCreate), so there is a single source of truth for task data.
8
+ */
9
+
10
+ import { defineTool } from './types.js';
11
+ import { randomUUID } from 'crypto';
12
+ import { getTaskStore } from './task-tools.js';
13
+
14
+ function requireStore() {
15
+ const store = getTaskStore();
16
+ if (!store) return { error: 'Task store not initialized. Session may still be loading.' };
17
+ return { store };
18
+ }
19
+
20
+ function newTaskId() {
21
+ return `task-${randomUUID().slice(0, 8)}`;
22
+ }
23
+
24
+ // ─── SpawnTask ──────────────────────────────────────────
25
+
26
+ export const spawnTask = defineTool({
27
+ name: 'SpawnTask',
28
+ description: `Spawn a new top-level task.
29
+
30
+ Equivalent to TaskCreate without a parent. Provided as a first-class
31
+ name so tasks and threads can be "spawned" with a uniform vocabulary.`,
32
+ parameters: {
33
+ type: 'object',
34
+ properties: {
35
+ title: { type: 'string' },
36
+ description: { type: 'string' },
37
+ priority: {
38
+ type: 'string',
39
+ enum: ['low', 'medium', 'high', 'critical'],
40
+ },
41
+ },
42
+ required: ['title'],
43
+ },
44
+ modes: ['work'],
45
+ isConcurrencySafe: () => false,
46
+ isReadOnly: () => false,
47
+ async execute(input) {
48
+ const got = requireStore();
49
+ if (got.error) return JSON.stringify({ error: got.error });
50
+ const { title, description = '', priority = 'medium' } = input || {};
51
+ if (!title) return JSON.stringify({ error: 'title is required' });
52
+
53
+ const id = newTaskId();
54
+ const now = Date.now();
55
+ got.store.create({
56
+ id,
57
+ title,
58
+ description,
59
+ priority,
60
+ status: 'pending',
61
+ parentId: null,
62
+ createdAt: now,
63
+ updatedAt: now,
64
+ });
65
+ return JSON.stringify({
66
+ success: true,
67
+ task: { id, title, priority, status: 'pending' },
68
+ message: `Task spawned: ${title} (${id})`,
69
+ });
70
+ },
71
+ });
72
+
73
+ // ─── SpawnSubtask ───────────────────────────────────────
74
+
75
+ export const spawnSubtask = defineTool({
76
+ name: 'SpawnSubtask',
77
+ description: `Spawn a subtask under an existing parent task.
78
+
79
+ Requires parent_task_id. The parent must exist. Use for breaking a
80
+ larger task into executable pieces.`,
81
+ parameters: {
82
+ type: 'object',
83
+ properties: {
84
+ parent_task_id: { type: 'string' },
85
+ title: { type: 'string' },
86
+ description: { type: 'string' },
87
+ priority: {
88
+ type: 'string',
89
+ enum: ['low', 'medium', 'high', 'critical'],
90
+ },
91
+ },
92
+ required: ['parent_task_id', 'title'],
93
+ },
94
+ modes: ['work'],
95
+ isConcurrencySafe: () => false,
96
+ isReadOnly: () => false,
97
+ async execute(input) {
98
+ const got = requireStore();
99
+ if (got.error) return JSON.stringify({ error: got.error });
100
+ const { parent_task_id, title, description = '', priority = 'medium' } = input || {};
101
+ if (!parent_task_id) return JSON.stringify({ error: 'parent_task_id is required' });
102
+ if (!title) return JSON.stringify({ error: 'title is required' });
103
+
104
+ const parent = got.store.get(parent_task_id);
105
+ if (!parent) return JSON.stringify({ error: `Parent task not found: ${parent_task_id}` });
106
+
107
+ const id = newTaskId();
108
+ const now = Date.now();
109
+ got.store.create({
110
+ id,
111
+ title,
112
+ description: description || `Subtask of: ${parent.title}`,
113
+ priority,
114
+ status: 'pending',
115
+ parentId: parent_task_id,
116
+ createdAt: now,
117
+ updatedAt: now,
118
+ });
119
+ return JSON.stringify({
120
+ success: true,
121
+ task: { id, title, priority, status: 'pending', parentId: parent_task_id },
122
+ message: `Subtask spawned: ${title} (${id}) under ${parent_task_id}`,
123
+ });
124
+ },
125
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * thread-tools.js — Thread-spawning tools for Unify Engine (Phase 1).
3
+ *
4
+ * Phase 1 scope (task-299):
5
+ * - SpawnThread — create a new thread
6
+ * - SwitchThread — set the engine's currentThreadId marker
7
+ * - ListThreads — list threads + current marker
8
+ * - AttachThreadToTask — bind a thread to an existing task
9
+ *
10
+ * Phase 1 uses the in-memory ThreadStore (agent/unify/threads/store.js)
11
+ * so these tools can ship and be tested before task-298's file-backed
12
+ * data layer merges. The tool surface is designed to remain stable when
13
+ * the store is replaced.
14
+ */
15
+
16
+ import { defineTool } from './types.js';
17
+ import { getThreadStore } from '../threads/store.js';
18
+ import { getTaskStore } from './task-tools.js';
19
+
20
+ // ─── SpawnThread ─────────────────────────────────────────
21
+
22
+ export const spawnThread = defineTool({
23
+ name: 'SpawnThread',
24
+ description: `Create a new thread (conversation track) for parallel work.
25
+
26
+ A thread is a named conversation track that groups related messages and
27
+ tool calls under a single goal. Use when the work needs a fresh focus
28
+ track separate from the current conversation.
29
+
30
+ Returns the new threadId (format: "thr-xxxxxxxx"). Does NOT switch the
31
+ engine to the new thread — call SwitchThread to activate it.`,
32
+ parameters: {
33
+ type: 'object',
34
+ properties: {
35
+ name: { type: 'string', description: 'Short human-readable name' },
36
+ goal: { type: 'string', description: 'Optional one-sentence goal' },
37
+ parent_thread_id: {
38
+ type: 'string',
39
+ description: 'Optional parent threadId for hierarchy',
40
+ },
41
+ },
42
+ required: ['name'],
43
+ },
44
+ modes: ['work'],
45
+ isConcurrencySafe: () => false,
46
+ isReadOnly: () => false,
47
+ async execute(input) {
48
+ const { name, goal, parent_thread_id } = input || {};
49
+ if (!name) return JSON.stringify({ error: 'name is required' });
50
+ try {
51
+ const store = getThreadStore();
52
+ const t = store.create({ name, goal, parentThreadId: parent_thread_id || null });
53
+ return JSON.stringify({
54
+ success: true,
55
+ thread: {
56
+ id: t.id,
57
+ name: t.name,
58
+ goal: t.goal,
59
+ parentThreadId: t.parentThreadId,
60
+ },
61
+ message: `Thread created: ${t.name} (${t.id})`,
62
+ });
63
+ } catch (err) {
64
+ return JSON.stringify({ error: err.message || String(err) });
65
+ }
66
+ },
67
+ });
68
+
69
+ // ─── SwitchThread ────────────────────────────────────────
70
+
71
+ export const switchThread = defineTool({
72
+ name: 'SwitchThread',
73
+ description: `Switch the engine's active thread marker.
74
+
75
+ All messages and tool calls persisted after this call will carry the new
76
+ threadId (Phase 1: marker only — the Engine reads it when persisting and
77
+ the web-bridge forwards it to the UI). Use 'main' to return to the root
78
+ thread.`,
79
+ parameters: {
80
+ type: 'object',
81
+ properties: {
82
+ thread_id: { type: 'string', description: 'Thread id to switch to' },
83
+ },
84
+ required: ['thread_id'],
85
+ },
86
+ modes: ['work'],
87
+ isConcurrencySafe: () => false,
88
+ isReadOnly: () => false,
89
+ async execute(input) {
90
+ const { thread_id } = input || {};
91
+ if (!thread_id) return JSON.stringify({ error: 'thread_id is required' });
92
+ try {
93
+ const store = getThreadStore();
94
+ store.switch(thread_id);
95
+ return JSON.stringify({
96
+ success: true,
97
+ currentThreadId: store.currentId,
98
+ message: `Switched to thread ${thread_id}`,
99
+ });
100
+ } catch (err) {
101
+ return JSON.stringify({ error: err.message || String(err) });
102
+ }
103
+ },
104
+ });
105
+
106
+ // ─── ListThreads ─────────────────────────────────────────
107
+
108
+ export const listThreads = defineTool({
109
+ name: 'ListThreads',
110
+ description: `List all threads and the engine's current thread marker.`,
111
+ parameters: { type: 'object', properties: {} },
112
+ modes: ['work'],
113
+ isConcurrencySafe: () => true,
114
+ isReadOnly: () => true,
115
+ async execute() {
116
+ const store = getThreadStore();
117
+ const threads = store.list().map(t => ({
118
+ id: t.id,
119
+ name: t.name,
120
+ goal: t.goal,
121
+ parentThreadId: t.parentThreadId,
122
+ attachedTaskId: store.attachedTask(t.id),
123
+ }));
124
+ return JSON.stringify(
125
+ {
126
+ currentThreadId: store.currentId,
127
+ threads,
128
+ totalCount: threads.length,
129
+ },
130
+ null,
131
+ 2,
132
+ );
133
+ },
134
+ });
135
+
136
+ // ─── AttachThreadToTask ──────────────────────────────────
137
+
138
+ export const attachThreadToTask = defineTool({
139
+ name: 'AttachThreadToTask',
140
+ description: `Link an existing thread to an existing task.
141
+
142
+ Use to record which thread is responsible for which task. The thread and
143
+ the task must both already exist. Overwrites any previous attachment for
144
+ the same thread.`,
145
+ parameters: {
146
+ type: 'object',
147
+ properties: {
148
+ thread_id: { type: 'string' },
149
+ task_id: { type: 'string' },
150
+ },
151
+ required: ['thread_id', 'task_id'],
152
+ },
153
+ modes: ['work'],
154
+ isConcurrencySafe: () => false,
155
+ isReadOnly: () => false,
156
+ async execute(input) {
157
+ const { thread_id, task_id } = input || {};
158
+ if (!thread_id) return JSON.stringify({ error: 'thread_id is required' });
159
+ if (!task_id) return JSON.stringify({ error: 'task_id is required' });
160
+
161
+ const taskStore = getTaskStore();
162
+ // If the task store is initialized, validate the task exists; if not
163
+ // initialized (e.g. Phase 1 unit tests running before session bootstrap),
164
+ // skip the task existence check — we still enforce thread existence.
165
+ if (taskStore) {
166
+ const task = taskStore.get(task_id);
167
+ if (!task) return JSON.stringify({ error: `Task not found: ${task_id}` });
168
+ }
169
+
170
+ try {
171
+ const store = getThreadStore();
172
+ store.attachTask(thread_id, task_id);
173
+ return JSON.stringify({
174
+ success: true,
175
+ threadId: thread_id,
176
+ taskId: task_id,
177
+ message: `Thread ${thread_id} attached to task ${task_id}`,
178
+ });
179
+ } catch (err) {
180
+ return JSON.stringify({ error: err.message || String(err) });
181
+ }
182
+ },
183
+ });
@@ -29,9 +29,6 @@ const QUERY_TIMEOUT_MS = 120_000;
29
29
  /** Virtual conversationId for the Unify session */
30
30
  let unifyConversationId = null;
31
31
 
32
- /** Current query mode: 'chat' or 'work' */
33
- let currentMode = 'chat';
34
-
35
32
  /** Accumulated conversation messages for context continuity across queries.
36
33
  * Each entry is { role: 'user'|'assistant', content: string|Array }.
37
34
  * Cleared on session reset or consolidation. */
@@ -79,14 +76,16 @@ function sendUnifyEvent(event) {
79
76
  * Handle a unify_chat message from the web UI.
80
77
  *
81
78
  * @param {{ prompt: string, mode?: string, userId?: string, username?: string }} msg
79
+ * NOTE: `mode` is deprecated (task-297) — Unify now runs in a single unified mode.
80
+ * If present, a warning is logged and the field is ignored.
82
81
  */
83
82
  export async function handleUnifyChat(msg) {
84
83
  const { prompt, mode } = msg;
85
84
  if (!prompt?.trim()) return;
86
85
 
87
- // Update mode if provided
88
- if (mode === 'chat' || mode === 'work') {
89
- currentMode = mode;
86
+ // Deprecation warning — task-297 removed chat/work mode distinction
87
+ if (mode !== undefined && mode !== null) {
88
+ console.warn('[Unify] unify_chat.mode is deprecated and ignored — Unify now runs in a single unified mode.');
90
89
  }
91
90
 
92
91
  try {
@@ -150,7 +149,6 @@ export async function handleUnifyChat(msg) {
150
149
  // ─── Stream Engine events → claude_output format ──
151
150
  for await (const event of session.engine.query({
152
151
  prompt,
153
- mode: currentMode,
154
152
  messages: conversationMessages,
155
153
  signal: currentAbort.signal,
156
154
  })) {
@@ -192,12 +190,15 @@ export async function handleUnifyChat(msg) {
192
190
  input: event.input,
193
191
  }],
194
192
  },
193
+ threadId: event.threadId,
195
194
  });
196
195
  break;
197
196
 
198
197
  // ── Tool execution started ──
199
198
  case 'tool_start':
200
- // Tool is running — the UI already shows it from tool_use block above
199
+ // Tool is running — the UI already shows it from tool_use block above.
200
+ // Forward threadId so the UI can group tool activity by thread (Phase 1).
201
+ sendUnifyEvent({ type: 'tool_start', id: event.id, name: event.name, threadId: event.threadId });
201
202
  break;
202
203
 
203
204
  // ── Tool execution completed ──
@@ -211,6 +212,7 @@ export async function handleUnifyChat(msg) {
211
212
  content: event.output || '',
212
213
  is_error: event.isError || false,
213
214
  }],
215
+ threadId: event.threadId,
214
216
  });
215
217
  break;
216
218
 
@@ -406,12 +408,12 @@ export async function handleUnifyChat(msg) {
406
408
 
407
409
  /**
408
410
  * Handle mode switch from the web UI.
409
- * @param {{ mode: 'chat' | 'work' }} msg
411
+ * DEPRECATED (task-297): Unify no longer has chat/work mode distinction.
412
+ * Retained as a no-op with warning for backward compatibility.
413
+ * @param {{ mode?: string }} _msg
410
414
  */
411
- export function handleUnifyModeSwitch(msg) {
412
- if (msg.mode === 'chat' || msg.mode === 'work') {
413
- currentMode = msg.mode;
414
- }
415
+ export function handleUnifyModeSwitch(_msg) {
416
+ console.warn('[Unify] unify_mode_switch is deprecated and ignored — Unify now runs in a single unified mode.');
415
417
  }
416
418
 
417
419
  /**
@@ -518,7 +520,6 @@ export async function resetUnifySession() {
518
520
  session = null;
519
521
  }
520
522
  unifyConversationId = null;
521
- currentMode = 'chat';
522
523
  conversationMessages = [];
523
524
 
524
525
  // Re-initialize session immediately so frontend gets updated config