feat: keep project understanding in sync after import

This commit is contained in:
kris
2026-04-04 08:09:47 +08:00
parent 908ad8858b
commit 01f438e3af
3 changed files with 640 additions and 7 deletions

View File

@@ -287,6 +287,9 @@ export interface ThreadConversationMeta {
folderName: string;
activityIconCount: number;
updatedAt: string;
lastObservedCodexActivityAt?: string;
lastProjectUnderstandingRequestedAt?: string;
lastProjectUnderstandingSyncedAt?: string;
codexThreadRef?: string;
codexFolderRef?: string;
}
@@ -321,6 +324,7 @@ export interface Project {
riskLevel: RiskLevel;
contextBudgetPct?: number;
contextBudgetLabel?: string;
projectUnderstanding?: ProjectUnderstandingSnapshot;
messages: Message[];
goals: GoalItem[];
versions: VersionEntry[];
@@ -495,6 +499,17 @@ export interface DeviceImportProjectUnderstanding {
updatedAt: string;
}
export interface ProjectUnderstandingSnapshot {
projectGoal: string;
currentProgress: string;
technicalArchitecture: string;
currentBlockers: string;
recommendedNextStep: string;
sourceTaskId: string;
updatedAt: string;
sourceKind: "device_import" | "thread_sync";
}
export interface VerificationCode {
id: string;
account: string;
@@ -661,6 +676,8 @@ export interface MasterAgentTask {
deviceImportDraftId?: string;
deviceImportCandidateId?: string;
deviceImportCandidateFolderName?: string;
projectUnderstandingTargetProjectId?: string;
projectUnderstandingReason?: "heartbeat_activity" | "thread_reply";
status: MasterAgentTaskStatus;
requestedAt: string;
claimedAt?: string;
@@ -1724,6 +1741,11 @@ function normalizeThreadMeta(
folderName: raw?.folderName ?? fallback?.folderName ?? (project.isGroup ? "群聊" : project.name),
activityIconCount: Math.max(1, raw?.activityIconCount ?? fallback?.activityIconCount ?? (project.isGroup ? 2 : 1)),
updatedAt: raw?.updatedAt ?? project.updatedAt ?? nowIso(),
lastObservedCodexActivityAt: raw?.lastObservedCodexActivityAt ?? fallback?.lastObservedCodexActivityAt,
lastProjectUnderstandingRequestedAt:
raw?.lastProjectUnderstandingRequestedAt ?? fallback?.lastProjectUnderstandingRequestedAt,
lastProjectUnderstandingSyncedAt:
raw?.lastProjectUnderstandingSyncedAt ?? fallback?.lastProjectUnderstandingSyncedAt,
codexThreadRef: raw?.codexThreadRef,
codexFolderRef: raw?.codexFolderRef,
};
@@ -2783,6 +2805,7 @@ function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
unreadCount:
typeof raw.unreadCount === "number" ? raw.unreadCount : base.unreadCount ?? 0,
riskLevel: raw.riskLevel ?? base.riskLevel ?? "low",
projectUnderstanding: normalizeProjectUnderstanding(raw.projectUnderstanding, fallback?.projectUnderstanding),
messages: ensureArray(raw.messages, base.messages).map(normalizeMessage),
goals: ensureArray(raw.goals, base.goals).map((goal) => ({
id: goal.id ?? randomToken("goal"),
@@ -2813,6 +2836,33 @@ function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
return project;
}
function normalizeProjectUnderstanding(
raw: Partial<ProjectUnderstandingSnapshot> | undefined,
fallback?: ProjectUnderstandingSnapshot,
): ProjectUnderstandingSnapshot | undefined {
if (!raw && !fallback) {
return undefined;
}
const projectGoal = raw?.projectGoal?.trim() ?? fallback?.projectGoal?.trim() ?? "";
const currentProgress = raw?.currentProgress?.trim() ?? fallback?.currentProgress?.trim() ?? "";
const technicalArchitecture = raw?.technicalArchitecture?.trim() ?? fallback?.technicalArchitecture?.trim() ?? "";
const currentBlockers = raw?.currentBlockers?.trim() ?? fallback?.currentBlockers?.trim() ?? "";
const recommendedNextStep = raw?.recommendedNextStep?.trim() ?? fallback?.recommendedNextStep?.trim() ?? "";
if (!projectGoal && !currentProgress && !technicalArchitecture && !currentBlockers && !recommendedNextStep) {
return undefined;
}
return {
projectGoal,
currentProgress,
technicalArchitecture,
currentBlockers,
recommendedNextStep,
sourceTaskId: raw?.sourceTaskId ?? fallback?.sourceTaskId ?? randomToken("mastertask"),
updatedAt: raw?.updatedAt ?? fallback?.updatedAt ?? nowIso(),
sourceKind: raw?.sourceKind ?? fallback?.sourceKind ?? "thread_sync",
};
}
function normalizeState(raw: Partial<BossState> | undefined): BossState {
const base = cloneInitialState();
if (!raw) return syncDerivedState(base);
@@ -2943,6 +2993,11 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
deviceImportDraftId: task.deviceImportDraftId,
deviceImportCandidateId: task.deviceImportCandidateId,
deviceImportCandidateFolderName: task.deviceImportCandidateFolderName,
projectUnderstandingTargetProjectId: task.projectUnderstandingTargetProjectId,
projectUnderstandingReason:
task.projectUnderstandingReason === "heartbeat_activity" || task.projectUnderstandingReason === "thread_reply"
? task.projectUnderstandingReason
: undefined,
status: task.status ?? "queued",
requestedAt: task.requestedAt ?? nowIso(),
claimedAt: task.claimedAt,
@@ -5149,6 +5204,8 @@ export async function queueMasterAgentTask(payload: {
orchestrationBackendLabel?: string;
deviceImportCandidateId?: string;
deviceImportCandidateFolderName?: string;
projectUnderstandingTargetProjectId?: string;
projectUnderstandingReason?: "heartbeat_activity" | "thread_reply";
}) {
const task = await mutateState((state) => {
const task: MasterAgentTask = {
@@ -5180,6 +5237,8 @@ export async function queueMasterAgentTask(payload: {
orchestrationBackendLabel: payload.orchestrationBackendLabel,
deviceImportCandidateId: payload.deviceImportCandidateId,
deviceImportCandidateFolderName: payload.deviceImportCandidateFolderName,
projectUnderstandingTargetProjectId: payload.projectUnderstandingTargetProjectId,
projectUnderstandingReason: payload.projectUnderstandingReason,
status: "queued",
requestedAt: nowIso(),
};
@@ -6139,6 +6198,10 @@ export async function completeMasterAgentTask(payload: {
task.taskType === "conversation_reply" &&
task.projectId === "master-agent" &&
Boolean(task.deviceImportDraftId && task.deviceImportCandidateId);
const isProjectUnderstandingSync =
task.taskType === "conversation_reply" &&
task.projectId === "master-agent" &&
Boolean(task.projectUnderstandingTargetProjectId);
const isThreadConversationReply =
task.taskType === "conversation_reply" &&
task.projectId !== "master-agent" &&
@@ -6148,6 +6211,18 @@ export async function completeMasterAgentTask(payload: {
if (draft) {
publishBossEvent("devices.updated", { deviceId: draft.deviceId });
}
} else if (isProjectUnderstandingSync) {
const understanding = parseStructuredProjectUnderstandingReply(task);
if (understanding && task.projectUnderstandingTargetProjectId) {
applyProjectUnderstandingSnapshotInState(state, {
projectId: task.projectUnderstandingTargetProjectId,
account: task.requestedByAccount,
snapshot: understanding,
sourceMessageId: task.requestMessageId,
sourceKind: "thread_sync",
});
publishBossEvent("conversation.updated", { projectId: task.projectUnderstandingTargetProjectId });
}
} else if (isThreadConversationReply) {
const threadProject = state.projects.find(
(item) => item.id === (task.targetProjectId ?? task.projectId),
@@ -6778,6 +6853,11 @@ export async function upsertDeviceHeartbeat(payload: {
}>;
}) {
const result = await mutateState((state) => {
const projectUnderstandingSyncRequests: Array<{
projectId: string;
observedActivityAt: string;
reason: "heartbeat_activity";
}> = [];
const existingDevice = state.devices.find((item) => item.id === payload.deviceId) ?? null;
const claimedEnrollment = claimEnrollment(
state,
@@ -6881,6 +6961,30 @@ export async function upsertDeviceHeartbeat(payload: {
candidates: normalizedCandidates,
});
for (const candidate of normalizedCandidates) {
const matchingProject = state.projects.find(
(project) =>
!project.isGroup &&
project.deviceIds.includes(payload.deviceId) &&
((candidate.codexThreadRef && project.threadMeta.codexThreadRef === candidate.codexThreadRef) ||
project.threadMeta.threadId === candidate.threadId),
);
if (!matchingProject) {
continue;
}
matchingProject.threadMeta.lastObservedCodexActivityAt = latestIsoTimestamp(
matchingProject.threadMeta.lastObservedCodexActivityAt,
candidate.lastActiveAt,
) ?? candidate.lastActiveAt;
if (shouldQueueProjectUnderstandingSync(matchingProject, candidate.lastActiveAt, state)) {
projectUnderstandingSyncRequests.push({
projectId: matchingProject.id,
observedActivityAt: candidate.lastActiveAt,
reason: "heartbeat_activity",
});
}
}
if (
draft &&
shouldAutoSyncHeartbeatCandidates({
@@ -6931,8 +7035,12 @@ export async function upsertDeviceHeartbeat(payload: {
token: claimedEnrollment?.token ?? device.token,
pairingStatus: claimedEnrollment?.status,
importDraft: draft,
projectUnderstandingSyncRequests,
};
});
for (const request of result.projectUnderstandingSyncRequests ?? []) {
await queueProjectUnderstandingSyncTask(request);
}
publishBossEvent("devices.updated", { deviceId: payload.deviceId });
publishBossEvent("conversation.updated", { deviceId: payload.deviceId });
return result;
@@ -7076,9 +7184,25 @@ function listDeviceImportUnderstandingTasks(state: BossState, draftId: string) {
function parseDeviceImportUnderstandingReply(
task: Pick<MasterAgentTask, "replyBody" | "deviceImportCandidateId" | "targetThreadDisplayName" | "deviceImportCandidateFolderName" | "taskId" | "completedAt" | "requestedAt">,
): DeviceImportProjectUnderstanding | null {
const understanding = parseStructuredProjectUnderstandingReply(task);
const candidateId = task.deviceImportCandidateId?.trim();
if (!candidateId || !understanding) {
return null;
}
return {
candidateId,
threadDisplayName: task.targetThreadDisplayName?.trim() || "未命名线程",
folderName: task.deviceImportCandidateFolderName?.trim() || "",
...understanding,
};
}
function parseStructuredProjectUnderstandingReply(
task: Pick<MasterAgentTask, "replyBody" | "taskId" | "completedAt" | "requestedAt">,
): ProjectUnderstandingSnapshot | null {
const replyBody = task.replyBody?.trim();
if (!candidateId || !replyBody) {
if (!replyBody) {
return null;
}
@@ -7109,9 +7233,6 @@ function parseDeviceImportUnderstandingReply(
}
return {
candidateId,
threadDisplayName: task.targetThreadDisplayName?.trim() || "未命名线程",
folderName: task.deviceImportCandidateFolderName?.trim() || "",
projectGoal,
currentProgress,
technicalArchitecture,
@@ -7119,6 +7240,7 @@ function parseDeviceImportUnderstandingReply(
recommendedNextStep,
sourceTaskId: task.taskId,
updatedAt: task.completedAt ?? task.requestedAt,
sourceKind: "thread_sync",
};
}
@@ -7145,6 +7267,181 @@ function deriveDeviceImportProjectUnderstandings(state: BossState, draftId: stri
return [...latestByCandidate.values()];
}
function applyProjectUnderstandingSnapshotInState(
state: BossState,
input: {
projectId: string;
account: string;
snapshot: ProjectUnderstandingSnapshot;
sourceMessageId?: string;
sourceKind: ProjectUnderstandingSnapshot["sourceKind"];
},
) {
const project = state.projects.find((item) => item.id === input.projectId);
if (!project) {
return null;
}
const snapshot: ProjectUnderstandingSnapshot = {
...input.snapshot,
sourceKind: input.sourceKind,
};
project.projectUnderstanding = snapshot;
project.threadMeta.lastProjectUnderstandingSyncedAt = snapshot.updatedAt;
project.threadMeta.lastObservedCodexActivityAt =
latestIsoTimestamp(project.threadMeta.lastObservedCodexActivityAt, snapshot.updatedAt) ?? snapshot.updatedAt;
const tags = [project.name, project.threadMeta.threadDisplayName].filter(Boolean);
if (snapshot.projectGoal) {
upsertAutoMasterMemoryInState(state, {
account: input.account,
scope: "project",
projectId: project.id,
title: `项目目标 · ${project.name}`,
content: snapshot.projectGoal,
memoryType: "project_progress",
tags: [...tags, "项目目标"],
sourceMessageId: input.sourceMessageId,
});
}
if (snapshot.currentProgress) {
upsertAutoMasterMemoryInState(state, {
account: input.account,
scope: "project",
projectId: project.id,
title: `项目进度 · ${project.name}`,
content: snapshot.currentProgress,
memoryType: "project_progress",
tags: [...tags, "项目进度"],
sourceMessageId: input.sourceMessageId,
});
}
if (snapshot.technicalArchitecture) {
upsertAutoMasterMemoryInState(state, {
account: input.account,
scope: "project",
projectId: project.id,
title: `技术架构 · ${project.name}`,
content: snapshot.technicalArchitecture,
memoryType: "research_note",
tags: [...tags, "技术架构"],
sourceMessageId: input.sourceMessageId,
});
}
if (snapshot.currentBlockers) {
upsertAutoMasterMemoryInState(state, {
account: input.account,
scope: "project",
projectId: project.id,
title: `当前阻塞 · ${project.name}`,
content: snapshot.currentBlockers,
memoryType: "blocking_issue",
tags: [...tags, "阻塞"],
sourceMessageId: input.sourceMessageId,
});
}
if (snapshot.recommendedNextStep) {
upsertAutoMasterMemoryInState(state, {
account: input.account,
scope: "project",
projectId: project.id,
title: `下一步建议 · ${project.name}`,
content: snapshot.recommendedNextStep,
memoryType: "workflow_rule",
tags: [...tags, "下一步"],
sourceMessageId: input.sourceMessageId,
});
}
return snapshot;
}
function shouldQueueProjectUnderstandingSync(project: Project, observedActivityAt: string, state: BossState) {
if (!isDispatchableThreadProject(project)) {
return false;
}
const observedTs = Date.parse(observedActivityAt);
if (!Number.isFinite(observedTs)) {
return false;
}
const latestWatermark = Date.parse(
project.threadMeta.lastProjectUnderstandingRequestedAt ??
project.threadMeta.lastProjectUnderstandingSyncedAt ??
project.projectUnderstanding?.updatedAt ??
"1970-01-01T00:00:00.000Z",
);
if (Number.isFinite(latestWatermark) && observedTs <= latestWatermark) {
return false;
}
return !state.masterAgentTasks.some(
(task) =>
task.taskType === "conversation_reply" &&
task.projectId === "master-agent" &&
task.projectUnderstandingTargetProjectId === project.id &&
(task.status === "queued" || task.status === "running"),
);
}
function buildProjectUnderstandingSyncPrompt(project: Project, reason: "heartbeat_activity" | "thread_reply") {
return [
"你正在向主 Agent 同步当前项目状态。",
`项目名称:${project.name}`,
`线程名称:${project.threadMeta.threadDisplayName}`,
`文件夹:${project.threadMeta.folderName}`,
`同步原因:${reason === "heartbeat_activity" ? "检测到线程有新活动" : "线程刚刚产生了新的执行结果"}`,
"",
"只输出 JSON不要输出解释性文字或 Markdown。",
"JSON 结构固定为:",
'{ "projectGoal": "一句中文目标", "currentProgress": "一句中文进度", "technicalArchitecture": "一句中文架构说明", "currentBlockers": "一句中文阻塞说明", "recommendedNextStep": "一句中文建议动作" }',
"",
"要求:",
"1. 只写当前项目最重要、对主 Agent 接手有帮助的事实。",
"2. 不要重复内部字段、线程编号、目录路径、设备 ID。",
"3. 如果某个字段暂时不清楚,填空字符串。",
].join("\n");
}
async function queueProjectUnderstandingSyncTask(input: {
projectId: string;
observedActivityAt: string;
reason: "heartbeat_activity" | "thread_reply";
}) {
const state = await readState();
const project = state.projects.find((item) => item.id === input.projectId);
if (!project || !shouldQueueProjectUnderstandingSync(project, input.observedActivityAt, state)) {
return null;
}
const requestedByAccount = state.user.account || project.deviceIds[0] || "17600003315";
const task = await queueMasterAgentTask({
projectId: "master-agent",
taskType: "conversation_reply",
requestMessageId: randomToken("project-understanding"),
requestText: `请同步项目《${project.name}》当前目标与进度`,
executionPrompt: buildProjectUnderstandingSyncPrompt(project, input.reason),
requestedBy: requestedByAccount,
requestedByAccount,
deviceId: project.deviceIds[0] || state.user.boundDeviceId || "mac-studio",
targetProjectId: project.id,
targetThreadId: project.threadMeta.threadId,
targetThreadDisplayName: project.threadMeta.threadDisplayName,
targetCodexThreadRef: project.threadMeta.codexThreadRef,
targetCodexFolderRef: project.threadMeta.codexFolderRef,
projectUnderstandingTargetProjectId: project.id,
projectUnderstandingReason: input.reason,
});
await mutateStateIfChanged((freshState) => {
const freshProject = freshState.projects.find((item) => item.id === input.projectId);
if (!freshProject) {
return { result: null, changed: false };
}
freshProject.threadMeta.lastProjectUnderstandingRequestedAt = task.requestedAt;
freshProject.threadMeta.lastObservedCodexActivityAt = latestIsoTimestamp(
freshProject.threadMeta.lastObservedCodexActivityAt,
input.observedActivityAt,
) ?? input.observedActivityAt;
return { result: null, changed: true };
});
return task;
}
export async function previewDeviceImportResolution(input: { deviceId: string }) {
const state = await readState();
const draft = state.deviceImportDrafts.find((item) => item.deviceId === input.deviceId);
@@ -7477,6 +7774,9 @@ function applyDeviceImportResolutionInState(
const selectedCandidates = draft.candidates.filter((candidate) =>
draft.selectedCandidateIds.includes(candidate.candidateId),
);
const understandingsByCandidate = new Map(
deriveDeviceImportProjectUnderstandings(state, draft.draftId).map((item) => [item.candidateId, item] as const),
);
const importedProjects: Project[] = [];
for (const item of resolution.items) {
const candidate = draft.candidates.find((entry) => entry.candidateId === item.candidateId);
@@ -7517,9 +7817,29 @@ function applyDeviceImportResolutionInState(
targetProject.threadMeta.codexFolderRef = candidate.codexFolderRef ?? candidate.folderRef;
targetProject.threadMeta.codexThreadRef = candidate.codexThreadRef;
targetProject.threadMeta.updatedAt = candidate.lastActiveAt;
targetProject.threadMeta.lastObservedCodexActivityAt = candidate.lastActiveAt;
targetProject.preview = `已导入 ${candidate.threadDisplayName}`;
targetProject.updatedAt = nowIso();
targetProject.lastMessageAt = targetProject.updatedAt;
const understanding = understandingsByCandidate.get(candidate.candidateId);
if (understanding) {
applyProjectUnderstandingSnapshotInState(state, {
projectId: targetProject.id,
account: device.account,
snapshot: {
projectGoal: understanding.projectGoal,
currentProgress: understanding.currentProgress,
technicalArchitecture: understanding.technicalArchitecture,
currentBlockers: understanding.currentBlockers,
recommendedNextStep: understanding.recommendedNextStep,
sourceTaskId: understanding.sourceTaskId,
updatedAt: understanding.updatedAt,
sourceKind: "device_import",
},
sourceMessageId: understanding.sourceTaskId,
sourceKind: "device_import",
});
}
importedProjects.push({ ...targetProject });
}
@@ -7959,7 +8279,7 @@ export async function appendProjectMessage(payload: {
kind?: MessageKind;
attachments?: MessageAttachment[];
}) {
const message = await mutateState((state) => {
const result = await mutateState((state) => {
const project = state.projects.find((item) => item.id === payload.projectId);
if (!project) throw new Error("PROJECT_NOT_FOUND");
@@ -8011,11 +8331,24 @@ export async function appendProjectMessage(payload: {
project.lastMessageAt = message.sentAt;
project.preview = message.body;
return message;
return {
message,
shouldQueueUnderstandingSync:
payload.sender !== "user" &&
isDispatchableThreadProject(project) &&
Boolean(project.threadMeta.codexThreadRef?.trim()),
};
});
if (result.shouldQueueUnderstandingSync) {
await queueProjectUnderstandingSyncTask({
projectId: payload.projectId,
observedActivityAt: result.message.sentAt,
reason: "thread_reply",
});
}
publishBossEvent("project.messages.updated", { projectId: payload.projectId });
publishBossEvent("conversation.updated", { projectId: payload.projectId });
return message;
return result.message;
}
export async function appendAttachmentMessage(payload: {

View File

@@ -246,6 +246,28 @@ function buildRuntimeDigest(
.filter((update) => update.status === "available")
.map((update) => `${update.version} -> ${update.targetScope}`)
.join("\n");
const activeProjectUnderstandings = state.projects
.filter((project) => project.id !== "master-agent" && project.projectUnderstanding)
.sort((left, right) =>
String(right.projectUnderstanding?.updatedAt ?? right.lastMessageAt).localeCompare(
String(left.projectUnderstanding?.updatedAt ?? left.lastMessageAt),
),
)
.slice(0, 3)
.map((project) => {
const understanding = project.projectUnderstanding!;
return [
`${project.name}`,
understanding.projectGoal ? `目标=${understanding.projectGoal}` : undefined,
understanding.currentProgress ? `进度=${understanding.currentProgress}` : undefined,
understanding.technicalArchitecture ? `架构=${understanding.technicalArchitecture}` : undefined,
understanding.currentBlockers ? `阻塞=${understanding.currentBlockers}` : undefined,
understanding.recommendedNextStep ? `下一步=${understanding.recommendedNextStep}` : undefined,
]
.filter(Boolean)
.join(" / ");
})
.join("\n");
const authSummary = [
`登录会话策略:成功登录后默认保持 ${Math.round(AUTH_SESSION_TTL_MS / 24 / 60 / 60_000)} 天。`,
@@ -265,6 +287,9 @@ function buildRuntimeDigest(
"最新 APP 日志:",
recentLogs || "无",
"",
"活跃项目理解:",
activeProjectUnderstandings || "无",
"",
"高风险线程:",
riskyThreads || "无",
"",