"use strict"; const fs = require("fs"); const fsp = fs.promises; const path = require("path"); const STATUS_SET = new Set(["pending", "in-progress", "completed", "bug"]); const NODE_TYPE_SET = new Set([ "prompt", "sub-goal", "thought", "action", "observation", "conclusion", ]); const EDGE_TYPE_SET = new Set(["hierarchy", "dependency", "reference", "merge"]); function nowIso() { return new Date().toISOString(); } function toStringSafe(value, maxLength) { if (value === undefined || value === null) return ""; return String(value).trim().slice(0, maxLength); } function toNullableString(value, maxLength) { if (value === undefined || value === null || value === "") return null; const next = String(value).trim().slice(0, maxLength); return next || null; } function isValidIso(value) { if (value === null || value === undefined || value === "") return false; return Number.isFinite(Date.parse(String(value))); } function normalizeIso(value, fallback = null) { if (value === null || value === undefined || value === "") return fallback; const iso = String(value).trim(); return isValidIso(iso) ? new Date(iso).toISOString() : fallback; } function normalizeStatus(value) { return STATUS_SET.has(value) ? value : "pending"; } function normalizeNodeType(value, hasParent) { if (NODE_TYPE_SET.has(value)) return value; return hasParent ? "thought" : "prompt"; } function createId(prefix = "node") { const rand = Math.random().toString(36).slice(2, 8); return `${prefix}-${Date.now()}-${rand}`; } function extractSessionKey(notes) { if (!notes) return null; const text = String(notes); const match = text.match(/sessionKey=([^\n\r]+)/); return match && match[1] ? match[1].trim() : null; } function extractContentFromNotes(notes) { if (!notes) return ""; const text = String(notes); const match = text.match(/(?:^|\n)content=([^\n]+)/); if (match && match[1]) return match[1].trim().slice(0, 5000); return ""; } function normalizeDiagnostics(input) { const source = input && typeof input === "object" ? input : {}; const tokenUsage = source.tokenUsage && typeof source.tokenUsage === "object" ? source.tokenUsage : {}; const stopReason = toNullableString(source.stopReason, 32); return { rawInputLength: Number.isFinite(source.rawInputLength) ? Math.max(0, Math.floor(source.rawInputLength)) : null, tokenUsage: { promptTokens: Number.isFinite(tokenUsage.promptTokens) ? Math.max(0, Math.floor(tokenUsage.promptTokens)) : null, completionTokens: Number.isFinite(tokenUsage.completionTokens) ? Math.max(0, Math.floor(tokenUsage.completionTokens)) : null, totalTokens: Number.isFinite(tokenUsage.totalTokens) ? Math.max(0, Math.floor(tokenUsage.totalTokens)) : null, }, stopReason, latencyMs: Number.isFinite(source.latencyMs) ? Math.max(0, Math.floor(source.latencyMs)) : null, }; } function normalizeNode(input, fallback = {}) { const parentId = toNullableString(input.parentId, 180); const created = normalizeIso(input?.timestamps?.created || input.createdAt, nowIso()); const started = normalizeIso(input?.timestamps?.started || input.startTime, null); const completed = normalizeIso(input?.timestamps?.completed || input.endTime, null); const updated = normalizeIso(input.updatedAt, nowIso()); const status = normalizeStatus(input.status); const node = { id: toStringSafe(input.id || fallback.id || createId("task"), 180), conversationId: toNullableString(input.conversationId || fallback.conversationId, 240), parentId, nodeType: normalizeNodeType(input.nodeType || fallback.nodeType, Boolean(parentId)), name: toStringSafe(input.name || fallback.name || "未命名节点", 200) || "未命名节点", status, content: toStringSafe(input.content || fallback.content, 5000), timestamps: { created, started, completed, }, llmDiagnostics: normalizeDiagnostics(input.llmDiagnostics || fallback.llmDiagnostics), plannedApproach: toStringSafe(input.plannedApproach || fallback.plannedApproach, 5000), bugDetails: toStringSafe(input.bugDetails || fallback.bugDetails, 5000), notes: toStringSafe(input.notes || fallback.notes, 8000), createdAt: created, updatedAt: updated, }; if (node.status === "in-progress" && !node.timestamps.started) { node.timestamps.started = nowIso(); } if ((node.status === "completed" || node.status === "bug") && !node.timestamps.completed) { node.timestamps.completed = nowIso(); } return node; } function normalizeEdge(input) { const type = EDGE_TYPE_SET.has(input.type) ? input.type : "reference"; return { id: toStringSafe(input.id || createId("edge"), 180), from: toStringSafe(input.from, 180), to: toStringSafe(input.to, 180), type, createdAt: normalizeIso(input.createdAt, nowIso()), }; } function topologicalConversationPropagation(nodes) { const byId = new Map(nodes.map((node) => [node.id, node])); for (let i = 0; i < nodes.length + 2; i += 1) { let changed = false; for (const node of nodes) { if (node.conversationId) continue; if (!node.parentId) continue; const parent = byId.get(node.parentId); if (parent && parent.conversationId) { node.conversationId = parent.conversationId; changed = true; } } if (!changed) break; } for (const node of nodes) { if (!node.conversationId && !node.parentId) { node.conversationId = `legacy:${node.id}`; } } for (let i = 0; i < nodes.length + 2; i += 1) { let changed = false; for (const node of nodes) { if (node.conversationId) continue; if (!node.parentId) continue; const parent = byId.get(node.parentId); if (parent && parent.conversationId) { node.conversationId = parent.conversationId; changed = true; } } if (!changed) break; } for (const node of nodes) { if (!node.conversationId) { node.conversationId = `legacy:${node.id}`; } } } function migrateLegacyArray(tasks) { const nodes = tasks.map((item) => { const hasParent = Boolean(item.parentId); return normalizeNode( { id: item.id, conversationId: extractSessionKey(item.notes), parentId: item.parentId || null, nodeType: hasParent ? "thought" : "prompt", name: item.name, status: item.status, content: extractContentFromNotes(item.notes) || item.notes || "", timestamps: { created: item.createdAt, started: item.startTime, completed: item.endTime, }, plannedApproach: item.plannedApproach, bugDetails: item.bugDetails, notes: item.notes, updatedAt: item.updatedAt, }, {}, ); }); topologicalConversationPropagation(nodes); const nodeSet = new Set(nodes.map((node) => node.id)); const edges = []; const dedup = new Set(); for (const node of nodes) { if (!node.parentId) continue; if (!nodeSet.has(node.parentId)) { node.parentId = null; continue; } const key = `${node.parentId}->${node.id}:hierarchy`; if (dedup.has(key)) continue; dedup.add(key); edges.push( normalizeEdge({ from: node.parentId, to: node.id, type: "hierarchy", }), ); } return { version: 2, updatedAt: nowIso(), nodes, edges, }; } function normalizeStore(raw) { if (Array.isArray(raw)) { return migrateLegacyArray(raw); } const source = raw && typeof raw === "object" ? raw : {}; const nodes = Array.isArray(source.nodes) ? source.nodes.map((item) => normalizeNode(item, item)) : []; const edgesRaw = Array.isArray(source.edges) ? source.edges : []; const nodeSet = new Set(nodes.map((node) => node.id)); const edges = []; const dedup = new Set(); for (const edge of edgesRaw) { const next = normalizeEdge(edge); if (!next.from || !next.to || next.from === next.to) continue; if (!nodeSet.has(next.from) || !nodeSet.has(next.to)) continue; const key = `${next.from}->${next.to}:${next.type}`; if (dedup.has(key)) continue; dedup.add(key); edges.push(next); } for (const node of nodes) { if (!node.parentId) continue; if (!nodeSet.has(node.parentId)) { node.parentId = null; continue; } const key = `${node.parentId}->${node.id}:hierarchy`; if (dedup.has(key)) continue; dedup.add(key); edges.push( normalizeEdge({ from: node.parentId, to: node.id, type: "hierarchy", }), ); } topologicalConversationPropagation(nodes); return { version: 2, updatedAt: normalizeIso(source.updatedAt, nowIso()), nodes, edges, }; } function buildHierarchyMaps(store) { const children = new Map(); const incomingHierarchy = new Map(); for (const edge of store.edges) { if (edge.type !== "hierarchy") continue; if (!children.has(edge.from)) children.set(edge.from, []); children.get(edge.from).push(edge.to); incomingHierarchy.set(edge.to, edge.from); } return { children, incomingHierarchy }; } class TaskRepository { constructor(options = {}) { this.filePath = options.filePath; this._mutationQueue = Promise.resolve(); } async ensureFile() { const dir = path.dirname(this.filePath); await fsp.mkdir(dir, { recursive: true }); if (!fs.existsSync(this.filePath)) { const initial = normalizeStore({ version: 2, nodes: [], edges: [] }); await this.writeStore(initial); } } async readStore() { await this.ensureFile(); try { const raw = await fsp.readFile(this.filePath, "utf8"); const parsed = JSON.parse(raw); const normalized = normalizeStore(parsed); return normalized; } catch { return normalizeStore({ version: 2, nodes: [], edges: [] }); } } async writeStore(store) { const normalized = normalizeStore(store); normalized.updatedAt = nowIso(); const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`; await fsp.writeFile(tmpPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8"); await fsp.rename(tmpPath, this.filePath); return normalized; } async mutate(mutator) { this._mutationQueue = this._mutationQueue.then(async () => { const store = await this.readStore(); const result = await mutator(store); const nextStore = await this.writeStore(store); return { result, store: nextStore }; }); return this._mutationQueue; } async listConversations() { const store = await this.readStore(); const { incomingHierarchy } = buildHierarchyMaps(store); const groups = new Map(); for (const node of store.nodes) { if (!groups.has(node.conversationId)) { groups.set(node.conversationId, []); } groups.get(node.conversationId).push(node); } const summaries = []; for (const [conversationId, nodes] of groups.entries()) { const nodeIds = new Set(nodes.map((node) => node.id)); const roots = nodes.filter((node) => !incomingHierarchy.has(node.id) || !nodeIds.has(incomingHierarchy.get(node.id))); const titleRoot = roots .slice() .sort((a, b) => (Date.parse(a.timestamps.created) || 0) - (Date.parse(b.timestamps.created) || 0))[0]; const updatedAt = nodes.reduce((max, node) => { const t = Date.parse(node.updatedAt || node.timestamps.created || "") || 0; return Math.max(max, t); }, 0); const runningCount = nodes.filter((node) => node.status === "in-progress").length; const bugCount = nodes.filter((node) => node.status === "bug").length; summaries.push({ conversationId, title: titleRoot ? titleRoot.name : conversationId, updatedAt: updatedAt ? new Date(updatedAt).toISOString() : nowIso(), nodeCount: nodes.length, runningCount, bugCount, }); } summaries.sort((a, b) => (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0)); return summaries; } async getConversationGraph(conversationId) { const store = await this.readStore(); const nodes = store.nodes.filter((node) => node.conversationId === conversationId); const nodeSet = new Set(nodes.map((node) => node.id)); const edges = store.edges.filter((edge) => nodeSet.has(edge.from) && nodeSet.has(edge.to)); return { conversationId, nodes, edges, summary: { nodeCount: nodes.length, edgeCount: edges.length, runningCount: nodes.filter((node) => node.status === "in-progress").length, bugCount: nodes.filter((node) => node.status === "bug").length, }, }; } async getNodeById(id) { const store = await this.readStore(); return store.nodes.find((node) => node.id === id) || null; } async upsertNode(input) { return this.mutate(async (store) => { const index = store.nodes.findIndex((node) => node.id === input.id); const existing = index >= 0 ? store.nodes[index] : null; const merged = normalizeNode(input, existing || {}); const byId = new Map(store.nodes.map((node) => [node.id, node])); if (existing && merged.id !== existing.id) { throw new Error("不允许变更节点 ID"); } if (merged.parentId && merged.parentId === merged.id) { throw new Error("父节点不能是自己"); } if (merged.parentId && !byId.has(merged.parentId)) { throw new Error("父节点不存在"); } if (!merged.conversationId) { if (merged.parentId && byId.get(merged.parentId)) { merged.conversationId = byId.get(merged.parentId).conversationId; } else { merged.conversationId = `legacy:${merged.id}`; } } merged.updatedAt = nowIso(); if (index >= 0) { store.nodes[index] = merged; } else { store.nodes.push(merged); } const nodeSet = new Set(store.nodes.map((node) => node.id)); store.edges = store.edges.filter((edge) => nodeSet.has(edge.from) && nodeSet.has(edge.to)); store.edges = store.edges.filter((edge) => !(edge.type === "hierarchy" && edge.to === merged.id)); if (merged.parentId) { store.edges.push( normalizeEdge({ from: merged.parentId, to: merged.id, type: "hierarchy", }), ); } return merged; }); } async deleteNode(id) { return this.mutate(async (store) => { const nodeIds = new Set(store.nodes.map((node) => node.id)); if (!nodeIds.has(id)) { throw new Error("节点不存在"); } const children = new Map(); for (const edge of store.edges) { if (edge.type !== "hierarchy") continue; if (!children.has(edge.from)) children.set(edge.from, []); children.get(edge.from).push(edge.to); } const deleting = new Set([id]); const stack = [id]; while (stack.length) { const current = stack.pop(); const nextChildren = children.get(current) || []; for (const childId of nextChildren) { if (deleting.has(childId)) continue; deleting.add(childId); stack.push(childId); } } store.nodes = store.nodes.filter((node) => !deleting.has(node.id)); store.edges = store.edges.filter((edge) => !deleting.has(edge.from) && !deleting.has(edge.to)); return { deletedCount: deleting.size }; }); } async upsertEdge(input) { return this.mutate(async (store) => { const edge = normalizeEdge(input); if (!edge.from || !edge.to) { throw new Error("边的 from/to 不能为空"); } if (edge.from === edge.to) { throw new Error("边不能自环"); } const nodeSet = new Set(store.nodes.map((node) => node.id)); if (!nodeSet.has(edge.from) || !nodeSet.has(edge.to)) { throw new Error("边关联的节点不存在"); } const exists = store.edges.find((item) => item.from === edge.from && item.to === edge.to && item.type === edge.type); if (exists) return exists; store.edges.push(edge); return edge; }); } async deleteEdge(params) { return this.mutate(async (store) => { const from = toStringSafe(params.from, 180); const to = toStringSafe(params.to, 180); const type = params.type ? toStringSafe(params.type, 48) : null; const before = store.edges.length; store.edges = store.edges.filter((edge) => { if (edge.from !== from || edge.to !== to) return true; if (type && edge.type !== type) return true; return false; }); return { deletedCount: before - store.edges.length }; }); } } module.exports = { TaskRepository, STATUS_SET, NODE_TYPE_SET, EDGE_TYPE_SET, createId, };