@co0ontty/wand 4.68.1 → 4.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,103 +1,78 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { provisionalTaskTitleFromDescription } from "./task-title.js";
2
3
  import { isSessionProvider } from "./session-provider.js";
3
4
  import { isClosedWandTaskStatus, normalizeWandTaskAgentMode } from "./task-types.js";
4
- const UNNAMED_TASK_NAME = "未命名任务";
5
- function boardDescriptionFor(task, workspace) {
6
- const lines = [`项目:${workspace.name}`];
7
- const cwd = task.worktree?.path || task.cwd || workspace.cwd;
8
- if (cwd)
9
- lines.push(`目录:${cwd}`);
10
- if (task.worktree?.branch)
11
- lines.push(`分支:${task.worktree.branch}`);
12
- return lines.join("\n");
13
- }
5
+ import { firstLayoutTabId } from "./layout-tree.js";
6
+ /** 未命名任务的占位名;服务端新建时写入,自动命名成功后被真实标题覆盖。 */
7
+ export const UNNAMED_WORKSPACE_TASK_NAME = "未命名任务";
8
+ /** 历史客户端(Android / 桌面)留空时回传的默认名,同样视为未命名。 */
9
+ const LEGACY_UNNAMED_TASK_NAMES = new Set(["新任务"]);
14
10
  export function isUnnamedWorkspaceTaskName(name) {
15
11
  const trimmed = name.trim();
16
- return !trimmed || trimmed === UNNAMED_TASK_NAME;
17
- }
18
- function normalizeTitle(title) {
19
- return title.trim().replace(/[。..!?!?]+$/g, "").replace(/\s+/g, " ");
12
+ return !trimmed || trimmed === UNNAMED_WORKSPACE_TASK_NAME || LEGACY_UNNAMED_TASK_NAMES.has(trimmed);
20
13
  }
