| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538 |
- "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,
- };
|