21
- function firstUserMessageText(session) {
14
+ /** Legacy title helper; prompts name sessions, not their containing task. */
15
+ export function boardTitleFromSession(session) {
16
+ const title = session.title?.trim();
17
+ if (title && !isUnnamedWorkspaceTaskName(title))
18
+ return provisionalTaskTitleFromDescription(title);
19
+ const description = session.description?.trim();
20
+ if (description)
21
+ return provisionalTaskTitleFromDescription(description);
22
22
  for (const turn of session.messages ?? []) {
23
23
  if (turn.role !== "user")
24
24
  continue;
25
- const text = turn.content
26
- .flatMap((block) => block.type === "text" ? [block.text.trim()] : [])
27
- .filter(Boolean)
28
- .join("\n");
25
+ const text = turn.content.flatMap((block) => block.type === "text" ? [block.text.trim()] : [])
26
+ .filter(Boolean).join("\n");
29
27
  if (text)
30
- return text;
28
+ return provisionalTaskTitleFromDescription(text);
31
29
  }
32
30
  return "";
33
31
  }
34
- /** 从未分组会话的标题 / 描述 / 首条用户消息里抽出看板标题。 */
35
- export function boardTitleFromSession(session) {
36
- const title = session.title?.trim() ?? "";
37
- if (title && !isUnnamedWorkspaceTaskName(title)) {
38
- return provisionalTaskTitleFromDescription(title);
32
+ /**
33
+ * 自动命名只覆盖「还没有人为名字」的任务:标题来源是 auto,或仍是占位名的历史卡片
34
+ * (老版本把未命名任务写成了 titleSource=user)。用户在面板 / 侧栏改过名后此处为 false。
35
+ */
36
+ export function isAutoNameableBoardTask(card) {
37
+ return card.titleSource === "auto" || isUnnamedWorkspaceTaskName(card.title);
38
+ }
39
+ /** 任务下所有会话(先按看板绑定,再看侧栏 workspace_task_id 兜底),最近的排在前面。 */
40
+ function taskNamingSessions(storage, card) {
41
+ const sessions = new Map();
42
+ const add = (session) => {
43
+ if (session && !sessions.has(session.id))
44
+ sessions.set(session.id, session);
45
+ };
46
+ if (card.workspaceTaskId) {
47
+ for (const session of storage.listSessionsByWorkspaceTask(card.workspaceTaskId))
48
+ add(session);
39
49
  }
40
- const description = session.description?.trim() ?? "";
41
- if (description)
42
- return provisionalTaskTitleFromDescription(description);
43
- const message = firstUserMessageText(session);
44
- if (message)
45
- return provisionalTaskTitleFromDescription(message);
46
- return "";
47
- }
48
- function boardDescriptionFromSession(session) {
49
- const description = session.description?.trim() ?? "";
50
- if (description)
51
- return description;
52
- const title = session.title?.trim() ?? "";
53
- if (title && !isUnnamedWorkspaceTaskName(title))
54
- return title;
55
- return firstUserMessageText(session);
50
+ for (const sessionId of storage.listWandTaskSessionIds(card.id))
51
+ add(storage.getSession(sessionId));
52
+ return [...sessions.values()].sort((left, right) => (right.startedAt ?? "").localeCompare(left.startedAt ?? ""));
56
53
  }
57
- function pickBestSession(sessions) {
58
- if (sessions.length === 0)
59
- return null;
60
- return sessions.find((session) => boardTitleFromSession(session)) ?? sessions[0] ?? null;
61
- }
62
- function isSyncedWorkspaceDescription(description) {
63
- const lines = description.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
64
- if (lines.length === 0)
65
- return true;
66
- return lines.every((line) => line.startsWith("项目:") || line.startsWith("目录:") || line.startsWith("分支:"));
67
- }
68
- function bindSessions(storage, taskId, sessionIds) {
69
- for (const sessionId of sessionIds) {
70
- try {
71
- storage.bindWandTaskSession(taskId, sessionId);
72
- }
73
- catch {
74
- // 会话可能已被删;跳过单条,不中断整次对账。
75
- }
54
+ /**
55
+ * 自动命名的输入:任务描述(去掉「项目:/ 目录:/ 分支:」同步元信息)+ 每个会话的可读摘要。
56
+ * 为空表示这个任务还没有可命名的内容,调用方应保留占位标题。
57
+ */
58
+ export function taskAutoNameSourceText(storage, card) {
59
+ const parts = [];
60
+ for (const line of card.description.split(/\r?\n/)) {
61
+ const trimmed = line.trim();
62
+ if (!trimmed || /^(?:项目|目录|分支)[::]/.test(trimmed))
63
+ continue;
64
+ parts.push(trimmed);
76
65
  }
66
+ for (const session of taskNamingSessions(storage, card)) {
67
+ const candidate = boardTitleFromSession(session);
68
+ if (candidate)
69
+ parts.push(candidate);
70
+ }
71
+ return parts.join("\n").slice(0, 4_000);
77
72
  }
78
- function findBoardTaskByTitle(tasks, title, workspaceId) {
79
- const needle = normalizeTitle(title);
80
- if (!needle)
81
- return null;
82
- const sameWorkspace = tasks.filter((task) => (task.workspaceId ?? "") === (workspaceId ?? "")
83
- && normalizeTitle(task.title) === needle);
84
- if (sameWorkspace[0])
85
- return sameWorkspace[0];
86
- return tasks.find((task) => normalizeTitle(task.title) === needle) ?? null;
87
- }
88
- function promoteUngroupedCard(storage, card, patch) {
89
- const nextTitle = patch.title?.trim() && isUnnamedWorkspaceTaskName(card.title) ? patch.title.trim() : card.title;
90
- const nextDescription = patch.description?.trim() && isSyncedWorkspaceDescription(card.description)
91
- ? patch.description.trim()
92
- : card.description;
93
- return storage.updateWandTask(card.id, {
94
- title: nextTitle,
95
- titleSource: nextTitle !== card.title ? "auto" : card.titleSource,
96
- description: nextDescription,
97
- status: isClosedWandTaskStatus(card.status) ? card.status : "doing",
98
- workspaceId: patch.workspaceId !== undefined ? patch.workspaceId : card.workspaceId,
99
- workspaceTaskId: patch.workspaceTaskId !== undefined ? patch.workspaceTaskId : card.workspaceTaskId,
100
- }) ?? card;
73
+ /** 输入指纹:内容没变就没必要再总结一次,也避免自动标题与模型结果来回震荡。 */
74
+ export function taskAutoNameSignature(source) {
75
+ return createHash("sha1").update(source).digest("hex");
101
76
  }
102
77
  function agentFromSession(session) {
103
78
  if (!isSessionProvider(session.provider))
@@ -110,149 +85,163 @@ function agentFromSession(session) {
110
85
  mode: normalizeWandTaskAgentMode(session.provider, session.mode),
111
86
  };
112
87
  }
113
- /** 侧栏工作任务与看板卡片对账:已关联则复用,同项目同名未关联则挂上,否则新建。 */
114
- export function ensureBoardTaskForWorkspaceTask(storage, task, workspace) {
115
- const linked = storage.getWandTaskByWorkspaceTaskId(task.id);
116
- if (linked)
117
- return linked;
118
- // 未命名侧栏任务在会话树里并进「未分组终端」;等同步函数根据会话内容补卡片。
119
- if (isUnnamedWorkspaceTaskName(task.name))
120
- return null;
121
- const reusable = storage.findUnlinkedWandTask(workspace.id, task.name);
122
- if (reusable) {
123
- return storage.updateWandTask(reusable.id, {
124
- workspaceTaskId: task.id,
125
- workspaceId: workspace.id,
126
- ...(task.milestoneId ? { milestoneId: task.milestoneId } : {}),
127
- }) ?? reusable;
128
- }
88
+ /** Identity is the task id, never its title. Same-named tasks stay independent. */
89
+ export function ensureBoardTaskForWorkspaceTask(storage, task, workspace, options = {}) {
90
+ const existing = storage.getWandTaskByWorkspaceTaskId(task.id);
91
+ if (existing)
92
+ return existing;
129
93
  return storage.createWandTask({
130
- workspaceId: workspace.id,
94
+ workspaceId: workspace.kind === "global" ? null : workspace.id,
131
95
  workspaceTaskId: task.id,
132
96
  title: task.name,
133
- description: boardDescriptionFor(task, workspace),
134
- status: task.status === "done" ? "done" : "doing",
97
+ // 占位名(未命名任务 / 新任务 / 空)走自动命名;用户填过名就是 user,永不被模型覆盖。
98
+ titleSource: options.titleSource ?? (isUnnamedWorkspaceTaskName(task.name) ? "auto" : "user"),
99
+ description: "",
100
+ status: task.status === "done" ? "done" : "todo",
135
101
  milestoneId: task.milestoneId ?? null,
136
102
  });
137
103
  }
138
- /**
139
- * 把历史未分组会话补到看板:用会话标题/首条消息当标题,绑上会话,放进「进行中」。
140
- * 幂等,打开看板列表时调用。
141
- */
142
- export function syncUngroupedSessionsToBoard(storage) {
143
- const boardTasks = storage.listWandTasks();
144
- const bound = new Set(storage.listBoundWandTaskSessionIds());
145
- for (const workspace of storage.listWorkspaces()) {
146
- for (const task of storage.listWorkspaceTasks(workspace.id)) {
147
- const sessions = storage.listSessionsByWorkspaceTask(task.id);
148
- if (!isUnnamedWorkspaceTaskName(task.name)) {
149
- let card = ensureBoardTaskForWorkspaceTask(storage, task, workspace);
150
- if (card && !card.agent) {
151
- const agent = sessions.map(agentFromSession).find((value) => value !== null);
152
- if (agent)
153
- card = storage.updateWandTask(card.id, { agent }) ?? card;
104
+ /** A board task always has a sidebar container, even before its first session. */
105
+ export function ensureWorkspaceTaskForBoardTask(storage, card) {
106
+ const existing = card.workspaceTaskId ? storage.getWorkspaceTask(card.workspaceTaskId) : null;
107
+ if (existing)
108
+ return existing;
109
+ const workspace = card.workspaceId ? storage.getWorkspace(card.workspaceId) : storage.ensureGlobalWorkspace();
110
+ if (!workspace)
111
+ throw new Error("任务所属工作区不存在。");
112
+ const task = storage.createWorkspaceTask({
113
+ workspaceId: workspace.id,
114
+ name: card.title,
115
+ status: isClosedWandTaskStatus(card.status) ? "done" : "active",
116
+ milestoneId: card.milestoneId,
117
+ });
118
+ storage.updateWandTask(card.id, { workspaceTaskId: task.id });
119
+ return task;
120
+ }
121
+ function removeSessionTab(node, sessionId) {
122
+ if (node.type === "split")
123
+ return {
124
+ ...node,
125
+ children: [removeSessionTab(node.children[0], sessionId), removeSessionTab(node.children[1], sessionId)],
126
+ };
127
+ const activeId = node.tabs[node.active]?.id;
128
+ const tabs = node.tabs.filter((tab) => tab.kind !== "session" || tab.sessionId !== sessionId);
129
+ const active = tabs.findIndex((tab) => tab.id === activeId);
130
+ return { ...node, tabs, active: active >= 0 ? active : Math.min(node.active, Math.max(0, tabs.length - 1)) };
131
+ }
132
+ /** Remove stale source tabs so opening an old task cannot re-open a moved session. */
133
+ function detachSessionLayout(storage, task, sessionId) {
134
+ if (!task.layout) {
135
+ storage.saveWorkspaceTaskLayout(task.id, null);
136
+ return;
137
+ }
138
+ const windows = task.layout.windows.map((window) => {
139
+ const layout = removeSessionTab(window.layout, sessionId);
140
+ return { ...window, layout, activeTabId: firstLayoutTabId(layout) };
141
+ });
142
+ storage.saveWorkspaceTaskLayout(task.id, { ...task.layout, windows });
143
+ }
144
+ /** Atomic exclusive ownership change. Never changes cwd, messages, or execution state. */
145
+ export function moveSessionToWorkspaceTask(storage, sessionId, taskId) {
146
+ storage.transaction(() => {
147
+ const session = storage.getSession(sessionId);
148
+ if (!session)
149
+ throw new Error("未找到该会话。");
150
+ const target = taskId ? storage.getWorkspaceTask(taskId) : null;
151
+ if (taskId && !target)
152
+ throw new Error("未找到目标任务。");
153
+ const source = session.workspaceTaskId ? storage.getWorkspaceTask(session.workspaceTaskId) : null;
154
+ if (source && source.id !== target?.id)
155
+ detachSessionLayout(storage, source, sessionId);
156
+ // Explicit task metadata is owned by storage, not by a runner checkpoint.
157
+ storage.setSessionWorkspaceTaskId(sessionId, target?.id ?? null);
158
+ for (const card of storage.listWandTasks()) {
159
+ if (card.workspaceTaskId !== target?.id)
160
+ storage.unbindWandTaskSession(card.id, sessionId);
161
+ }
162
+ if (!target)
163
+ return;
164
+ const workspace = storage.getWorkspace(target.workspaceId);
165
+ if (!workspace)
166
+ throw new Error("目标工作区不存在。");
167
+ storage.setSessionWorkspaceId(sessionId, workspace.id);
168
+ const card = ensureBoardTaskForWorkspaceTask(storage, target, workspace);
169
+ const newlyBound = !storage.listWandTaskSessionIds(card.id).includes(sessionId);
170
+ storage.bindWandTaskSession(card.id, sessionId);
171
+ if (newlyBound && card.status === "todo")
172
+ storage.updateWandTask(card.id, { status: "doing" });
173
+ });
174
+ }
175
+ /** Reconcile legacy records by explicit ids. Unassigned sessions remain unassigned. */
176
+ export function syncSidebarTasksFromBoard(storage) {
177
+ storage.transaction(() => {
178
+ for (const card of storage.listWandTasks()) {
179
+ // Deleted sidebar tasks leave an archived card, not a resurrected container.
180
+ if (!card.workspaceTaskId && card.status === "archived")
181
+ continue;
182
+ const task = ensureWorkspaceTaskForBoardTask(storage, card);
183
+ for (const sessionId of storage.listWandTaskSessionIds(card.id)) {
184
+ const session = storage.getSession(sessionId);
185
+ if (!session)
186
+ continue;
187
+ if (session.workspaceTaskId && session.workspaceTaskId !== task.id) {
188
+ storage.unbindWandTaskSession(card.id, sessionId);
154
189
  }
155
- if (card) {
156
- bindSessions(storage, card.id, sessions.map((session) => session.id));
157
- for (const session of sessions)
158
- bound.add(session.id);
190
+ else if (session.workspaceTaskId !== task.id || session.workspaceId !== task.workspaceId) {
191
+ storage.setSessionWorkspaceTaskId(sessionId, task.id);
192
+ storage.setSessionWorkspaceId(sessionId, task.workspaceId);
159
193
  }
160
- continue;
161
- }
162
- const best = pickBestSession(sessions);
163
- const title = best ? boardTitleFromSession(best) : "";
164
- if (!best || !title)
165
- continue;
166
- const description = boardDescriptionFromSession(best);
167
- let card = storage.getWandTaskByWorkspaceTaskId(task.id)
168
- ?? findBoardTaskByTitle(boardTasks, title, workspace.id);
169
- if (!card) {
170
- card = storage.createWandTask({
171
- workspaceId: workspace.id,
172
- workspaceTaskId: task.id,
173
- title,
174
- titleSource: "auto",
175
- description,
176
- status: "doing",
177
- // 未命名任务建立板卡时才补上:建任务时选的里程碑不能丢。
178
- milestoneId: task.milestoneId ?? null,
179
- });
180
- boardTasks.push(card);
181
194
  }
182
- else {
183
- card = promoteUngroupedCard(storage, card, {
184
- title,
185
- description,
186
- workspaceId: workspace.id,
187
- workspaceTaskId: task.id,
188
- });
189
- if (!card.milestoneId && task.milestoneId) {
190
- card = storage.updateWandTask(card.id, { milestoneId: task.milestoneId }) ?? card;
195
+ }
196
+ });
197
+ }
198
+ export function syncUngroupedSessionsToBoard(storage) {
199
+ syncSidebarTasksFromBoard(storage);
200
+ storage.transaction(() => {
201
+ for (const workspace of storage.listWorkspaces()) {
202
+ for (const task of storage.listWorkspaceTasks(workspace.id)) {
203
+ let card = ensureBoardTaskForWorkspaceTask(storage, task, workspace);
204
+ const sessions = storage.listSessionsByWorkspaceTask(task.id);
205
+ const agent = sessions.map(agentFromSession).find((value) => value !== null);
206
+ const bound = new Set(storage.listWandTaskSessionIds(card.id));
207
+ const started = card.status === "todo" && sessions.some((session) => !bound.has(session.id));
208
+ if ((!card.agent && agent) || started) {
209
+ card = storage.updateWandTask(card.id, {
210
+ ...(!card.agent && agent ? { agent } : {}),
211
+ ...(started ? { status: "doing" } : {}),
212
+ }) ?? card;
191
213
  }
214
+ for (const session of sessions)
215
+ storage.bindWandTaskSession(card.id, session.id);
192
216
  }
193
- bindSessions(storage, card.id, sessions.map((session) => session.id));
194
- for (const session of sessions)
195
- bound.add(session.id);
196
- }
197
- }
198
- for (const session of storage.loadSessions()) {
199
- if (bound.has(session.id))
200
- continue;
201
- if (session.workspaceTaskId)
202
- continue;
203
- const title = boardTitleFromSession(session);
204
- if (!title)
205
- continue;
206
- let card = findBoardTaskByTitle(boardTasks, title, session.workspaceId ?? null);
207
- if (!card) {
208
- card = storage.createWandTask({
209
- workspaceId: session.workspaceId ?? null,
210
- title,
211
- titleSource: "auto",
212
- description: boardDescriptionFromSession(session),
213
- status: "doing",
214
- });
215
- boardTasks.push(card);
216
- }
217
- else if (!isClosedWandTaskStatus(card.status)) {
218
- card = promoteUngroupedCard(storage, card, {
219
- title,
220
- description: boardDescriptionFromSession(session),
221
- workspaceId: session.workspaceId ?? card.workspaceId,
222
- });
223
217
  }
224
- bindSessions(storage, card.id, [session.id]);
225
- bound.add(session.id);
226
- }
227
- }
228
- function closeLinkedWorkspaceTask(storage, workspaceTaskId) {
229
- if (!workspaceTaskId)
230
- return;
231
- const workspaceTask = storage.getWorkspaceTask(workspaceTaskId);
232
- if (workspaceTask && workspaceTask.status !== "done") {
233
- storage.updateWorkspaceTask(workspaceTask.id, { status: "done" });
234
- }
218
+ });
235
219
  }
236
- /** 看板确认/归档时,把关联的侧栏工作任务标成 done(不删会话)。 */
220
+ /** Status and title projection is centralized in the storage writer. */
237
221
  export function syncClosedBoardTask(storage, task) {
238
- if (!isClosedWandTaskStatus(task.status))
239
- return;
240
- closeLinkedWorkspaceTask(storage, task.workspaceTaskId);
222
+ if (isClosedWandTaskStatus(task.status) && task.workspaceTaskId) {
223
+ storage.updateWorkspaceTask(task.workspaceTaskId, { status: "done" });
224
+ }
241
225
  }
242
- /** 看板归档:卡片进入归档目录,并同步把关联的侧栏工作任务标成 done(不删会话)。 */
243
226
  export function archiveBoardTask(storage, id) {
244
- const current = storage.getWandTask(id);
245
- if (!current)
246
- return null;
247
- const archived = storage.updateWandTask(id, { status: "archived" });
248
- if (archived)
249
- closeLinkedWorkspaceTask(storage, archived.workspaceTaskId);
250
- return archived;
227
+ return storage.updateWandTask(id, { status: "archived" });
228
+ }
229
+ /**
230
+ * 归档侧栏任务是软删除:终端继续运行、worktree 与布局都保留,只有看板卡片进入
231
+ * 归档文件夹、侧栏不再显示。恢复方式是看板里把卡片拖回任一列(或右键恢复)。
232
+ */
233
+ export function archiveWorkspaceTask(storage, task) {
234
+ return storage.transaction(() => {
235
+ const card = storage.getWandTaskByWorkspaceTaskId(task.id);
236
+ // 先归档卡片,再把侧栏任务标成已完成:反向投影见到 archived 就不会降级成 done。
237
+ if (card)
238
+ storage.updateWandTask(card.id, { status: "archived" });
239
+ storage.updateWorkspaceTask(task.id, { status: "done" });
240
+ return card ? storage.getWandTask(card.id) : null;
241
+ });
251
242
  }
252
- /** 侧栏工作任务完成/删除时,把对应看板卡片标成已完成;已归档的保持归档。 */
253
243
  export function archiveBoardTaskForWorkspaceTask(storage, workspaceTaskId) {
254
244
  const linked = storage.getWandTaskByWorkspaceTaskId(workspaceTaskId);
255
- if (!linked || isClosedWandTaskStatus(linked.status))
256
- return;
257
- storage.updateWandTask(linked.id, { status: "done" });
245
+ if (linked && !isClosedWandTaskStatus(linked.status))
246
+ storage.updateWandTask(linked.id, { status: "done" });
258
247
  }